import { strict as assert } from 'node:assert'; import { it } from 'node:test'; import type { RichTextNode, RichTextRoot } from '@unito/integration-api'; import type { CanRender } from '../richTextCodec.js'; import { canonicalFixtures } from '../richTextFixtures/index.js'; // A richText codec for one field: parse provider wire to canonical AST, // serialize canonical AST back to wire, and the renderability predicate // the serializer uses to decide what gets sentinel-encoded. type Field = { canRender: CanRender; parse: (wire: string) => RichTextRoot; serialize: (root: RichTextRoot) => string; }; export type ConformanceField = { key: string; module: Field }; // Given a fixture name, its AST, and the field under test, returns a reason // string marking that fixture as a documented, intentional divergence for // this integration, or `false` to require an exact round-trip. export type DivergenceReason = (name: string, ast: RichTextRoot, field: Field) => string | false; // `{type:'break'}` and `{type:'break',children:[]}` are the same canonical // node; integrations differ on whether they carry the empty array. Compare // modulo that variant so conformance flags data loss, not representation. function canonicalize(node: RichTextNode): RichTextNode { const out: RichTextNode = { ...node }; if (out.children) { if (out.children.length === 0) { delete out.children; } else { out.children = out.children.map(canonicalize); } } return out; } function normalize(root: RichTextRoot): RichTextRoot { return { type: 'root', children: (root.children ?? []).map(canonicalize) }; } // For every canonical fixture × field, registers two checks: // • no-throw — `serialize` then `parse` must not throw (catches a // missing handler or an unsupported node type). // • round-trip — `parse(serialize(ast))` must deep-equal the original // (catches silent data loss), modulo the break-children // representation variant `normalize` folds away. // A fixture marked by `divergence` is instead asserted to STILL diverge // (strict xfail): if the codec is later fixed and it round-trips again, // the test fails so the now-stale divergence is removed. Nothing is ever // silently skipped, and a divergence reason that no longer holds is a // failure. // // Example — a codec that renders tables but drops their header-cell flag: // // describeConformance(listFields(), name => // name === 'tables' ? 'header-cell flag is not preserved' : false, // ); export function describeConformance(fields: ConformanceField[], divergence: DivergenceReason = () => false): void { if (fields.length === 0) { throw new Error('describeConformance received no fields — listFields() returned empty'); } for (const [name, ast] of Object.entries(canonicalFixtures)) { for (const { key, module: field } of fields) { it(`no-throw: ${name} → ${key}`, () => { field.parse(field.serialize(ast)); }); const reason = divergence(name, ast, field); it(`${reason ? 'divergence' : 'round-trip'}: ${name} → ${key}`, () => { const actual = normalize(field.parse(field.serialize(ast))); const expected = normalize(ast); if (reason) { assert.notDeepEqual(actual, expected, `no longer diverges — remove divergence (${reason})`); } else { assert.deepEqual(actual, expected); } }); } } }