Files
docmost-sync/test/transforms-extra.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

165 lines
8.3 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import fc from 'fast-check';
import {
setCalloutRange,
insertMarkerAfter,
mdToInlineNodes,
commentsToFootnotes,
} from '../packages/docmost-client/src/lib/transforms.js';
// Gaps NOT covered by transforms.test.ts (test-strategy report §2). The base
// file covers the happy paths and basic edges; these add the foot-gun cases:
// TWO ranges in ONE text node (static /g lastIndex), multi-occurrence + >2-run
// offset accumulation, prefix-strip ordering, orderedList-before-heading throw,
// NUL-sentinel non-collision, empty note, and the renumber property.
const text = (t: string, marks?: any[]): any =>
marks ? { type: 'text', text: t, marks } : { type: 'text', text: t };
const para = (...runs: any[]): any => ({ type: 'paragraph', attrs: { id: 'p' }, content: runs });
const callout = (...children: any[]): any => ({ type: 'callout', content: children });
const doc = (...blocks: any[]): any => ({ type: 'doc', content: blocks });
// ===========================================================================
describe('setCalloutRange — two ranges in ONE text node (static /g lastIndex)', () => {
it('rewrites BOTH [1]…[K] ranges that share a single text node', () => {
// The base file covers two ranges across two SEPARATE text nodes; this pins
// the harder case where the static /g regex must rewrite both ranges within
// a single `.replace()` call on one string (lastIndex foot-gun).
const d = doc(callout(para(text('a [1]…[2] and b [1]…[3] end'))));
const r = setCalloutRange(d, 7);
// One text node touched, but BOTH ranges rewritten by the global replace.
expect(r.changed).toBe(1);
expect(r.doc.content[0].content[0].content[0].text).toBe('a [1]…[7] and b [1]…[7] end');
});
});
// ===========================================================================
describe('insertMarkerAfter — multi-occurrence, multi-run, block fall-through', () => {
it('inserts after the FIRST occurrence when the anchor appears twice', () => {
const d = doc(para(text('foo here and foo there')));
const r = insertMarkerAfter(d, 'foo', '[1]');
expect(r.inserted).toBe(true);
expect(r.doc.content[0].content.map((n: any) => n.text)).toEqual([
'foo',
' [1]',
' here and foo there',
]);
});
it('accumulates the offset across >2 runs and splits inside the correct run, preserving marks', () => {
// "aa "(plain) + "bb "(bold) + "cc dd"(italic); anchor "aa bb cc" ends inside
// the third run, so offset must accumulate across the first two runs.
const d = doc(
para(text('aa '), text('bb ', [{ type: 'bold' }]), text('cc dd', [{ type: 'italic' }])),
);
const r = insertMarkerAfter(d, 'aa bb cc', '[9]');
expect(r.doc.content[0].content).toEqual([
{ type: 'text', text: 'aa ' },
{ type: 'text', text: 'bb ', marks: [{ type: 'bold' }] },
{ type: 'text', text: 'cc', marks: [{ type: 'italic' }] },
{ type: 'text', text: ' [9]' },
{ type: 'text', text: ' dd', marks: [{ type: 'italic' }] },
]);
});
it('falls through a block that lacks the anchor and inserts in the next matching block', () => {
const d = doc(para(text('nothing here')), para(text('target word follows')));
const r = insertMarkerAfter(d, 'target word', '[1]');
expect(r.inserted).toBe(true);
expect(r.doc.content[1].content.map((n: any) => n.text)).toEqual([
'target word',
' [1]',
' follows',
]);
// The first block stays untouched.
expect(r.doc.content[0].content).toEqual([text('nothing here')]);
});
});
// ===========================================================================
describe('mdToInlineNodes — prefix-strip ordering', () => {
it('strips a leading "комментарий:" THEN a "N." prefix (both, in that order)', () => {
// The "комментарий:" strip runs first, then the "N." strip; with both leading
// prefixes present in that order, both are removed.
expect(mdToInlineNodes('комментарий: 3. real note')).toEqual([
{ type: 'text', text: 'real note' },
]);
});
it('does NOT strip "комментарий:" when it follows the numeric prefix (order matters)', () => {
// Reversed order: only the leading "3." is at the very start, so the
// "комментарий:" (no longer leading) is preserved in the body text.
expect(mdToInlineNodes('3. комментарий: real note')).toEqual([
{ type: 'text', text: 'комментарий: real note' },
]);
});
});
// ===========================================================================
describe('commentsToFootnotes — additional edges', () => {
const HEADING = 'Примечания переводчика';
const heading = (): any => ({ type: 'heading', attrs: { id: 'h', level: 2 }, content: [text(HEADING)] });
const ol = (items: any[] = []): any => ({ type: 'orderedList', attrs: { id: 'ol' }, content: items });
const findNotesList = (d: any): any => d.content.find((n: any) => n.type === 'orderedList');
it('throws when the orderedList sits BEFORE the notes heading (not at/after it)', () => {
// The notes list must live at or after the heading; a list before the heading
// is not the notes list, so it is reported missing.
const d = doc(ol([]), para(text('body')), heading());
expect(() => commentsToFootnotes(d, [])).toThrow(/orderedList not found/);
});
it('does NOT mistake a literal "FN0" in body text for the NUL-delimited placeholder', () => {
// The placeholder is "FN<i>"; a literal "FN0" in prose must NOT be
// renumbered or consumed. Only the real anchored selection becomes "[1]".
const d = doc(para(text('press FN0 button then anchor here')), heading(), ol([]));
const r = commentsToFootnotes(d, [{ id: 'c1', content: 'note', selection: 'anchor here' }]);
const bodyText = r.doc.content[0].content.map((n: any) => n.text).join('');
expect(bodyText).toBe('press FN0 button then anchor here [1]');
expect(r.consumed).toEqual(['c1']);
});
it('builds a note item with EMPTY inline content for an empty comment body', () => {
const d = doc(para(text('anchor word here')), heading(), ol([]));
const r = commentsToFootnotes(d, [{ id: 'c1', content: '', selection: 'anchor word' }]);
expect(r.consumed).toEqual(['c1']);
const list = findNotesList(r.doc);
expect(list.content).toHaveLength(1);
// mdToInlineNodes('') -> [], so the note paragraph has empty content.
expect(list.content[0].content[0].content).toEqual([]);
});
});
// ===========================================================================
describe('commentsToFootnotes — renumber property ([1]..[K] in reading order)', () => {
const HEADING = 'Примечания переводчика';
const heading = (): any => ({ type: 'heading', attrs: { id: 'h', level: 2 }, content: [text(HEADING)] });
const ol = (): any => ({ type: 'orderedList', attrs: { id: 'ol' }, content: [] });
it('K distinct anchored comments produce body markers reading exactly [1]..[K], notes length == K', () => {
// Distinct lowercase words so each selection is unique and order is the word
// order in the body. Permuting the comment array must not change the result.
const wordArb = fc
.uniqueArray(fc.stringMatching(/^[a-z]{3,6}$/), { minLength: 1, maxLength: 6 })
.filter((ws) => ws.length >= 1);
fc.assert(
fc.property(wordArb, fc.boolean(), (words, reverse) => {
const body = { type: 'paragraph', attrs: { id: 'p' }, content: [text(words.join(' ') + ' end')] };
const d = doc(body, heading(), ol());
const comments = words.map((w, i) => ({ id: 'c' + i, content: 'note ' + i, selection: w }));
if (reverse) comments.reverse(); // comment-array order must not matter
const r = commentsToFootnotes(d, comments);
const bodyText = r.doc.content[0].content.map((n: any) => n.text ?? '').join('');
const markers = [...bodyText.matchAll(/\[(\d+)\]/g)].map((m) => Number(m[1]));
const K = words.length;
// Markers read 1..K in reading order regardless of comment-array order.
expect(markers).toEqual(Array.from({ length: K }, (_, i) => i + 1));
const notes = r.doc.content.find((n: any) => n.type === 'orderedList');
expect(notes.content).toHaveLength(K);
expect(r.consumed).toHaveLength(K);
}),
{ numRuns: 100 },
);
});
});