1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
|
import type { LayoutTree, SplitNode } from "@dispatch/layout-contract";
import { render, screen } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event";
import { gsap } from "gsap";
import { describe, expect, it, vi } from "vitest";
import { createCollapseStore, registerView, unregisterView } from "../index";
import FixtureA from "./fixtures/FixtureA.svelte";
import FixtureB from "./fixtures/FixtureB.svelte";
import FixtureC from "./fixtures/FixtureC.svelte";
import TileRoot from "./TileRoot.svelte";
// Fixture registry: two panel-eligible views + one content-only view (a THIRD
// component, so every fixture testid is unique in the rendered tree).
registerView({ id: "fix-a", label: "Alpha View", component: FixtureA });
registerView({ id: "fix-b", label: "Beta View", component: FixtureB });
registerView({ id: "fix-content", label: "Content", component: FixtureC, panelEligible: false });
function leaf(view: string): { kind: "leaf"; view: string } {
return { kind: "leaf", view };
}
/** split[ stack[leaf a (grow), leaf content], collapsible panels[leaf b] ] */
function defaultTestTree(): LayoutTree {
return {
version: 1,
root: {
kind: "split",
id: "root",
dir: "row",
children: [
{
size: 1,
node: {
kind: "stack",
id: "main",
children: [{ grow: 1, node: leaf("fix-a") }, { node: leaf("fix-content") }],
},
},
{
size: 0,
basis: 320,
collapsible: true,
node: { kind: "panels", id: "side", panels: [leaf("fix-b")] },
},
],
},
};
}
/** A bare panels root — panel EDITS without the collapsible chrome in the way. */
function panelsOnlyTree(...views: string[]): LayoutTree {
return { version: 1, root: { kind: "panels", id: "side", panels: views.map(leaf) } };
}
/** The default test tree WITH the diagnostic markers on its two root tiles. */
function markedTestTree(): LayoutTree {
return {
version: 1,
root: {
kind: "split",
id: "root",
dir: "row",
children: [
{
size: 1,
node: {
kind: "stack",
id: "main",
marker: "main",
children: [{ grow: 1, node: leaf("fix-a") }],
},
},
{
size: 0,
basis: 320,
collapsible: true,
node: { kind: "panels", id: "side", marker: "side", panels: [leaf("fix-b")] },
},
],
},
};
}
function sidePanelsOf(tree: LayoutTree | null): string[] {
if (tree === null) return [];
const side = (tree.root as SplitNode).children?.[1]?.node;
return side?.kind === "panels"
? side.panels.map((p) => p.view)
: tree.root.kind === "panels"
? tree.root.panels.map((p) => p.view)
: [];
}
describe("TileRoot", () => {
it("renders every node kind: split, stack, panels, leaf", () => {
// A FRESH collapse store per test — the store is stateful chrome.
render(TileRoot, {
props: { tree: defaultTestTree(), collapse: createCollapseStore({ viewportWidth: 1024 }) },
});
// Stack children mount their registered components.
expect(screen.getByTestId("fixture-a")).toBeInTheDocument();
expect(screen.getByTestId("fixture-c")).toBeInTheDocument();
// The panels node renders its panel leaf + the dropdown.
expect(screen.getByTestId("fixture-b")).toBeInTheDocument();
expect(screen.getByRole("combobox", { name: "Select a view" })).toBeInTheDocument();
});
it("shows a fallback tile (never a crash) for an unknown view id", () => {
const tree: LayoutTree = {
version: 1,
root: { kind: "leaf", view: "not-registered-anywhere" },
};
render(TileRoot, { props: { tree, collapse: createCollapseStore() } });
const fallback = screen.getByTestId("fallback-tile");
expect(fallback).toHaveTextContent("not-registered-anywhere");
expect(fallback).toHaveTextContent("View not registered.");
});
it("renders nothing for the titlebar slot while it is unset (D-L5)", () => {
const { container } = render(TileRoot, {
props: { tree: defaultTestTree(), collapse: createCollapseStore() },
});
expect(container.querySelectorAll("[data-testid='fallback-tile']")).toHaveLength(0);
});
it("a set titlebar renders its node (the slot works; nothing populates it today)", () => {
const tree: LayoutTree = {
version: 1,
titlebar: { kind: "leaf", view: "fix-a" },
root: { kind: "leaf", view: "fix-b" },
};
render(TileRoot, { props: { tree, collapse: createCollapseStore() } });
expect(screen.getByTestId("fixture-a")).toBeInTheDocument();
expect(screen.getByTestId("fixture-b")).toBeInTheDocument();
});
it("the collapsible child reflects the collapse store and toggles through it", async () => {
const collapse = createCollapseStore({ viewportWidth: 1024 });
const { container } = render(TileRoot, { props: { tree: defaultTestTree(), collapse } });
const aside = container.querySelector<HTMLElement>("[data-testid='collapsible-child']");
expect(aside).not.toBeNull();
// Wide viewport default: open → full basis width.
expect(aside).toHaveAttribute("data-open", "true");
expect(aside?.style.width).toBe("320px");
await collapse.toggle("side");
expect(aside).toHaveAttribute("data-open", "false");
expect(aside?.style.width).toBe("0px");
});
it("a dropdown swap emits replaceView and the owner receives the changed tree", async () => {
const user = userEvent.setup();
let changed: LayoutTree | null = null;
render(TileRoot, {
props: {
tree: panelsOnlyTree("fix-b"),
collapse: createCollapseStore(),
onTreeChange: (t) => (changed = t),
},
});
await user.selectOptions(screen.getByRole("combobox", { name: "Select a view" }), "fix-a");
expect(sidePanelsOf(changed)).toEqual(["fix-a"]);
// TileRoot is a CONTROLLED component: applying the op is the owner's job
// (App re-renders with the new tree; the test doesn't feed it back).
});
it("the + button emits insertPanel pre-selecting the first eligible view", async () => {
const user = userEvent.setup();
let changed: LayoutTree | null = null;
render(TileRoot, {
props: {
tree: panelsOnlyTree("fix-b"),
collapse: createCollapseStore(),
onTreeChange: (t) => (changed = t),
},
});
await user.click(screen.getByRole("button", { name: "Add view" }));
expect(sidePanelsOf(changed)).toEqual(["fix-b", "fix-a"]);
});
it("the ✕ button emits removeNode for that panel (absent on the first panel)", async () => {
const user = userEvent.setup();
let changed: LayoutTree | null = null;
render(TileRoot, {
props: {
tree: panelsOnlyTree("fix-b", "fix-a"),
collapse: createCollapseStore(),
onTreeChange: (t) => (changed = t),
},
});
// Only panels after the first can be removed — like ViewSidebar today.
const removeButtons = screen.getAllByRole("button", { name: "Remove view" });
expect(removeButtons).toHaveLength(1);
await user.click(removeButtons[0] as HTMLElement);
expect(sidePanelsOf(changed)).toEqual(["fix-b"]);
});
it("an invalid edit is refused: the first panel has no remove button", () => {
const tree = panelsOnlyTree("fix-b");
let changed: LayoutTree | null = null;
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
try {
render(TileRoot, {
props: { tree, collapse: createCollapseStore(), onTreeChange: (t) => (changed = t) },
});
expect(screen.queryByRole("button", { name: "Remove view" })).toBeNull();
expect(changed).toBeNull();
} finally {
consoleError.mockRestore();
}
});
it("a stack child with grow renders in a flex-grown wrapper; others natural", () => {
const { container } = render(TileRoot, {
props: { tree: defaultTestTree(), collapse: createCollapseStore() },
});
const grown = container.querySelector("[data-testid='fixture-a']")?.closest(".min-h-0");
expect(grown?.getAttribute("style")).toContain("flex: 1 1 0%");
const natural = container.querySelector("[data-testid='fixture-c']")?.closest(".min-h-0");
expect(natural?.getAttribute("style")).toContain("flex: 0 0 auto");
});
it("the stack column is full-height so a grow child fills it even when empty", () => {
// jsdom can't compute flex layout — the honest seam is the CSS class.
// WITHOUT `h-full` the stack column's height is content-based, so the
// grow child's `flex: 1 1 0%` has no space to fill: an EMPTY chat leaf
// collapses to ~0 and the composer below it rides to the top (instead of
// staying pinned at the bottom of the column).
const { container } = render(TileRoot, {
props: { tree: defaultTestTree(), collapse: createCollapseStore() },
});
const stackColumn = container.querySelector("[data-testid='fixture-a']")?.closest(".flex-col");
expect(stackColumn).toHaveClass("h-full");
});
it("a marked stack/panels node draws its diagnostic outline (main red, side green)", () => {
// The inline outline IS the honest seam: marker values arrive in runtime
// tree data (Tailwind can't see them — dynamic classes would silently
// render nothing), so the renderer applies an inline style, and the test
// asserts exactly what lands on the tile wrapper.
const { container } = render(TileRoot, {
props: { tree: markedTestTree(), collapse: createCollapseStore({ viewportWidth: 1024 }) },
});
const stack = container.querySelector("[data-testid='fixture-a']")?.closest(".flex-col");
expect(stack?.getAttribute("style")).toContain("outline: 4px solid red");
// The SIDE marker frames the SCROLL CONTAINER — the sidebar's fixed,
// opaquely-painted viewport (the aside's bg is transparent; this div's
// bg-base-100 would cover an outline drawn on the aside). The panels
// root is its SCROLLED content, so an outline there would wrap the
// whole scrollable region and scroll along with it.
const viewport = container.querySelector("[data-testid='sidebar-scroll-viewport']");
expect(viewport?.getAttribute("style")).toContain("outline: 4px solid green");
});
it("outlines are INSET so they draw inside the tile's box (never clipped at screen edges)", () => {
// Tiles span their containers edge-to-edge, so a default (outside)
// outline overflows the visible area and is clipped away — the offset
// pulls the ring inside the box. Pin it on both marked tiles.
const { container } = render(TileRoot, {
props: { tree: markedTestTree(), collapse: createCollapseStore({ viewportWidth: 1024 }) },
});
const stack = container.querySelector("[data-testid='fixture-a']")?.closest(".flex-col");
expect(stack?.getAttribute("style")).toContain("outline-offset: -4px");
const viewport = container.querySelector("[data-testid='sidebar-scroll-viewport']");
expect(viewport?.getAttribute("style")).toContain("outline-offset: -4px");
});
it("the panels CONTENT carries no outline (it scrolls — the marker must not scroll with it)", () => {
const { container } = render(TileRoot, {
props: { tree: markedTestTree(), collapse: createCollapseStore({ viewportWidth: 1024 }) },
});
const panels = container.querySelector("[data-testid='fixture-b']")?.closest("[data-panels]");
expect(panels?.getAttribute("style") ?? "").not.toContain("outline");
});
it("an unmarked tree draws NO outlines (trees without the field render exactly as before)", () => {
const { container } = render(TileRoot, {
props: { tree: defaultTestTree(), collapse: createCollapseStore() },
});
expect(container.querySelectorAll("[style*='outline']")).toHaveLength(0);
});
it("an unknown marker value draws nothing (the renderer ignores what it doesn't know)", () => {
// Both root tiles marked with a value the renderer doesn't know.
const tree = markedTestTree();
const root = tree.root;
if (root.kind !== "split") return;
const junked = root.children.map((child) => ({
...child,
node: { ...child.node, marker: "purple" } as unknown as typeof child.node,
}));
const marked: LayoutTree = { version: 1, root: { ...root, children: junked } };
const { container } = render(TileRoot, {
props: { tree: marked, collapse: createCollapseStore({ viewportWidth: 1024 }) },
});
expect(container.querySelectorAll("[style*='outline']")).toHaveLength(0);
});
it("a marked LEAF draws its outline around a wrapper (the only wrapperless node kind)", () => {
const tree: LayoutTree = {
version: 1,
root: { kind: "leaf", view: "fix-a", marker: "side" },
};
const { container } = render(TileRoot, { props: { tree, collapse: createCollapseStore() } });
const fixture = container.querySelector("[data-testid='fixture-a']");
expect(fixture?.parentElement?.getAttribute("style")).toContain("outline: 4px solid green");
});
it("the dropdown lists panel-eligible views only (decision 5)", () => {
render(TileRoot, { props: { tree: panelsOnlyTree("fix-b"), collapse: createCollapseStore() } });
const select = screen.getByRole("combobox", { name: "Select a view" });
const labels = [...select.querySelectorAll("option")].map((o) => o.textContent);
expect(labels).toContain("Alpha View");
expect(labels).toContain("Beta View");
expect(labels).not.toContain("Content");
});
it("an unregistered view id inside a panel renders the fallback (not a crash)", () => {
render(TileRoot, {
props: { tree: panelsOnlyTree("ghost-view"), collapse: createCollapseStore() },
});
expect(screen.getByTestId("fallback-tile")).toBeInTheDocument();
});
});
describe("TileRoot registry cleanup", () => {
it("unregisters the fixture views (the registry is module-global per test file)", () => {
unregisterView("fix-a");
unregisterView("fix-b");
unregisterView("fix-content");
const tree: LayoutTree = { version: 1, root: { kind: "leaf", view: "fix-a" } };
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
try {
render(TileRoot, { props: { tree, collapse: createCollapseStore() } });
// Unregistered → the fallback tile, not a crash.
expect(screen.getByTestId("fallback-tile")).toBeInTheDocument();
} finally {
consoleError.mockRestore();
}
});
});
describe("TilePanels — panel identity + height animation", () => {
const wrappers = (): HTMLElement[] => [
...document.querySelectorAll<HTMLElement>("[data-panels] > [data-panel-key]"),
];
it("closing the MIDDLE panel removes that panel's DOM, keeping the others' nodes", async () => {
const { rerender } = render(TileRoot, {
props: { tree: panelsOnlyTree("fix-b", "fix-a", "fix-b"), collapse: createCollapseStore() },
});
const [first, , third] = wrappers();
await rerender({ tree: panelsOnlyTree("fix-b", "fix-b"), collapse: createCollapseStore() });
// Identity, not position: the third panel's node survived (index keying
// would have reused the middle node and dropped the last one).
expect(wrappers()).toEqual([first, third]);
expect(screen.queryByTestId("fixture-a")).toBeNull();
});
it("✕ on one of two IDENTICAL views removes exactly that panel", async () => {
const user = userEvent.setup();
const tree = panelsOnlyTree("fix-b", "fix-a", "fix-a");
const { rerender } = render(TileRoot, { props: { tree, collapse: createCollapseStore() } });
const [first, , third] = wrappers();
// Click the MIDDLE panel's ✕ (the first panel has none), then the owner
// applies the edit — the two fix-a panels are indistinguishable by data.
const [middleClose] = screen.getAllByRole("button", { name: "Remove view" });
await user.click(middleClose as HTMLElement);
await rerender({ tree: panelsOnlyTree("fix-b", "fix-a"), collapse: createCollapseStore() });
expect(wrappers()).toEqual([first, third]);
});
it("with motion allowed, a closed panel stays in flow while it shrinks out", async () => {
vi.stubGlobal(
"matchMedia",
vi.fn(() => ({ matches: false }) as unknown as MediaQueryList),
);
try {
const { rerender } = render(TileRoot, {
props: { tree: panelsOnlyTree("fix-b", "fix-a"), collapse: createCollapseStore() },
});
const closing = wrappers()[1];
await rerender({ tree: panelsOnlyTree("fix-b"), collapse: createCollapseStore() });
// jsdom's stubbed Element.animate never finishes, so the outro holds.
expect(closing?.isConnected).toBe(true);
expect(gsap.getTweensOf(closing as HTMLElement)).toHaveLength(1);
} finally {
vi.unstubAllGlobals();
}
});
});
|