@cosmicdrift/kumiko-renderer-web 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/package.json +63 -0
  2. package/src/__tests__/avatar.test.tsx +34 -0
  3. package/src/__tests__/combobox.test.tsx +240 -0
  4. package/src/__tests__/config-edit.test.tsx +172 -0
  5. package/src/__tests__/create-app.test.tsx +261 -0
  6. package/src/__tests__/date-input.test.tsx +91 -0
  7. package/src/__tests__/default-app-shell.test.tsx +60 -0
  8. package/src/__tests__/dispatcher-context.test.tsx +101 -0
  9. package/src/__tests__/dispatcher-status-wiring.test.tsx +119 -0
  10. package/src/__tests__/kumiko-screen.test.tsx +1014 -0
  11. package/src/__tests__/language-switcher.test.tsx +100 -0
  12. package/src/__tests__/money-input.test.tsx +232 -0
  13. package/src/__tests__/nav-base-path.test.tsx +388 -0
  14. package/src/__tests__/nav-search-params.test.tsx +88 -0
  15. package/src/__tests__/nav-tree.test.tsx +183 -0
  16. package/src/__tests__/nav.test.tsx +253 -0
  17. package/src/__tests__/primitives.test.tsx +936 -0
  18. package/src/__tests__/render-edit.test.tsx +178 -0
  19. package/src/__tests__/render-list-column-renderer.test.tsx +124 -0
  20. package/src/__tests__/render-list-debounce.test.tsx +128 -0
  21. package/src/__tests__/render-list.test.tsx +151 -0
  22. package/src/__tests__/sidebar.test.tsx +59 -0
  23. package/src/__tests__/test-utils.tsx +144 -0
  24. package/src/__tests__/theme-toggle.test.tsx +101 -0
  25. package/src/__tests__/toast.test.tsx +162 -0
  26. package/src/__tests__/use-form.test.tsx +112 -0
  27. package/src/__tests__/use-query-live.test.tsx +152 -0
  28. package/src/__tests__/use-query.test.tsx +88 -0
  29. package/src/__tests__/use-store.test.tsx +139 -0
  30. package/src/__tests__/workspace-shell.test.tsx +772 -0
  31. package/src/app/browser-locale.ts +85 -0
  32. package/src/app/client-plugin.tsx +63 -0
  33. package/src/app/create-app.tsx +380 -0
  34. package/src/app/nav.tsx +226 -0
  35. package/src/index.ts +137 -0
  36. package/src/layout/app-layout.tsx +35 -0
  37. package/src/layout/avatar.tsx +93 -0
  38. package/src/layout/default-app-shell.tsx +74 -0
  39. package/src/layout/language-switcher.tsx +101 -0
  40. package/src/layout/nav-tree.tsx +281 -0
  41. package/src/layout/profile-menu.tsx +40 -0
  42. package/src/layout/sidebar.tsx +65 -0
  43. package/src/layout/theme-toggle.tsx +44 -0
  44. package/src/layout/topbar.tsx +22 -0
  45. package/src/layout/workspace-shell.tsx +282 -0
  46. package/src/layout/workspace-switcher.tsx +62 -0
  47. package/src/lib/cn.ts +10 -0
  48. package/src/primitives/action-menu.tsx +111 -0
  49. package/src/primitives/combobox.tsx +261 -0
  50. package/src/primitives/date-input.tsx +165 -0
  51. package/src/primitives/dialog.tsx +119 -0
  52. package/src/primitives/dropdown-menu.tsx +103 -0
  53. package/src/primitives/index.tsx +1271 -0
  54. package/src/primitives/money-input.tsx +192 -0
  55. package/src/primitives/toast.tsx +166 -0
  56. package/src/sse/live-events.ts +90 -0
  57. package/src/styles.css +113 -0
  58. package/src/tokens.ts +63 -0
@@ -0,0 +1,772 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import type { WorkspaceSchema } from "@cosmicdrift/kumiko-renderer";
4
+ import {
5
+ createStaticLocaleResolver,
6
+ LocaleProvider,
7
+ NavProvider,
8
+ PrimitivesProvider,
9
+ } from "@cosmicdrift/kumiko-renderer";
10
+ import { render as _render, act } from "@testing-library/react";
11
+ import type { ReactNode } from "react";
12
+ import { beforeEach, describe, expect, test, vi } from "vitest";
13
+ import { useBrowserNavApi } from "../app/nav";
14
+ import {
15
+ filterByAccess,
16
+ firstNavScreenId,
17
+ resolveDefaultId,
18
+ WorkspaceShell,
19
+ } from "../layout/workspace-shell";
20
+ import { WorkspaceSwitcher } from "../layout/workspace-switcher";
21
+ import { defaultPrimitives } from "../primitives";
22
+ import { fireEvent, render, screen } from "./test-utils";
23
+
24
+ // jsdom shares window.history across tests in the same file. Reset to /
25
+ // before each render so URL-driven workspace state from one test doesn't
26
+ // leak into the next. Same pattern as nav.test.tsx.
27
+ beforeEach(() => {
28
+ window.history.replaceState(null, "", "/");
29
+ });
30
+
31
+ // Custom render wrapper: real `useBrowserNavApi` instead of test-utils'
32
+ // stub, because WorkspaceShell now reads workspace state from the nav
33
+ // route (URL-driven). The stub's no-op navigate() would freeze tab
34
+ // clicks. Workspaces-mode is on by default for these tests; the parser
35
+ // expects the first path segment to be a workspace short id.
36
+ function renderShell(ui: ReactNode): ReturnType<typeof _render> {
37
+ function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
38
+ const nav = useBrowserNavApi({ hasWorkspaces: true });
39
+ return (
40
+ <LocaleProvider resolver={createStaticLocaleResolver()}>
41
+ <PrimitivesProvider value={defaultPrimitives}>
42
+ <NavProvider value={nav}>{children}</NavProvider>
43
+ </PrimitivesProvider>
44
+ </LocaleProvider>
45
+ );
46
+ }
47
+ return _render(ui, { wrapper: Wrapper });
48
+ }
49
+
50
+ // Build a minimal WorkspaceSchema by hand — production-side, this comes
51
+ // from a registry-builder, but the shell must work with whatever shape
52
+ // FeatureSchema.workspaces is, so test against the contract not a helper.
53
+ function ws(
54
+ id: string,
55
+ options: {
56
+ label?: string;
57
+ order?: number;
58
+ roles?: readonly string[];
59
+ openToAll?: boolean;
60
+ isDefault?: boolean;
61
+ navMembers?: readonly string[];
62
+ } = {},
63
+ ): WorkspaceSchema {
64
+ const access = options.openToAll
65
+ ? ({ openToAll: true } as const)
66
+ : options.roles !== undefined
67
+ ? ({ roles: options.roles } as const)
68
+ : undefined;
69
+ return {
70
+ definition: {
71
+ id,
72
+ label: options.label ?? id,
73
+ ...(options.order !== undefined && { order: options.order }),
74
+ ...(access !== undefined && { access }),
75
+ ...(options.isDefault === true && { default: true }),
76
+ },
77
+ navMembers: options.navMembers ?? [],
78
+ };
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Pure helpers (no React, no providers)
83
+ // ---------------------------------------------------------------------------
84
+
85
+ describe("filterByAccess", () => {
86
+ test("openToAll is shown to everyone", () => {
87
+ const list = [ws("a", { openToAll: true })];
88
+ expect(filterByAccess(list, []).map((w) => w.definition.id)).toEqual(["a"]);
89
+ });
90
+
91
+ test("undefined access is shown (engine convention)", () => {
92
+ expect(filterByAccess([ws("a")], []).map((w) => w.definition.id)).toEqual(["a"]);
93
+ });
94
+
95
+ test("role-gated workspace shown when user has matching role", () => {
96
+ const list = [ws("a", { roles: ["admin"] })];
97
+ expect(filterByAccess(list, ["admin"]).map((w) => w.definition.id)).toEqual(["a"]);
98
+ });
99
+
100
+ test("role-gated workspace hidden when user roles don't match", () => {
101
+ const list = [ws("a", { roles: ["admin"] })];
102
+ expect(filterByAccess(list, ["driver"])).toHaveLength(0);
103
+ });
104
+
105
+ test("intersects on any-role match (OR semantics)", () => {
106
+ const list = [ws("a", { roles: ["dispatcher", "admin"] })];
107
+ expect(filterByAccess(list, ["dispatcher"])).toHaveLength(1);
108
+ });
109
+
110
+ test("sorts by order then insertion order", () => {
111
+ const list = [
112
+ ws("c", { openToAll: true, order: 3 }),
113
+ ws("a", { openToAll: true, order: 1 }),
114
+ ws("b", { openToAll: true, order: 2 }),
115
+ ws("d", { openToAll: true }), // no order — sorts last
116
+ ];
117
+ expect(filterByAccess(list, []).map((w) => w.definition.id)).toEqual(["a", "b", "c", "d"]);
118
+ });
119
+ });
120
+
121
+ describe("resolveDefaultId", () => {
122
+ const visible = [
123
+ ws("admin", { openToAll: true }),
124
+ ws("dispatch", { openToAll: true, isDefault: true }),
125
+ ws("driver", { openToAll: true }),
126
+ ];
127
+
128
+ test("preferred id wins when accessible", () => {
129
+ expect(resolveDefaultId(visible, "driver")).toBe("driver");
130
+ });
131
+
132
+ test("preferred id ignored when not in visible set", () => {
133
+ expect(resolveDefaultId(visible, "ghost")).toBe("dispatch");
134
+ });
135
+
136
+ test("default-flagged workspace picked when no preference", () => {
137
+ expect(resolveDefaultId(visible, undefined)).toBe("dispatch");
138
+ });
139
+
140
+ test("first visible workspace when no default flagged", () => {
141
+ const noDefault = [ws("a", { openToAll: true }), ws("b", { openToAll: true })];
142
+ expect(resolveDefaultId(noDefault, undefined)).toBe("a");
143
+ });
144
+
145
+ test("undefined when no workspaces visible", () => {
146
+ expect(resolveDefaultId([], undefined)).toBeUndefined();
147
+ });
148
+ });
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // firstNavScreenId — Resolver für den default-screen eines Workspaces.
152
+ // Pinst die Kontrakte gegen den prod-Bug 2026-05-02 (workspace-Tab-Klick
153
+ // landete auf nav.id statt nav.screen) PLUS die Fallback-Branches.
154
+ // ---------------------------------------------------------------------------
155
+
156
+ describe("firstNavScreenId", () => {
157
+ function appWithNavs(navs: ReadonlyArray<{ readonly id: string; readonly screen?: string }>) {
158
+ return {
159
+ features: [
160
+ {
161
+ featureName: "demo",
162
+ entities: {},
163
+ screens: [],
164
+ navs: navs.map((n) => ({ id: n.id, label: n.id, ...(n.screen && { screen: n.screen }) })),
165
+ },
166
+ ],
167
+ };
168
+ }
169
+
170
+ test("nimmt nav.screen, NICHT nav.id (der prod-Bug)", () => {
171
+ const app = appWithNavs([{ id: "components", screen: "demo:screen:component-list" }]);
172
+ expect(firstNavScreenId(app, ["demo:nav:components"])).toBe("component-list");
173
+ });
174
+
175
+ test("überspringt section-headers (nav ohne screen) zum nächsten member", () => {
176
+ const app = appWithNavs([
177
+ { id: "settings" }, // section-header, kein screen
178
+ { id: "settings-branding", screen: "demo:screen:branding-settings" },
179
+ ]);
180
+ expect(firstNavScreenId(app, ["demo:nav:settings", "demo:nav:settings-branding"])).toBe(
181
+ "branding-settings",
182
+ );
183
+ });
184
+
185
+ test("returnt '' wenn alle members section-headers sind (caller MUSS guarden)", () => {
186
+ const app = appWithNavs([{ id: "settings" }, { id: "more-headers" }]);
187
+ expect(firstNavScreenId(app, ["demo:nav:settings", "demo:nav:more-headers"])).toBe("");
188
+ });
189
+
190
+ test("returnt '' bei unbekannter nav-QN (drift zwischen workspace.nav und registered navs)", () => {
191
+ const app = appWithNavs([{ id: "real", screen: "demo:screen:real" }]);
192
+ expect(firstNavScreenId(app, ["demo:nav:ghost"])).toBe("");
193
+ });
194
+
195
+ test("returnt '' bei undefined navMembers", () => {
196
+ expect(firstNavScreenId(appWithNavs([]), undefined)).toBe("");
197
+ });
198
+
199
+ test("returnt '' bei leerem navMembers-array", () => {
200
+ expect(firstNavScreenId(appWithNavs([]), [])).toBe("");
201
+ });
202
+ });
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // WorkspaceSwitcher (presentational)
206
+ // ---------------------------------------------------------------------------
207
+
208
+ describe("WorkspaceSwitcher", () => {
209
+ test("renders nothing for a single workspace (no choice = no UI)", () => {
210
+ const { container } = render(
211
+ <WorkspaceSwitcher
212
+ workspaces={[ws("only", { openToAll: true })]}
213
+ activeId="only"
214
+ onSelect={() => {}}
215
+ />,
216
+ );
217
+ expect(container.firstChild).toBeNull();
218
+ });
219
+
220
+ test("renders a tab per workspace and marks the active one", () => {
221
+ render(
222
+ <WorkspaceSwitcher
223
+ workspaces={[
224
+ ws("admin", { label: "Admin", openToAll: true }),
225
+ ws("driver", { label: "Driver", openToAll: true }),
226
+ ]}
227
+ activeId="admin"
228
+ onSelect={() => {}}
229
+ />,
230
+ );
231
+ const adminTab = screen.getByTestId("workspace-tab-admin");
232
+ const driverTab = screen.getByTestId("workspace-tab-driver");
233
+ expect(adminTab.getAttribute("aria-selected")).toBe("true");
234
+ expect(driverTab.getAttribute("aria-selected")).toBe("false");
235
+ });
236
+
237
+ test("clicking a tab calls onSelect with that workspace id", () => {
238
+ const onSelect = vi.fn();
239
+ render(
240
+ <WorkspaceSwitcher
241
+ workspaces={[
242
+ ws("admin", { label: "Admin", openToAll: true }),
243
+ ws("driver", { label: "Driver", openToAll: true }),
244
+ ]}
245
+ activeId="admin"
246
+ onSelect={onSelect}
247
+ />,
248
+ );
249
+ fireEvent.click(screen.getByTestId("workspace-tab-driver"));
250
+ expect(onSelect).toHaveBeenCalledWith("driver");
251
+ });
252
+ });
253
+
254
+ // ---------------------------------------------------------------------------
255
+ // WorkspaceShell (integration of switcher + nav-tree filter)
256
+ // ---------------------------------------------------------------------------
257
+
258
+ describe("WorkspaceShell", () => {
259
+ const schema = {
260
+ featureName: "bmc",
261
+ entities: {},
262
+ screens: [],
263
+ // navs müssen `screen` haben damit firstScreenIdInWorkspace die
264
+ // screen-id auflösen kann. Resolver nimmt explizit nav.screen,
265
+ // nicht nav.id (siehe workspace-shell.tsx Comment).
266
+ navs: [
267
+ { id: "system", label: "System", screen: "bmc:screen:system" },
268
+ { id: "orders", label: "Orders", screen: "bmc:screen:orders" },
269
+ { id: "tours", label: "Tours", screen: "bmc:screen:tours" },
270
+ ],
271
+ workspaces: [
272
+ ws("admin", {
273
+ label: "Admin",
274
+ roles: ["admin"],
275
+ order: 1,
276
+ isDefault: true,
277
+ navMembers: ["bmc:nav:system", "bmc:nav:orders"],
278
+ }),
279
+ ws("driver", {
280
+ label: "Driver",
281
+ roles: ["driver"],
282
+ order: 2,
283
+ navMembers: ["bmc:nav:tours"],
284
+ }),
285
+ ws("dispatch", {
286
+ label: "Cockpit",
287
+ roles: ["dispatcher", "admin"],
288
+ order: 3,
289
+ navMembers: ["bmc:nav:orders", "bmc:nav:tours"],
290
+ }),
291
+ ],
292
+ } as const;
293
+
294
+ test("an admin sees admin + dispatch in the switcher (driver hidden)", () => {
295
+ renderShell(
296
+ <WorkspaceShell
297
+ brand={<div>Brand</div>}
298
+ schema={schema}
299
+ user={{ id: "u1", roles: ["admin"] }}
300
+ >
301
+ <div>content</div>
302
+ </WorkspaceShell>,
303
+ );
304
+ expect(screen.getByTestId("workspace-tab-admin")).toBeTruthy();
305
+ expect(screen.getByTestId("workspace-tab-dispatch")).toBeTruthy();
306
+ expect(screen.queryByTestId("workspace-tab-driver")).toBeNull();
307
+ });
308
+
309
+ test("default workspace (admin) is active on first render for an admin", () => {
310
+ renderShell(
311
+ <WorkspaceShell
312
+ brand={<div>Brand</div>}
313
+ schema={schema}
314
+ user={{ id: "u1", roles: ["admin"] }}
315
+ >
316
+ <div>content</div>
317
+ </WorkspaceShell>,
318
+ );
319
+ expect(screen.getByTestId("workspace-tab-admin").getAttribute("aria-selected")).toBe("true");
320
+ });
321
+
322
+ test("only the active workspace's nav members appear in the sidebar", () => {
323
+ renderShell(
324
+ <WorkspaceShell
325
+ brand={<div>Brand</div>}
326
+ schema={schema}
327
+ user={{ id: "u1", roles: ["admin"] }}
328
+ >
329
+ <div>content</div>
330
+ </WorkspaceShell>,
331
+ );
332
+ // admin → system + orders, NOT tours
333
+ expect(screen.getByText("System")).toBeTruthy();
334
+ expect(screen.getByText("Orders")).toBeTruthy();
335
+ expect(screen.queryByText("Tours")).toBeNull();
336
+ });
337
+
338
+ test("clicking the dispatch tab swaps the visible nav set", () => {
339
+ renderShell(
340
+ <WorkspaceShell
341
+ brand={<div>Brand</div>}
342
+ schema={schema}
343
+ user={{ id: "u1", roles: ["admin"] }}
344
+ >
345
+ <div>content</div>
346
+ </WorkspaceShell>,
347
+ );
348
+ fireEvent.click(screen.getByTestId("workspace-tab-dispatch"));
349
+ // dispatch → orders + tours, NOT system
350
+ expect(screen.getByText("Orders")).toBeTruthy();
351
+ expect(screen.getByText("Tours")).toBeTruthy();
352
+ expect(screen.queryByText("System")).toBeNull();
353
+ });
354
+
355
+ test("a driver lands on the driver workspace (their only one)", () => {
356
+ renderShell(
357
+ <WorkspaceShell
358
+ brand={<div>Brand</div>}
359
+ schema={schema}
360
+ user={{ id: "u2", roles: ["driver"] }}
361
+ >
362
+ <div>content</div>
363
+ </WorkspaceShell>,
364
+ );
365
+ // Only one workspace → switcher renders nothing, but the nav still
366
+ // shows that workspace's members.
367
+ expect(screen.queryByTestId("workspace-tab-driver")).toBeNull();
368
+ expect(screen.getByText("Tours")).toBeTruthy();
369
+ expect(screen.queryByText("System")).toBeNull();
370
+ expect(screen.queryByText("Orders")).toBeNull();
371
+ });
372
+
373
+ test("schema without workspaces falls back to plain rendering (all navs visible)", () => {
374
+ renderShell(
375
+ <WorkspaceShell
376
+ brand={<div>Brand</div>}
377
+ schema={{ ...schema, workspaces: undefined }}
378
+ user={{ id: "u1", roles: ["admin"] }}
379
+ >
380
+ <div>content</div>
381
+ </WorkspaceShell>,
382
+ );
383
+ // No allow-set → NavTree renders every entry.
384
+ expect(screen.getByText("System")).toBeTruthy();
385
+ expect(screen.getByText("Orders")).toBeTruthy();
386
+ expect(screen.getByText("Tours")).toBeTruthy();
387
+ // No switcher.
388
+ expect(document.querySelector('[data-kumiko-layout="workspace-switcher"]')).toBeNull();
389
+ });
390
+
391
+ test("initialWorkspaceId picks a non-default workspace at mount", () => {
392
+ renderShell(
393
+ <WorkspaceShell
394
+ brand={<div>Brand</div>}
395
+ schema={schema}
396
+ user={{ id: "u1", roles: ["admin"] }}
397
+ initialWorkspaceId="dispatch"
398
+ >
399
+ <div>content</div>
400
+ </WorkspaceShell>,
401
+ );
402
+ expect(screen.getByTestId("workspace-tab-dispatch").getAttribute("aria-selected")).toBe("true");
403
+ });
404
+
405
+ // Security regression — without the empty-allow-set branch, the NavTree
406
+ // would fall back to "no filter" and leak admin nav items to a user
407
+ // that has zero accessible workspaces.
408
+ test("user with no accessible workspace sees an empty sidebar (not all navs)", () => {
409
+ renderShell(
410
+ <WorkspaceShell
411
+ brand={<div>Brand</div>}
412
+ schema={schema}
413
+ user={{ id: "u3", roles: ["nobody"] }}
414
+ >
415
+ <div>content</div>
416
+ </WorkspaceShell>,
417
+ );
418
+ expect(screen.queryByText("System")).toBeNull();
419
+ expect(screen.queryByText("Orders")).toBeNull();
420
+ expect(screen.queryByText("Tours")).toBeNull();
421
+ });
422
+ });
423
+
424
+ // ---------------------------------------------------------------------------
425
+ // NavTree integration — orphaned children when their parent is filtered out
426
+ // ---------------------------------------------------------------------------
427
+
428
+ describe("WorkspaceShell — nav hierarchy after filter", () => {
429
+ test("a nested child whose parent is filtered out surfaces as a top-level entry", () => {
430
+ // catalog (parent) → catalog-products (child). Workspace lists the
431
+ // CHILD only; the parent isn't a member. Expected: child renders as
432
+ // a link instead of nesting silently disappearing.
433
+ const schema = {
434
+ featureName: "shop",
435
+ entities: {},
436
+ screens: [],
437
+ navs: [
438
+ { id: "catalog", label: "Catalog" },
439
+ { id: "catalog-products", label: "Products", parent: "catalog" },
440
+ ],
441
+ workspaces: [
442
+ ws("ops", {
443
+ openToAll: true,
444
+ navMembers: ["shop:nav:catalog-products"],
445
+ }),
446
+ ],
447
+ } as const;
448
+ renderShell(
449
+ <WorkspaceShell brand={<div>B</div>} schema={schema} user={{ id: "u", roles: [] }}>
450
+ <div>content</div>
451
+ </WorkspaceShell>,
452
+ );
453
+ expect(screen.getByText("Products")).toBeTruthy();
454
+ expect(screen.queryByText("Catalog")).toBeNull();
455
+ });
456
+ });
457
+
458
+ // ---------------------------------------------------------------------------
459
+ // URL sync — workspace lives in the path: /<workspace>/<screen>[/<id>]
460
+ // ---------------------------------------------------------------------------
461
+
462
+ describe("WorkspaceShell — URL sync (path-based)", () => {
463
+ const schema = {
464
+ featureName: "bmc",
465
+ entities: {},
466
+ screens: [],
467
+ // navs müssen `screen` haben damit firstScreenIdInWorkspace die
468
+ // screen-id auflösen kann. Resolver nimmt explizit nav.screen,
469
+ // nicht nav.id (siehe workspace-shell.tsx Comment).
470
+ navs: [
471
+ { id: "system", label: "System", screen: "bmc:screen:system" },
472
+ { id: "orders", label: "Orders", screen: "bmc:screen:orders" },
473
+ { id: "tours", label: "Tours", screen: "bmc:screen:tours" },
474
+ ],
475
+ workspaces: [
476
+ ws("admin", {
477
+ roles: ["admin"],
478
+ order: 1,
479
+ isDefault: true,
480
+ navMembers: ["bmc:nav:system", "bmc:nav:orders"],
481
+ }),
482
+ ws("dispatch", {
483
+ roles: ["admin"],
484
+ order: 2,
485
+ navMembers: ["bmc:nav:orders", "bmc:nav:tours"],
486
+ }),
487
+ ],
488
+ } as const;
489
+
490
+ test("URL /<workspace>/<screen> wins over the engine-default at mount", () => {
491
+ window.history.replaceState(null, "", "/dispatch/orders");
492
+ renderShell(
493
+ <WorkspaceShell brand={<div>B</div>} schema={schema} user={{ id: "u1", roles: ["admin"] }}>
494
+ <div>content</div>
495
+ </WorkspaceShell>,
496
+ );
497
+ expect(screen.getByTestId("workspace-tab-dispatch").getAttribute("aria-selected")).toBe("true");
498
+ expect(screen.getByTestId("workspace-tab-admin").getAttribute("aria-selected")).toBe("false");
499
+ });
500
+
501
+ test("clicking a tab pushes /<workspace>/<screen> to the URL", () => {
502
+ renderShell(
503
+ <WorkspaceShell brand={<div>B</div>} schema={schema} user={{ id: "u1", roles: ["admin"] }}>
504
+ <div>content</div>
505
+ </WorkspaceShell>,
506
+ );
507
+ fireEvent.click(screen.getByTestId("workspace-tab-dispatch"));
508
+ // dispatch's first nav-member is bmc:nav:orders → screenId "orders"
509
+ expect(window.location.pathname).toBe("/dispatch/orders");
510
+ });
511
+
512
+ test("initial-sync uses replaceState (no extra history entry)", () => {
513
+ // pushState during the mount-time URL fill would trap the user in a
514
+ // back-loop: Back → / → useEffect re-pushes → Back stays inside.
515
+ // The fix is replaceState — same URL, no history bloat. Asserts on
516
+ // history.length so a regression to navigate() (which is pushState)
517
+ // would fail loud.
518
+ window.history.replaceState(null, "", "/");
519
+ const before = window.history.length;
520
+ renderShell(
521
+ <WorkspaceShell brand={<div>B</div>} schema={schema} user={{ id: "u1", roles: ["admin"] }}>
522
+ <div>content</div>
523
+ </WorkspaceShell>,
524
+ );
525
+ expect(window.history.length).toBe(before);
526
+ expect(window.location.pathname).toBe("/admin/system");
527
+ });
528
+
529
+ test("URL /<workspace> with no screen fills in the default screen", () => {
530
+ // User types `/admin` directly (or has an old bookmark). Workspace
531
+ // matches but screenId is empty — the effect must still fill the
532
+ // default screen, otherwise RoutedScreen has nothing to render.
533
+ window.history.replaceState(null, "", "/admin");
534
+ renderShell(
535
+ <WorkspaceShell brand={<div>B</div>} schema={schema} user={{ id: "u1", roles: ["admin"] }}>
536
+ <div>content</div>
537
+ </WorkspaceShell>,
538
+ );
539
+ expect(window.location.pathname).toBe("/admin/system");
540
+ });
541
+
542
+ test("mounting on / writes the default workspace into the URL", () => {
543
+ // Before this fix, an empty pathname meant nav.route?.workspaceId was
544
+ // undefined, so NavTree links rendered without /<workspace>/ prefix
545
+ // and a click would land on a flat path that the workspace-mode parser
546
+ // then misreads as a workspace id. WorkspaceShell now syncs on mount.
547
+ window.history.replaceState(null, "", "/");
548
+ renderShell(
549
+ <WorkspaceShell brand={<div>B</div>} schema={schema} user={{ id: "u1", roles: ["admin"] }}>
550
+ <div>content</div>
551
+ </WorkspaceShell>,
552
+ );
553
+ // admin = default. First nav-member of admin is bmc:nav:system →
554
+ // screenId "system".
555
+ expect(window.location.pathname).toBe("/admin/system");
556
+ });
557
+
558
+ test("URL /<unknown-workspace> falls through to the engine-default", () => {
559
+ window.history.replaceState(null, "", "/ghost/whatever");
560
+ renderShell(
561
+ <WorkspaceShell brand={<div>B</div>} schema={schema} user={{ id: "u1", roles: ["admin"] }}>
562
+ <div>content</div>
563
+ </WorkspaceShell>,
564
+ );
565
+ // Default workspace = admin. Ghost id ignored, no error thrown.
566
+ expect(screen.getByTestId("workspace-tab-admin").getAttribute("aria-selected")).toBe("true");
567
+ });
568
+
569
+ test("popstate (back/forward) updates the active tab", () => {
570
+ window.history.replaceState(null, "", "/admin/system");
571
+ renderShell(
572
+ <WorkspaceShell brand={<div>B</div>} schema={schema} user={{ id: "u1", roles: ["admin"] }}>
573
+ <div>content</div>
574
+ </WorkspaceShell>,
575
+ );
576
+ expect(screen.getByTestId("workspace-tab-admin").getAttribute("aria-selected")).toBe("true");
577
+ // Simulate the user hitting "forward" to /dispatch/orders. pushState
578
+ // alone doesn't fire popstate — we synthesize the event so the
579
+ // hook's listener-set notifies subscribers. act() flushes the
580
+ // ensuing React render before the next assertion.
581
+ act(() => {
582
+ window.history.pushState(null, "", "/dispatch/orders");
583
+ window.dispatchEvent(new PopStateEvent("popstate"));
584
+ });
585
+ expect(screen.getByTestId("workspace-tab-dispatch").getAttribute("aria-selected")).toBe("true");
586
+ });
587
+ });
588
+
589
+ // ---------------------------------------------------------------------------
590
+ // AppSchema (multi-feature) — workspaces with cross-feature nav members
591
+ // ---------------------------------------------------------------------------
592
+ //
593
+ // Hier deckt die Test-Klammer den eigentlichen Use-Case ab: ein Workspace
594
+ // dessen navMembers Navs aus mehreren Features referenzieren. Vorher
595
+ // ging das nur über pre-qualifizierte ids im single-feature schema, jetzt
596
+ // sauber über AppSchema.features[].
597
+
598
+ describe("WorkspaceShell — AppSchema (multi-feature)", () => {
599
+ test("admin Workspace zeigt Navs aus zwei Features in einer Sidebar", () => {
600
+ const app = {
601
+ features: [
602
+ {
603
+ featureName: "orders",
604
+ entities: {},
605
+ screens: [],
606
+ navs: [{ id: "list", label: "Order List" }],
607
+ },
608
+ {
609
+ featureName: "fleet",
610
+ entities: {},
611
+ screens: [],
612
+ navs: [{ id: "vehicles", label: "Vehicles" }],
613
+ },
614
+ ],
615
+ workspaces: [
616
+ ws("admin", {
617
+ label: "Admin",
618
+ openToAll: true,
619
+ isDefault: true,
620
+ navMembers: ["orders:nav:list", "fleet:nav:vehicles"],
621
+ }),
622
+ ],
623
+ } as const;
624
+
625
+ renderShell(
626
+ <WorkspaceShell brand={<div>Brand</div>} schema={app} user={{ id: "u1", roles: ["admin"] }}>
627
+ <div>content</div>
628
+ </WorkspaceShell>,
629
+ );
630
+ // Beide Navs müssen in der Sidebar landen, jede mit ihrem eigenen
631
+ // featureName qualifiziert. Vorher (single-feature schema) hätte
632
+ // qualifyNavId orders+fleet beide unter dem einen featureName
633
+ // qualifiziert und der allowedNavQns-Filter hätte fleet:nav:vehicles
634
+ // nie matchen können.
635
+ expect(screen.getByText("Order List")).toBeTruthy();
636
+ expect(screen.getByText("Vehicles")).toBeTruthy();
637
+ });
638
+
639
+ test("Workspace-Filter respektiert features-übergreifende Membership", () => {
640
+ const app = {
641
+ features: [
642
+ {
643
+ featureName: "orders",
644
+ entities: {},
645
+ screens: [],
646
+ navs: [{ id: "list", label: "Order List" }],
647
+ },
648
+ {
649
+ featureName: "fleet",
650
+ entities: {},
651
+ screens: [],
652
+ navs: [{ id: "vehicles", label: "Vehicles" }],
653
+ },
654
+ ],
655
+ workspaces: [
656
+ ws("admin", {
657
+ label: "Admin",
658
+ openToAll: true,
659
+ isDefault: true,
660
+ navMembers: ["orders:nav:list"], // Nur Orders-Navs
661
+ }),
662
+ ws("dispatch", {
663
+ label: "Dispatch",
664
+ openToAll: true,
665
+ navMembers: ["fleet:nav:vehicles"], // Nur Fleet-Navs
666
+ }),
667
+ ],
668
+ } as const;
669
+
670
+ renderShell(
671
+ <WorkspaceShell brand={<div>Brand</div>} schema={app} user={{ id: "u1", roles: ["admin"] }}>
672
+ <div>content</div>
673
+ </WorkspaceShell>,
674
+ );
675
+ // Admin = aktiv (default) → nur Order List, KEIN Vehicles.
676
+ expect(screen.getByText("Order List")).toBeTruthy();
677
+ expect(screen.queryByText("Vehicles")).toBeNull();
678
+ });
679
+
680
+ // sidebarFooter-Slot — symmetrisch zu DefaultAppShell.sidebarFooter.
681
+ // Apps nutzen das für Build-Info / Version-SHA / Help-Link am unteren
682
+ // Sidebar-Rand. Ohne den Slot mussten Apps den Footer als bottom-fixed
683
+ // div neben der Shell mounten — sieht aus, ist aber außerhalb der
684
+ // Layout-Hierarchie und überlappt bei kleinen Viewports den Content.
685
+ // Regression-Anker: Wenn jemand den Slot wegrefactoriert, fällt das
686
+ // hier auf, nicht erst beim nächsten Workspace-Sample.
687
+ test("sidebarFooter-Slot rendert unten in der Sidebar", () => {
688
+ const legacy = {
689
+ featureName: "demo",
690
+ entities: {},
691
+ screens: [],
692
+ navs: [{ id: "list", label: "List" }],
693
+ workspaces: [
694
+ ws("admin", {
695
+ label: "Admin",
696
+ openToAll: true,
697
+ isDefault: true,
698
+ navMembers: ["demo:nav:list"],
699
+ }),
700
+ ],
701
+ } as const;
702
+
703
+ renderShell(
704
+ <WorkspaceShell
705
+ brand={<div>Brand</div>}
706
+ schema={legacy}
707
+ user={{ id: "u1", roles: [] }}
708
+ sidebarFooter={<div data-testid="sidebar-footer">v1.2.3</div>}
709
+ >
710
+ <div>content</div>
711
+ </WorkspaceShell>,
712
+ );
713
+ expect(screen.getByTestId("sidebar-footer")).toBeTruthy();
714
+ expect(screen.getByTestId("sidebar-footer").textContent).toBe("v1.2.3");
715
+ });
716
+
717
+ test("ohne sidebarFooter-Prop rendert die Sidebar ohne Footer-Slot (default)", () => {
718
+ const legacy = {
719
+ featureName: "demo",
720
+ entities: {},
721
+ screens: [],
722
+ navs: [{ id: "list", label: "List" }],
723
+ workspaces: [
724
+ ws("admin", {
725
+ label: "Admin",
726
+ openToAll: true,
727
+ isDefault: true,
728
+ navMembers: ["demo:nav:list"],
729
+ }),
730
+ ],
731
+ } as const;
732
+
733
+ renderShell(
734
+ <WorkspaceShell brand={<div>Brand</div>} schema={legacy} user={{ id: "u1", roles: [] }}>
735
+ <div>content</div>
736
+ </WorkspaceShell>,
737
+ );
738
+ expect(screen.queryByTestId("sidebar-footer")).toBeNull();
739
+ });
740
+
741
+ test("toAppSchema hebt FeatureSchema.workspaces auf App-Ebene (Backwards-Compat)", () => {
742
+ // Legacy single-feature shape mit inline workspaces — der Wrapper
743
+ // soll exakt das gleiche Rendering liefern wie ein expliziter
744
+ // AppSchema-Aufruf mit demselben Inhalt.
745
+ const legacy = {
746
+ featureName: "demo",
747
+ entities: {},
748
+ screens: [],
749
+ navs: [{ id: "list", label: "List" }],
750
+ workspaces: [
751
+ ws("admin", {
752
+ label: "Admin",
753
+ openToAll: true,
754
+ isDefault: true,
755
+ navMembers: ["demo:nav:list"],
756
+ }),
757
+ ],
758
+ } as const;
759
+
760
+ renderShell(
761
+ <WorkspaceShell brand={<div>Brand</div>} schema={legacy} user={{ id: "u1", roles: [] }}>
762
+ <div>content</div>
763
+ </WorkspaceShell>,
764
+ );
765
+ // Sidebar zeigt den nav — beweist dass die Legacy-Schema-Workspaces
766
+ // korrekt zur App-Ebene hochgehoben wurden und der allowedNavQns-
767
+ // Filter den Eintrag durchließ. WorkspaceSwitcher rendert bei einem
768
+ // einzelnen Workspace nichts (no-choice-no-UI), das ist nicht der
769
+ // Test-Punkt hier.
770
+ expect(screen.getByText("List")).toBeTruthy();
771
+ });
772
+ });