test: smoke harness for KB registry + loop matcher (task C-01)

scripts/smoke.mjs imports real kb/index.js + match.js: asserts registry integrity
(unique ids, consistent lengths, guitar present) and matcher correctness (ii-V-I + its
rotation -> same id, axis match, chromatic -> no-match, findLoopPosition bounds). Exits
non-zero on failure, mirrors validate-kb. Critic adversarially confirmed it catches a
broken matcher. Ids derived from KB (survives growth).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
vadimwit
2026-06-15 08:34:21 +01:00
parent 8a7f932170
commit 66a06b2884
+240
View File
@@ -0,0 +1,240 @@
// 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
// ─── 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. 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)