jevstrudel.git / website / src / jev / critic.test.mjs
1import { describe, expect, it } from 'vitest';
2import { CRITERIA, CRITIC_MODEL, RUBRIC, meanVerdict, setCritic, setRevisionArt } from './critic.mjs';
3
4const verdict = (art, score) => ({ art, criteria: Object.fromEntries(Object.keys(CRITERIA).map((k) => [k, score])) });
5
6describe('meanVerdict', () => {
7  it('means art and every criterion, and keeps each run', () => {
8    const v = meanVerdict([verdict(0.58, 2.9), verdict(0.64, 3.2), verdict(0.61, 3.1)]);
9    expect(v.art).toBe(0.61);
10    expect(v.artRuns).toEqual([0.58, 0.64, 0.61]);
11    for (const k of Object.keys(CRITERIA)) expect(v.criteria[k]).toBe(3.1);
12  });
13
14  it('rounds like one verdict: art to the hundredth, criteria to the tenth', () => {
15    const v = meanVerdict([verdict(0.6, 3), verdict(0.61, 3.1), verdict(0.61, 3.1)]);
16    expect(v.art).toBe(0.61); // 0.6067
17    expect(v.criteria.hook).toBe(3.1); // 3.0667
18  });
19
20  it('is one verdict unchanged when there is one run', () => {
21    expect(meanVerdict([verdict(0.57, 2.8)])).toEqual({ ...verdict(0.57, 2.8), artRuns: [0.57] });
22  });
23
24  it('refuses no runs', () => {
25    expect(() => meanVerdict([])).toThrow();
26  });
27});
28
29const spec = `---
30title: X
31song: song.js
32revisions:
33  - rev: 1
34    date: 2026-09-24
35    output: null  # overwritten by rev 2
36    art: 0.57
37  - rev: 2
38    date: 2026-09-25
39    output: song.js
40---
41
42# X
43`;
44
45describe('recording verdicts', () => {
46  it('puts art and artRuns after output, replacing a single-run art', () => {
47    const text = setRevisionArt(spec, 1, { art: 0.6, artRuns: [0.58, 0.6, 0.62] });
48    expect(text).toContain(
49      '    output: null  # overwritten by rev 2\n    art: 0.6\n    artRuns: [0.58, 0.6, 0.62]\n  - rev: 2',
50    );
51    // rewriting replaces both lines, never duplicates them
52    const again = setRevisionArt(text, 1, { art: 0.5, artRuns: [0.5] });
53    expect(again.match(/art:/g)).toHaveLength(1);
54    expect(again.match(/artRuns:/g)).toHaveLength(1);
55    expect(again).toContain('    art: 0.5\n    artRuns: [0.5]\n');
56  });
57
58  it('records an unscorable revision as null, dropping its runs', () => {
59    const scored = setRevisionArt(spec, 2, { art: 0.6, artRuns: [0.6] });
60    const text = setRevisionArt(scored, 2, { reason: 'song.js is missing' });
61    expect(text).toContain('    output: song.js\n    art: null  # not scored: song.js is missing\n---');
62    expect(text).not.toContain('artRuns');
63  });
64
65  it('writes the critic block after song:, then replaces it', () => {
66    const v = meanVerdict([verdict(0.58, 2.9), verdict(0.64, 3.2)]);
67    const text = setCritic(spec, v, '2026-09-25');
68    expect(text).toContain(
69      `song: song.js\ncritic:\n  date: 2026-09-25\n  model: ${CRITIC_MODEL}\n  method: ${RUBRIC}\n  art: 0.61\n` +
70        '  artRuns: [0.58, 0.64]\n  criteria:\n    hook: 3.1\n',
71    );
72    const again = setCritic(text, meanVerdict([verdict(0.5, 2.5)]), '2026-09-26');
73    expect(again.match(/^critic:/gm)).toHaveLength(1);
74    expect(again.match(/artRuns:/g)).toHaveLength(1);
75    expect(again).toContain('  art: 0.5\n  artRuns: [0.5]\n');
76    expect(again).toContain('\nrevisions:\n  - rev: 1');
77  });
78});