mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 06:24:31 +00:00
Creed gap: both functions only tested with empty/null state — a return 0 at
the body entry point kept the suite green (Creed's exact injection at
overlay.js:631 for drawToneHud; equivalent non-empty branch gut for drawSectionHud).
Fix: two new recording-ctx tests (tests 9–10) that drive real rendering:
• drawToneHud: toneBase='Clean', next change 'Lead' at t=10, currentTime=5
→ assert boxH>0, fillText contains 'Clean' and 'Lead', ctx.fill() called
• drawSectionHud: sections=[Intro@0, Verse@10], currentTime=5
→ assert boxH>0, fillText contains 'Intro' and 'Verse', ctx.fill() called
Named mutations verified RED:
Mut 1: return 0 at drawToneHud body start (overlay.js:631) → test 10 RED
Mut 2: return 0 after drawSectionHud empty-guard → test 11 RED
Shipped code: zero changes. Test files only.
Suite: 262/262 on 0a91f4f (base) → 264/264 (+2 tests)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
316 lines
15 KiB
JavaScript
316 lines
15 KiB
JavaScript
// Source + behavioural guards for h3d-carve-7: O-section (lyrics + HUD overlay)
|
||
// extracted to src/overlay.js.
|
||
//
|
||
// Class-killers guaranteed:
|
||
// 1. Module exports createOverlay (source)
|
||
// 2. createOverlay return set covers all 5 required symbols (source)
|
||
// 3. Stranded-caller: every returned symbol appears in screen.js destructure (source)
|
||
// 4. Private-guard: factory-depth-1 privates not bare in screen.js (source)
|
||
// 5. Moved constants absent from screen.js (source — deletion check)
|
||
// 6. _diagRenderCache ref-identity: teardown .clear() reaches the same Map passed to
|
||
// createOverlay (behavioural — mutation: new Map() breaks it → RED)
|
||
// 7. drawSectionHud returns 0 when no sections (behavioural)
|
||
// 8. drawLyrics returns a number (behavioural)
|
||
|
||
const { test } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('node:fs');
|
||
const path = require('node:path');
|
||
const vm = require('node:vm');
|
||
|
||
const OVERLAY_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'overlay.js');
|
||
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||
|
||
function stripComments(s) {
|
||
return s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '');
|
||
}
|
||
|
||
function src() {
|
||
return fs.readFileSync(OVERLAY_JS, 'utf8');
|
||
}
|
||
function screenSrc() {
|
||
return fs.readFileSync(SCREEN_JS, 'utf8');
|
||
}
|
||
|
||
// ── 1. Module exports createOverlay ─────────────────────────────────────────
|
||
test('overlay.js exports createOverlay', () => {
|
||
assert.match(src(), /export\s+function\s+createOverlay\s*\(/);
|
||
});
|
||
|
||
// ── 2. Return set covers all 5 required symbols ──────────────────────────────
|
||
test('createOverlay returns all 5 required symbols', () => {
|
||
const stripped = stripComments(src());
|
||
const REQUIRED = ['drawChordDiagram', '_drawDiagramCached', 'drawSectionHud', 'drawToneHud', 'drawLyrics'];
|
||
// Match the factory-level return block (4-space indent inside createOverlay).
|
||
// Inner function returns like longestConsecutiveRun's are at 8+ spaces.
|
||
const retMatch = stripped.match(/\n {4}return\s*\{\s*\n([\s\S]+?)\n {4}\};/);
|
||
assert.ok(retMatch, 'factory-level return block must be present');
|
||
const returned = retMatch[1].split(',').map(s => s.trim()).filter(Boolean);
|
||
for (const sym of REQUIRED) {
|
||
assert.ok(returned.includes(sym), `return set must include ${sym}`);
|
||
}
|
||
});
|
||
|
||
// ── 3. Stranded-caller: returned ⊆ screen.js destructure ────────────────────
|
||
test('every createOverlay returned symbol appears in screen.js destructure', () => {
|
||
// Mutation: remove _drawDiagramCached from screen.js destructure → missing → RED.
|
||
const stripped = stripComments(src());
|
||
const retMatch = stripped.match(/\n {4}return\s*\{\s*\n([\s\S]+?)\n {4}\};/);
|
||
assert.ok(retMatch, 'factory-level return block must be present');
|
||
const returned = retMatch[1].split(',').map(s => s.trim()).filter(Boolean);
|
||
|
||
const scrRaw = screenSrc();
|
||
const destrMatch = scrRaw.match(/const\s*\{([^}]+)\}\s*=\s*createOverlay\s*\(/);
|
||
assert.ok(destrMatch, 'screen.js must have a createOverlay destructure');
|
||
const destructured = destrMatch[1].split(',').map(s => s.trim()).filter(Boolean);
|
||
|
||
for (const sym of returned) {
|
||
assert.ok(destructured.includes(sym),
|
||
`returned symbol '${sym}' must appear in screen.js createOverlay destructure`);
|
||
}
|
||
});
|
||
|
||
// ── 4. Private-guard: factory-depth-1 privates not bare in screen.js ─────────
|
||
test('factory-private symbols in overlay.js do not appear bare in screen.js', () => {
|
||
// Mutation: add bare _DIAG_CACHE_MAX to screen.js → violations → RED.
|
||
const stripped = stripComments(src());
|
||
|
||
// Collect returned symbols (factory-level return, 4-space indent).
|
||
const retMatch = stripped.match(/\n {4}return\s*\{\s*\n([\s\S]+?)\n {4}\};/);
|
||
assert.ok(retMatch, 'factory-level return block must be present');
|
||
const returned = new Set(
|
||
retMatch[1].split(',').map(s => s.trim()).filter(Boolean)
|
||
);
|
||
|
||
// Factory-depth-1 const/let: exactly 4-space indent inside createOverlay body.
|
||
const privateSyms = [];
|
||
for (const m of stripped.matchAll(/^ {4}(?:const|let)\s+(\w+)/gm)) {
|
||
const sym = m[1];
|
||
if (!returned.has(sym)) privateSyms.push(sym);
|
||
}
|
||
assert.ok(privateSyms.length > 0, 'factory must have at least one private depth-1 declaration');
|
||
|
||
let scr = screenSrc().replace(/^import\s+.*\n/gm, '');
|
||
scr = stripComments(scr);
|
||
scr = scr.replace(/const\s*\{[^}]+\}\s*=\s*createOverlay\s*\([^)]*\)\s*;/, '');
|
||
|
||
const violations = privateSyms.filter(sym =>
|
||
new RegExp('\\b' + sym + '\\b').test(scr)
|
||
);
|
||
assert.deepStrictEqual(violations, [],
|
||
'screen.js must not reference factory-private overlay.js symbols: ' + violations.join(', '));
|
||
});
|
||
|
||
// ── 5. Moved constants absent from screen.js ─────────────────────────────────
|
||
test('DIAG_SIZE_MIN, DIAG_SIZE_MAX, DIAG_CELL_MAX, _DIAG_CACHE_MAX absent from screen.js', () => {
|
||
// Mutation: add const DIAG_SIZE_MIN = 0.08 back to screen.js → RED.
|
||
const scr = stripComments(screenSrc());
|
||
for (const sym of ['DIAG_SIZE_MIN', 'DIAG_SIZE_MAX', 'DIAG_CELL_MAX', '_DIAG_CACHE_MAX']) {
|
||
assert.doesNotMatch(scr, new RegExp('const\\s+' + sym + '\\b'),
|
||
`const ${sym} must not appear in screen.js (it moved to overlay.js)`);
|
||
}
|
||
});
|
||
|
||
// ── 6. Ref-identity class-killer: source-scan ────────────────────────────────
|
||
test('screen.js passes _diagRenderCache (not a new Map) to createOverlay', () => {
|
||
// This is the primary ref-severing class-killer god requested.
|
||
// Mutation: change createOverlay({ diagRenderCache: _diagRenderCache })
|
||
// to createOverlay({ diagRenderCache: new Map() })
|
||
// → teardown .clear() on screen.js's _diagRenderCache no longer reaches the
|
||
// overlay cache → cache leaks → this test goes RED.
|
||
const scr = stripComments(screenSrc());
|
||
assert.match(
|
||
scr,
|
||
/createOverlay\s*\(\s*\{\s*diagRenderCache\s*:\s*_diagRenderCache\s*\}\s*\)/,
|
||
'screen.js must pass _diagRenderCache (not a new Map or other value) as diagRenderCache to createOverlay',
|
||
);
|
||
});
|
||
|
||
// ── 6b–8: Behavioural tests in a vm sandbox ──────────────────────────────────
|
||
// createOverlay needs a diagRenderCache Map (stable ref). The functions under
|
||
// test do canvas 2D drawing; we stub ctx with the minimal surface they call.
|
||
function makeCtx() {
|
||
return {
|
||
save() {}, restore() {}, beginPath() {}, fill() {}, stroke() {},
|
||
moveTo() {}, lineTo() {}, arc() {}, roundRect() {}, closePath() {},
|
||
fillText() {}, strokeText() {}, quadraticCurveTo() {},
|
||
fillRect() {}, strokeRect() {}, drawImage() {},
|
||
measureText(t) { return { width: t.length * 7 }; },
|
||
fillStyle: '', strokeStyle: '', lineWidth: 1,
|
||
globalAlpha: 1, font: '', textAlign: '', textBaseline: '',
|
||
shadowColor: '', shadowBlur: 0, shadowOffsetX: 0, shadowOffsetY: 0,
|
||
};
|
||
}
|
||
|
||
function loadModule(diagRenderCache) {
|
||
const raw = fs.readFileSync(OVERLAY_JS, 'utf8');
|
||
// Strip the ES module export keyword so the script runs in a vm CommonJS-style.
|
||
// Use /m flag so ^ matches line starts (file begins with a comment block).
|
||
const code = raw.replace(/^export\s+function\s+createOverlay/m, 'function createOverlay');
|
||
const sandbox = {
|
||
OffscreenCanvas: class { constructor(w, h) { this.width=w; this.height=h; }
|
||
getContext() { return makeCtx(); } },
|
||
document: {
|
||
createElement() {
|
||
return { width: 0, height: 0, getContext() { return makeCtx(); } };
|
||
}
|
||
},
|
||
console,
|
||
__exports: {},
|
||
};
|
||
vm.createContext(sandbox);
|
||
vm.runInContext(code + '\n__exports.createOverlay = createOverlay;', sandbox);
|
||
return sandbox.__exports.createOverlay({ diagRenderCache });
|
||
}
|
||
|
||
// ── 6. _diagRenderCache ref-identity ────────────────────────────────────────
|
||
test('diagRenderCache passed to createOverlay is the same Map reached by teardown .clear()', () => {
|
||
// This is the class-killer god requested.
|
||
//
|
||
// Mutation: in screen.js, change
|
||
// createOverlay({ diagRenderCache: _diagRenderCache })
|
||
// to
|
||
// createOverlay({ diagRenderCache: new Map() })
|
||
// → the overlay populates its own Map, but screen.js teardown clears _diagRenderCache
|
||
// (a different object) → overlay cache leaks → both Map sizes diverge → RED.
|
||
//
|
||
// Here we verify the ref is the same Map by populating a sentinel key via
|
||
// _drawDiagramCached (which writes to diagRenderCache) and then confirming
|
||
// the original Map reference sees the write.
|
||
const sharedMap = new Map();
|
||
const { _drawDiagramCached } = loadModule(sharedMap);
|
||
|
||
const ctx = makeCtx();
|
||
// entranceT < 1 bypasses cache; entranceT = 1.0 triggers the cache write.
|
||
// Set opacity=0 to short-circuit before the cache write → use opacity=1.
|
||
_drawDiagramCached(ctx, {
|
||
name: 'Am', frets: [0, 0, 2, 2, 1, 0], nStr: 6,
|
||
inverted: false, sizeSlider: 0.5, position: 'tl',
|
||
canvasW: 600, canvasH: 400, opacity: 1, entranceT: 1.0,
|
||
lyricsBottom: 0, stackOffset: 0,
|
||
});
|
||
|
||
// The overlay must have written to sharedMap (the same ref we passed in).
|
||
assert.ok(sharedMap.size > 0,
|
||
'overlay must write to the diagRenderCache Map reference passed in via DI; ' +
|
||
'if size=0 the ref was severed (createOverlay got a different Map)');
|
||
|
||
// Simulating teardown: clear the same Map as screen.js would.
|
||
sharedMap.clear();
|
||
assert.equal(sharedMap.size, 0, 'Map cleared by teardown must now be empty');
|
||
|
||
// A second call re-populates the shared Map (not a separate internal one).
|
||
_drawDiagramCached(ctx, {
|
||
name: 'G', frets: [3, 2, 0, 0, 3, 3], nStr: 6,
|
||
inverted: false, sizeSlider: 0.5, position: 'tl',
|
||
canvasW: 600, canvasH: 400, opacity: 1, entranceT: 1.0,
|
||
lyricsBottom: 0, stackOffset: 0,
|
||
});
|
||
assert.ok(sharedMap.size > 0, 'cache repopulated via the same shared Map reference');
|
||
});
|
||
|
||
// ── 7. drawSectionHud returns 0 for no sections ───────────────────────────────
|
||
test('drawSectionHud returns 0 when sections array is empty', () => {
|
||
const { drawSectionHud } = loadModule(new Map());
|
||
const ctx = makeCtx();
|
||
const result = drawSectionHud(ctx, {
|
||
sections: [], currentTime: 10,
|
||
canvasW: 800, canvasH: 600,
|
||
});
|
||
assert.equal(result, 0);
|
||
});
|
||
|
||
// ── 8. drawLyrics returns a number ───────────────────────────────────────────
|
||
test('drawLyrics returns a finite number', () => {
|
||
const { drawLyrics } = loadModule(new Map());
|
||
const ctx = makeCtx();
|
||
const lyrics = [
|
||
{ w: 'Hel-', t: 0, d: 0.3 }, { w: 'lo+', t: 0.3, d: 0.3 },
|
||
{ w: 'World', t: 0.6, d: 0.4 },
|
||
];
|
||
const result = drawLyrics(lyrics, 0.15, ctx, 800, 600);
|
||
assert.ok(typeof result === 'number' && isFinite(result),
|
||
'drawLyrics must return a finite number (bottom Y of lyrics banner)');
|
||
});
|
||
|
||
// Recording ctx — captures fillText/roundRect/fill for discriminating render assertions.
|
||
// Only used by tests 9 and 10 below; makeCtx() remains the non-recording stub.
|
||
function makeRecordingCtx() {
|
||
const calls = [];
|
||
const base = makeCtx();
|
||
return new Proxy(base, {
|
||
get(t, prop) {
|
||
if (prop === '_calls') return calls;
|
||
if (prop === 'fillText') {
|
||
return function(text, x, y) { calls.push({ method: 'fillText', text }); };
|
||
}
|
||
if (prop === 'roundRect') {
|
||
return function(...args) { calls.push({ method: 'roundRect' }); };
|
||
}
|
||
if (prop === 'fill') {
|
||
return function() { calls.push({ method: 'fill' }); };
|
||
}
|
||
return typeof t[prop] === 'function' ? t[prop].bind(t) : t[prop];
|
||
},
|
||
set(t, prop, val) { t[prop] = val; return true; },
|
||
});
|
||
}
|
||
|
||
// ── 9. drawToneHud real rendering path — discriminating (class-killer) ────────
|
||
test('drawToneHud renders tone name and HUD card when tone state is non-empty', () => {
|
||
// Mutation that must go RED: `return 0` inserted at overlay.js:631 (Creed's exact
|
||
// injection, top of drawToneHud body after the destructure).
|
||
// With that mutation: _calls stays empty, boxH=0 → all three assertions fail → RED.
|
||
//
|
||
// Scenario: t=5, toneBase='Clean', one upcoming change at t=10 ('Lead').
|
||
const { drawToneHud } = loadModule(new Map());
|
||
const ctx = makeRecordingCtx();
|
||
|
||
const boxH = drawToneHud(ctx, {
|
||
toneBase: 'Clean',
|
||
toneChanges: [{ t: 10, name: 'Lead' }],
|
||
currentTime: 5,
|
||
canvasW: 800, canvasH: 600,
|
||
position: 'tl', sizeSlider: 0.5,
|
||
});
|
||
|
||
assert.ok(boxH > 0,
|
||
'drawToneHud must return boxH > 0 with non-empty tone state (current=Clean, next=Lead)');
|
||
const texts = ctx._calls.filter(c => c.method === 'fillText').map(c => c.text);
|
||
assert.ok(texts.some(t => t.includes('Clean')),
|
||
'drawToneHud must fillText the current tone name; got: ' + JSON.stringify(texts));
|
||
assert.ok(texts.some(t => t.includes('Lead')),
|
||
'drawToneHud must fillText the next tone name; got: ' + JSON.stringify(texts));
|
||
assert.ok(ctx._calls.some(c => c.method === 'fill'),
|
||
'drawToneHud must call ctx.fill() (background card) with non-empty state');
|
||
});
|
||
|
||
// ── 10. drawSectionHud real rendering path — discriminating (class-killer) ────
|
||
test('drawSectionHud renders section name and HUD card when sections are non-empty', () => {
|
||
// Mutation that must go RED: `return 0` inserted after the early-exit guard
|
||
// (after the `if (!sections || !sections.length) return 0;` line), gutting the
|
||
// non-empty rendering branch of drawSectionHud.
|
||
// With that mutation: _calls stays empty, boxH=0 → all three assertions fail → RED.
|
||
//
|
||
// Scenario: two sections, currentTime in the first one.
|
||
const { drawSectionHud } = loadModule(new Map());
|
||
const ctx = makeRecordingCtx();
|
||
|
||
const boxH = drawSectionHud(ctx, {
|
||
sections: [{ time: 0, name: 'Intro' }, { time: 10, name: 'Verse' }],
|
||
currentTime: 5,
|
||
canvasW: 800, canvasH: 600,
|
||
position: 'tr', sizeSlider: 0.5,
|
||
});
|
||
|
||
assert.ok(boxH > 0,
|
||
'drawSectionHud must return boxH > 0 with non-empty sections (cur=Intro, next=Verse)');
|
||
const texts = ctx._calls.filter(c => c.method === 'fillText').map(c => c.text);
|
||
assert.ok(texts.some(t => t.includes('Intro')),
|
||
'drawSectionHud must fillText the current section name; got: ' + JSON.stringify(texts));
|
||
assert.ok(texts.some(t => t.includes('Verse')),
|
||
'drawSectionHud must fillText the next section name; got: ' + JSON.stringify(texts));
|
||
assert.ok(ctx._calls.some(c => c.method === 'fill'),
|
||
'drawSectionHud must call ctx.fill() (background card) with non-empty state');
|
||
});
|