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.
165 lines
8.3 KiB
TypeScript
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 " |