import assert from 'node:assert/strict' import { test } from 'node:test' import vm from 'node:vm' import { DEFAULT_PART_ID, PARTS, PARTS_JS, PART_STORAGE_KEY } from '../src/parts.ts' type Part = (typeof PARTS)[number] type Api = { list: Part[] byId(id: unknown): Part | null read(): Part write(id: unknown): Part homePos(whites: number[], part: Part, span: number): number } function load(initial: Record = {}, opts: { throws?: boolean } = {}) { const store = new Map(Object.entries(initial)) const localStorage = { getItem(key: string) { if (opts.throws) throw new Error('blocked') return store.has(key) ? store.get(key)! : null }, setItem(key: string, value: string) { if (opts.throws) throw new Error('blocked') store.set(key, value) }, } const window: { pocketPianoParts?: Api } = {} vm.runInNewContext(PARTS_JS, { window, localStorage }) return { api: window.pocketPianoParts!, store } } const WHITE_PCS = [0, 2, 4, 5, 7, 9, 11] const whites: number[] = [] for (let m = 21; m <= 108; m++) if (WHITE_PCS.includes(m % 12)) whites.push(m) const name = (midi: number) => ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'][midi % 12] + String(Math.floor(midi / 12) - 1) test('part ranges match their note names', () => { const expected = { soprano: ['C4', 'A5'], alto: ['F3', 'D5'], tenor: ['C3', 'A4'], baritone: ['A2', 'F4'], bass: ['E2', 'E4'], } assert.deepEqual(PARTS.map((p) => p.id), Object.keys(expected)) for (const part of PARTS) { assert.deepEqual([name(part.low), name(part.high)], expected[part.id as keyof typeof expected]) assert.deepEqual([part.lowName, part.highName], expected[part.id as keyof typeof expected]) } }) test('defaults to tenor when nothing is saved, the value is junk, or storage throws', () => { assert.equal(DEFAULT_PART_ID, 'tenor') assert.equal(load().api.read().id, 'tenor') assert.equal(load({ [PART_STORAGE_KEY]: '"countertenor"' }).api.read().id, 'tenor') assert.equal(load({ [PART_STORAGE_KEY]: 'not json' }).api.read().id, 'tenor') assert.equal(load({}, { throws: true }).api.read().id, 'tenor') }) test('write persists a known part and read returns it', () => { const { api, store } = load() assert.equal(api.write('alto').id, 'alto') assert.equal(store.get(PART_STORAGE_KEY), '"alto"') assert.equal(api.read().id, 'alto') assert.equal(load(Object.fromEntries(store)).api.read().id, 'alto') assert.equal(api.write('nope').id, 'alto') assert.equal(store.get(PART_STORAGE_KEY), '"alto"') }) test('home position shows the whole part, centered, preferring a C start', () => { const { api } = load() const span = 15 // portrait (8 + 7) and full landscape const windowFor = (id: string) => { const pos = api.homePos(whites, api.byId(id)!, span) return [name(whites[pos]!), name(whites[pos + span - 1]!)] } assert.deepEqual(windowFor('tenor'), ['C3', 'C5']) // unchanged from before assert.deepEqual(windowFor('soprano'), ['C4', 'C6']) assert.deepEqual(windowFor('alto'), ['E3', 'E5']) assert.deepEqual(windowFor('baritone'), ['G2', 'G4']) assert.deepEqual(windowFor('bass'), ['E2', 'E4']) for (const part of PARTS) { const pos = api.homePos(whites, part, span) assert.ok(whites[pos]! <= part.low && whites[pos + span - 1]! >= part.high, part.id) } }) test('home position centers and clamps when the window is narrower than the part', () => { const { api } = load() const tenor = api.byId('tenor')! const pos = api.homePos(whites, tenor, 7) assert.equal(name(whites[pos]!), 'F3') // C3 to A4 is 13 white keys; 7 centered starts on F3 assert.equal(api.homePos(whites, tenor, whites.length), 0) })