Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions tests/behavior/helpers/sdt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,8 @@ export interface InlineSdtSnapshot {
sdtContent: string | null;
sdtPos: number;
docText: string;
docSize: number;
paragraphCount: number;
}

/** Snapshot the current selection plus the existence/content/position of inline SDT `sdtId`. */
Expand Down Expand Up @@ -257,6 +259,71 @@ export async function getInlineSdtSnapshot(page: Page, sdtId: string): Promise<I
sdtContent,
sdtPos,
docText: state.doc.textContent,
docSize: state.doc.content.size,
paragraphCount: state.doc.content.childCount,
};
}, sdtId);
}

// ---------------------------------------------------------------------------
// Parity axis helpers. Pure functions that derive a Word-parity-contract axis
// value from a snapshot (+ the SDT range), using the same vocabulary as the
// word-api contracts (see parity-contracts/TRANSLATION.md). Keeping them as
// pure compute functions makes them unit-testable without a browser and makes
// each parity spec a one-liner: expect(selectionScope(snap, sdt)).toBe(...).
// ---------------------------------------------------------------------------

export type SelectionScope =
| 'collapsed'
| 'cc-content'
| 'whole-content-control'
| 'within-cc'
| 'cc-and-beyond'
| 'whole-document'
| 'outside-cc';

/** Classify the current selection relative to the SDT range (current, post-edit). */
export function selectionScope(snap: InlineSdtSnapshot, range: InlineSdtRange): SelectionScope {
if (snap.empty) return 'collapsed';
if (snap.from <= 1 && snap.to >= snap.docSize - 1) return 'whole-document';
if (snap.from === range.start && snap.to === range.end) return 'cc-content';
if (snap.from === range.pos && snap.to === range.nodeEnd) return 'whole-content-control';
if (snap.from >= range.start && snap.to <= range.end) return 'within-cc';
if (snap.from < range.end && snap.to > range.start) return 'cc-and-beyond';
return 'outside-cc';
}

export type ContentControlLifecycle = 'preserved' | 'emptied' | 'deleted' | 'created' | 'none';

/** Classify what happened to the SDT wrapper between two snapshots. */
export function contentControlLifecycle(before: InlineSdtSnapshot, after: InlineSdtSnapshot): ContentControlLifecycle {
if (before.sdtExists && !after.sdtExists) return 'deleted';
if (!before.sdtExists && after.sdtExists) return 'created';
if (before.sdtExists && after.sdtExists) {
const wasNonEmpty = !!before.sdtContent;
const nowEmpty = !after.sdtContent;
if (wasNonEmpty && nowEmpty) return 'emptied';
return 'preserved';
}
return 'none';
}

export type CaretLocation = 'inside-cc' | 'before-cc' | 'after-cc' | 'outside-cc';

/** Collapsed-caret position relative to the SDT range; null when the selection is a range. */
export function caretLocation(snap: InlineSdtSnapshot, range: InlineSdtRange): CaretLocation | null {
if (!snap.empty) return null;
if (snap.from >= range.start && snap.from <= range.end) return 'inside-cc';
if (snap.from <= range.pos) return 'before-cc';
if (snap.from >= range.nodeEnd) return 'after-cc';
return 'outside-cc';
}

export type BodyMutation = 'none' | 'text-changed' | 'structure-changed';

/** Whole-document body text / paragraph change between two snapshots (excludes CC lifecycle). */
export function bodyMutation(before: InlineSdtSnapshot, after: InlineSdtSnapshot): BodyMutation {
if (before.paragraphCount !== after.paragraphCount) return 'structure-changed';
if (before.docText !== after.docText) return 'text-changed';
return 'none';
}
120 changes: 120 additions & 0 deletions tests/behavior/tests/helpers/sdt-parity.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { test, expect } from '@playwright/test';
Comment thread
caio-pizzol marked this conversation as resolved.
Outdated
import {
selectionScope,
contentControlLifecycle,
caretLocation,
bodyMutation,
type InlineSdtSnapshot,
type InlineSdtRange,
} from '../../helpers/sdt.js';

/**
* Pure unit tests for the parity axis helpers - no browser. Synthetic
* snapshots exercise each axis value so the consumer specs can rely on these
* one-liners instead of re-deriving from/to math (which is where the
* edge-direction bug crept in earlier).
*/

// Inline SDT at pos 8: node [8,24), content [9,23). " After" follows.
const RANGE: InlineSdtRange = { id: '1', pos: 8, start: 9, end: 23, nodeEnd: 24, content: 'inline value' };

function snap(p: Partial<InlineSdtSnapshot>): InlineSdtSnapshot {
return {
from: 0,
to: 0,
empty: true,
nodeType: null,
sdtExists: true,
sdtContent: 'inline value',
sdtPos: 8,
docText: 'Before inline value After',
docSize: 33,
paragraphCount: 1,
...p,
};
}

test.describe('selectionScope', () => {
test('collapsed', () => {
expect(selectionScope(snap({ from: 12, to: 12, empty: true }), RANGE)).toBe('collapsed');
});
test('cc-content (exact content range)', () => {
expect(selectionScope(snap({ from: 9, to: 23, empty: false }), RANGE)).toBe('cc-content');
});
test('whole-content-control (node incl boundaries)', () => {
expect(selectionScope(snap({ from: 8, to: 24, empty: false }), RANGE)).toBe('whole-content-control');
});
test('within-cc (sub-range of content)', () => {
expect(selectionScope(snap({ from: 10, to: 14, empty: false }), RANGE)).toBe('within-cc');
});
test('cc-and-beyond (overlaps and spills out)', () => {
expect(selectionScope(snap({ from: 12, to: 28, empty: false }), RANGE)).toBe('cc-and-beyond');
});
test('whole-document', () => {
expect(selectionScope(snap({ from: 0, to: 33, empty: false, docSize: 33 }), RANGE)).toBe('whole-document');
});
test('outside-cc', () => {
expect(selectionScope(snap({ from: 1, to: 5, empty: false }), RANGE)).toBe('outside-cc');
});
});

test.describe('contentControlLifecycle', () => {
test('preserved (present, content unchanged)', () => {
expect(contentControlLifecycle(snap({}), snap({}))).toBe('preserved');
});
test('preserved (present, content changed but not emptied)', () => {
expect(contentControlLifecycle(snap({ sdtContent: 'inline value' }), snap({ sdtContent: 'inline valu' }))).toBe(
'preserved',
);
});
test('emptied (non-empty to empty, still present)', () => {
expect(contentControlLifecycle(snap({ sdtContent: 'inline value' }), snap({ sdtContent: '' }))).toBe('emptied');
});
test('deleted (present to absent)', () => {
expect(contentControlLifecycle(snap({ sdtExists: true }), snap({ sdtExists: false, sdtContent: null }))).toBe(
'deleted',
);
});
test('created (absent to present)', () => {
expect(contentControlLifecycle(snap({ sdtExists: false, sdtContent: null }), snap({ sdtExists: true }))).toBe(
'created',
);
});
test('none (absent throughout)', () => {
expect(
contentControlLifecycle(
snap({ sdtExists: false, sdtContent: null }),
snap({ sdtExists: false, sdtContent: null }),
),
).toBe('none');
});
});

test.describe('caretLocation', () => {
test('inside-cc', () => {
expect(caretLocation(snap({ from: 15, to: 15, empty: true }), RANGE)).toBe('inside-cc');
});
test('before-cc', () => {
expect(caretLocation(snap({ from: 8, to: 8, empty: true }), RANGE)).toBe('before-cc');
});
test('after-cc', () => {
expect(caretLocation(snap({ from: 24, to: 24, empty: true }), RANGE)).toBe('after-cc');
});
test('null for a range selection', () => {
expect(caretLocation(snap({ from: 9, to: 23, empty: false }), RANGE)).toBeNull();
});
});

test.describe('bodyMutation', () => {
test('none', () => {
expect(bodyMutation(snap({}), snap({}))).toBe('none');
});
test('text-changed', () => {
expect(
bodyMutation(snap({ docText: 'Before inline value After' }), snap({ docText: 'Before inline valu After' })),
).toBe('text-changed');
});
test('structure-changed (paragraph count differs)', () => {
expect(bodyMutation(snap({ paragraphCount: 1 }), snap({ paragraphCount: 2 }))).toBe('structure-changed');
});
});
15 changes: 6 additions & 9 deletions tests/behavior/tests/sdt/sdt-select-all-inside.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import path from 'path';
import { fileURLToPath } from 'url';
import { test, expect, type SuperDocFixture } from '../../fixtures/superdoc.js';
import { getInlineSdtRange, getInlineSdtSnapshot } from '../../helpers/sdt.js';
import { getInlineSdtRange, getInlineSdtSnapshot, selectionScope } from '../../helpers/sdt.js';

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

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