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.
318 lines
12 KiB
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);
|
|
});
|
|
});
|