Skip to content
← Public packages

@kentcdodds/pocket-piano

A pocket piano for choir practice: your singing part's range on your phone, installable and offline.

FollowForkOpen app

test/parts.test.ts

94 lines · 3.6 KB · TypeScript
Raw
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<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)
})