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.
330 lines
11 KiB
TypeScript
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 }]);
|
|
});
|
|
});
|
|
});
|