Files
docmost-sync/test/json-edit.test.ts
vvzvlad d9d8538846 test(sync): implement test-strategy Phase 1-2 (pure unit/golden/property), +102 tests
Work through test-strategy-report.md, high-ROI no-refactor subset (no regen).

- R-Infra: vitest resolve.alias docmost-client -> packages/docmost-client/src
  (fixes the dist-vs-src coverage artifact: canonicalize 0% -> real)
- R-Cfg-1: export parseArgs + tests
- canonicalize: align family / comment.resolved kept / link non-default +
  fixpoint & docsCanonicallyEqual reflexive/symmetric properties (0 -> 100%)
- markdown-converter golden matrix: columns/embed/audio/pdf, drawio data-align
  rule, inline-mark matrix, textAlign, escaping idempotence, table sanitization
  (61 -> 79%)
- schema parse-closures via generateJSON (TextStyle/comment/mention/Highlight/Column)
- node-ops (immutability, table edge cases, makeFreshId property), transforms
  (setCalloutRange/insertMarkerAfter/commentsToFootnotes + renumber property)
- stabilize normalize-on-write fixpoint (0 -> 100%); diff coarse-fallback;
  client-utils; firstDivergence; corpus fixtures details/columns/mention
- 593 -> 695 green; build clean; corpus STABLE

Deferred (Phase 3-4, refactor-gated): pull/collab/client-REST/git-merge integration.
2026-06-17 01:01:26 +03:00

330 lines
11 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { applyTextEdits } from '../packages/docmost-client/src/lib/json-edit.js';
// Helper: build a ProseMirror text node.
function text(value: string, extra: Record<string, unknown> = {}) {
return { type: 'text', text: value, ...extra };
}
// Helper: a simple doc with a single paragraph containing one text node.
function singleParagraph(value: string) {
return {
type: 'doc',
content: [{ type: 'paragraph', content: [text(value)] }],
};
}
describe('applyTextEdits', () => {
describe('single match', () => {
it('replaces an exact single match and reports one replacement', () => {
const doc = singleParagraph('hello world');
const { doc: result, results } = applyTextEdits(doc, [
{ find: 'world', replace: 'there' },
]);
expect(result.content[0].content[0].text).toBe('hello there');
expect(results).toEqual([{ find: 'world', replacements: 1 }]);
});
it('preserves node id/marks around the edited text', () => {
const doc = {
type: 'doc',
content: [
{
type: 'paragraph',
attrs: { id: 'p1' },
content: [
text('old value', { marks: [{ type: 'bold' }], custom: 42 }),
],
},
],
};
const { doc: result } = applyTextEdits(doc, [
{ find: 'old', replace: 'new' },
]);
const node = result.content[0].content[0];
expect(node.text).toBe('new value');
expect(node.marks).toEqual([{ type: 'bold' }]);
expect(node.custom).toBe(42);
expect(result.content[0].attrs).toEqual({ id: 'p1' });
});
});
describe('replaceAll', () => {
it('replaces every occurrence inside a single node when replaceAll is set', () => {
const doc = singleParagraph('a a a');
const { doc: result, results } = applyTextEdits(doc, [
{ find: 'a', replace: 'b', replaceAll: true },
]);
expect(result.content[0].content[0].text).toBe('b b b');
expect(results).toEqual([{ find: 'a', replacements: 3 }]);
});
it('replaces occurrences across multiple separate text nodes', () => {
const doc = {
type: 'doc',
content: [
{ type: 'paragraph', content: [text('foo and foo')] },
{ type: 'paragraph', content: [text('also foo')] },
],
};
const { doc: result, results } = applyTextEdits(doc, [
{ find: 'foo', replace: 'bar', replaceAll: true },
]);
expect(result.content[0].content[0].text).toBe('bar and bar');
expect(result.content[1].content[0].text).toBe('also bar');
// 2 + 1 occurrences counted.
expect(results).toEqual([{ find: 'foo', replacements: 3 }]);
});
});
describe('multi-match without replaceAll', () => {
it('throws reporting the match count', () => {
const doc = singleParagraph('x x x');
expect(() => applyTextEdits(doc, [{ find: 'x', replace: 'y' }])).toThrow(
/matches 3 times/,
);
});
it('counts matches across nodes when deciding to throw', () => {
const doc = {
type: 'doc',
content: [
{ type: 'paragraph', content: [text('dup')] },
{ type: 'paragraph', content: [text('dup')] },
],
};
expect(() => applyTextEdits(doc, [{ find: 'dup', replace: 'z' }])).toThrow(
/matches 2 times/,
);
});
});
describe('zero match', () => {
it('throws "text not found" when text is absent entirely', () => {
const doc = singleParagraph('hello world');
expect(() =>
applyTextEdits(doc, [{ find: 'absent', replace: 'x' }]),
).toThrow(/text not found in the document/);
});
});
describe('text split across formatting runs', () => {
it('throws a distinct "spans multiple formatting runs" error', () => {
// "hello world" exists in the concatenated text but no single text node
// contains it: it is split across a bold run and a plain run.
const doc = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
text('hello ', { marks: [{ type: 'bold' }] }),
text('world'),
],
},
],
};
expect(() =>
applyTextEdits(doc, [{ find: 'hello world', replace: 'x' }]),
).toThrow(/spans multiple formatting runs/);
});
it('does not raise the spans error when text is genuinely missing', () => {
const doc = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [text('hello ', { marks: [{ type: 'bold' }] }), text('world')],
},
],
};
// "nope" is in neither the runs nor the concatenated text.
expect(() =>
applyTextEdits(doc, [{ find: 'nope', replace: 'x' }]),
).toThrow(/text not found in the document/);
});
});
describe('empty find', () => {
it('throws on an empty find string', () => {
const doc = singleParagraph('hello');
expect(() => applyTextEdits(doc, [{ find: '', replace: 'x' }])).toThrow(
/edit\.find must be a non-empty string/,
);
});
});
describe('literal replacement (regex foot-gun)', () => {
it('inserts $& literally without regex expansion', () => {
const doc = singleParagraph('price NUM here');
const { doc: result } = applyTextEdits(doc, [
{ find: 'NUM', replace: '$&100' },
]);
// If String.replace were used, "$&" would expand to the matched "NUM".
expect(result.content[0].content[0].text).toBe('price $&100 here');
});
it('inserts $1 literally without regex expansion', () => {
const doc = singleParagraph('token TKN end');
const { doc: result } = applyTextEdits(doc, [
{ find: 'TKN', replace: 'a$1b$$c' },
]);
expect(result.content[0].content[0].text).toBe('token a$1b$$c end');
});
it('inserts $& literally for replaceAll as well', () => {
const doc = singleParagraph('K K');
const { doc: result } = applyTextEdits(doc, [
{ find: 'K', replace: '[$&]', replaceAll: true },
]);
expect(result.content[0].content[0].text).toBe('[$&] [$&]');
});
});
describe('pruning emptied nodes', () => {
it('removes a text node that becomes empty after the edit', () => {
const doc = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [text('REMOVE'), text(' keep')],
},
],
};
const { doc: result } = applyTextEdits(doc, [
{ find: 'REMOVE', replace: '' },
]);
// The emptied node is pruned, leaving only the surviving node.
expect(result.content[0].content).toHaveLength(1);
expect(result.content[0].content[0].text).toBe(' keep');
});
});
describe('immutability', () => {
it('does not mutate the input document', () => {
const doc = {
type: 'doc',
content: [
{
type: 'paragraph',
attrs: { id: 'p1' },
content: [text('hello world', { marks: [{ type: 'bold' }] })],
},
],
};
const snapshot = structuredClone(doc);
applyTextEdits(doc, [{ find: 'world', replace: 'there' }]);
expect(doc).toEqual(snapshot);
});
});
describe('deeply nested recursion', () => {
it('finds and replaces text several levels deep', () => {
const doc = {
type: 'doc',
content: [
{
type: 'bulletList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [
{
type: 'blockquote',
content: [
{
type: 'paragraph',
content: [text('deep target value')],
},
],
},
],
},
],
},
],
},
],
};
const { doc: result, results } = applyTextEdits(doc, [
{ find: 'target', replace: 'goal' },
]);
const leaf =
result.content[0].content[0].content[0].content[0].content[0]
.content[0];
expect(leaf.text).toBe('deep goal value');
expect(results).toEqual([{ find: 'target', replacements: 1 }]);
});
});
describe('multiple edits in sequence', () => {
it('applies each edit and returns a result per edit in order', () => {
const doc = singleParagraph('alpha beta gamma');
const { doc: result, results } = applyTextEdits(doc, [
{ find: 'alpha', replace: 'A' },
{ find: 'gamma', replace: 'G' },
]);
expect(result.content[0].content[0].text).toBe('A beta G');
expect(results).toEqual([
{ find: 'alpha', replacements: 1 },
{ find: 'gamma', replacements: 1 },
]);
});
it('a LATE edit failure leaves the INPUT document unmodified (no partial mutation)', () => {
// The first edit is valid but a later one is absent and throws. Because the
// function mutates only a deep copy and throws before returning, the
// caller's input must be byte-identical afterwards (no partial apply leaks).
const doc = singleParagraph('alpha beta');
const snapshot = structuredClone(doc);
expect(() =>
applyTextEdits(doc, [
{ find: 'alpha', replace: 'A' }, // would succeed
{ find: 'MISSING', replace: 'X' }, // throws "text not found"
]),
).toThrow(/text not found/);
// Input doc is untouched: the successful first edit was applied only to the
// internal copy, which was discarded when the second edit threw.
expect(doc).toEqual(snapshot);
});
});
describe('find === replace and self-containing replaceAll', () => {
it('a find === replace edit is a no-op on the text but still reports a replacement', () => {
const doc = singleParagraph('hello world');
const { doc: result, results } = applyTextEdits(doc, [
{ find: 'world', replace: 'world' },
]);
expect(result.content[0].content[0].text).toBe('hello world');
// The replacement still counts (it spliced the same value back in).
expect(results).toEqual([{ find: 'world', replacements: 1 }]);
});
it('replaceAll where the replacement CONTAINS the find ("a" -> "aa") does not re-scan', () => {
// split().join() replaces all original occurrences in one pass; it must NOT
// re-match the inserted "aa" (which would loop). "a a a" -> "aa aa aa".
const doc = singleParagraph('a a a');
const { doc: result, results } = applyTextEdits(doc, [
{ find: 'a', replace: 'aa', replaceAll: true },
]);
expect(result.content[0].content[0].text).toBe('aa aa aa');
// Exactly the 3 original occurrences are counted, not 6.
expect(results).toEqual([{ find: 'a', replacements: 3 }]);
});
});
});