|
|
|
|
@ -0,0 +1,443 @@
|
|
|
|
|
/**
|
|
|
|
|
* R4A.1 — operations-v2 foundation suite.
|
|
|
|
|
*
|
|
|
|
|
* Covers: normalizeDocumentV2 (idempotent + autogen ids + coalesce
|
|
|
|
|
* text + seed empty containers), setBlockVisual (per-type whitelist
|
|
|
|
|
* enforced + clear via undefined + omits empty visual), block-level
|
|
|
|
|
* ops (updateBlockAt, deleteBlockAt, insertBlockAt, moveBlockAt,
|
|
|
|
|
* duplicateBlockAt, moveBlockToAt).
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
|
import {
|
|
|
|
|
deleteBlockAt,
|
|
|
|
|
duplicateBlockAt,
|
|
|
|
|
insertBlockAt,
|
|
|
|
|
moveBlockAt,
|
|
|
|
|
moveBlockToAt,
|
|
|
|
|
normalizeDocumentV2,
|
|
|
|
|
setBlockVisual,
|
|
|
|
|
updateBlockAt,
|
|
|
|
|
type WordsEditorStateV2
|
|
|
|
|
} from './index';
|
|
|
|
|
import { WORDS_DOCUMENT_VERSION_V2, type WordsDocumentV2 } from '../types-v2';
|
|
|
|
|
|
|
|
|
|
// ── Deterministic id generator for tests ─────────────────────────────────
|
|
|
|
|
|
|
|
|
|
function makeSeqIdGen(): () => string {
|
|
|
|
|
let n = 0;
|
|
|
|
|
return () => `id-${++n}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function makeState(...children: WordsDocumentV2['children']): WordsEditorStateV2 {
|
|
|
|
|
return {
|
|
|
|
|
document: { version: WORDS_DOCUMENT_VERSION_V2, children },
|
|
|
|
|
selection: null,
|
|
|
|
|
activeMarks: []
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── normalizeDocumentV2 ──────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
describe('normalizeDocumentV2', () => {
|
|
|
|
|
it('autogenerates ids for blocks without one', () => {
|
|
|
|
|
const idGen = makeSeqIdGen();
|
|
|
|
|
const r = normalizeDocumentV2(
|
|
|
|
|
{
|
|
|
|
|
version: WORDS_DOCUMENT_VERSION_V2,
|
|
|
|
|
children: [
|
|
|
|
|
{ type: 'paragraph', children: [{ type: 'text', text: 'a' }] },
|
|
|
|
|
{ type: 'paragraph', children: [{ type: 'text', text: 'b' }] }
|
|
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
{ idGenerator: idGen }
|
|
|
|
|
);
|
|
|
|
|
expect(r.mutated).toBe(true);
|
|
|
|
|
expect((r.document.children[0] as { id?: string }).id).toBe('id-1');
|
|
|
|
|
expect((r.document.children[1] as { id?: string }).id).toBe('id-2');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('preserves existing ids', () => {
|
|
|
|
|
const r = normalizeDocumentV2(
|
|
|
|
|
{
|
|
|
|
|
version: WORDS_DOCUMENT_VERSION_V2,
|
|
|
|
|
children: [
|
|
|
|
|
{ type: 'paragraph', id: 'p-keep', children: [{ type: 'text', text: 'a' }] }
|
|
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
{ idGenerator: makeSeqIdGen() }
|
|
|
|
|
);
|
|
|
|
|
expect((r.document.children[0] as { id?: string }).id).toBe('p-keep');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('seeds an empty document with one paragraph', () => {
|
|
|
|
|
const r = normalizeDocumentV2(
|
|
|
|
|
{ version: WORDS_DOCUMENT_VERSION_V2, children: [] },
|
|
|
|
|
{ idGenerator: makeSeqIdGen() }
|
|
|
|
|
);
|
|
|
|
|
expect(r.mutated).toBe(true);
|
|
|
|
|
expect(r.document.children).toHaveLength(1);
|
|
|
|
|
expect(r.document.children[0].type).toBe('paragraph');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('seeds an empty list with one item', () => {
|
|
|
|
|
const r = normalizeDocumentV2(
|
|
|
|
|
{
|
|
|
|
|
version: WORDS_DOCUMENT_VERSION_V2,
|
|
|
|
|
children: [{ type: 'list', kind: 'unordered', items: [] }]
|
|
|
|
|
},
|
|
|
|
|
{ idGenerator: makeSeqIdGen() }
|
|
|
|
|
);
|
|
|
|
|
const list = r.document.children[0];
|
|
|
|
|
if (list.type !== 'list') throw new Error('expected list');
|
|
|
|
|
expect(list.items).toHaveLength(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('seeds an empty table with a 1×1 cell', () => {
|
|
|
|
|
const r = normalizeDocumentV2(
|
|
|
|
|
{
|
|
|
|
|
version: WORDS_DOCUMENT_VERSION_V2,
|
|
|
|
|
children: [{ type: 'table', rows: [] }]
|
|
|
|
|
},
|
|
|
|
|
{ idGenerator: makeSeqIdGen() }
|
|
|
|
|
);
|
|
|
|
|
const table = r.document.children[0];
|
|
|
|
|
if (table.type !== 'table') throw new Error('expected table');
|
|
|
|
|
expect(table.rows).toHaveLength(1);
|
|
|
|
|
expect(table.rows[0].cells).toHaveLength(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('seeds empty inline children with one empty text', () => {
|
|
|
|
|
const r = normalizeDocumentV2(
|
|
|
|
|
{
|
|
|
|
|
version: WORDS_DOCUMENT_VERSION_V2,
|
|
|
|
|
children: [{ type: 'paragraph', children: [] }]
|
|
|
|
|
},
|
|
|
|
|
{ idGenerator: makeSeqIdGen() }
|
|
|
|
|
);
|
|
|
|
|
const p = r.document.children[0];
|
|
|
|
|
if (p.type !== 'paragraph') throw new Error('expected paragraph');
|
|
|
|
|
expect(p.children).toHaveLength(1);
|
|
|
|
|
expect(p.children[0]).toEqual({ type: 'text', text: '' });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('coalesces adjacent text inlines with identical marks', () => {
|
|
|
|
|
const r = normalizeDocumentV2(
|
|
|
|
|
{
|
|
|
|
|
version: WORDS_DOCUMENT_VERSION_V2,
|
|
|
|
|
children: [
|
|
|
|
|
{
|
|
|
|
|
type: 'paragraph',
|
|
|
|
|
children: [
|
|
|
|
|
{ type: 'text', text: 'hel' },
|
|
|
|
|
{ type: 'text', text: 'lo ' },
|
|
|
|
|
{ type: 'text', text: 'world' }
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
{ idGenerator: makeSeqIdGen() }
|
|
|
|
|
);
|
|
|
|
|
const p = r.document.children[0];
|
|
|
|
|
if (p.type !== 'paragraph') throw new Error('expected paragraph');
|
|
|
|
|
expect(p.children).toHaveLength(1);
|
|
|
|
|
expect(p.children[0]).toEqual({ type: 'text', text: 'hello world' });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('does NOT coalesce text inlines with different marks', () => {
|
|
|
|
|
const r = normalizeDocumentV2(
|
|
|
|
|
{
|
|
|
|
|
version: WORDS_DOCUMENT_VERSION_V2,
|
|
|
|
|
children: [
|
|
|
|
|
{
|
|
|
|
|
type: 'paragraph',
|
|
|
|
|
children: [
|
|
|
|
|
{ type: 'text', text: 'a', marks: ['bold'] },
|
|
|
|
|
{ type: 'text', text: 'b' }
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
{ idGenerator: makeSeqIdGen() }
|
|
|
|
|
);
|
|
|
|
|
const p = r.document.children[0];
|
|
|
|
|
if (p.type !== 'paragraph') throw new Error('expected paragraph');
|
|
|
|
|
expect(p.children).toHaveLength(2);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('strips marks from code block children', () => {
|
|
|
|
|
const r = normalizeDocumentV2(
|
|
|
|
|
{
|
|
|
|
|
version: WORDS_DOCUMENT_VERSION_V2,
|
|
|
|
|
children: [
|
|
|
|
|
{
|
|
|
|
|
type: 'code',
|
|
|
|
|
children: [{ type: 'text', text: 'x', marks: ['bold'] }]
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
{ idGenerator: makeSeqIdGen() }
|
|
|
|
|
);
|
|
|
|
|
const code = r.document.children[0];
|
|
|
|
|
if (code.type !== 'code') throw new Error('expected code');
|
|
|
|
|
expect(code.children[0]).toEqual({ type: 'text', text: 'x' });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns the same document reference when nothing changes', () => {
|
|
|
|
|
const input: WordsDocumentV2 = {
|
|
|
|
|
version: WORDS_DOCUMENT_VERSION_V2,
|
|
|
|
|
children: [
|
|
|
|
|
{ type: 'paragraph', id: 'p-1', children: [{ type: 'text', text: 'x' }] }
|
|
|
|
|
]
|
|
|
|
|
};
|
|
|
|
|
const r = normalizeDocumentV2(input, { idGenerator: makeSeqIdGen() });
|
|
|
|
|
expect(r.mutated).toBe(false);
|
|
|
|
|
expect(r.document).toBe(input);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── setBlockVisual ───────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
describe('setBlockVisual', () => {
|
|
|
|
|
it('sets a single visual property on a paragraph', () => {
|
|
|
|
|
const state = makeState({
|
|
|
|
|
type: 'paragraph',
|
|
|
|
|
id: 'p-1',
|
|
|
|
|
children: [{ type: 'text', text: 'x' }]
|
|
|
|
|
});
|
|
|
|
|
const r = setBlockVisual(state, 0, { background: '#fafafa' });
|
|
|
|
|
expect(r.changed).toBe(true);
|
|
|
|
|
expect(
|
|
|
|
|
(r.state.document.children[0] as { visual?: { background?: string } }).visual?.background
|
|
|
|
|
).toBe('#fafafa');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('merges with existing visual', () => {
|
|
|
|
|
const state = makeState({
|
|
|
|
|
type: 'paragraph',
|
|
|
|
|
id: 'p-1',
|
|
|
|
|
children: [{ type: 'text', text: 'x' }],
|
|
|
|
|
visual: { background: '#fafafa', padding: 12 }
|
|
|
|
|
});
|
|
|
|
|
const r = setBlockVisual(state, 0, { cornerRadius: 4 });
|
|
|
|
|
const visual = (r.state.document.children[0] as { visual: any }).visual;
|
|
|
|
|
expect(visual.background).toBe('#fafafa');
|
|
|
|
|
expect(visual.padding).toBe(12);
|
|
|
|
|
expect(visual.cornerRadius).toBe(4);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('clears a property when patched with undefined', () => {
|
|
|
|
|
const state = makeState({
|
|
|
|
|
type: 'paragraph',
|
|
|
|
|
id: 'p-1',
|
|
|
|
|
children: [{ type: 'text', text: 'x' }],
|
|
|
|
|
visual: { background: '#fafafa', padding: 12 }
|
|
|
|
|
});
|
|
|
|
|
const r = setBlockVisual(state, 0, { background: undefined });
|
|
|
|
|
const visual = (r.state.document.children[0] as { visual?: any }).visual;
|
|
|
|
|
expect(visual?.background).toBeUndefined();
|
|
|
|
|
expect(visual?.padding).toBe(12);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('omits the visual key entirely when all properties cleared', () => {
|
|
|
|
|
const state = makeState({
|
|
|
|
|
type: 'paragraph',
|
|
|
|
|
id: 'p-1',
|
|
|
|
|
children: [{ type: 'text', text: 'x' }],
|
|
|
|
|
visual: { background: '#fafafa' }
|
|
|
|
|
});
|
|
|
|
|
const r = setBlockVisual(state, 0, { background: undefined });
|
|
|
|
|
expect((r.state.document.children[0] as { visual?: unknown }).visual).toBeUndefined();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('silently drops visual keys not in the per-type whitelist (P8)', () => {
|
|
|
|
|
// `shadow` is allowed on image but NOT paragraph.
|
|
|
|
|
const state = makeState({
|
|
|
|
|
type: 'paragraph',
|
|
|
|
|
id: 'p-1',
|
|
|
|
|
children: [{ type: 'text', text: 'x' }]
|
|
|
|
|
});
|
|
|
|
|
const r = setBlockVisual(state, 0, {
|
|
|
|
|
shadow: { x: 0, y: 4, blur: 8, color: '#000' }
|
|
|
|
|
} as any);
|
|
|
|
|
// Nothing applied → no change
|
|
|
|
|
expect(r.changed).toBe(false);
|
|
|
|
|
expect((r.state.document.children[0] as { visual?: unknown }).visual).toBeUndefined();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('accepts shadow on image', () => {
|
|
|
|
|
const state = makeState({ type: 'image', src: 'https://x', id: 'img-1' });
|
|
|
|
|
const r = setBlockVisual(state, 0, {
|
|
|
|
|
shadow: { x: 0, y: 4, blur: 12, color: '#00000033' }
|
|
|
|
|
});
|
|
|
|
|
expect(r.changed).toBe(true);
|
|
|
|
|
const visual = (r.state.document.children[0] as { visual: any }).visual;
|
|
|
|
|
expect(visual.shadow.blur).toBe(12);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns no-op when patch matches existing visual', () => {
|
|
|
|
|
const state = makeState({
|
|
|
|
|
type: 'image',
|
|
|
|
|
src: 'x',
|
|
|
|
|
id: 'img-1',
|
|
|
|
|
visual: { cornerRadius: 16 }
|
|
|
|
|
});
|
|
|
|
|
const r = setBlockVisual(state, 0, { cornerRadius: 16 });
|
|
|
|
|
expect(r.changed).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns no-op for invalid blockIndex', () => {
|
|
|
|
|
const state = makeState({
|
|
|
|
|
type: 'paragraph',
|
|
|
|
|
id: 'p-1',
|
|
|
|
|
children: [{ type: 'text', text: 'x' }]
|
|
|
|
|
});
|
|
|
|
|
const r = setBlockVisual(state, 99, { background: '#fafafa' });
|
|
|
|
|
expect(r.changed).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── Block-level operations ───────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
describe('updateBlockAt', () => {
|
|
|
|
|
it('shallow-merges patch into the block', () => {
|
|
|
|
|
const state = makeState({ type: 'image', src: 'x', id: 'img-1', alt: 'old' });
|
|
|
|
|
const r = updateBlockAt(state, 0, { alt: 'new', caption: 'fluffy' });
|
|
|
|
|
expect(r.changed).toBe(true);
|
|
|
|
|
const img = r.state.document.children[0] as { alt: string; caption: string };
|
|
|
|
|
expect(img.alt).toBe('new');
|
|
|
|
|
expect(img.caption).toBe('fluffy');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('refuses to change the block type', () => {
|
|
|
|
|
const state = makeState({ type: 'paragraph', id: 'p-1', children: [{ type: 'text', text: 'x' }] });
|
|
|
|
|
const r = updateBlockAt(state, 0, { type: 'heading' as never, level: 1 });
|
|
|
|
|
const block = r.state.document.children[0];
|
|
|
|
|
expect(block.type).toBe('paragraph');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns no-op for invalid blockIndex', () => {
|
|
|
|
|
const state = makeState({ type: 'paragraph', id: 'p-1', children: [{ type: 'text', text: 'x' }] });
|
|
|
|
|
const r = updateBlockAt(state, 99, { alt: 'x' });
|
|
|
|
|
expect(r.changed).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('deleteBlockAt', () => {
|
|
|
|
|
it('removes the block at the given index', () => {
|
|
|
|
|
const state = makeState(
|
|
|
|
|
{ type: 'paragraph', id: 'a', children: [{ type: 'text', text: 'a' }] },
|
|
|
|
|
{ type: 'paragraph', id: 'b', children: [{ type: 'text', text: 'b' }] },
|
|
|
|
|
{ type: 'paragraph', id: 'c', children: [{ type: 'text', text: 'c' }] }
|
|
|
|
|
);
|
|
|
|
|
const r = deleteBlockAt(state, 1);
|
|
|
|
|
expect(r.state.document.children).toHaveLength(2);
|
|
|
|
|
expect((r.state.document.children[0] as { id: string }).id).toBe('a');
|
|
|
|
|
expect((r.state.document.children[1] as { id: string }).id).toBe('c');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('seeds an empty paragraph when the last block is deleted', () => {
|
|
|
|
|
const state = makeState({
|
|
|
|
|
type: 'paragraph',
|
|
|
|
|
id: 'only',
|
|
|
|
|
children: [{ type: 'text', text: 'x' }]
|
|
|
|
|
});
|
|
|
|
|
const r = deleteBlockAt(state, 0);
|
|
|
|
|
expect(r.state.document.children).toHaveLength(1);
|
|
|
|
|
expect(r.state.document.children[0].type).toBe('paragraph');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns no-op for invalid blockIndex', () => {
|
|
|
|
|
const state = makeState({ type: 'paragraph', id: 'p', children: [{ type: 'text', text: 'x' }] });
|
|
|
|
|
const r = deleteBlockAt(state, 99);
|
|
|
|
|
expect(r.changed).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('insertBlockAt', () => {
|
|
|
|
|
it('inserts the new block at the index', () => {
|
|
|
|
|
const state = makeState(
|
|
|
|
|
{ type: 'paragraph', id: 'a', children: [{ type: 'text', text: 'a' }] },
|
|
|
|
|
{ type: 'paragraph', id: 'c', children: [{ type: 'text', text: 'c' }] }
|
|
|
|
|
);
|
|
|
|
|
const r = insertBlockAt(state, 1, {
|
|
|
|
|
type: 'paragraph',
|
|
|
|
|
id: 'b',
|
|
|
|
|
children: [{ type: 'text', text: 'b' }]
|
|
|
|
|
});
|
|
|
|
|
expect(r.state.document.children).toHaveLength(3);
|
|
|
|
|
expect((r.state.document.children[1] as { id: string }).id).toBe('b');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('clamps index above children.length', () => {
|
|
|
|
|
const state = makeState({ type: 'paragraph', id: 'a', children: [{ type: 'text', text: 'a' }] });
|
|
|
|
|
const r = insertBlockAt(state, 99, {
|
|
|
|
|
type: 'paragraph',
|
|
|
|
|
id: 'b',
|
|
|
|
|
children: [{ type: 'text', text: 'b' }]
|
|
|
|
|
});
|
|
|
|
|
expect(r.state.document.children).toHaveLength(2);
|
|
|
|
|
expect((r.state.document.children[1] as { id: string }).id).toBe('b');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('moveBlockAt', () => {
|
|
|
|
|
it('swaps a block up with its predecessor', () => {
|
|
|
|
|
const state = makeState(
|
|
|
|
|
{ type: 'paragraph', id: 'a', children: [{ type: 'text', text: 'a' }] },
|
|
|
|
|
{ type: 'paragraph', id: 'b', children: [{ type: 'text', text: 'b' }] }
|
|
|
|
|
);
|
|
|
|
|
const r = moveBlockAt(state, 1, 'up');
|
|
|
|
|
expect((r.state.document.children[0] as { id: string }).id).toBe('b');
|
|
|
|
|
expect((r.state.document.children[1] as { id: string }).id).toBe('a');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns no-op when moving past edges', () => {
|
|
|
|
|
const state = makeState({
|
|
|
|
|
type: 'paragraph',
|
|
|
|
|
id: 'a',
|
|
|
|
|
children: [{ type: 'text', text: 'a' }]
|
|
|
|
|
});
|
|
|
|
|
expect(moveBlockAt(state, 0, 'up').changed).toBe(false);
|
|
|
|
|
expect(moveBlockAt(state, 0, 'down').changed).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('duplicateBlockAt', () => {
|
|
|
|
|
it('inserts a copy immediately after the original (with fresh id)', () => {
|
|
|
|
|
const state = makeState({
|
|
|
|
|
type: 'paragraph',
|
|
|
|
|
id: 'orig',
|
|
|
|
|
children: [{ type: 'text', text: 'x' }]
|
|
|
|
|
});
|
|
|
|
|
const r = duplicateBlockAt(state, 0);
|
|
|
|
|
expect(r.state.document.children).toHaveLength(2);
|
|
|
|
|
expect((r.state.document.children[0] as { id: string }).id).toBe('orig');
|
|
|
|
|
// The duplicate gets a fresh id from normalize (NOT 'orig').
|
|
|
|
|
expect((r.state.document.children[1] as { id: string }).id).not.toBe('orig');
|
|
|
|
|
expect((r.state.document.children[1] as { id: string }).id).toBeTruthy();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('moveBlockToAt', () => {
|
|
|
|
|
it('moves block 0 to position 2', () => {
|
|
|
|
|
const state = makeState(
|
|
|
|
|
{ type: 'paragraph', id: 'a', children: [{ type: 'text', text: 'a' }] },
|
|
|
|
|
{ type: 'paragraph', id: 'b', children: [{ type: 'text', text: 'b' }] },
|
|
|
|
|
{ type: 'paragraph', id: 'c', children: [{ type: 'text', text: 'c' }] }
|
|
|
|
|
);
|
|
|
|
|
const r = moveBlockToAt(state, 0, 2);
|
|
|
|
|
expect((r.state.document.children[0] as { id: string }).id).toBe('b');
|
|
|
|
|
expect((r.state.document.children[1] as { id: string }).id).toBe('c');
|
|
|
|
|
expect((r.state.document.children[2] as { id: string }).id).toBe('a');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('no-op when from === to', () => {
|
|
|
|
|
const state = makeState({
|
|
|
|
|
type: 'paragraph',
|
|
|
|
|
id: 'a',
|
|
|
|
|
children: [{ type: 'text', text: 'a' }]
|
|
|
|
|
});
|
|
|
|
|
expect(moveBlockToAt(state, 0, 0).changed).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
});
|