Skip to content

Commit 69854be

Browse files
fix: normalize test renderer rect origins (#407)
* fix: normalize test renderer rect origins * fix: preserve offscreen test rect origins * test: stabilize manual hot reload checks
1 parent c24561e commit 69854be

3 files changed

Lines changed: 98 additions & 12 deletions

File tree

packages/core/src/testing/__tests__/testRenderer.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,77 @@ describe("createTestRenderer", () => {
8585
assert.ok((bottom?.rect.y ?? 0) >= (top?.rect.y ?? 0) + (top?.rect.h ?? 0));
8686
});
8787

88+
test("query rect origins are clamped to the viewport while raw layout keeps scroll offsets", () => {
89+
const viewport = { cols: 5, rows: 3 };
90+
const renderer = createTestRenderer({ viewport });
91+
92+
const result = renderer.render(
93+
ui.column(
94+
{ id: "scroll", width: 5, height: 3, overflow: "scroll", scrollX: 99, scrollY: 99 },
95+
[
96+
ui.box({ id: "oversized", border: "none", mr: -4, mb: -1 }, [
97+
ui.text("123456789"),
98+
ui.text("line2"),
99+
ui.text("line3"),
100+
ui.text("line4"),
101+
]),
102+
],
103+
),
104+
);
105+
106+
for (const node of result.nodes) {
107+
assert.ok(node.rect.x >= 0, `${node.kind} x should be inside viewport`);
108+
assert.ok(node.rect.y >= 0, `${node.kind} y should be inside viewport`);
109+
assert.ok(node.rect.w >= 0, `${node.kind} width should be non-negative`);
110+
assert.ok(node.rect.h >= 0, `${node.kind} height should be non-negative`);
111+
}
112+
113+
assert.deepEqual(result.findById("oversized")?.rect, { x: 0, y: 0, w: 9, h: 4 });
114+
115+
let rawOversizedX: number | null = null;
116+
let rawOversizedY: number | null = null;
117+
result.forEachLayoutNode((rect, props) => {
118+
if (props.id === "oversized") {
119+
rawOversizedX = rect.x;
120+
rawOversizedY = rect.y;
121+
}
122+
});
123+
assert.notEqual(rawOversizedX, null);
124+
assert.notEqual(rawOversizedY, null);
125+
assert.ok((rawOversizedX ?? 0) < 0);
126+
assert.ok((rawOversizedY ?? 0) < 0);
127+
});
128+
129+
test("query rect origins preserve positive offscreen layout positions", () => {
130+
const viewport = { cols: 8, rows: 2 };
131+
const renderer = createTestRenderer({ viewport });
132+
133+
const result = renderer.render(
134+
ui.column({ gap: 0 }, [
135+
ui.box({ id: "anchor", border: "none", height: 1 }, [ui.text("anchor")]),
136+
ui.box({ id: "offscreen", border: "none", position: "absolute", left: 11, top: 4 }, [
137+
ui.text("offscreen"),
138+
]),
139+
]),
140+
);
141+
142+
const offscreen = result.findById("offscreen");
143+
assert.notEqual(offscreen, null);
144+
assert.ok((offscreen?.rect.x ?? 0) > viewport.cols);
145+
assert.ok((offscreen?.rect.y ?? 0) > viewport.rows);
146+
147+
let rawOffscreenX: number | null = null;
148+
let rawOffscreenY: number | null = null;
149+
result.forEachLayoutNode((rect, props) => {
150+
if (props.id === "offscreen") {
151+
rawOffscreenX = rect.x;
152+
rawOffscreenY = rect.y;
153+
}
154+
});
155+
assert.equal(offscreen?.rect.x, rawOffscreenX);
156+
assert.equal(offscreen?.rect.y, rawOffscreenY);
157+
});
158+
88159
test("trace callback receives render timing and detail payload", () => {
89160
const events: unknown[] = [];
90161
const renderer = createTestRenderer({

packages/core/src/testing/renderer.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,15 @@ function normalizeTheme(theme: Theme | ThemeDefinition | undefined): Theme {
130130
const EMPTY_PROPS: TestNodeProps = Object.freeze({});
131131
const EMPTY_PATH: readonly number[] = Object.freeze([]);
132132

133+
function normalizeRectOriginToViewport(rect: Rect, _viewport: TestViewport): Rect {
134+
return {
135+
x: Math.max(rect.x, 0),
136+
y: Math.max(rect.y, 0),
137+
w: rect.w,
138+
h: rect.h,
139+
};
140+
}
141+
133142
function asPropsRecord(value: unknown, mode: TestRendererMode): TestNodeProps {
134143
if (typeof value !== "object" || value === null) return EMPTY_PROPS;
135144
// Runtime mode favors lower allocation overhead for hot render paths.
@@ -219,7 +228,11 @@ class RecordingDrawlistBuilder implements DrawlistBuilder {
219228
}
220229
}
221230

222-
function collectNodes(layoutTree: LayoutTree, mode: TestRendererMode): readonly TestRenderNode[] {
231+
function collectNodes(
232+
layoutTree: LayoutTree,
233+
viewport: TestViewport,
234+
mode: TestRendererMode,
235+
): readonly TestRenderNode[] {
223236
const out: TestRenderNode[] = [];
224237

225238
const walk = (node: LayoutTree, path: readonly number[]): void => {
@@ -229,7 +242,7 @@ function collectNodes(layoutTree: LayoutTree, mode: TestRendererMode): readonly
229242

230243
const base: TestRenderNode = {
231244
kind: node.vnode.kind,
232-
rect: node.rect,
245+
rect: normalizeRectOriginToViewport(node.rect, viewport),
233246
props,
234247
id,
235248
path: mode === "runtime" ? EMPTY_PATH : Object.freeze(path.slice()),
@@ -549,10 +562,10 @@ export function createTestRenderer(opts: TestRendererOptions = {}): TestRenderer
549562

550563
const ops = builder.snapshotOps();
551564
let nodesCache: readonly TestRenderNode[] | null =
552-
mode === "test" ? collectNodes(layoutTree, mode) : null;
565+
mode === "test" ? collectNodes(layoutTree, viewport, mode) : null;
553566
const getNodes = (): readonly TestRenderNode[] => {
554567
if (nodesCache !== null) return nodesCache;
555-
nodesCache = collectNodes(layoutTree, mode);
568+
nodesCache = collectNodes(layoutTree, viewport, mode);
556569
return nodesCache;
557570
};
558571
let screenTextCache: string | null = null;

packages/node/src/__tests__/hotStateReload.test.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { type HotStateReloadErrorContext, createHotStateReload } from "../dev/ho
88

99
type State = Readonly<{ count: number }>;
1010

11+
const MANUAL_RELOAD_DEBOUNCE_MS = 10_000;
12+
1113
async function withTempDir<T>(run: (dir: string) => Promise<T> | T): Promise<T> {
1214
const dir = mkdtempSync(join(tmpdir(), "rezi-hsr-test-"));
1315
try {
@@ -192,7 +194,7 @@ test("createHotStateReload reloadNow refreshes transitive imports and swaps view
192194
},
193195
viewModule,
194196
moduleRoot: dir,
195-
debounceMs: 25,
197+
debounceMs: MANUAL_RELOAD_DEBOUNCE_MS,
196198
});
197199

198200
try {
@@ -236,7 +238,7 @@ test("createHotStateReload resolves bare package imports from nearest node_modul
236238
},
237239
viewModule,
238240
moduleRoot: src,
239-
debounceMs: 25,
241+
debounceMs: MANUAL_RELOAD_DEBOUNCE_MS,
240242
});
241243

242244
try {
@@ -276,7 +278,7 @@ test("createHotStateReload keeps previous view when reload fails", async () => {
276278
},
277279
viewModule,
278280
moduleRoot: dir,
279-
debounceMs: 25,
281+
debounceMs: MANUAL_RELOAD_DEBOUNCE_MS,
280282
onError: (_error, context) => {
281283
errors.push(context);
282284
},
@@ -471,7 +473,7 @@ test("createHotStateReload reloadNow refreshes transitive imports and swaps rout
471473
},
472474
routesModule,
473475
moduleRoot: dir,
474-
debounceMs: 25,
476+
debounceMs: MANUAL_RELOAD_DEBOUNCE_MS,
475477
});
476478

477479
try {
@@ -509,7 +511,7 @@ test("createHotStateReload keeps previous routes when route reload fails", async
509511
},
510512
routesModule,
511513
moduleRoot: dir,
512-
debounceMs: 25,
514+
debounceMs: MANUAL_RELOAD_DEBOUNCE_MS,
513515
onError: (_error, context) => {
514516
errors.push(context);
515517
},
@@ -561,7 +563,7 @@ test("createHotStateReload routes mode supports default export resolver", async
561563
},
562564
routesModule,
563565
moduleRoot: dir,
564-
debounceMs: 25,
566+
debounceMs: MANUAL_RELOAD_DEBOUNCE_MS,
565567
});
566568

567569
try {
@@ -594,7 +596,7 @@ test("createHotStateReload routes mode reports invalid exports and keeps previou
594596
},
595597
routesModule: stableModule,
596598
moduleRoot: dir,
597-
debounceMs: 25,
599+
debounceMs: MANUAL_RELOAD_DEBOUNCE_MS,
598600
onError: (_error, context) => {
599601
contexts.push(context);
600602
},
@@ -616,7 +618,7 @@ test("createHotStateReload routes mode reports invalid exports and keeps previou
616618
},
617619
routesModule: invalidModule,
618620
moduleRoot: dir,
619-
debounceMs: 25,
621+
debounceMs: MANUAL_RELOAD_DEBOUNCE_MS,
620622
onError: (_error, context) => {
621623
contexts.push(context);
622624
},

0 commit comments

Comments
 (0)