jevstrudel.git / website / src / jev / allSongs.test.mjs

Every song in songs///song.js, evaluated the way the REPL does and played through to its end against a stand-in Jev, so a song that cannot run fails here instead of in a listener's tab: a question jev() refuses, a fallback that is not an option, a form walk() rejects, a syntax error, or a request the relay would refuse (worker/src/relay.ts). It also measures each song's calls a minute against the relay's rate limit (worker/wrangler.json; the budget's arithmetic is in relay.ts).

Runs in nix flake check (flake.nix, checks.jev-tests).

10import { readdirSync, readFileSync, statSync } from 'node:fs';
11import { join } from 'node:path';
12import { describe, expect, it } from 'vitest';
13import { LIMIT, MAX_BYTES, whyNot } from '../../../worker/src/relay';
14import wrangler from '../../../worker/wrangler.json';
15import { artRequest, CRITIC_MODEL, songCode, specBody } from './critic.mjs';
16import { measuredFor, readMeasured, withoutSectionParts } from './measured.mjs';
17import { playThrough, STEP } from './playThrough.mjs';

JEV_SONGS_DIR points it at another songs/ tree, e.g. to prove a break fails.

20const SONGS = process.env.JEV_SONGS_DIR ?? new URL('../../../songs/', import.meta.url).pathname;

A song this long has not ended: every form has a cap well under it.

22const MAX_CYCLES = 480;
24export const songDirs = () =>
25  readdirSync(SONGS)
26    .filter((theme) => statSync(join(SONGS, theme)).isDirectory())
27    .flatMap((theme) =>
28      readdirSync(join(SONGS, theme))
29        .filter((song) => statSync(join(SONGS, theme, song)).isDirectory())
30        .map((song) => ({ id: `${theme}/${song}`, file: join(SONGS, theme, song, 'song.js') })),
31    );

Calls a minute at the song's busiest, at its own tempo: the most requests in any minute of it, or, for a song shorter than a minute, its requests scaled to one (the radio plays the next song straight after).

36function callsPerMinute(requests, cps, length) {
37  const minute = cps * 60;
38  const window = Math.min(minute, length);
39  const at = requests.map((r) => r.cycle).sort((a, b) => a - b);
40  let most = 0;
41  for (let i = 0, j = 0; i < at.length; i++) {
42    while (at[i] - at[j] >= window) j++;
43    most = Math.max(most, i - j + 1);
44  }
45  return (most * minute) / window;
46}
48describe('the rate limit', () => {
49  it('is the same in production and in dev', () => {
50    // wrangler does not let an environment inherit its bindings, so the
51    // limit is written twice
52    expect(wrangler.env.dev.ratelimits).toEqual(wrangler.ratelimits);
53    expect(LIMIT.period, 'the budget in relay.ts counts calls a minute').toBe(60);
54  });
55});
56
57describe('every song', () => {
58  for (const { id, file } of songDirs()) {
59    it(`${id} evaluates, asks what the relay accepts, and ends`, async () => {
60      const { requests, cps, heard, lastSound, views } = await playThrough(readFileSync(file, 'utf8'), MAX_CYCLES);
61
62      expect(heard, 'the song plays').toBeGreaterThan(0);
63      expect(lastSound, 'the song ends').toBeLessThan(MAX_CYCLES - 16);
64      // The stand-in always answers, so a decision that fell back for a
65      // reason is the song's own rule throwing (an `allow` reading the
66      // section before the opening fell back silently, 2026-09-26)
67      const threw = views.flatMap((v) =>
68        v.decisions.filter((d) => d?.status === 'fallback' && d.reason && !d.ended),
69      );
70      expect(threw.map((d) => `${d.cycles}: ${d.reason}`), 'no rule of the song throws').toEqual([]);
71      const refused = requests.filter((r) => r.why);
72      expect(refused.map((r) => r.why), 'the relay accepts every request').toEqual([]);
73      for (const r of requests) expect(r.bytes, 'a request fits the relay').toBeLessThanOrEqual(MAX_BYTES);
74      if (/\bjev\s*\(/.test(readFileSync(file, 'utf8'))) {
75        expect(requests.length, 'Jev is asked').toBeGreaterThan(0);
76        expect(cps, 'the song sets its tempo, so its rate can be measured').toBeGreaterThan(0);
77        // half the limit: the other half is a second tab, the mood picker,
78        // the art check and the radio (relay.ts)
79        expect(callsPerMinute(requests, cps, lastSound + STEP), 'calls a minute').toBeLessThanOrEqual(LIMIT.limit / 2);
80        // a form asked after its first sections hears what the jev()s asked after it arranged
81        if (/after:\s*\{/.test(readFileSync(file, 'utf8'))) {
82          const later = requests.filter((r) => r.form && r.cycle > 16);
83          if (later.length) expect(later.every((r) => r.arranged), "the form hears the arrangement").toBe(true);
84        }
85      }

"is it art?" on the unedited song sends its spec, code and measured mix whole (JevPicks.jsx, songs.mjs): the relay must take that too.

89      const specText = readFileSync(join(file, '..', 'SPEC.md'), 'utf8');
90      const raw = readFileSync(file, 'utf8');
91      const title = specText.split('---')[1].match(/^title:\s*(.+)$/m)[1];
92      const art = {
93        model: CRITIC_MODEL,
94        ...artRequest({
95          title,
96          spec: specBody(specText),
97          code: songCode(`// @title ${title}\n${raw}`),
98          measured: withoutSectionParts(measuredFor(readMeasured(specText), raw)),
99        }),
100      };
101      expect(whyNot(art), 'the relay accepts the art check').toBeFalsy();
102      expect(new TextEncoder().encode(JSON.stringify(art)).length, 'the art check fits the relay').toBeLessThanOrEqual(MAX_BYTES);
103    }, 120_000);
104  }
105});