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:
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