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(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 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 const overview = raw.overview as { cards: Array> } 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) }) })