import { readFileSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, test } from 'vitest' import { assertTranslatePreservesStructure, heatmapCellIds, resolveActFinalState, validateInteractiveDemo, type InteractiveDemoV1, } from '../../lib/interactive-demo' const fixturePath = join( __dirname, '../../lib/interactive-demo/fixtures/attnres.demo.json' ) function loadFixture(): InteractiveDemoV1 { return JSON.parse(readFileSync(fixturePath, 'utf8')) as InteractiveDemoV1 } function clone(v: T): T { return JSON.parse(JSON.stringify(v)) as T } describe('validateInteractiveDemo', () => { test('AttnRes fixture passes (positive)', () => { const result = validateInteractiveDemo(loadFixture()) expect(result.ok).toBe(true) if (result.ok) { expect(result.demo.id).toBe('demo.attnres') expect(result.demo.lang).toBe('fr') expect(result.demo.schemaVersion).toBe(1) } }) test('allows hex mentioned in human speak (no false positive)', () => { const demo = clone(loadFixture()) demo.acts[0].steps[0].speak = 'La couleur #FF0000 signale l\'erreur ; aussi rgb(20,20,20).' const result = validateInteractiveDemo(demo) expect(result.ok).toBe(true) }) test('rejects hex color in non-human structural field', () => { const demo = clone(loadFixture()) // Force a structural string via chartType bypass — use node id? ids can't be hex easily. // Inject forbidden color into a future-proof structural key by mutating after parse path: // Put hex in edge style is enum-only; use disclaimer is human. // Put on a custom field that round-trips through JSON under panels payload as unknown — Zod strips unknown. // Use annotate text is human. So inject via re-validate raw object with hex in `id` of a node: demo.scene.panels[0] = { id: 'panel.trunk', type: 'svg-scene', payload: { nodes: [ { id: 'residualTrunk', label: 'ok' }, { id: '#ff0000', label: 'bad id that is also hex-like' }, ], }, } // Wait — id "#ff0000" matches FORBIDDEN_COLOR_RE when scanning the id string. // But refs will also break. Better: add hex only as scanned string on a kept structural key. // chartType is enum. triangular is enum. // Scan walks all non-human strings — node `id` is structural. const result = validateInteractiveDemo(demo) expect(result.ok).toBe(false) if (!result.ok) { expect(result.issues.some((i) => i.code === 'forbidden_color')).toBe(true) } }) test('rejects unknown pattern', () => { const demo = clone(loadFixture()) // @ts-expect-error intentional invalid pattern demo.acts[0].steps[0].pattern = 'zoomIntoDetail' const result = validateInteractiveDemo(demo) expect(result.ok).toBe(false) if (!result.ok) { expect(result.issues.some((i) => i.path.includes('pattern'))).toBe(true) } }) test('rejects unknown chartType', () => { const demo = clone(loadFixture()) const panel = demo.scene.panels[1] if (panel.type === 'chart') { // @ts-expect-error intentional panel.payload.chartType = 'pie3D' } const result = validateInteractiveDemo(demo) expect(result.ok).toBe(false) if (!result.ok) { expect(result.issues.some((i) => i.path.includes('chartType'))).toBe(true) } }) test('rejects pointTo to missing element id', () => { const demo = clone(loadFixture()) demo.acts[0].steps[0].pointTo = ['doesNotExist'] const result = validateInteractiveDemo(demo) expect(result.ok).toBe(false) if (!result.ok) { expect(result.issues.some((i) => i.code === 'unknown_element_id')).toBe(true) } }) test('rejects step id not matching act.id.sN', () => { const demo = clone(loadFixture()) demo.acts[0].steps[0].id = 's1' const result = validateInteractiveDemo(demo) expect(result.ok).toBe(false) if (!result.ok) { expect(result.issues.some((i) => i.code === 'step_id_format')).toBe(true) } }) test('rejects more than 5 scenes', () => { const demo = clone(loadFixture()) const extraScene = clone(demo.scene) for (let i = 0; i < 4; i++) { demo.acts.push({ id: `a${10 + i}`, title: `Extra ${i}`, scene: { ...extraScene, id: `scene.extra${i}` }, steps: [ { id: `a${10 + i}.s1`, speak: 'Extra step for scene cap.', pattern: 'overview', }, ], }) } const result = validateInteractiveDemo(demo) expect(result.ok).toBe(false) if (!result.ok) { expect(result.issues.some((i) => i.code === 'too_many_scenes')).toBe(true) } }) test('rejects more than 12 steps per act', () => { const demo = clone(loadFixture()) const act = demo.acts[0] while (act.steps.length < 13) { const n = act.steps.length + 1 act.steps.push({ id: `a1.s${n}`, speak: `Étape de remplissage ${n}.`, pattern: 'overview', }) } const result = validateInteractiveDemo(demo) expect(result.ok).toBe(false) if (!result.ok) { expect( result.issues.some( (i) => i.path.includes('steps') || i.message.toLowerCase().includes('array') || i.code === 'too_big' ) ).toBe(true) } }) test('rejects annotate that overlaps pointTo in same step', () => { const demo = clone(loadFixture()) demo.acts[0].steps[0].annotate = [ { kind: 'arrow', targetIds: ['e.L1.h'], scope: 'act', }, ] const result = validateInteractiveDemo(demo) expect(result.ok).toBe(false) if (!result.ok) { expect(result.issues.some((i) => i.code === 'annotate_pointto_overlap')).toBe( true ) } }) }) describe('assertTranslatePreservesStructure', () => { test('accepts lang + speak change with same ids', () => { const source = loadFixture() const translated = clone(source) translated.lang = 'en' translated.acts[0].title = 'The Problem — Depth Dilution' translated.acts[0].steps[0].speak = 'Each layer enters the residual trunk with a **fixed ×1 coefficient**.' const result = assertTranslatePreservesStructure(source, translated) expect(result.ok).toBe(true) }) test('rejects translated variant that mutates an element id', () => { const source = loadFixture() const translated = clone(source) translated.lang = 'en' translated.scene.panels[0] = { ...translated.scene.panels[0], type: 'svg-scene', payload: { nodes: [ { id: 'residualTrunkMUTATED', label: 'Residual trunk' }, { id: 'L1', label: 'Layer 1' }, { id: 'L2', label: 'Layer 2' }, ], edges: [ { id: 'e.L1.h', from: 'L1', to: 'residualTrunkMUTATED', style: 'solid', weight: 1, intent: 'flow', }, ], }, } translated.acts[0].steps[0].pointTo = ['residualTrunkMUTATED', 'e.L1.h'] const result = assertTranslatePreservesStructure(source, translated) expect(result.ok).toBe(false) if (!result.ok) { expect(result.issues.some((i) => i.code === 'translate_structure_drift')).toBe( true ) } }) test('rejects mutation of unknown structural string key (default-keep)', () => { const source = clone(loadFixture()) as InteractiveDemoV1 & { scene: InteractiveDemoV1['scene'] & { layout?: string } } const translated = clone(source) as typeof source // Simulate a future structural field present on both, then mutated on translate ;(source.scene as { layout?: string }).layout = 'horizontal' ;(translated.scene as { layout?: string }).layout = 'vertical' translated.lang = 'en' const result = assertTranslatePreservesStructure( source as InteractiveDemoV1, translated as InteractiveDemoV1 ) expect(result.ok).toBe(false) if (!result.ok) { expect(result.issues.some((i) => i.code === 'translate_structure_drift')).toBe( true ) } }) }) describe('resolveInteractiveDemo', () => { test('heatmap lower-triangular has 36 addressable cells', () => { expect(heatmapCellIds(8, 8, 'lower')).toHaveLength(36) }) test('a2 final state after s7: 36 cells revealed + 4 badges', () => { const demo = loadFixture() const final = resolveActFinalState(demo, 'a2') expect(final).toBeDefined() const revealedIds = Object.keys(final!.revealed) expect(revealedIds).toHaveLength(36) expect(revealedIds.every((id) => /^r\d+\.c\d+$/.test(id))).toBe(true) const badges = final!.annotations.filter((a) => a.kind === 'badge') expect(badges).toHaveLength(4) expect(badges.map((b) => b.badgeIndex)).toEqual([1, 2, 3, 4]) expect(badges.map((b) => b.targetIds[0]).sort()).toEqual( ['r2.c2', 'r5.c4', 'r7.c1', 'r8.c1'].sort() ) }) })