Files
Momento/memento-note/tests/unit/interactive-demo-validate.test.ts
Antigravity 69c99e4f4f feat: page interactive, démos Play/Step et simulateur Carnot
Ajoute le pipeline PageSpec (validation, rendu, publication /p/{slug}),
les démos TipTap /demo, et le simulateur Carnot (modes frigo/PAC/moteur,
énergie kJ vs puissance W, unités K/°C/°F) avec correctifs d’équations KaTeX.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 17:51:43 +00:00

275 lines
8.9 KiB
TypeScript
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.
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<T>(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()
)
})
})