Files
JamBuddy/scripts/smoke.mjs
T
vadimwit f98528733d test(piano): smoke coverage for the piano voicing resolver (task C-10)
+705 checks (776 total): per quality x root x style, asserts pcs subset of voicingToneSet,
real 3rd present, true-7th when hasTrueSeventh, notes in [0,36], bass==min, no duplicate
absolute note, determinism, chain pcs-preservation. Surfaced the sus2 dup-note wart (fixed
by L-13). Critic adversarially confirmed the checks bite. Derived expectations, no hardcoding.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-16 11:00:41 +01:00

408 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Smoke-test harness — asserts the KB registry and the loop matcher are wired
// correctly before a merge. Imports the REAL src/data/kb/index.js and
// src/lib/match.js (no mocks), runs structural + behavioural checks, and exits
// non-zero on any failure so CI can gate on it.
//
// Run: node scripts/smoke.mjs (exit 1 on any failure, 0 on all-pass)
//
// Style mirrors scripts/validate-kb.mjs: plain Node ESM, node: imports, a flat
// list of checks with ✓/✗ per check, a summary line, and process.exit().
import { register } from 'node:module'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { dirname, join } from 'node:path'
// match.js imports './theory' extensionless (resolved by Vite at build time, but
// raw Node ESM requires the extension). Register a tiny resolve hook that retries
// a failed extensionless relative specifier with '.js' appended, so we can import
// the REAL match.js unmodified (it's locked to task L-01 — we must not touch it).
register(
'data:text/javascript,' +
encodeURIComponent(`
export async function resolve(specifier, context, next) {
try {
return await next(specifier, context)
} catch (e) {
if (/^\\.{1,2}\\//.test(specifier) && !/\\.[mc]?js$/.test(specifier)) {
return await next(specifier + '.js', context)
}
throw e
}
}
`),
import.meta.url,
)
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
const load = async (rel) => (await import(pathToFileURL(join(ROOT, rel)).href))
// ─── Tiny assertion harness ───────────────────────────────────────────────────
let passed = 0
const failures = []
function check(label, fn) {
try {
fn()
passed++
console.log(` ✓ ${label}`)
} catch (e) {
failures.push(`${label}: ${e.message}`)
console.log(` ✗ ${label}${e.message}`)
}
}
function warn(label, msg) {
console.log(` ⚠ ${label}${msg} (skipped)`)
}
function assert(cond, msg) {
if (!cond) throw new Error(msg)
}
// ─── Load the real modules ────────────────────────────────────────────────────
const kb = (await load('src/data/kb/index.js')).default
const match = await load('src/lib/match.js')
const { buildLoopIndex, matchLoopToProgression, findLoopPosition } = match
const theory = await load('src/lib/theory.js')
const { CHORD_TYPES } = theory
const piano = await load('src/lib/piano.js')
const { pianoVoicing, pianoVoicingChain, voicingToneSet, hasTrueSeventh } = piano
// ─── 1. Registry integrity ────────────────────────────────────────────────────
console.log('\nRegistry integrity:')
check('KB default export is a non-empty object with ≥1 style', () => {
assert(kb && typeof kb === 'object', 'kb default export is not an object')
assert(Object.keys(kb).length >= 1, 'kb has no styles')
})
const styleNames = kb && typeof kb === 'object' ? Object.keys(kb) : []
const allIds = new Map() // id → style (for uniqueness across the whole KB)
for (const styleName of styleNames) {
const style = kb[styleName]
check(`style '${styleName}' has meta, progressions[], instruments.guitar`, () => {
assert(style && typeof style === 'object', 'style entry is not an object')
assert(style.meta && typeof style.meta === 'object', 'missing meta')
assert(Array.isArray(style.progressions) && style.progressions.length > 0, 'progressions must be a non-empty array')
assert(style.instruments && typeof style.instruments === 'object', 'missing instruments')
assert(style.instruments.guitar && typeof style.instruments.guitar === 'object', 'missing instruments.guitar')
})
const progs = Array.isArray(style?.progressions) ? style.progressions : []
for (const p of progs) {
check(`'${styleName}' progression '${p?.id ?? '?'}' has consistent degrees/qualities/rn/bars + unique id`, () => {
assert(typeof p.id === 'string' && p.id.length > 0, 'progression id missing')
assert(!allIds.has(p.id), `duplicate id '${p.id}' (also in style '${allIds.get(p.id)}')`)
allIds.set(p.id, styleName)
const n = Array.isArray(p.degrees) ? p.degrees.length : 0
assert(n > 0, 'degrees missing/empty')
for (const [field, arr] of [['qualities', p.qualities], ['rn', p.rn], ['bars', p.bars]]) {
assert(Array.isArray(arr), `${field} is not an array`)
assert(arr.length === n, `${field} length ${arr.length} ≠ degrees length ${n}`)
}
})
}
}
// ─── 2. Matcher correctness on sample loops ───────────────────────────────────
console.log('\nMatcher correctness:')
const index = buildLoopIndex(kb)
check('buildLoopIndex returns a { byCanonical: Map } shape', () => {
assert(index && index.byCanonical instanceof Map, 'byCanonical is not a Map')
assert(index.byCanonical.size > 0, 'index is empty')
})
// Helper: rotate a degree array by r so it starts at index r.
const rotate = (arr, r) => arr.map((_, i) => arr[(r + i) % arr.length])
// Helper: re-base a degree array so its first element is 0 (the loop "shape").
const rebase = (arr) => {
const base = arr[0]
return arr.map((d) => (((d - base) % 12) + 12) % 12)
}
// --- iiVI → some jazz 2-5-1, matched:true; derive the expected id from KB ---
// (don't hardcode 'jazz-251-major' — look up what the loop actually maps to and
// assert its structural truth: the matched progression's degrees rotate to the
// loop's degree shape.)
{
const loop = ['Dm7', 'G7', 'Cmaj7'] // iiVI in C → degree shape [0,5,10]
const r = matchLoopToProgression(loop, index)
check('iiVI [Dm7,G7,Cmaj7] matches a progression (matched:true)', () => {
assert(r.matched === true, `expected matched:true, got ${JSON.stringify(r)}`)
assert(typeof r.id === 'string' && r.id.length > 0, 'matched but no id')
assert(kb[r.style], `matched style '${r.style}' not in KB`)
})
check("iiVI maps to a 2-5-1-shaped progression (derived from KB)", () => {
// Structural: the matched progression's degrees, rotated by the reported
// rotation to align with the loop, must equal the loop's own degree shape.
const loopShape = rebase([2, 7, 0]) // Dm7 G7 Cmaj7 pcs → re-based shape [0,5,10]
const kbDeg = r.progression.degrees
const rotatedKbShape = rebase(rotate(kbDeg, r.rotation))
assert(rotatedKbShape.join(',') === loopShape.join(','),
`KB degrees ${kbDeg} rotated by ${r.rotation}${rotatedKbShape} ≠ loop shape ${loopShape}`)
// Soft: this loop is a major 2-5-1, expect the major-quality (maj7) candidate.
assert(/251/.test(r.id) || r.progression.qualities.includes('maj7'),
`expected a 2-5-1-shaped id, got '${r.id}'`)
})
// Remember the id for the rotation test below (derived, not hardcoded).
globalThis.__ii_v_i_id = r.id
}
// --- A rotation of iiVI matches the SAME id with non-zero rotation ----------
{
const loop = ['G7', 'Cmaj7', 'Dm7'] // VIii: same loop, rotated by 1
const r = matchLoopToProgression(loop, index)
check('rotation [G7,Cmaj7,Dm7] matches the SAME id as iiVI', () => {
assert(r.matched === true, `expected matched:true, got ${JSON.stringify(r)}`)
assert(r.id === globalThis.__ii_v_i_id,
`rotation matched '${r.id}', expected same id '${globalThis.__ii_v_i_id}'`)
})
check('rotation reports a non-zero rotation index', () => {
assert(r.rotation !== 0, `expected non-zero rotation, got ${r.rotation}`)
assert(r.rotation > 0 && r.rotation < loop.length, `rotation ${r.rotation} out of range`)
// The rotation index should point at the loop slot aligning with KB degrees[0].
// KB iiVI starts on ii (Dm7); Dm7 is at loop index 2 here.
assert(loop[r.rotation] === 'Dm7', `loop[${r.rotation}] is '${loop[r.rotation]}', expected 'Dm7'`)
})
}
// --- IVviIV axis loop → axis progression (pop may be absent) ----------------
{
const loop = ['C', 'G', 'Am', 'F'] // IVviIV, degree shape [0,7,9,5]
const r = matchLoopToProgression(loop, index)
const loopShape = rebase([0, 7, 9, 5])
if (r.matched) {
check('axis [C,G,Am,F] matches an axis-shaped progression', () => {
assert(kb[r.style], `matched style '${r.style}' not in KB`)
// Whatever style owns the axis, its degrees must rotate to the loop shape.
const rotatedKbShape = rebase(rotate(r.progression.degrees, r.rotation))
assert(rotatedKbShape.join(',') === loopShape.join(','),
`matched '${r.id}' degrees ${r.progression.degrees} rotated by ${r.rotation} don't match axis shape ${loopShape}`)
// The axis is the four-chord IVviIV; sanity-check it's a 4-chord loop.
assert(r.progression.degrees.length === 4, `expected a 4-chord axis, got length ${r.progression.degrees.length}`)
})
} else {
warn('axis [C,G,Am,F] match', 'no axis-shaped 4-chord progression in current KB')
}
}
// --- Chromatic / garbage loop → matched:false, no throw -----------------------
{
check('chromatic loop [C,C#,D] returns matched:false cleanly (no throw)', () => {
const r = matchLoopToProgression(['C', 'C#', 'D'], index)
assert(r && r.matched === false, `expected matched:false, got ${JSON.stringify(r)}`)
assert(r.id === null, `expected id:null on no-match, got ${r.id}`)
})
}
// --- findLoopPosition: in-range index for a known loop, -1 for no-match --------
{
const loop = ['Dm7', 'G7', 'Cmaj7']
check('findLoopPosition returns an in-range index for a known loop', () => {
const history = ['Dm7', 'G7'] // player is on G7
const pos = findLoopPosition(history, loop)
assert(Number.isInteger(pos), `expected an integer, got ${pos}`)
assert(pos >= 0 && pos < loop.length, `index ${pos} out of range [0,${loop.length})`)
assert(loop[pos] === 'G7', `loop[${pos}] is '${loop[pos]}', expected 'G7'`)
})
check('findLoopPosition returns -1 when the last chord is not in the loop', () => {
const pos = findLoopPosition(['Ebmaj7'], loop)
assert(pos === -1, `expected -1, got ${pos}`)
})
check('findLoopPosition returns -1 on empty inputs', () => {
assert(findLoopPosition([], loop) === -1, 'empty history should be -1')
assert(findLoopPosition(['Dm7'], []) === -1, 'empty loop should be -1')
})
}
// ─── 3. Piano voicing resolver (C-10) ─────────────────────────────────────────
//
// Exercise src/lib/piano.js across every CHORD_TYPES quality (all 14) at a
// couple of roots, for each forced style + the default. Expected facts are
// derived from the resolver/theory (voicingToneSet / hasTrueSeventh / the chord
// intervals), never hardcoded as brittle note arrays.
console.log('\nPiano resolver:')
const QUALITIES = Object.keys(CHORD_TYPES) // all 14
const PIANO_ROOTS = [0, 7] // C and G
const PIANO_STYLES = ['root', 'shell', 'rootlessA', 'rootlessB', undefined] // undefined = default
const mod12 = (n) => (((n % 12) + 12) % 12)
const styleName = (s) => s ?? 'default'
check(`CHORD_TYPES exposes all 14 qualities for the piano sweep`, () => {
assert(QUALITIES.length === 14, `expected 14 CHORD_TYPES, got ${QUALITIES.length}`)
})
// --- 3a. Per (quality × root × style): valid shape, no wrong notes, 3rd & 7th --
for (const quality of QUALITIES) {
const ints = CHORD_TYPES[quality].intervals
const hasReal3rd = ints.some((i) => i === 3 || i === 4)
// The "real 3rd" target pc: minor/major 3rd if present, else the suspension
// tone (sus2 → 2, sus4 → 5) that stands in the 3rd slot.
const susTone = ints[1] // sus2 → 2, sus4 → 5 (index-1 stand-in for the 3rd)
const trueSeventh = hasTrueSeventh(quality) // interval 10 or 11 present
for (const rootPc of PIANO_ROOTS) {
// expected 3rd-slot pc (absolute pc)
const third3 = mod12(rootPc + 3)
const third4 = mod12(rootPc + 4)
const susPc = mod12(rootPc + susTone)
const seventhInt = ints.find((i) => i === 10 || i === 11)
const seventhPc = seventhInt === undefined ? null : mod12(rootPc + seventhInt)
for (const style of PIANO_STYLES) {
const chord = { rootPc, quality }
const opts = style === undefined ? {} : { style }
const tag = `${quality}@${rootPc} [${styleName(style)}]`
check(`${tag}: valid voicing shape`, () => {
const v = pianoVoicing(chord, opts)
assert(v && typeof v === 'object', 'no voicing object')
assert(Array.isArray(v.notes) && v.notes.length > 0, 'notes must be a non-empty array')
assert(v.notes.every((n) => Number.isFinite(n)), 'notes must all be numbers')
assert(Array.isArray(v.pcs) && v.pcs.length > 0, 'pcs must be a non-empty array')
assert(typeof v.bass === 'number', 'bass must be a number')
assert(typeof v.style === 'string' && v.style.length > 0, 'style must be a non-empty string')
assert(typeof v.label === 'string' && v.label.length > 0, 'label must be a non-empty string')
})
check(`${tag}: no wrong notes (pcs ⊆ voicingToneSet)`, () => {
const v = pianoVoicing(chord, opts)
// Scope the legal-tone set to the voicing's ACTUAL produced style.
const legal = voicingToneSet({ rootPc, quality }, v.style)
for (const pc of v.pcs) {
assert(legal.has(pc), `pc ${pc} not in voicingToneSet(${quality}, ${v.style}) {${[...legal].sort((a, b) => a - b)}}`)
}
})
check(`${tag}: real 3rd (or suspension) present`, () => {
const v = pianoVoicing(chord, opts)
if (hasReal3rd) {
assert(v.pcs.includes(third3) || v.pcs.includes(third4),
`expected a 3rd (pc ${third3} or ${third4}) in pcs {${v.pcs}}`)
} else {
// sus2/sus4 — the 2 or 4 stands in the 3rd slot.
assert(v.pcs.includes(susPc),
`expected suspension tone pc ${susPc} in pcs {${v.pcs}}`)
}
})
check(`${tag}: true 7th present in shell/rootless`, () => {
const v = pianoVoicing(chord, opts)
const isSeventhVoicing = v.style === 'shell' || v.style === 'rootlessA' || v.style === 'rootlessB'
if (trueSeventh && isSeventhVoicing) {
assert(v.pcs.includes(seventhPc),
`${v.style} of a true-7th chord must include the 7th pc ${seventhPc}, got {${v.pcs}}`)
}
})
check(`${tag}: notes within [0,36] and bass == min(notes)`, () => {
const v = pianoVoicing(chord, opts)
assert(v.notes.every((n) => n >= 0 && n <= 36),
`notes ${JSON.stringify(v.notes)} out of [0,36]`)
assert(v.bass === Math.min(...v.notes),
`bass ${v.bass} ≠ min(notes) ${Math.min(...v.notes)}`)
})
}
}
}
// --- 3b. No duplicate absolute note within a single voicing (sus2 nit watch) ---
// Sweep every quality × style (forced + default). A duplicate absolute note in
// one voicing's `notes` is a defect surfaced as a ✗ (L-10 gate flagged a
// forced-rootless sus2 collision). If the resolver was since deduped, this
// passes. We report WHICH quality/style collides so it's visible.
{
const dupes = []
for (const quality of QUALITIES) {
for (const rootPc of PIANO_ROOTS) {
for (const style of PIANO_STYLES) {
const opts = style === undefined ? {} : { style }
const v = pianoVoicing({ rootPc, quality }, opts)
if (v.notes.length !== new Set(v.notes).size) {
dupes.push(`${quality}@${rootPc} [${styleName(style)}] notes=${JSON.stringify(v.notes)}`)
}
}
}
}
check('no duplicate absolute note within any single voicing (all qualities × styles)', () => {
assert(dupes.length === 0,
`duplicate-note voicing(s) found: ${dupes.join('; ')}`)
})
}
// --- 3c. Determinism: same input → identical notes -----------------------------
{
check('pianoVoicing is deterministic (same input → identical notes)', () => {
for (const quality of QUALITIES) {
for (const rootPc of PIANO_ROOTS) {
for (const style of PIANO_STYLES) {
const opts = style === undefined ? {} : { style }
const a = pianoVoicing({ rootPc, quality }, opts)
const b = pianoVoicing({ rootPc, quality }, opts)
assert(a.notes.join(',') === b.notes.join(','),
`non-deterministic notes for ${quality}@${rootPc} [${styleName(style)}]: ${a.notes} vs ${b.notes}`)
}
}
}
})
}
// --- 3d. Voice-leading sanity: chain pcs == per-chord unthreaded pcs ------------
// Threading re-registers (slides whole shapes by octaves) but never changes the
// pitch CONTENT — each chained voicing's pcs must equal the same chord voiced
// alone (default style), as a set.
{
const chords = [
{ rootPc: 2, quality: 'min7' }, // Dm7
{ rootPc: 7, quality: 'dom7' }, // G7
{ rootPc: 0, quality: 'maj7' }, // Cmaj7
]
const chain = pianoVoicingChain(chords)
const pcSet = (arr) => [...new Set(arr)].sort((a, b) => a - b).join(',')
check('pianoVoicingChain(iiVI) returns 3 voicings', () => {
assert(Array.isArray(chain) && chain.length === 3,
`expected 3 voicings, got ${Array.isArray(chain) ? chain.length : typeof chain}`)
})
check('chain pcs match the per-chord unthreaded pcs (threading never changes pitch content)', () => {
chords.forEach((chord, i) => {
const solo = pianoVoicing(chord) // same default style, no prev
assert(pcSet(chain[i].pcs) === pcSet(solo.pcs),
`chord ${i} (${chord.quality}@${chord.rootPc}): chain pcs {${pcSet(chain[i].pcs)}} ≠ unthreaded {${pcSet(solo.pcs)}}`)
})
})
}
// ─── 4. Summary + exit code ───────────────────────────────────────────────────
const total = passed + failures.length
console.log('')
if (failures.length) {
console.error(`✗ Smoke test FAILED — ${failures.length}/${total} check(s) failed:\n`)
for (const f of failures) console.error(' ' + f)
process.exit(1)
}
console.log(`✓ Smoke test passed — ${passed}/${total} checks green (${styleNames.length} styles, ${allIds.size} progressions)`)
process.exit(0)