Skip to content

Commit 1ee57cb

Browse files
committed
fix: test suite bootstrap regressions and layout test timeouts
1 parent 0d18ef2 commit 1ee57cb

103 files changed

Lines changed: 833 additions & 144 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/document-api/src/types/story.types.test.ts

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,42 @@ describe('isStoryLocator', () => {
7474
expect(isStoryLocator({ kind: 'story', storyType: 'unknown' })).toBe(false);
7575
});
7676

77+
it('returns false for an incomplete headerFooterSlot locator', () => {
78+
expect(isStoryLocator({ kind: 'story', storyType: 'headerFooterSlot' })).toBe(false);
79+
});
80+
81+
it('returns false for a headerFooterSlot locator missing its section', () => {
82+
expect(
83+
isStoryLocator({
84+
kind: 'story',
85+
storyType: 'headerFooterSlot',
86+
headerFooterKind: 'header',
87+
variant: 'default',
88+
}),
89+
).toBe(false);
90+
});
91+
92+
it('returns false for a headerFooterSlot locator with an invalid resolution', () => {
93+
expect(
94+
isStoryLocator({
95+
kind: 'story',
96+
storyType: 'headerFooterSlot',
97+
section: { kind: 'section', sectionId: 'sec1' },
98+
headerFooterKind: 'header',
99+
variant: 'default',
100+
resolution: 'sideways',
101+
}),
102+
).toBe(false);
103+
});
104+
105+
it('returns false for a headerFooterPart locator missing refId', () => {
106+
expect(isStoryLocator({ kind: 'story', storyType: 'headerFooterPart' })).toBe(false);
107+
});
108+
109+
it('returns false for a footnote locator missing noteId', () => {
110+
expect(isStoryLocator({ kind: 'story', storyType: 'footnote' })).toBe(false);
111+
});
112+
77113
it('returns false for primitives', () => {
78114
expect(isStoryLocator('body')).toBe(false);
79115
expect(isStoryLocator(42)).toBe(false);
@@ -112,7 +148,9 @@ describe('storyLocatorToKey', () => {
112148
});
113149

114150
it('produces correct key for headerFooterSlot', () => {
115-
expect(storyLocatorToKey(hfSlotLocator)).toBe('story:headerFooterSlot:sec1:header:default');
151+
expect(storyLocatorToKey(hfSlotLocator)).toBe(
152+
'story:headerFooterSlot:sec1:header:default:effective:materializeIfInherited',
153+
);
116154
});
117155

118156
it('produces correct key for headerFooterSlot with different variants', () => {
@@ -123,7 +161,23 @@ describe('storyLocatorToKey', () => {
123161
headerFooterKind: 'footer',
124162
variant: 'even',
125163
};
126-
expect(storyLocatorToKey(evenFooter)).toBe('story:headerFooterSlot:sec2:footer:even');
164+
expect(storyLocatorToKey(evenFooter)).toBe(
165+
'story:headerFooterSlot:sec2:footer:even:effective:materializeIfInherited',
166+
);
167+
});
168+
169+
it('includes explicit headerFooterSlot resolution and onWrite values in the key', () => {
170+
const explicitSlot: HeaderFooterSlotStoryLocator = {
171+
kind: 'story',
172+
storyType: 'headerFooterSlot',
173+
section: { kind: 'section', sectionId: 'sec2' },
174+
headerFooterKind: 'footer',
175+
variant: 'even',
176+
resolution: 'explicit',
177+
onWrite: 'error',
178+
};
179+
180+
expect(storyLocatorToKey(explicitSlot)).toBe('story:headerFooterSlot:sec2:footer:even:explicit:error');
127181
});
128182

129183
it('produces correct key for headerFooterPart', () => {

packages/document-api/src/types/story.types.ts

Lines changed: 102 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,25 @@ import type { SectionAddress } from '../sections/sections.types.js';
1616
/** All recognized story types. */
1717
export const STORY_TYPES = ['body', 'headerFooterSlot', 'headerFooterPart', 'footnote', 'endnote'] as const;
1818

19+
/** Valid header/footer story kinds. */
20+
export const STORY_HEADER_FOOTER_KINDS = ['header', 'footer'] as const;
21+
22+
/** Valid header/footer slot variants. */
23+
export const STORY_HEADER_FOOTER_VARIANTS = ['default', 'first', 'even'] as const;
24+
25+
/** Valid header/footer slot resolution modes. */
26+
export const STORY_HEADER_FOOTER_RESOLUTIONS = ['effective', 'explicit'] as const;
27+
28+
/** Valid header/footer slot write modes. */
29+
export const STORY_HEADER_FOOTER_ON_WRITE_VALUES = ['materializeIfInherited', 'editResolvedPart', 'error'] as const;
30+
1931
export type StoryType = (typeof STORY_TYPES)[number];
2032

33+
export type StoryHeaderFooterKind = (typeof STORY_HEADER_FOOTER_KINDS)[number];
34+
export type StoryHeaderFooterVariant = (typeof STORY_HEADER_FOOTER_VARIANTS)[number];
35+
export type StoryHeaderFooterResolution = (typeof STORY_HEADER_FOOTER_RESOLUTIONS)[number];
36+
export type StoryHeaderFooterOnWrite = (typeof STORY_HEADER_FOOTER_ON_WRITE_VALUES)[number];
37+
2138
// ---------------------------------------------------------------------------
2239
// StoryLocator — discriminated union
2340
// ---------------------------------------------------------------------------
@@ -47,12 +64,12 @@ export interface HeaderFooterSlotStoryLocator {
4764
kind: 'story';
4865
storyType: 'headerFooterSlot';
4966
section: SectionAddress;
50-
headerFooterKind: 'header' | 'footer';
51-
variant: 'default' | 'first' | 'even';
67+
headerFooterKind: StoryHeaderFooterKind;
68+
variant: StoryHeaderFooterVariant;
5269
/** Resolution strategy. Defaults to `'effective'` when omitted. */
53-
resolution?: 'effective' | 'explicit';
70+
resolution?: StoryHeaderFooterResolution;
5471
/** Write behavior when the slot is inherited. Defaults to `'materializeIfInherited'`. */
55-
onWrite?: 'materializeIfInherited' | 'editResolvedPart' | 'error';
72+
onWrite?: StoryHeaderFooterOnWrite;
5673
}
5774

5875
/**
@@ -100,16 +117,34 @@ export type StoryLocator =
100117
/**
101118
* Type guard — returns `true` if `value` is a valid {@link StoryLocator}.
102119
*
103-
* Checks structural shape: `kind === 'story'` and `storyType` is a known value.
120+
* Checks the full discriminated-union shape so malformed partial locators do
121+
* not leak through validation and fail later with raw property-access errors.
104122
*/
105123
export function isStoryLocator(value: unknown): value is StoryLocator {
106-
if (typeof value !== 'object' || value === null) return false;
107-
const obj = value as Record<string, unknown>;
108-
return (
109-
obj.kind === 'story' &&
110-
typeof obj.storyType === 'string' &&
111-
(STORY_TYPES as readonly string[]).includes(obj.storyType)
112-
);
124+
if (!isObjectRecord(value) || value.kind !== 'story' || !isStringEnumMember(value.storyType, STORY_TYPES)) {
125+
return false;
126+
}
127+
128+
switch (value.storyType) {
129+
case 'body':
130+
return true;
131+
132+
case 'headerFooterSlot':
133+
return (
134+
isSectionAddress(value.section) &&
135+
isStringEnumMember(value.headerFooterKind, STORY_HEADER_FOOTER_KINDS) &&
136+
isStringEnumMember(value.variant, STORY_HEADER_FOOTER_VARIANTS) &&
137+
isOptionalStringEnumMember(value.resolution, STORY_HEADER_FOOTER_RESOLUTIONS) &&
138+
isOptionalStringEnumMember(value.onWrite, STORY_HEADER_FOOTER_ON_WRITE_VALUES)
139+
);
140+
141+
case 'headerFooterPart':
142+
return isNonEmptyString(value.refId);
143+
144+
case 'footnote':
145+
case 'endnote':
146+
return isNonEmptyString(value.noteId);
147+
}
113148
}
114149

115150
/**
@@ -119,6 +154,24 @@ export function isBodyStory(locator: StoryLocator): locator is BodyStoryLocator
119154
return locator.storyType === 'body';
120155
}
121156

157+
/**
158+
* Returns the effective resolution mode for a header/footer slot locator.
159+
*/
160+
export function getStoryHeaderFooterResolution(
161+
locator: Pick<HeaderFooterSlotStoryLocator, 'resolution'>,
162+
): StoryHeaderFooterResolution {
163+
return locator.resolution ?? 'effective';
164+
}
165+
166+
/**
167+
* Returns the effective write mode for a header/footer slot locator.
168+
*/
169+
export function getStoryHeaderFooterOnWrite(
170+
locator: Pick<HeaderFooterSlotStoryLocator, 'onWrite'>,
171+
): StoryHeaderFooterOnWrite {
172+
return locator.onWrite ?? 'materializeIfInherited';
173+
}
174+
122175
// ---------------------------------------------------------------------------
123176
// Canonical key serialization
124177
// ---------------------------------------------------------------------------
@@ -132,7 +185,7 @@ export function isBodyStory(locator: StoryLocator): locator is BodyStoryLocator
132185
* Examples:
133186
* - `{ kind: 'story', storyType: 'body' }` → `'story:body'`
134187
* - `{ kind: 'story', storyType: 'footnote', noteId: 'fn1' }` → `'story:footnote:fn1'`
135-
* - `{ kind: 'story', storyType: 'headerFooterSlot', section: { kind: 'section', sectionId: 's1' }, headerFooterKind: 'header', variant: 'default' }` → `'story:headerFooterSlot:s1:header:default'`
188+
* - `{ kind: 'story', storyType: 'headerFooterSlot', section: { kind: 'section', sectionId: 's1' }, headerFooterKind: 'header', variant: 'default' }` → `'story:headerFooterSlot:s1:header:default:effective:materializeIfInherited'`
136189
* - `{ kind: 'story', storyType: 'headerFooterPart', refId: 'rId7' }` → `'story:headerFooterPart:rId7'`
137190
*/
138191
export function storyLocatorToKey(locator: StoryLocator): string {
@@ -141,7 +194,14 @@ export function storyLocatorToKey(locator: StoryLocator): string {
141194
return 'story:body';
142195

143196
case 'headerFooterSlot':
144-
return `story:headerFooterSlot:${locator.section.sectionId}:${locator.headerFooterKind}:${locator.variant}`;
197+
return [
198+
'story:headerFooterSlot',
199+
locator.section.sectionId,
200+
locator.headerFooterKind,
201+
locator.variant,
202+
getStoryHeaderFooterResolution(locator),
203+
getStoryHeaderFooterOnWrite(locator),
204+
].join(':');
145205

146206
case 'headerFooterPart':
147207
return `story:headerFooterPart:${locator.refId}`;
@@ -153,3 +213,31 @@ export function storyLocatorToKey(locator: StoryLocator): string {
153213
return `story:endnote:${locator.noteId}`;
154214
}
155215
}
216+
217+
function isObjectRecord(value: unknown): value is Record<string, unknown> {
218+
return typeof value === 'object' && value !== null;
219+
}
220+
221+
function isNonEmptyString(value: unknown): value is string {
222+
return typeof value === 'string' && value.length > 0;
223+
}
224+
225+
function isStringEnumMember<const T extends readonly string[]>(value: unknown, allowed: T): value is T[number] {
226+
return typeof value === 'string' && (allowed as readonly string[]).includes(value);
227+
}
228+
229+
function isOptionalStringEnumMember<const T extends readonly string[]>(
230+
value: unknown,
231+
allowed: T,
232+
): value is T[number] | undefined {
233+
return value === undefined || isStringEnumMember(value, allowed);
234+
}
235+
236+
function isSectionAddress(value: unknown): value is SectionAddress {
237+
return (
238+
isObjectRecord(value) &&
239+
value.kind === 'section' &&
240+
typeof value.sectionId === 'string' &&
241+
value.sectionId.length > 0
242+
);
243+
}

packages/document-api/src/validation/story-validator.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,27 @@ describe('validateStoryLocator', () => {
7474
);
7575
});
7676

77+
it('throws INVALID_INPUT for an incomplete headerFooterSlot locator', () => {
78+
expect(() => validateStoryLocator({ kind: 'story', storyType: 'headerFooterSlot' }, 'input.in')).toThrow(
79+
DocumentApiValidationError,
80+
);
81+
});
82+
83+
it('throws INVALID_INPUT for a headerFooterSlot locator with an invalid section shape', () => {
84+
expect(() =>
85+
validateStoryLocator(
86+
{
87+
kind: 'story',
88+
storyType: 'headerFooterSlot',
89+
section: { kind: 'other', sectionId: 'sec1' },
90+
headerFooterKind: 'header',
91+
variant: 'default',
92+
},
93+
'input.in',
94+
),
95+
).toThrow(DocumentApiValidationError);
96+
});
97+
7798
it('throws INVALID_INPUT for a string', () => {
7899
expect(() => validateStoryLocator('body', 'input.in')).toThrow(DocumentApiValidationError);
79100
});
@@ -132,6 +153,32 @@ describe('validateStoryConsistency', () => {
132153
expect(() => validateStoryConsistency(bodyLocator, footnoteLocator, undefined)).toThrow(DocumentApiValidationError);
133154
});
134155

156+
it('throws STORY_MISMATCH when header/footer slot locators differ only by resolution mode', () => {
157+
expect(() =>
158+
validateStoryConsistency(
159+
hfSlotLocator,
160+
{
161+
...hfSlotLocator,
162+
resolution: 'explicit',
163+
},
164+
undefined,
165+
),
166+
).toThrow(DocumentApiValidationError);
167+
});
168+
169+
it('throws STORY_MISMATCH when header/footer slot locators differ only by onWrite mode', () => {
170+
expect(() =>
171+
validateStoryConsistency(
172+
hfSlotLocator,
173+
{
174+
...hfSlotLocator,
175+
onWrite: 'error',
176+
},
177+
undefined,
178+
),
179+
).toThrow(DocumentApiValidationError);
180+
});
181+
135182
it('throws INVALID_INPUT when withinStory is set', () => {
136183
try {
137184
validateStoryConsistency(undefined, undefined, bodyLocator);

packages/layout-engine/layout-bridge/test/performance.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ const describeIfRealCanvas = usingStub ? describe.skip : describe;
2626
const IS_CI = Boolean(process.env.CI);
2727
// Full-suite parallel runs cause significant CPU contention locally;
2828
// CI targets (500/700/1000 ms) are the real regression gate.
29-
const NON_CI_LATENCY_VARIANCE_FACTOR = 4;
29+
const NON_CI_LATENCY_VARIANCE_FACTOR = 8;
3030
const LATENCY_TARGETS = IS_CI
3131
? {
3232
// CI environments are slower and more variable; use generous buffers

packages/layout-engine/tests/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"@superdoc/common": "workspace:*"
1919
},
2020
"devDependencies": {
21+
"@vitejs/plugin-vue": "catalog:",
2122
"tsx": "catalog:",
2223
"jsdom": "catalog:"
2324
}

packages/layout-engine/tests/src/multi-section-page-count.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ describe('Multi-Section Document Page Count', () => {
150150

151151
// Load/convert once; this conversion is expensive under full-suite parallel runs.
152152
loadedFixture = await docxToPMJson(MULTI_SECTION_DOCX_PATH);
153-
}, 60000);
153+
}, 180000);
154154

155155
it('should render multi_section_doc.docx as exactly 4 pages', async () => {
156156
if (!loadedFixture) {

packages/layout-engine/tests/src/sd-1495-auto-page-break.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ describe('SD-1495 auto page breaks', () => {
101101
loadedFixtures.forEach(({ filename, data }) => {
102102
fixtureCache.set(filename, data);
103103
});
104-
}, 60000);
104+
}, 180000);
105105

106106
it.each(FIXTURES)('pushes heading to next page for %s', async ({ filename, headingText }) => {
107107
const cachedFixture = fixtureCache.get(filename);

packages/layout-engine/tests/vitest.config.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { defineConfig } from 'vitest/config';
2+
import vue from '@vitejs/plugin-vue';
23
import baseConfig from '../../../vitest.baseConfig';
34

45
const includeBench = process.env.VITEST_BENCH === 'true';
56

67
export default defineConfig({
78
...baseConfig,
9+
plugins: [...(baseConfig.plugins ?? []), vue()],
810
test: {
911
// Use happy-dom for faster tests (set VITEST_DOM=jsdom to use jsdom)
1012
environment: process.env.VITEST_DOM || 'happy-dom',

packages/super-editor/src/core/Editor.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import type { SchemaSummaryJSON } from './types/EditorSchema.js';
1212
import { EditorState as PmEditorState } from 'prosemirror-state';
1313
import { DOMSerializer as PmDOMSerializer } from 'prosemirror-model';
1414
import { yXmlFragmentToProseMirrorRootNode } from 'y-prosemirror';
15-
import { helpers } from '@core/index.js';
15+
import * as helpers from './helpers/index.js';
1616
import { EventEmitter } from './EventEmitter.js';
1717
import { ExtensionService } from './ExtensionService.js';
1818
import { CommandService } from './CommandService.js';

packages/super-editor/src/core/helpers/getMarksFromSelection.resolved-fallback.test.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const resolveRunProperties = vi.fn(() => ({ bold: true }));
66

77
vi.mock('@superdoc/style-engine/ooxml', () => ({
88
resolveRunProperties,
9+
TABLE_STYLE_ID_TABLE_GRID: 'TableGrid',
910
}));
1011

1112
vi.mock('@extensions/paragraph/resolvedPropertiesCache.js', () => ({

0 commit comments

Comments
 (0)