Skip to content

Commit 052410b

Browse files
authored
Merge pull request #3558 from superdoc-dev/caio/sdt-parity-helpers
test(behavior): parity axis helpers for SDT contracts (SD-3237/SD-3218)
2 parents ebe43cb + c1c23f2 commit 052410b

3 files changed

Lines changed: 198 additions & 9 deletions

File tree

tests/behavior/helpers/sdt.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,8 @@ export interface InlineSdtSnapshot {
229229
sdtContent: string | null;
230230
sdtPos: number;
231231
docText: string;
232+
docSize: number;
233+
paragraphCount: number;
232234
}
233235

234236
/** Snapshot the current selection plus the existence/content/position of inline SDT `sdtId`. */
@@ -257,6 +259,76 @@ export async function getInlineSdtSnapshot(page: Page, sdtId: string): Promise<I
257259
sdtContent,
258260
sdtPos,
259261
docText: state.doc.textContent,
262+
docSize: state.doc.content.size,
263+
paragraphCount: state.doc.content.childCount,
260264
};
261265
}, sdtId);
262266
}
267+
268+
// ---------------------------------------------------------------------------
269+
// Parity axis helpers. Pure functions that derive a Word-parity-contract axis
270+
// value from a snapshot (+ the SDT range), using the same vocabulary as the
271+
// word-api contracts (see parity-contracts/TRANSLATION.md). Keeping them as
272+
// pure compute functions makes them unit-testable without a browser and makes
273+
// each parity spec a one-liner: expect(selectionScope(snap, sdt)).toBe(...).
274+
// ---------------------------------------------------------------------------
275+
276+
export type SelectionScope =
277+
| 'collapsed'
278+
| 'cc-content'
279+
| 'whole-content-control'
280+
| 'within-cc'
281+
| 'cc-and-beyond'
282+
| 'whole-document'
283+
| 'outside-cc';
284+
285+
/** Classify the current selection relative to the SDT range (current, post-edit). */
286+
export function selectionScope(snap: InlineSdtSnapshot, range: InlineSdtRange): SelectionScope {
287+
if (snap.empty) return 'collapsed';
288+
if (snap.from <= 1 && snap.to >= snap.docSize - 1) return 'whole-document';
289+
if (snap.from === range.start && snap.to === range.end) return 'cc-content';
290+
if (snap.from === range.pos && snap.to === range.nodeEnd) return 'whole-content-control';
291+
if (snap.from >= range.start && snap.to <= range.end) return 'within-cc';
292+
if (snap.from < range.end && snap.to > range.start) return 'cc-and-beyond';
293+
return 'outside-cc';
294+
}
295+
296+
export type ContentControlLifecycle = 'preserved' | 'emptied' | 'deleted' | 'created' | 'none';
297+
298+
/** Classify what happened to the SDT wrapper between two snapshots. */
299+
export function contentControlLifecycle(before: InlineSdtSnapshot, after: InlineSdtSnapshot): ContentControlLifecycle {
300+
if (before.sdtExists && !after.sdtExists) return 'deleted';
301+
if (!before.sdtExists && after.sdtExists) return 'created';
302+
if (before.sdtExists && after.sdtExists) {
303+
const wasNonEmpty = !!before.sdtContent;
304+
const nowEmpty = !after.sdtContent;
305+
if (wasNonEmpty && nowEmpty) return 'emptied';
306+
return 'preserved';
307+
}
308+
return 'none';
309+
}
310+
311+
export type CaretLocation = 'inside-cc' | 'before-cc' | 'after-cc' | 'outside-cc';
312+
313+
/** Collapsed-caret position relative to the SDT range; null when the selection is a range. */
314+
export function caretLocation(snap: InlineSdtSnapshot, range: InlineSdtRange): CaretLocation | null {
315+
if (!snap.empty) return null;
316+
if (snap.from >= range.start && snap.from <= range.end) return 'inside-cc';
317+
if (snap.from <= range.pos) return 'before-cc';
318+
if (snap.from >= range.nodeEnd) return 'after-cc';
319+
return 'outside-cc';
320+
}
321+
322+
export type BodyMutation = 'none' | 'text-changed' | 'structure-changed';
323+
324+
/**
325+
* Whole-document body text / paragraph change between two snapshots. Compares
326+
* the whole `doc.textContent`, so it INCLUDES changes to the SDT's own content
327+
* (e.g. emptying it reads as text-changed). It excludes only wrapper lifecycle
328+
* (existence / empty-state) - that is the contentControlLifecycle axis.
329+
*/
330+
export function bodyMutation(before: InlineSdtSnapshot, after: InlineSdtSnapshot): BodyMutation {
331+
if (before.paragraphCount !== after.paragraphCount) return 'structure-changed';
332+
if (before.docText !== after.docText) return 'text-changed';
333+
return 'none';
334+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { test, expect } from '../../fixtures/superdoc.js';
2+
import {
3+
selectionScope,
4+
contentControlLifecycle,
5+
caretLocation,
6+
bodyMutation,
7+
type InlineSdtSnapshot,
8+
type InlineSdtRange,
9+
} from '../../helpers/sdt.js';
10+
11+
/**
12+
* Pure unit tests for the parity axis helpers - no browser. Synthetic
13+
* snapshots exercise each axis value so the consumer specs can rely on these
14+
* one-liners instead of re-deriving from/to math (which is where the
15+
* edge-direction bug crept in earlier).
16+
*/
17+
18+
// Inline SDT at pos 8: node [8,24), content [9,23). " After" follows.
19+
const RANGE: InlineSdtRange = { id: '1', pos: 8, start: 9, end: 23, nodeEnd: 24, content: 'inline value' };
20+
21+
function snap(p: Partial<InlineSdtSnapshot>): InlineSdtSnapshot {
22+
return {
23+
from: 0,
24+
to: 0,
25+
empty: true,
26+
nodeType: null,
27+
sdtExists: true,
28+
sdtContent: 'inline value',
29+
sdtPos: 8,
30+
docText: 'Before inline value After',
31+
docSize: 33,
32+
paragraphCount: 1,
33+
...p,
34+
};
35+
}
36+
37+
test.describe('selectionScope', () => {
38+
test('collapsed', () => {
39+
expect(selectionScope(snap({ from: 12, to: 12, empty: true }), RANGE)).toBe('collapsed');
40+
});
41+
test('cc-content (exact content range)', () => {
42+
expect(selectionScope(snap({ from: 9, to: 23, empty: false }), RANGE)).toBe('cc-content');
43+
});
44+
test('whole-content-control (node incl boundaries)', () => {
45+
expect(selectionScope(snap({ from: 8, to: 24, empty: false }), RANGE)).toBe('whole-content-control');
46+
});
47+
test('within-cc (sub-range of content)', () => {
48+
expect(selectionScope(snap({ from: 10, to: 14, empty: false }), RANGE)).toBe('within-cc');
49+
});
50+
test('cc-and-beyond (overlaps and spills out)', () => {
51+
expect(selectionScope(snap({ from: 12, to: 28, empty: false }), RANGE)).toBe('cc-and-beyond');
52+
});
53+
test('whole-document', () => {
54+
expect(selectionScope(snap({ from: 0, to: 33, empty: false, docSize: 33 }), RANGE)).toBe('whole-document');
55+
});
56+
test('outside-cc', () => {
57+
expect(selectionScope(snap({ from: 1, to: 5, empty: false }), RANGE)).toBe('outside-cc');
58+
});
59+
});
60+
61+
test.describe('contentControlLifecycle', () => {
62+
test('preserved (present, content unchanged)', () => {
63+
expect(contentControlLifecycle(snap({}), snap({}))).toBe('preserved');
64+
});
65+
test('preserved (present, content changed but not emptied)', () => {
66+
expect(contentControlLifecycle(snap({ sdtContent: 'inline value' }), snap({ sdtContent: 'inline valu' }))).toBe(
67+
'preserved',
68+
);
69+
});
70+
test('emptied (non-empty to empty, still present)', () => {
71+
expect(contentControlLifecycle(snap({ sdtContent: 'inline value' }), snap({ sdtContent: '' }))).toBe('emptied');
72+
});
73+
test('deleted (present to absent)', () => {
74+
expect(contentControlLifecycle(snap({ sdtExists: true }), snap({ sdtExists: false, sdtContent: null }))).toBe(
75+
'deleted',
76+
);
77+
});
78+
test('created (absent to present)', () => {
79+
expect(contentControlLifecycle(snap({ sdtExists: false, sdtContent: null }), snap({ sdtExists: true }))).toBe(
80+
'created',
81+
);
82+
});
83+
test('none (absent throughout)', () => {
84+
expect(
85+
contentControlLifecycle(
86+
snap({ sdtExists: false, sdtContent: null }),
87+
snap({ sdtExists: false, sdtContent: null }),
88+
),
89+
).toBe('none');
90+
});
91+
});
92+
93+
test.describe('caretLocation', () => {
94+
test('inside-cc', () => {
95+
expect(caretLocation(snap({ from: 15, to: 15, empty: true }), RANGE)).toBe('inside-cc');
96+
});
97+
test('before-cc', () => {
98+
expect(caretLocation(snap({ from: 8, to: 8, empty: true }), RANGE)).toBe('before-cc');
99+
});
100+
test('after-cc', () => {
101+
expect(caretLocation(snap({ from: 24, to: 24, empty: true }), RANGE)).toBe('after-cc');
102+
});
103+
test('null for a range selection', () => {
104+
expect(caretLocation(snap({ from: 9, to: 23, empty: false }), RANGE)).toBeNull();
105+
});
106+
});
107+
108+
test.describe('bodyMutation', () => {
109+
test('none', () => {
110+
expect(bodyMutation(snap({}), snap({}))).toBe('none');
111+
});
112+
test('text-changed', () => {
113+
expect(
114+
bodyMutation(snap({ docText: 'Before inline value After' }), snap({ docText: 'Before inline valu After' })),
115+
).toBe('text-changed');
116+
});
117+
test('structure-changed (paragraph count differs)', () => {
118+
expect(bodyMutation(snap({ paragraphCount: 1 }), snap({ paragraphCount: 2 }))).toBe('structure-changed');
119+
});
120+
});

tests/behavior/tests/sdt/sdt-select-all-inside.spec.ts

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import path from 'path';
22
import { fileURLToPath } from 'url';
33
import { test, expect, type SuperDocFixture } from '../../fixtures/superdoc.js';
4-
import { getInlineSdtRange, getInlineSdtSnapshot } from '../../helpers/sdt.js';
4+
import { getInlineSdtRange, getInlineSdtSnapshot, selectionScope } from '../../helpers/sdt.js';
55

66
/**
77
* Select-all (Ctrl/Cmd+A) with the caret inside an inline SDT.
@@ -31,7 +31,7 @@ async function selectAllInside(superdoc: SuperDocFixture, fixture: string) {
3131
await superdoc.waitForStable();
3232
await superdoc.press('ControlOrMeta+a');
3333
await superdoc.waitForStable();
34-
return getInlineSdtSnapshot(superdoc.page, sdt!.id);
34+
return { sdt: sdt!, snap: await getInlineSdtSnapshot(superdoc.page, sdt!.id) };
3535
}
3636

3737
test.describe('SDT select-all from inside - Word parity', () => {
@@ -41,14 +41,11 @@ test.describe('SDT select-all from inside - Word parity', () => {
4141
test(`${mode}: select-all inside the SDT selects the whole document, not just the control`, async ({
4242
superdoc,
4343
}) => {
44-
const s = await selectAllInside(superdoc, FIXTURE[mode]);
45-
expect(s.empty).toBe(false);
46-
const docSize = await superdoc.page.evaluate(() => (window as any).editor.state.doc.content.size);
47-
// selection spans the whole body, not just the SDT content
48-
expect(s.from).toBeLessThanOrEqual(1);
49-
expect(s.to).toBeGreaterThanOrEqual(docSize - 1);
44+
const { sdt, snap } = await selectAllInside(superdoc, FIXTURE[mode]);
45+
// Word's contract axis: selection escapes the control to the whole document.
46+
expect(selectionScope(snap, sdt)).toBe('whole-document');
5047
// and the SDT is untouched by a non-destructive select-all
51-
expect(s.sdtExists).toBe(true);
48+
expect(snap.sdtExists).toBe(true);
5249
});
5350
}
5451
});

0 commit comments

Comments
 (0)