From f786f5c526b78aecbef4453e0c3250d55982ffcb Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Thu, 28 May 2026 20:56:51 -0300 Subject: [PATCH 1/3] test(behavior): add parity axis helpers for SDT contracts (SD-3237/SD-3218) Adds pure compute helpers that derive a Word-parity-contract axis value from a snapshot, using the same vocabulary as the word-api contracts: - selectionScope(snap, range) - contentControlLifecycle(before, after) - caretLocation(snap, range) - bodyMutation(before, after) Each parity spec becomes a one-liner (expect(selectionScope(snap, sdt)).toBe(...)) instead of re-deriving from/to math - which is where the earlier edge-direction bug crept in. Extends getInlineSdtSnapshot with docSize + paragraphCount so whole-document scope and structure-changed mutation are computable. Pure unit-tested (tests/helpers/sdt-parity.spec.ts, no browser) and demonstrated against real behavior by refactoring sdt-select-all-inside to use selectionScope. --- tests/behavior/helpers/sdt.ts | 67 ++++++++++ .../behavior/tests/helpers/sdt-parity.spec.ts | 120 ++++++++++++++++++ .../tests/sdt/sdt-select-all-inside.spec.ts | 15 +-- 3 files changed, 193 insertions(+), 9 deletions(-) create mode 100644 tests/behavior/tests/helpers/sdt-parity.spec.ts diff --git a/tests/behavior/helpers/sdt.ts b/tests/behavior/helpers/sdt.ts index 0650197b7b..a0ae230c08 100644 --- a/tests/behavior/helpers/sdt.ts +++ b/tests/behavior/helpers/sdt.ts @@ -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`. */ @@ -257,6 +259,71 @@ export async function getInlineSdtSnapshot(page: Page, sdtId: string): Promise= 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'; +} diff --git a/tests/behavior/tests/helpers/sdt-parity.spec.ts b/tests/behavior/tests/helpers/sdt-parity.spec.ts new file mode 100644 index 0000000000..70d45e3da4 --- /dev/null +++ b/tests/behavior/tests/helpers/sdt-parity.spec.ts @@ -0,0 +1,120 @@ +import { test, expect } from '@playwright/test'; +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 { + 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'); + }); +}); diff --git a/tests/behavior/tests/sdt/sdt-select-all-inside.spec.ts b/tests/behavior/tests/sdt/sdt-select-all-inside.spec.ts index d3e048295e..ca90efe05f 100644 --- a/tests/behavior/tests/sdt/sdt-select-all-inside.spec.ts +++ b/tests/behavior/tests/sdt/sdt-select-all-inside.spec.ts @@ -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. @@ -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', () => { @@ -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); }); } }); From 5ec01734acdad57933ccf07f6fcdb10571bd6c12 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Fri, 29 May 2026 06:27:08 -0300 Subject: [PATCH 2/3] docs(behavior): clarify bodyMutation excludes wrapper lifecycle, not content text It compares whole-doc textContent, so SDT content changes (e.g. emptying) do count as text-changed; only wrapper lifecycle is excluded. --- tests/behavior/helpers/sdt.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/behavior/helpers/sdt.ts b/tests/behavior/helpers/sdt.ts index a0ae230c08..45c92af096 100644 --- a/tests/behavior/helpers/sdt.ts +++ b/tests/behavior/helpers/sdt.ts @@ -321,7 +321,12 @@ export function caretLocation(snap: InlineSdtSnapshot, range: InlineSdtRange): C export type BodyMutation = 'none' | 'text-changed' | 'structure-changed'; -/** Whole-document body text / paragraph change between two snapshots (excludes CC lifecycle). */ +/** + * Whole-document body text / paragraph change between two snapshots. Compares + * the whole `doc.textContent`, so it INCLUDES changes to the SDT's own content + * (e.g. emptying it reads as text-changed). It excludes only wrapper lifecycle + * (existence / empty-state) - that is the contentControlLifecycle axis. + */ export function bodyMutation(before: InlineSdtSnapshot, after: InlineSdtSnapshot): BodyMutation { if (before.paragraphCount !== after.paragraphCount) return 'structure-changed'; if (before.docText !== after.docText) return 'text-changed'; From c1c23f289a194017ba6b6ab7619e7ead575317fb Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Fri, 29 May 2026 06:32:23 -0300 Subject: [PATCH 3/3] test(behavior): import test/expect from the behavior fixture, not @playwright/test Per tests/behavior/AGENTS.md. The helper tests stay pure because the superdoc fixture is lazy and never requested here. --- tests/behavior/tests/helpers/sdt-parity.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/behavior/tests/helpers/sdt-parity.spec.ts b/tests/behavior/tests/helpers/sdt-parity.spec.ts index 70d45e3da4..124f06d95b 100644 --- a/tests/behavior/tests/helpers/sdt-parity.spec.ts +++ b/tests/behavior/tests/helpers/sdt-parity.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from '../../fixtures/superdoc.js'; import { selectionScope, contentControlLifecycle,