Los 3 tests recomendados por la auditoría (autorados + verificados adversarialmente: cada uno falla si se revierte el invariante que fija). - normalize.test.ts (nuevo): fija el flanqueo de texto de los break. 5 casos vía normalizeDocument con factories reales: break al inicio / al final / entre dos breaks / junto a un link → siempre queda un text inline a ambos lados; break ya flanqueado → sin empties espurios. Caen si se borra el paso de normalize.ts:299-317. - palabras-provider.svelte.test.ts (+2): (a) slashMenuItems filtra por la allowlist insertableBlocks (['heading','paragraph'] excluye code-block; [] vacía; undefined = todo) — cae si se quita el filtro del motor. (b) paridad canonicalSlashType (soma) === canonicalBlockType (eidos) sobre el set real de ids del registry — cae si las dos copias byte-idénticas del cruce de capa divergen. - palabras-provider.svelte.ts: canonicalSlashType pasa a exportarse (única fuente del cambio) para poder compararla contra la de eidos. Verificado: 58 tests verdes (5 + 53); check con 0 errores en palabras. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>alpha-0.1-sec-dom
parent
0b2170d626
commit
36a9bf6b09
@ -0,0 +1,107 @@
|
||||
/**
|
||||
* normalize — break-flanking invariant lock.
|
||||
*
|
||||
* The flanking pass in `normalizeInlineChildren` (normalize.ts) rebuilds the
|
||||
* inline list so EVERY atomic `break` inline is immediately preceded AND
|
||||
* followed by a text inline — inserting an empty `{ type: 'text', text: '' }`
|
||||
* wherever a text neighbour is missing (list start, list end, between two
|
||||
* adjacent breaks, or between a break and a non-text inline such as a link).
|
||||
*
|
||||
* These cases would fail if that pass were removed.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { normalizeDocument } from './normalize';
|
||||
import { createBreak, createLink, createParagraph, createText } from './factories';
|
||||
import { PALABRAS_VERSION, type PalabrasDocument, type PalabrasInline } from '../types';
|
||||
|
||||
// Deterministic ids so nothing depends on crypto.randomUUID.
|
||||
function makeSeqIdGen(): () => string {
|
||||
let n = 0;
|
||||
return () => `id-${++n}`;
|
||||
}
|
||||
|
||||
// Wrap the inlines in a paragraph block and route through the real normalizer,
|
||||
// then read the normalized paragraph's inline children back out.
|
||||
function normalizeInlines(...inlines: PalabrasInline[]): readonly PalabrasInline[] {
|
||||
const doc: PalabrasDocument = {
|
||||
version: PALABRAS_VERSION,
|
||||
children: [createParagraph(inlines)]
|
||||
};
|
||||
const block = normalizeDocument(doc, { idGenerator: makeSeqIdGen() }).document.children[0];
|
||||
if (block.type !== 'paragraph') throw new Error('expected a paragraph block');
|
||||
return block.children;
|
||||
}
|
||||
|
||||
// The invariant itself: every break sits between two text inlines.
|
||||
function expectEveryBreakFlanked(inlines: readonly PalabrasInline[]): void {
|
||||
inlines.forEach((inline, i) => {
|
||||
if (inline.type !== 'break') return;
|
||||
expect(inlines[i - 1]?.type).toBe('text');
|
||||
expect(inlines[i + 1]?.type).toBe('text');
|
||||
});
|
||||
}
|
||||
|
||||
describe('normalize — break flanking', () => {
|
||||
it('break between two non-empty texts stays flanked with no spurious empties', () => {
|
||||
const inlines = normalizeInlines(createText('a'), createBreak(), createText('b'));
|
||||
|
||||
expectEveryBreakFlanked(inlines);
|
||||
// Structure preserved verbatim — the pass must not inject empties here.
|
||||
expect(inlines).toHaveLength(3);
|
||||
expect(inlines[0]).toMatchObject({ type: 'text', text: 'a' });
|
||||
expect(inlines[1]).toMatchObject({ type: 'break' });
|
||||
expect(inlines[2]).toMatchObject({ type: 'text', text: 'b' });
|
||||
});
|
||||
|
||||
it('break at the start gets an empty text inserted before it', () => {
|
||||
const inlines = normalizeInlines(createBreak(), createText('a'));
|
||||
|
||||
expectEveryBreakFlanked(inlines);
|
||||
expect(inlines[0]).toMatchObject({ type: 'text', text: '' });
|
||||
expect(inlines[1]).toMatchObject({ type: 'break' });
|
||||
// A break can never be the first inline.
|
||||
expect(inlines[0].type).not.toBe('break');
|
||||
});
|
||||
|
||||
it('break at the end gets an empty text inserted after it', () => {
|
||||
const inlines = normalizeInlines(createText('a'), createBreak());
|
||||
|
||||
expectEveryBreakFlanked(inlines);
|
||||
const last = inlines[inlines.length - 1];
|
||||
expect(last).toMatchObject({ type: 'text', text: '' });
|
||||
expect(inlines[inlines.length - 2]).toMatchObject({ type: 'break' });
|
||||
// A break can never be the last inline.
|
||||
expect(last.type).not.toBe('break');
|
||||
});
|
||||
|
||||
it('two consecutive breaks get an empty text inserted between them', () => {
|
||||
const inlines = normalizeInlines(
|
||||
createText('a'),
|
||||
createBreak(),
|
||||
createBreak(),
|
||||
createText('b')
|
||||
);
|
||||
|
||||
expectEveryBreakFlanked(inlines);
|
||||
const breakIndices = inlines.flatMap((inline, i) => (inline.type === 'break' ? [i] : []));
|
||||
expect(breakIndices).toHaveLength(2);
|
||||
// The two breaks are not adjacent — a text sits between them.
|
||||
expect(breakIndices[1]).toBe(breakIndices[0] + 2);
|
||||
expect(inlines[breakIndices[0] + 1]).toMatchObject({ type: 'text', text: '' });
|
||||
});
|
||||
|
||||
it('break adjacent to a link gets an empty text inserted between them', () => {
|
||||
const inlines = normalizeInlines(
|
||||
createText('a'),
|
||||
createBreak(),
|
||||
createLink('https://example.test', [createText('link')])
|
||||
);
|
||||
|
||||
expectEveryBreakFlanked(inlines);
|
||||
const breakIndex = inlines.findIndex((inline) => inline.type === 'break');
|
||||
// The inline right after the break is the inserted empty text, not the link.
|
||||
expect(inlines[breakIndex + 1]).toMatchObject({ type: 'text', text: '' });
|
||||
expect(inlines[breakIndex + 2]).toMatchObject({ type: 'link' });
|
||||
});
|
||||
});
|
||||
Loading…
Reference in new issue