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

The jev reference: what api_reference and the reference tab say about jev(), choice, score, noul and walk comes from jevCore.mjs's jsdoc (package.json's jsdoc-json). Every function the REPL's scope gets from jev.mjs must have an entry, and every example in one, like the song the hosted MCP's instructions teach with (worker/src/primer.ts), is played here against a stand-in Jev, so the docs cannot drift from the code.

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

9import { spawnSync } from 'node:child_process';
10import { readFileSync } from 'node:fs';
11import { describe, expect, it } from 'vitest';
12import { JEV_PRIMER, PRIMER_SONG } from '../../../worker/src/primer';
13import { playThrough } from './playThrough.mjs';
14import { referenceData } from './reference.mjs';
16const ROOT = new URL('../../../', import.meta.url).pathname;

jsdoc's doclets for jevCore.mjs, as doc.json has them (its -X is the same parse without a template).

20function jevDocs() {
21  const run = spawnSync(
22    `${ROOT}node_modules/.bin/jsdoc`,
23    ['-X', '-c', 'jsdoc/jsdoc.config.json', 'website/src/jev/jevCore.mjs'],
24    { cwd: ROOT, encoding: 'utf8', maxBuffer: 16 << 20 },
25  );
26  if (run.status !== 0) throw new Error(`jsdoc failed: ${run.stderr}`);
27  return JSON.parse(run.stdout).filter((d) => !d.undocumented && d.kind !== 'package');
28}

The names jev.mjs puts in the REPL's scope (it evaluates every export).

31const scopeNames = () => {
32  const src = readFileSync(`${ROOT}website/src/jev/jev.mjs`, 'utf8');
33  const listed = [...src.matchAll(/^export \{([^}]*)\}/gm)].flatMap((m) => m[1].split(',').map((s) => s.trim()));
34  const declared = [...src.matchAll(/^export (?:const|function) (\w+)/gm)].map((m) => m[1]);
35  return [...listed, ...declared].filter(Boolean).sort();
36};
38const docs = jevDocs();
39const reference = referenceData(docs);

A played example asks Jev, is accepted by the relay, and no rule of it throws.

42async function expectPlays(code, cycles) {
43  const played = await playThrough(code, cycles);
44  expect(played.heard, 'it plays').toBeGreaterThan(0);
45  expect(played.requests.length, 'Jev is asked').toBeGreaterThan(0);
46  expect(played.requests.filter((r) => r.why).map((r) => r.why), 'the relay accepts every request').toEqual([]);
47  const threw = played.views.flatMap((v) =>
48    v.decisions.filter((d) => d?.status === 'fallback' && d.reason && !d.ended),
49  );
50  expect(threw.map((d) => `${d.cycles}: ${d.reason}`), 'no rule throws').toEqual([]);
51  return played;
52}
54describe('the jev reference', () => {
55  it('has an entry, tagged jev, for everything jev.mjs puts in the REPL scope', () => {
56    expect(reference.filter((f) => f.tags.includes('jev')).map((f) => f.name).sort()).toEqual(scopeNames());
57  });
58
59  it('gives each a description, its parameters and an example', () => {
60    for (const f of reference) {
61      expect(f.description.length, f.name).toBeGreaterThan(200);
62      expect(f.params.length, f.name).toBeGreaterThan(0);
63      expect(f.examples.length, f.name).toBeGreaterThan(0);
64    }
65  });
66
67  it("keeps the description's lists as lines", () => {
68    const jev = reference.find((f) => f.name === 'jev');
69    expect(jev.description).toMatch(/^- fallback: \{ name: value \}/m);
70  });
71
72  for (const f of reference) {
73    f.examples.forEach((example, i) => {
74      it(`${f.name}'s example ${i + 1} plays`, async () => {
75        await expectPlays(example, 48);
76      }, 60_000);
77    });
78  }
79});
80
81describe("the hosted MCP's primer", () => {
82  it('teaches with its song, and points at the reference', () => {
83    expect(JEV_PRIMER).toContain(PRIMER_SONG);
84    for (const name of scopeNames()) expect(JEV_PRIMER, name).toMatch(new RegExp(`\\b${name}\\b`));
85  });
86
87  it('has a song that plays, asks Jev, and ends', async () => {
88    const { lastSound, cps } = await expectPlays(PRIMER_SONG, 64);
89    expect(cps, 'it sets its tempo').toBeGreaterThan(0);
90    expect(lastSound, 'it ends').toBeLessThan(40);
91  }, 60_000);
92});