Skip to content

Commit 7f2872d

Browse files
authored
[3/3] test(painter): lock ResolvedLayout-only boundary (SD-2836) (#3118)
* test(painter): lock ResolvedLayout-only boundary (SD-2836) * chore(painter): drop unused imports and skipped legacy tests (SD-2836) renderer.ts: drop unused Layout, Page, Measure, FlowBlock, ParagraphBorder type imports left over from the migration. index.test.ts: delete the skipped 'decoration item synthesis' describe block (it was protecting the synthesis path that has been removed). * chore(deps): regenerate lockfile after dropping layout-resolved runtime dep (SD-2836) Moves @superdoc/layout-resolved to devDependencies in the lockfile to match package.json, so CI's --frozen-lockfile install matches. Boundary tests still need it under devDependencies.
1 parent 198adac commit 7f2872d

7 files changed

Lines changed: 97 additions & 185 deletions

File tree

packages/layout-engine/AGENTS.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,16 @@ ProseMirror Doc → pm-adapter → FlowBlock[] → layout-engine → Layout[]
2222

2323
## Key Insight: DomPainter is "Dumb"
2424

25-
DomPainter receives pre-computed `Layout` with positioned fragments and renders them.
26-
It does NOT do layout logic - that's in `layout-engine/`.
25+
DomPainter receives a single paint-ready input — `ResolvedLayout` — with
26+
positioned fragments, pre-resolved styles, and `fragment` back-pointers on
27+
every `ResolvedPaintItem` — and renders the result to DOM. It does NOT do
28+
layout logic, measurement, or PM-adapter conversion (that's upstream in
29+
`layout-engine/` / `layout-resolved/` / `pm-adapter/`).
30+
31+
The painter has zero runtime imports from `@superdoc/pm-adapter`,
32+
`@superdoc/layout-bridge`, or `@superdoc/layout-resolved`. Architecture
33+
boundary tests in `tests/src/architecture-boundaries.test.ts` (Guard D)
34+
enforce this.
2735

2836
## Common Tasks
2937

packages/layout-engine/painters/dom/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
"@superdoc/contracts": "workspace:*",
2222
"@superdoc/dom-contract": "workspace:*",
2323
"@superdoc/font-utils": "workspace:*",
24-
"@superdoc/layout-resolved": "workspace:*",
2524
"@superdoc/preset-geometry": "workspace:*",
2625
"@superdoc/url-validation": "workspace:*"
2726
},
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* Compile-time + runtime contract lockdown for the painter's public surface.
3+
*
4+
* These assertions fail when someone reintroduces a legacy field on
5+
* `DomPainterInput`, adds a method to `DomPainterHandle`, or makes
6+
* `PageDecorationPayload.items` optional. The boundary tests in
7+
* `tests/src/architecture-boundaries.test.ts` cover the import side; this
8+
* file covers the type-shape side.
9+
*/
10+
import { describe, expectTypeOf, it } from 'vitest';
11+
import type { ResolvedLayout, ResolvedPaintItem } from '@superdoc/contracts';
12+
import type { DomPainterHandle, DomPainterInput, PageDecorationPayload } from './index.js';
13+
14+
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
15+
type AssertTrue<T extends true> = T;
16+
17+
describe('DomPainter public contract shape', () => {
18+
it('DomPainterInput is exactly { resolvedLayout: ResolvedLayout }', () => {
19+
type _Check = AssertTrue<Equal<DomPainterInput, { resolvedLayout: ResolvedLayout }>>;
20+
expectTypeOf<DomPainterInput>().toEqualTypeOf<{ resolvedLayout: ResolvedLayout }>();
21+
});
22+
23+
it('DomPainterHandle exposes only the painter-owned methods', () => {
24+
type ExpectedKeys =
25+
| 'paint'
26+
| 'setProviders'
27+
| 'setVirtualizationPins'
28+
| 'getMountedPageIndices'
29+
| 'onScroll'
30+
| 'setZoom'
31+
| 'setScrollContainer';
32+
type _Check = AssertTrue<Equal<keyof DomPainterHandle, ExpectedKeys>>;
33+
expectTypeOf<keyof DomPainterHandle>().toEqualTypeOf<ExpectedKeys>();
34+
});
35+
36+
it('PageDecorationPayload.items is required (synthesis path is gone)', () => {
37+
type ItemsType = PageDecorationPayload['items'];
38+
type _Check = AssertTrue<Equal<ItemsType, ResolvedPaintItem[]>>;
39+
expectTypeOf<ItemsType>().toEqualTypeOf<ResolvedPaintItem[]>();
40+
});
41+
});

packages/layout-engine/painters/dom/src/index.test.ts

Lines changed: 0 additions & 175 deletions
Original file line numberDiff line numberDiff line change
@@ -10857,181 +10857,6 @@ describe('applyRunDataAttributes', () => {
1085710857
});
1085810858
});
1085910859

10860-
describe.skip('decoration item synthesis (legacy, deleted in PR2 of SD-2836)', () => {
10861-
let mount: HTMLElement;
10862-
10863-
beforeEach(() => {
10864-
mount = document.createElement('div');
10865-
document.body.appendChild(mount);
10866-
});
10867-
10868-
afterEach(() => {
10869-
document.body.removeChild(mount);
10870-
});
10871-
10872-
it('synthesizes missing header items from legacy setData bridge data', () => {
10873-
const mainBlock: FlowBlock = {
10874-
kind: 'paragraph',
10875-
id: 'main-block',
10876-
runs: [{ text: 'Main', fontFamily: 'Arial', fontSize: 16, pmStart: 0, pmEnd: 4 }],
10877-
};
10878-
const mainMeasure: Measure = {
10879-
kind: 'paragraph',
10880-
lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 4, width: 40, ascent: 12, descent: 4, lineHeight: 20 }],
10881-
totalHeight: 20,
10882-
};
10883-
const headerBlock: FlowBlock = {
10884-
kind: 'paragraph',
10885-
id: 'hf-header-synth',
10886-
runs: [{ text: 'Synth Header', fontFamily: 'Arial', fontSize: 14, pmStart: 0, pmEnd: 12 }],
10887-
};
10888-
const headerMeasure: Measure = {
10889-
kind: 'paragraph',
10890-
lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 12, width: 90, ascent: 10, descent: 3, lineHeight: 16 }],
10891-
totalHeight: 16,
10892-
};
10893-
const layout: Layout = {
10894-
pageSize: { w: 400, h: 500 },
10895-
pages: [{ number: 1, fragments: [] }],
10896-
};
10897-
10898-
const painter = createDomPainter({
10899-
blocks: [mainBlock],
10900-
measures: [mainMeasure],
10901-
headerProvider: () => ({
10902-
height: 16,
10903-
offset: 0,
10904-
fragments: [{ kind: 'para', blockId: 'hf-header-synth', fromLine: 0, toLine: 1, x: 0, y: 0, width: 120 }],
10905-
}),
10906-
});
10907-
10908-
painter.setData([mainBlock], [mainMeasure], [headerBlock], [headerMeasure]);
10909-
painter.paint(layout, mount);
10910-
10911-
expect(mount.querySelector('.superdoc-page-header')?.textContent).toContain('Synth Header');
10912-
expect(mount.querySelector('.render-error-placeholder')).toBeNull();
10913-
});
10914-
10915-
it('synthesizes missing footer items from direct DomPainterInput bridge data', () => {
10916-
const footerBlock: FlowBlock = {
10917-
kind: 'paragraph',
10918-
id: 'hf-footer-synth',
10919-
runs: [{ text: 'Synth Footer', fontFamily: 'Arial', fontSize: 14, pmStart: 0, pmEnd: 12 }],
10920-
};
10921-
const footerMeasure: Measure = {
10922-
kind: 'paragraph',
10923-
lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 12, width: 88, ascent: 10, descent: 3, lineHeight: 16 }],
10924-
totalHeight: 16,
10925-
};
10926-
const layout: Layout = {
10927-
pageSize: { w: 400, h: 500 },
10928-
pages: [{ number: 1, fragments: [] }],
10929-
};
10930-
10931-
const painter = createDomPainter({
10932-
footerProvider: () => ({
10933-
height: 16,
10934-
offset: 460,
10935-
fragments: [{ kind: 'para', blockId: 'hf-footer-synth', fromLine: 0, toLine: 1, x: 0, y: 0, width: 120 }],
10936-
}),
10937-
});
10938-
10939-
painter.paint(
10940-
{
10941-
resolvedLayout: emptyResolved,
10942-
sourceLayout: layout,
10943-
footerBlocks: [footerBlock],
10944-
footerMeasures: [footerMeasure],
10945-
},
10946-
mount,
10947-
);
10948-
10949-
expect(mount.querySelector('.superdoc-page-footer')?.textContent).toContain('Synth Footer');
10950-
expect(mount.querySelector('.render-error-placeholder')).toBeNull();
10951-
});
10952-
10953-
it('validates optional decoration block/measure pairs on direct input', () => {
10954-
const painter = createDomPainter({});
10955-
const layout: Layout = {
10956-
pageSize: { w: 400, h: 500 },
10957-
pages: [{ number: 1, fragments: [] }],
10958-
};
10959-
10960-
expect(() =>
10961-
painter.paint(
10962-
{
10963-
resolvedLayout: emptyResolved,
10964-
sourceLayout: layout,
10965-
headerBlocks: [
10966-
{
10967-
kind: 'paragraph',
10968-
id: 'hf-header-invalid',
10969-
runs: [{ text: 'Invalid', fontFamily: 'Arial', fontSize: 12, pmStart: 0, pmEnd: 7 }],
10970-
},
10971-
],
10972-
},
10973-
mount,
10974-
),
10975-
).toThrow('headerBlocks and headerMeasures must both be provided or both be omitted.');
10976-
});
10977-
10978-
it('validates optional decoration block/measure pairs in setData', () => {
10979-
const painter = createDomPainter({});
10980-
10981-
expect(() =>
10982-
painter.setData(
10983-
[
10984-
{
10985-
kind: 'paragraph',
10986-
id: 'body',
10987-
runs: [{ text: 'Body', fontFamily: 'Arial', fontSize: 12, pmStart: 0, pmEnd: 4 }],
10988-
},
10989-
],
10990-
[
10991-
{
10992-
kind: 'paragraph',
10993-
lines: [
10994-
{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 4, width: 30, ascent: 10, descent: 3, lineHeight: 16 },
10995-
],
10996-
totalHeight: 16,
10997-
},
10998-
],
10999-
[
11000-
{
11001-
kind: 'paragraph',
11002-
id: 'hf-header-invalid',
11003-
runs: [{ text: 'Invalid', fontFamily: 'Arial', fontSize: 12, pmStart: 0, pmEnd: 7 }],
11004-
},
11005-
],
11006-
),
11007-
).toThrow('headerBlocks and headerMeasures must both be provided or both be omitted.');
11008-
});
11009-
11010-
it('uses setResolvedLayout for legacy layout paints', () => {
11011-
const painter = createDomPainter({});
11012-
const layout: Layout = {
11013-
pageSize: { w: 400, h: 500 },
11014-
pages: [{ number: 1, fragments: [] }],
11015-
};
11016-
11017-
painter.setResolvedLayout(emptyResolved);
11018-
11019-
expect(() => painter.paint(layout, mount)).not.toThrow();
11020-
expect(mount.querySelector('.superdoc-page')).toBeTruthy();
11021-
});
11022-
11023-
it('creates an empty resolved layout for legacy paints without block data', () => {
11024-
const painter = createDomPainter({});
11025-
const layout: Layout = {
11026-
pageSize: { w: 400, h: 500 },
11027-
pages: [{ number: 1, fragments: [] }],
11028-
};
11029-
11030-
expect(() => painter.paint(layout, mount)).not.toThrow();
11031-
expect(mount.querySelector('.superdoc-page')).toBeTruthy();
11032-
});
11033-
});
11034-
1103510860
describe('footer alignment logic', () => {
1103610861
let mount: HTMLElement;
1103710862

packages/layout-engine/painters/dom/src/renderer.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,19 +17,15 @@ import type {
1717
ImageFragment,
1818
ImageHyperlink,
1919
ImageRun,
20-
Layout,
2120
Line,
2221
LineSegment,
2322
ListBlock,
2423
ListItemFragment,
2524
ListMeasure,
26-
Measure,
27-
Page,
2825
PageMargins,
2926
ParaFragment,
3027
ParagraphAttrs,
3128
ParagraphBlock,
32-
ParagraphBorder,
3329
ParagraphMeasure,
3430
PositionedDrawingGeometry,
3531
Run,

packages/layout-engine/tests/src/architecture-boundaries.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,4 +187,47 @@ describe('architecture boundaries', () => {
187187
expectNoViolations(findRelativeImportViolations(srcDir, /from\s+['"].*layout-engine\//));
188188
});
189189
});
190+
191+
describe('Guard D: painter-dom is a dumb final renderer with no upstream dependencies', () => {
192+
it('painter-dom runtime src does not import @superdoc/pm-adapter', () => {
193+
const srcDir = path.join(LAYOUT_ENGINE_ROOT, 'painters/dom/src');
194+
expectNoViolations(findImportViolations(srcDir, '@superdoc/pm-adapter'));
195+
});
196+
197+
it('painter-dom runtime src does not import @superdoc/layout-bridge', () => {
198+
const srcDir = path.join(LAYOUT_ENGINE_ROOT, 'painters/dom/src');
199+
expectNoViolations(findImportViolations(srcDir, '@superdoc/layout-bridge'));
200+
});
201+
202+
it('painter-dom runtime src does not import @superdoc/layout-resolved (test-only utility)', () => {
203+
const srcDir = path.join(LAYOUT_ENGINE_ROOT, 'painters/dom/src');
204+
// _test-utils.ts is test-only and excluded from runtime collection. The
205+
// architecture-boundary check passes when no runtime file imports
206+
// layout-resolved.
207+
const files = collectRuntimeSources(srcDir).filter((f) => !f.endsWith('_test-utils.ts'));
208+
const violations: { file: string; line: string }[] = [];
209+
const pattern = new RegExp(`['"]@superdoc/layout-resolved(?:[/'"]|$)`);
210+
for (const file of files) {
211+
const raw = fs.readFileSync(file, 'utf-8');
212+
const processed = preprocessSource(raw);
213+
const lines = processed.split('\n');
214+
for (const ln of lines) {
215+
if (pattern.test(ln)) {
216+
violations.push({ file: path.relative(LAYOUT_ENGINE_ROOT, file), line: ln.trim() });
217+
}
218+
}
219+
}
220+
expectNoViolations(violations);
221+
});
222+
223+
it('painter-dom runtime src does not import relative pm-adapter paths', () => {
224+
const srcDir = path.join(LAYOUT_ENGINE_ROOT, 'painters/dom/src');
225+
expectNoViolations(findRelativeImportViolations(srcDir, /from\s+['"].*pm-adapter\//));
226+
});
227+
228+
it('painter-dom runtime src does not import relative layout-bridge paths', () => {
229+
const srcDir = path.join(LAYOUT_ENGINE_ROOT, 'painters/dom/src');
230+
expectNoViolations(findRelativeImportViolations(srcDir, /from\s+['"].*layout-bridge\//));
231+
});
232+
});
190233
});

pnpm-lock.yaml

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)