palabras: tests que fijan los invariantes de la auditoría

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
dev 3 months ago
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' });
});
});

@ -11,6 +11,7 @@ import { createSomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte
import { FieldProvider } from '../field';
import {
canonicalSlashType,
PalabrasBubbleMenuProvider,
PalabrasCommandButtonProvider,
PalabrasContentProvider,
@ -20,6 +21,7 @@ import {
PalabrasProvider,
PalabrasSlashMenuProvider
} from './palabras-provider.svelte';
import { canonicalBlockType } from '$uix/eidos/components/palabras/palabras-block-types';
import type { PalabrasCommandName } from './types';
import {
createCodeBlock,
@ -2423,6 +2425,48 @@ describe('PalabrasProvider', () => {
}
});
it('filters slash menu items by the insertableBlocks allowlist', () => {
const { dom } = installSomaHarness();
const root = document.createElement('div');
document.body.append(root);
const opts = palabrasOpts(root);
const { result, cleanup } = withEffectRoot(() => {
const provider = PalabrasProvider.create(opts);
return { provider };
});
const ids = () => result.provider.slashMenuItems.map((item) => item.id);
// No allowlist → every default insertable entry (query defaults to ''),
// so a disallowed-by-family id like code-block is present here.
opts.insertableBlocks.current = undefined;
flushSync();
const allIds = ids();
expect(allIds).toContain('code-block');
expect(allIds).toContain('heading-1');
expect(allIds).toContain('paragraph');
// Allowlist heading + paragraph → only those canonical families survive.
opts.insertableBlocks.current = ['heading', 'paragraph'];
flushSync();
const filteredIds = ids();
expect(filteredIds).toContain('heading-1');
expect(filteredIds).toContain('paragraph');
expect(filteredIds).not.toContain('code-block');
expect(filteredIds).not.toContain('image');
expect(filteredIds).not.toContain('table');
expect(allIds.length).toBeGreaterThan(filteredIds.length);
// Empty allowlist → nothing is insertable.
opts.insertableBlocks.current = [];
flushSync();
expect(result.provider.slashMenuItems).toHaveLength(0);
cleanup();
dom.dispose();
});
describe('uploadImage (file-picker upload pipeline)', () => {
/** jsdom ships no `URL.createObjectURL` — stub it with unique fake
* blob URLs (uniqueness is what the swap-by-src matching relies on). */
@ -2579,3 +2623,30 @@ describe('PalabrasProvider', () => {
});
});
});
describe('canonical block-type parity — soma slash vs eidos blocks', () => {
// `canonicalSlashType` (soma) and `canonicalBlockType` (eidos) are deliberately
// duplicated across the soma/eidos layer boundary (soma must not import eidos).
// This guard fails if the two ever drift.
it('agrees on the canonical type for every representative registry id', () => {
const ids = [
'heading-1',
'heading-2',
'heading-3',
'ordered-list',
'unordered-list',
'check-list',
'code-block',
'paragraph',
'quote',
'image',
'callout',
'divider',
'table',
'columns'
];
for (const id of ids) {
expect(canonicalSlashType(id)).toBe(canonicalBlockType(id));
}
});
});

@ -3407,7 +3407,7 @@ function isEmptyEditableTextBlock(block: unknown): boolean {
// blocks appear automatically.
/** Registry id → canonical block type — the `insertableBlocks` allowlist vocab
* (`heading-1`→`heading`, `unordered-list`→`list`, `code-block`→`code`). */
function canonicalSlashType(id: string): string {
export function canonicalSlashType(id: string): string {
if (id.startsWith('heading')) return 'heading';
if (id.endsWith('-list')) return 'list';
if (id === 'code-block') return 'code';

Loading…
Cancel
Save

Powered by TurnKey Linux.