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>
This commit is contained in:
274
memento-note/tests/unit/interactive-demo-validate.test.ts
Normal file
274
memento-note/tests/unit/interactive-demo-validate.test.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
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()
|
||||
)
|
||||
})
|
||||
})
|
||||
124
memento-note/tests/unit/interactive-page-validate.test.ts
Normal file
124
memento-note/tests/unit/interactive-page-validate.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import {
|
||||
validateInteractivePage,
|
||||
type PageSpecV1,
|
||||
} from '../../lib/interactive-page'
|
||||
|
||||
const fixturePath = join(
|
||||
__dirname,
|
||||
'../../lib/interactive-page/fixtures/thermo-page.json'
|
||||
)
|
||||
|
||||
function loadFixture(): PageSpecV1 {
|
||||
return JSON.parse(readFileSync(fixturePath, 'utf8')) as PageSpecV1
|
||||
}
|
||||
|
||||
function clone<T>(v: T): T {
|
||||
return JSON.parse(JSON.stringify(v)) as T
|
||||
}
|
||||
|
||||
describe('validateInteractivePage', () => {
|
||||
test('thermo fixture passes (positive)', () => {
|
||||
const result = validateInteractivePage(loadFixture())
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.page.id).toBe('page.thermo-test')
|
||||
expect(result.page.schemaVersion).toBe(1)
|
||||
expect(result.page.sections.length).toBeGreaterThanOrEqual(3)
|
||||
}
|
||||
})
|
||||
|
||||
test('rejects unknown block type', () => {
|
||||
const page = clone(loadFixture()) as Record<string, unknown>
|
||||
const sections = page.sections as Array<{ blocks: unknown[] }>
|
||||
sections[0].blocks.push({ type: 'carousel', items: [] })
|
||||
const result = validateInteractivePage(page)
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(
|
||||
result.issues.some(
|
||||
(i) =>
|
||||
i.path.includes('blocks') ||
|
||||
i.code === 'invalid_union_discriminator' ||
|
||||
i.message.toLowerCase().includes('discriminat')
|
||||
)
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test('rejects hex color in non-human structural field', () => {
|
||||
const page = clone(loadFixture())
|
||||
// intent is enum — inject hex via overview card by forcing raw object
|
||||
const raw = clone(loadFixture()) as Record<string, unknown>
|
||||
const overview = raw.overview as {
|
||||
cards: Array<Record<string, unknown>>
|
||||
}
|
||||
overview.cards[0].intent = '#ff0000'
|
||||
const result = validateInteractivePage(raw)
|
||||
expect(result.ok).toBe(false)
|
||||
})
|
||||
|
||||
test('rejects duplicate section id', () => {
|
||||
const page = clone(loadFixture())
|
||||
page.sections[1].id = page.sections[0].id
|
||||
const result = validateInteractivePage(page)
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(result.issues.some((i) => i.code === 'duplicate_section_id')).toBe(
|
||||
true
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('rejects invalid embedded demo (delegation)', () => {
|
||||
const page = clone(loadFixture())
|
||||
const demoBlock = page.sections
|
||||
.flatMap((s) => s.blocks)
|
||||
.find((b) => b.type === 'demo')
|
||||
expect(demoBlock?.type).toBe('demo')
|
||||
if (demoBlock?.type === 'demo') {
|
||||
// @ts-expect-error intentional
|
||||
demoBlock.demo.acts[0].steps[0].pattern = 'notARealPattern'
|
||||
}
|
||||
const result = validateInteractivePage(page)
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(
|
||||
result.issues.some(
|
||||
(i) =>
|
||||
i.path.includes('demo') ||
|
||||
i.code.startsWith('demo_') ||
|
||||
i.path.includes('pattern')
|
||||
)
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test('rejects table row width mismatch', () => {
|
||||
const page = clone(loadFixture())
|
||||
const table = page.sections
|
||||
.flatMap((s) => s.blocks)
|
||||
.find((b) => b.type === 'table')
|
||||
expect(table?.type).toBe('table')
|
||||
if (table?.type === 'table') {
|
||||
table.rows[0] = ['only-one']
|
||||
}
|
||||
const result = validateInteractivePage(page)
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(result.issues.some((i) => i.code === 'table_shape')).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test('allows hex mentioned in human prose', () => {
|
||||
const page = clone(loadFixture())
|
||||
const prose = page.sections[0].blocks.find((b) => b.type === 'prose')
|
||||
if (prose?.type === 'prose') {
|
||||
prose.md = 'La couleur #FF0000 est un signal d’alarme.'
|
||||
}
|
||||
const result = validateInteractivePage(page)
|
||||
expect(result.ok).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user