You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/uix/soma/components/words/engine/operations/delete-ops.test.ts

318 lines
12 KiB

/**
* R4A.4 — deleteBackward + deleteForward tests.
*/
import { describe, expect, it } from 'vitest';
import { deleteBackward, deleteForward } from './delete-ops';
import { createState } from './text';
import { createCollapsedSelection } from '../selection';
import { resolveTextNode } from './helpers';
import { WORDS_VERSION, type WordsDocument } from '../types';
function doc(...children: WordsDocument['children']): WordsDocument {
return { version: WORDS_VERSION, children };
}
function rangeSelection(
startPath: readonly number[],
startOffset: number,
endPath: readonly number[],
endOffset: number
) {
return {
anchor: { path: startPath, offset: startOffset },
focus: { path: endPath, offset: endOffset }
};
}
// ── deleteBackward — within text ─────────────────────────────────────────
describe('deleteBackward — within text', () => {
it('deletes the char to the left of the caret', () => {
const d = doc({ type: 'paragraph', children: [{ type: 'text', text: 'hello' }] });
const sel = createCollapsedSelection([0, 0], 5);
const r = deleteBackward(createState(d, sel));
expect(r.changed).toBe(true);
expect(resolveTextNode(r.state.document, [0, 0])!.text).toBe('hell');
expect(r.state.selection?.focus.offset).toBe(4);
});
it('non-collapsed → delegates to deleteRange', () => {
const d = doc({ type: 'paragraph', children: [{ type: 'text', text: 'hello' }] });
const sel = rangeSelection([0, 0], 1, [0, 0], 4);
const r = deleteBackward(createState(d, sel));
expect(r.changed).toBe(true);
expect(resolveTextNode(r.state.document, [0, 0])!.text).toBe('ho');
});
it('caret at start of first paragraph in doc → no-op', () => {
const d = doc({ type: 'paragraph', children: [{ type: 'text', text: 'hi' }] });
const sel = createCollapsedSelection([0, 0], 0);
const r = deleteBackward(createState(d, sel));
expect(r.changed).toBe(false);
});
});
// ── deleteBackward — merge with previous block ───────────────────────────
describe('deleteBackward — merge previous top-level block', () => {
it('merges two paragraphs when at start of second', () => {
const d = doc(
{ type: 'paragraph', children: [{ type: 'text', text: 'hello' }] },
{ type: 'paragraph', children: [{ type: 'text', text: ' world' }] }
);
const sel = createCollapsedSelection([1, 0], 0);
const r = deleteBackward(createState(d, sel));
expect(r.changed).toBe(true);
expect(r.state.document.children).toHaveLength(1);
expect(resolveTextNode(r.state.document, [0, 0])!.text).toBe('hello world');
// Caret lands at the merge boundary.
expect(r.state.selection?.focus.offset).toBe(5);
});
it('merges paragraph into heading: result keeps heading type', () => {
const d = doc(
{ type: 'heading', level: 2, children: [{ type: 'text', text: 'Title' }] },
{ type: 'paragraph', children: [{ type: 'text', text: ' body' }] }
);
const sel = createCollapsedSelection([1, 0], 0);
const r = deleteBackward(createState(d, sel));
expect(r.changed).toBe(true);
expect(r.state.document.children).toHaveLength(1);
const block = r.state.document.children[0];
expect(block.type).toBe('heading');
if (block.type !== 'heading') return;
expect(block.children[0]).toMatchObject({ type: 'text', text: 'Title body' });
});
it('no-op when previous block is atomic (image)', () => {
const d = doc(
{ type: 'image', src: 'x' },
{ type: 'paragraph', children: [{ type: 'text', text: 'hi' }] }
);
const sel = createCollapsedSelection([1, 0], 0);
const r = deleteBackward(createState(d, sel));
expect(r.changed).toBe(false);
});
});
// ── deleteBackward — list ────────────────────────────────────────────────
describe('deleteBackward — list', () => {
it('item index > 0: merges into previous item', () => {
const d = doc({
type: 'list',
kind: 'unordered',
items: [
{ children: [{ type: 'text', text: 'first' }] },
{ children: [{ type: 'text', text: 'second' }] }
]
});
const sel = createCollapsedSelection([0, 1, 0], 0);
const r = deleteBackward(createState(d, sel));
expect(r.changed).toBe(true);
const list = r.state.document.children[0];
if (list.type !== 'list') throw new Error('expected list');
expect(list.items).toHaveLength(1);
expect(list.items[0].children[0]).toMatchObject({ text: 'firstsecond' });
// Caret at merge boundary.
expect(r.state.selection?.focus.path).toEqual([0, 0, 0]);
expect(r.state.selection?.focus.offset).toBe(5);
});
it('item index 0: exits the list (first item becomes paragraph)', () => {
const d = doc({
type: 'list',
kind: 'unordered',
items: [
{ children: [{ type: 'text', text: 'first' }] },
{ children: [{ type: 'text', text: 'second' }] }
]
});
const sel = createCollapsedSelection([0, 0, 0], 0);
const r = deleteBackward(createState(d, sel));
expect(r.changed).toBe(true);
// Now: [paragraph(first), list(second)]
expect(r.state.document.children).toHaveLength(2);
expect(r.state.document.children[0].type).toBe('paragraph');
expect(r.state.document.children[1].type).toBe('list');
});
it('item index 0 + only one item: becomes a single paragraph', () => {
const d = doc({
type: 'list',
kind: 'unordered',
items: [{ children: [{ type: 'text', text: 'lone' }] }]
});
const sel = createCollapsedSelection([0, 0, 0], 0);
const r = deleteBackward(createState(d, sel));
expect(r.changed).toBe(true);
expect(r.state.document.children).toHaveLength(1);
expect(r.state.document.children[0].type).toBe('paragraph');
});
});
// ── deleteBackward — table cell ──────────────────────────────────────────
describe('deleteBackward — table cell', () => {
it('cell col > 0: backspace at cell start does NOT merge across cells', () => {
const d = doc({
type: 'table',
rows: [
{
cells: [
{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'A' }] }] },
{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'B' }] }] }
]
}
]
});
// Caret at the start of the SECOND cell's first block. Cells are
// fixed grid positions — backspace here is a no-op (no cross-cell merge).
const sel = createCollapsedSelection([0, 0, 1, 0, 0], 0);
const r = deleteBackward(createState(d, sel));
expect(r.changed).toBe(false);
const table = r.state.document.children[0];
if (table.type !== 'table') throw new Error('expected table');
expect(table.rows[0].cells).toHaveLength(2);
});
it('cell col 0: no-op (does NOT merge across rows)', () => {
const d = doc({
type: 'table',
rows: [
{
cells: [{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'r0c0' }] }] }]
},
{
cells: [{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'r1c0' }] }] }]
}
]
});
const sel = createCollapsedSelection([0, 1, 0, 0], 0);
const r = deleteBackward(createState(d, sel));
expect(r.changed).toBe(false);
});
});
// ── deleteBackward — callout ─────────────────────────────────────────────
describe('deleteBackward — callout', () => {
it('merges two inner paragraphs when at start of second', () => {
const d = doc({
type: 'callout',
intent: 'risk',
children: [
{ type: 'paragraph', children: [{ type: 'text', text: 'first' }] },
{ type: 'paragraph', children: [{ type: 'text', text: 'second' }] }
]
});
const sel = createCollapsedSelection([0, 1, 0], 0);
const r = deleteBackward(createState(d, sel));
expect(r.changed).toBe(true);
const callout = r.state.document.children[0];
if (callout.type !== 'callout') throw new Error('expected callout');
expect(callout.children).toHaveLength(1);
const inner = callout.children[0];
if (inner.type !== 'paragraph') throw new Error('expected paragraph');
expect(inner.children[0]).toMatchObject({ text: 'firstsecond' });
});
it('first inner block at offset 0: no-op (does NOT exit callout)', () => {
const d = doc({
type: 'callout',
intent: 'risk',
children: [{ type: 'paragraph', children: [{ type: 'text', text: 'inside' }] }]
});
const sel = createCollapsedSelection([0, 0, 0], 0);
const r = deleteBackward(createState(d, sel));
expect(r.changed).toBe(false);
});
});
// ── deleteForward — within text ──────────────────────────────────────────
describe('deleteForward — within text', () => {
it('deletes the char to the right of the caret', () => {
const d = doc({ type: 'paragraph', children: [{ type: 'text', text: 'hello' }] });
const sel = createCollapsedSelection([0, 0], 0);
const r = deleteForward(createState(d, sel));
expect(r.changed).toBe(true);
expect(resolveTextNode(r.state.document, [0, 0])!.text).toBe('ello');
expect(r.state.selection?.focus.offset).toBe(0);
});
it('non-collapsed → delegates to deleteRange', () => {
const d = doc({ type: 'paragraph', children: [{ type: 'text', text: 'hello' }] });
const sel = rangeSelection([0, 0], 1, [0, 0], 4);
const r = deleteForward(createState(d, sel));
expect(r.changed).toBe(true);
expect(resolveTextNode(r.state.document, [0, 0])!.text).toBe('ho');
});
});
// ── deleteForward — merge with next block ────────────────────────────────
describe('deleteForward — merge next block', () => {
it('merges next paragraph into current', () => {
const d = doc(
{ type: 'paragraph', children: [{ type: 'text', text: 'hello' }] },
{ type: 'paragraph', children: [{ type: 'text', text: ' world' }] }
);
const sel = createCollapsedSelection([0, 0], 5);
const r = deleteForward(createState(d, sel));
expect(r.changed).toBe(true);
expect(r.state.document.children).toHaveLength(1);
expect(resolveTextNode(r.state.document, [0, 0])!.text).toBe('hello world');
});
it('no-op at end of last paragraph', () => {
const d = doc({ type: 'paragraph', children: [{ type: 'text', text: 'hello' }] });
const sel = createCollapsedSelection([0, 0], 5);
const r = deleteForward(createState(d, sel));
expect(r.changed).toBe(false);
});
});
describe('deleteForward — list / table', () => {
it('list: merges with next item when at end of current', () => {
const d = doc({
type: 'list',
kind: 'unordered',
items: [
{ children: [{ type: 'text', text: 'first' }] },
{ children: [{ type: 'text', text: 'second' }] }
]
});
const sel = createCollapsedSelection([0, 0, 0], 5);
const r = deleteForward(createState(d, sel));
expect(r.changed).toBe(true);
const list = r.state.document.children[0];
if (list.type !== 'list') throw new Error('expected list');
expect(list.items).toHaveLength(1);
expect(list.items[0].children[0]).toMatchObject({ text: 'firstsecond' });
});
it('table cell: deleteForward at cell end does NOT merge across cells', () => {
const d = doc({
type: 'table',
rows: [
{
cells: [
{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'A' }] }] },
{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'B' }] }] }
]
}
]
});
// Caret at the end of the FIRST cell's first block. Cells are fixed
// grid positions — deleteForward here is a no-op (no cross-cell merge).
const sel = createCollapsedSelection([0, 0, 0, 0, 0], 1);
const r = deleteForward(createState(d, sel));
expect(r.changed).toBe(false);
const table = r.state.document.children[0];
if (table.type !== 'table') throw new Error('expected table');
expect(table.rows[0].cells).toHaveLength(2);
});
});

Powered by TurnKey Linux.