← Public packages
@kentcdodds/pocket-piano
A pocket piano for choir practice: your singing part's range on your phone, installable and offline.
test/parts.test.ts
94 lines · 3.6 KB · TypeScriptimport 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<string, string> = {}, 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)
})