jevstrudel.git / website / src / jev / takes.test.mjs
1import { describe, expect, it } from 'vitest';
2import { readdirSync, readFileSync } from 'node:fs';
3import { fetchMoves, fetchTakes, formMap, positions, writtenOrder } from './takes.mjs';
4
5// every site song (not allSongs.test.mjs's songDirs: importing a test file
6// would run its tests here too)
7const SONGS = new URL('../../../songs/', import.meta.url).pathname;
8const siteSongs = () =>
9  readdirSync(SONGS, { withFileTypes: true })
10    .filter((t) => t.isDirectory())
11    .flatMap((t) =>
12      readdirSync(`${SONGS}${t.name}`, { withFileTypes: true })
13        .filter((d) => d.isDirectory())
14        .map((d) => ({ id: `${t.name}/${d.name}`, file: `${SONGS}${t.name}/${d.name}/song.js` })),
15    );
16
17const moves = (sections) => ({ song: 'jev/x', performances: 2, of: 2, sections });
18const s = (section, next = [], more = {}) => ({ section, plays: 1, asked: 1, fallbacks: 0, ms: null, next, ...more });
19
20describe('writtenOrder', () => {
21  it("reads a walk() form's sections in the order they start", () => {
22    const code = `const form = walk(section, {
23      max: 16,
24      sections: {
25        drop: { at: 8, next: ['outro'] },
26        intro: { at: 0, next: ['drop'] },
27        outro:{at:12,next:[]},
28      },
29    })`;
30    expect(writtenOrder(code)).toEqual(['intro', 'drop', 'outro']);
31  });
32
33  it('resolves `at: at.name` through the object it names, as LIGHTNING IN A BOTTLE writes it', () => {
34    const code = `const at = { intro: 0, riff: 8, outro: 16 }
35      walk(section, { sections: { riff: { at: at.riff, next: [] }, intro: { at: at.intro, next: [] }, outro: { at: at.outro, next: [] } } })`;
36    expect(writtenOrder(code)).toEqual(['intro', 'riff', 'outro']);
37  });
38
39  it('claims no order it cannot read: an unresolved `at`, or sections built in code', () => {
40    expect(writtenOrder('walk(s, { sections: { a: { at: somewhere(), next: [] }, b: { at: 4, next: [] } } })')).toEqual([]);
41    expect(writtenOrder('const sections = { intro: { at: 0, next: [] } }\nfor (const s of SLOTS) sections[s] = { at: E }')).toEqual([]);
42  });
43
44  it("reads every site song's form that is written out, and none that is built in code", () => {
45    const songs = siteSongs();
46    expect(songs.length).toBeGreaterThan(10);
47    for (const { id, file } of songs) {
48      const order = writtenOrder(readFileSync(file, 'utf8'));
49      if (id === 'jev/all-green') expect(order, id).toEqual([]);
50      else expect(order.length, id).toBeGreaterThan(3);
51    }
52  });
53
54  it('is empty for a song without a form', () => {
55    expect(writtenOrder('s("bd*4")')).toEqual([]);
56    expect(writtenOrder(undefined)).toEqual([]);
57  });
58});
59
60describe('formMap', () => {
61  it('lays the sections out as written, marking the written way on', () => {
62    const rows = formMap(
63      moves([
64        s('drop', [{ section: 'outro', n: 2, share: 1 }]),
65        s('intro', [
66          { section: 'drop', n: 3, share: 0.75 },
67          { section: 'outro', n: 1, share: 0.25 },
68        ]),
69        s('outro'),
70        s('gone'),
71      ]),
72      ['intro', 'drop', 'outro'],
73    );
74    expect(rows.map((r) => r.section)).toEqual(['intro', 'drop', 'outro', 'gone']);
75    expect(rows[0].next.map((n) => [n.section, n.asWritten])).toEqual([
76      ['drop', true],
77      ['outro', false],
78    ]);
79    expect(rows[2].writtenNext).toBeNull();
80    expect(rows[3].writtenNext, 'a section the code no longer has').toBeNull();
81  });
82
83  it("orders a form it cannot read by the sections' places in the takes, claiming no written way on", () => {
84    const placed = positions([{ path: ['intro', 'guess', 'end'] }, { path: ['intro', 'guess', 'guess', 'end'] }, { path: null }]);
85    const rows = formMap(moves([s('end'), s('guess', [{ section: 'end', n: 2, share: 1 }]), s('intro')]), [], placed);
86    expect(rows.map((r) => [r.section, r.writtenNext])).toEqual([
87      ['intro', null],
88      ['guess', null],
89      ['end', null],
90    ]);
91    expect(rows[1].next[0].asWritten).toBe(false);
92  });
93
94  it('numbers the sections of a song without a form', () => {
95    const rows = formMap(moves([s('10'), s('2'), s('1')]), []);
96    expect(rows.map((r) => [r.section, r.writtenNext])).toEqual([
97      ['1', '2'],
98      ['2', '3'],
99      ['10', '11'],
100    ]);
101  });
102});
103
104describe('fetching', () => {
105  it("asks for a song's takes a page at a time, and its moves", async () => {
106    const asked = [];
107    const fetchImpl = async (url) => (asked.push(url), new Response('{"ok":1}'));
108    expect(await fetchTakes('jev/dial-up', 20, fetchImpl)).toEqual({ ok: 1 });
109    await fetchMoves('jev/dial-up', fetchImpl);
110    expect(asked).toEqual([
111      '/jev/performances?song=jev%2Fdial-up&offset=20&limit=20',
112      '/jev/performances/moves?song=jev%2Fdial-up',
113    ]);
114  });
115
116  it('throws on an error, so the view shows nothing rather than stale data', async () => {
117    await expect(fetchMoves('jev/x', async () => new Response('', { status: 503 }))).rejects.toThrow(/503/);
118  });
119});