@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,1014 @@
1
+ // @vitest-environment jsdom
2
+ import type {
3
+ ActionFormScreenDefinition,
4
+ EntityDefinition,
5
+ EntityEditScreenDefinition,
6
+ EntityListScreenDefinition,
7
+ } from "@cosmicdrift/kumiko-framework/ui-types";
8
+ import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
9
+ import type { FeatureSchema } from "@cosmicdrift/kumiko-renderer";
10
+ import { DispatcherProvider, KumikoScreen } from "@cosmicdrift/kumiko-renderer";
11
+ import userEvent from "@testing-library/user-event";
12
+ import { describe, expect, test, vi } from "vitest";
13
+ import { createMockDispatcher, fireEvent, render, screen, waitFor } from "./test-utils";
14
+
15
+ const taskEntity = {
16
+ fields: {
17
+ title: { type: "text", required: true },
18
+ count: { type: "number" },
19
+ done: { type: "boolean" },
20
+ },
21
+ } as unknown as EntityDefinition;
22
+
23
+ const editScreen: EntityEditScreenDefinition = {
24
+ id: "task-edit",
25
+ type: "entityEdit",
26
+ entity: "task",
27
+ layout: {
28
+ sections: [{ title: "Basics", fields: ["title", "count", "done"] }],
29
+ },
30
+ };
31
+
32
+ const listScreen: EntityListScreenDefinition = {
33
+ id: "task-list",
34
+ type: "entityList",
35
+ entity: "task",
36
+ columns: ["title", "count", "done"],
37
+ };
38
+
39
+ const schema: FeatureSchema = {
40
+ featureName: "tasks",
41
+ entities: { task: taskEntity },
42
+ screens: [editScreen, listScreen],
43
+ };
44
+
45
+ function makeDispatcher(overrides: Partial<Dispatcher> = {}): Dispatcher {
46
+ const base = createMockDispatcher({
47
+ query: (async () => ({
48
+ isSuccess: true,
49
+ data: { rows: [], nextCursor: null },
50
+ })) as unknown as Dispatcher["query"],
51
+ });
52
+ return { ...base, ...overrides };
53
+ }
54
+
55
+ describe("KumikoScreen", () => {
56
+ test("unknown qn → not-found placeholder", () => {
57
+ render(
58
+ <DispatcherProvider dispatcher={makeDispatcher()}>
59
+ <KumikoScreen schema={schema} qn="tasks:screen:ghost" />
60
+ </DispatcherProvider>,
61
+ );
62
+ expect(screen.getByTestId("kumiko-screen-not-found")).toBeTruthy();
63
+ });
64
+
65
+ test("entityEdit → renders RenderEdit form for the screen's entity", () => {
66
+ render(
67
+ <DispatcherProvider dispatcher={makeDispatcher()}>
68
+ <KumikoScreen schema={schema} qn="tasks:screen:task-edit" />
69
+ </DispatcherProvider>,
70
+ );
71
+ expect(screen.getByTestId("render-edit-form")).toBeTruthy();
72
+ expect(screen.getByTestId("field-title")).toBeTruthy();
73
+ expect(screen.getByTestId("field-count")).toBeTruthy();
74
+ expect(screen.getByTestId("field-done")).toBeTruthy();
75
+ });
76
+
77
+ test("entityList → fires useQuery with derived query QN and renders RenderList", async () => {
78
+ const seenTypes: string[] = [];
79
+ const query = vi.fn(async (type: string) => {
80
+ seenTypes.push(type);
81
+ return {
82
+ isSuccess: true,
83
+ data: {
84
+ rows: [{ id: "r1", title: "hello", count: 3, done: false }],
85
+ nextCursor: null,
86
+ },
87
+ } as never;
88
+ });
89
+ const dispatcher = makeDispatcher({
90
+ query: query as unknown as Dispatcher["query"],
91
+ });
92
+
93
+ render(
94
+ <DispatcherProvider dispatcher={dispatcher}>
95
+ <KumikoScreen schema={schema} qn="tasks:screen:task-list" />
96
+ </DispatcherProvider>,
97
+ );
98
+
99
+ await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
100
+ expect(screen.getByTestId("render-list-table")).toBeTruthy();
101
+ expect(screen.getByTestId("cell-r1-title").textContent).toBe("hello");
102
+ // Derived query QN matches the server-side qualification rule.
103
+ expect(seenTypes).toEqual(["tasks:query:task:list"]);
104
+ });
105
+
106
+ test("entityEdit with unknown entity on the screen → entity-missing placeholder", () => {
107
+ const brokenScreen: EntityEditScreenDefinition = {
108
+ id: "broken",
109
+ type: "entityEdit",
110
+ entity: "ghost-entity",
111
+ layout: { sections: [{ title: "x", fields: [] }] },
112
+ };
113
+ const brokenSchema: FeatureSchema = {
114
+ ...schema,
115
+ screens: [brokenScreen],
116
+ };
117
+ render(
118
+ <DispatcherProvider dispatcher={makeDispatcher()}>
119
+ <KumikoScreen schema={brokenSchema} qn="tasks:screen:broken" />
120
+ </DispatcherProvider>,
121
+ );
122
+ expect(screen.getByTestId("kumiko-screen-entity-missing")).toBeTruthy();
123
+ });
124
+
125
+ test("entityEdit mit entityId → lädt detail, pre-fillt Form, submit update mit {id,version,changes}", async () => {
126
+ const writeCalls: { type: string; payload: unknown }[] = [];
127
+ const dispatcher = makeDispatcher({
128
+ query: (async () => ({
129
+ isSuccess: true,
130
+ data: { id: "task-1", version: 7, title: "loaded-title", count: 3, done: false },
131
+ })) as unknown as Dispatcher["query"],
132
+ write: (async (type: string, payload: unknown) => {
133
+ writeCalls.push({ type, payload });
134
+ return { isSuccess: true, data: { id: "task-1" } };
135
+ }) as unknown as Dispatcher["write"],
136
+ });
137
+
138
+ render(
139
+ <DispatcherProvider dispatcher={dispatcher}>
140
+ <KumikoScreen schema={schema} qn="tasks:screen:task-edit" entityId="task-1" />
141
+ </DispatcherProvider>,
142
+ );
143
+
144
+ // Zuerst Loading, dann Form mit den geladenen Werten.
145
+ await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
146
+ expect(screen.getByTestId("render-edit-form")).toBeTruthy();
147
+ const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
148
+ expect(titleInput.value).toBe("loaded-title");
149
+
150
+ // Edit + submit → write command trägt { id, version, changes: {title} }
151
+ fireEvent.change(titleInput, { target: { value: "edited-title" } });
152
+ fireEvent.click(screen.getByTestId("render-edit-submit"));
153
+
154
+ await waitFor(() => expect(writeCalls.length).toBe(1));
155
+ const [call] = writeCalls;
156
+ expect(call?.type).toBe("tasks:write:task:update");
157
+ expect(call?.payload).toEqual({
158
+ id: "task-1",
159
+ version: 7,
160
+ changes: { title: "edited-title" },
161
+ });
162
+ });
163
+
164
+ test("entityList onRowClick → Callback feuert mit Row-Viewmodel", async () => {
165
+ const clicks: { id: string }[] = [];
166
+ const dispatcher = makeDispatcher({
167
+ query: (async () => ({
168
+ isSuccess: true,
169
+ data: {
170
+ rows: [{ id: "row-1", title: "hello", count: 3, done: false }],
171
+ nextCursor: null,
172
+ },
173
+ })) as unknown as Dispatcher["query"],
174
+ });
175
+
176
+ render(
177
+ <DispatcherProvider dispatcher={dispatcher}>
178
+ <KumikoScreen
179
+ schema={schema}
180
+ qn="tasks:screen:task-list"
181
+ onRowClick={(row) => clicks.push({ id: row.id })}
182
+ />
183
+ </DispatcherProvider>,
184
+ );
185
+
186
+ await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
187
+ fireEvent.click(screen.getByTestId("row-row-1"));
188
+ expect(clicks).toEqual([{ id: "row-1" }]);
189
+ });
190
+
191
+ test("entityEdit update-mode: Delete-Button öffnet Confirm-Dialog + write('delete')", async () => {
192
+ const user = userEvent.setup();
193
+ const writeCalls: { type: string; payload: unknown }[] = [];
194
+ const dispatcher = makeDispatcher({
195
+ query: (async () => ({
196
+ isSuccess: true,
197
+ data: { id: "task-1", version: 7, title: "loaded", count: 3, done: false },
198
+ })) as unknown as Dispatcher["query"],
199
+ write: (async (type: string, payload: unknown) => {
200
+ writeCalls.push({ type, payload });
201
+ return { isSuccess: true, data: {} };
202
+ }) as unknown as Dispatcher["write"],
203
+ });
204
+ render(
205
+ <DispatcherProvider dispatcher={dispatcher}>
206
+ <KumikoScreen schema={schema} qn="tasks:screen:task-edit" entityId="task-1" />
207
+ </DispatcherProvider>,
208
+ );
209
+
210
+ await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
211
+
212
+ // userEvent statt fireEvent: Radix Dialog feuert async State-Updates
213
+ // (Presence/FocusScope/DismissableLayer) — fireEvent würde sie un-
214
+ // gewickelt lassen und mit ~26 act()-Warnings spammen.
215
+ await user.click(screen.getByTestId("render-edit-delete"));
216
+ expect(screen.getByTestId("render-edit-delete-dialog")).toBeTruthy();
217
+ expect(writeCalls.length).toBe(0);
218
+
219
+ await user.click(screen.getByTestId("render-edit-delete-dialog-confirm"));
220
+ await waitFor(() => expect(writeCalls.length).toBe(1));
221
+ expect(writeCalls[0]).toEqual({
222
+ type: "tasks:write:task:delete",
223
+ payload: { id: "task-1" },
224
+ });
225
+ });
226
+
227
+ test("entityEdit create-mode: kein Delete-Button (keine entity-id → nichts zu löschen)", () => {
228
+ render(
229
+ <DispatcherProvider dispatcher={makeDispatcher()}>
230
+ <KumikoScreen schema={schema} qn="tasks:screen:task-edit" />
231
+ </DispatcherProvider>,
232
+ );
233
+ expect(screen.queryByTestId("render-edit-delete")).toBeNull();
234
+ });
235
+
236
+ test("entityEdit update-mode: version_conflict → Banner + 'Neu laden' triggert detail-refetch", async () => {
237
+ let detailCalls = 0;
238
+ const dispatcher = makeDispatcher({
239
+ query: (async () => {
240
+ detailCalls += 1;
241
+ return {
242
+ isSuccess: true,
243
+ data: {
244
+ id: "task-1",
245
+ version: detailCalls,
246
+ title: `v${detailCalls}`,
247
+ count: 0,
248
+ done: false,
249
+ },
250
+ };
251
+ }) as unknown as Dispatcher["query"],
252
+ write: (async () => ({
253
+ isSuccess: false,
254
+ error: {
255
+ code: "version_conflict",
256
+ httpStatus: 409,
257
+ i18nKey: "errors.versionConflict",
258
+ message: "stale",
259
+ },
260
+ })) as unknown as Dispatcher["write"],
261
+ });
262
+
263
+ render(
264
+ <DispatcherProvider dispatcher={dispatcher}>
265
+ <KumikoScreen schema={schema} qn="tasks:screen:task-edit" entityId="task-1" />
266
+ </DispatcherProvider>,
267
+ );
268
+
269
+ await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
270
+ const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
271
+
272
+ // Dirty machen damit der Submit überhaupt feuert.
273
+ fireEvent.change(titleInput, { target: { value: "edited" } });
274
+ fireEvent.click(screen.getByTestId("render-edit-submit"));
275
+
276
+ // Banner muss den i18nKey zeigen und einen Reload-Button anbieten.
277
+ await waitFor(() => expect(screen.queryByTestId("render-edit-form-error")).toBeTruthy());
278
+ expect(screen.getByTestId("render-edit-form-error-key").textContent).toBe(
279
+ "errors.versionConflict",
280
+ );
281
+
282
+ expect(detailCalls).toBe(1);
283
+ fireEvent.click(screen.getByTestId("render-edit-form-error-reload"));
284
+ await waitFor(() => expect(detailCalls).toBe(2));
285
+ // Banner verschwindet nach dem Reload.
286
+ expect(screen.queryByTestId("render-edit-form-error")).toBeNull();
287
+ });
288
+
289
+ // RowActions-Mapping (Tier 2.7a Resolution-Layer): pinst dass
290
+ // EntityListBody die Schema-Form (handler-QN, label-i18nKey, payload-
291
+ // builder, visible-Function, confirmLabel) zu DataTableRowAction
292
+ // (translated, dispatcher-resolved) korrekt transformiert. Vorher
293
+ // nur indirekt über DataTable-Tests + manuelle Inspection abgedeckt.
294
+ test("entityList rowActions: Schema → translate + dispatch wiring", async () => {
295
+ const writeCalls: { type: string; payload: unknown }[] = [];
296
+ const dispatcher = makeDispatcher({
297
+ query: (async () => ({
298
+ isSuccess: true,
299
+ data: {
300
+ rows: [{ id: "r1", title: "Alpha", count: 1, done: false }],
301
+ nextCursor: null,
302
+ },
303
+ })) as unknown as Dispatcher["query"],
304
+ write: (async (type: string, payload: unknown) => {
305
+ writeCalls.push({ type, payload });
306
+ return { isSuccess: true, data: {} };
307
+ }) as unknown as Dispatcher["write"],
308
+ });
309
+
310
+ const screenWithActions: EntityListScreenDefinition = {
311
+ id: "task-list",
312
+ type: "entityList",
313
+ entity: "task",
314
+ columns: ["title"],
315
+ rowActions: [
316
+ {
317
+ id: "archive",
318
+ label: "actions.archive",
319
+ handler: "tasks:write:task:archive",
320
+ payload: (row) => ({ id: row["id"], reason: "manual" }),
321
+ },
322
+ ],
323
+ };
324
+ const schemaWithActions: FeatureSchema = {
325
+ ...schema,
326
+ screens: [screenWithActions],
327
+ };
328
+
329
+ render(
330
+ <DispatcherProvider dispatcher={dispatcher}>
331
+ <KumikoScreen schema={schemaWithActions} qn="tasks:screen:task-list" />
332
+ </DispatcherProvider>,
333
+ );
334
+ await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
335
+
336
+ // Inline-Button mit der ID aus dem Schema. Label kommt durch
337
+ // translate() — fallback ist der Key wenn kein Bundle (test-utils
338
+ // mountet eins mit identity-translator).
339
+ const button = screen.getByTestId("row-r1-action-archive");
340
+ expect(button).toBeTruthy();
341
+
342
+ // Click → dispatcher.write mit handler-QN + custom payload (NICHT
343
+ // default `{id}`, sondern der schema-payload-builder muss greifen).
344
+ fireEvent.click(button);
345
+ await waitFor(() => expect(writeCalls.length).toBe(1));
346
+ expect(writeCalls[0]).toEqual({
347
+ type: "tasks:write:task:archive",
348
+ payload: { id: "r1", reason: "manual" },
349
+ });
350
+ });
351
+
352
+ // Tier 2.7e-1: rowAction kind="navigate" — Click ruft nav.navigate
353
+ // mit screen-id, ggf. mit URL-Search-Params aus params(row).
354
+ test("entityList rowActions kind=navigate: Click → nav.navigate + setSearchParams", async () => {
355
+ const navigateCalls: { screenId: string }[] = [];
356
+ const searchParamUpdates: Record<string, string | null>[] = [];
357
+ const memoryNav = {
358
+ route: { screenId: "task-list" },
359
+ navigate: (target: { screenId: string }) => navigateCalls.push(target),
360
+ replace: () => undefined,
361
+ hrefFor: (t: { screenId: string }) => `/${t.screenId}`,
362
+ searchParams: {},
363
+ setSearchParams: (u: Record<string, string | null>) => searchParamUpdates.push(u),
364
+ };
365
+ const dispatcher = makeDispatcher({
366
+ query: (async () => ({
367
+ isSuccess: true,
368
+ data: {
369
+ rows: [{ id: "r1", title: "Alpha", count: 1, done: false }],
370
+ nextCursor: null,
371
+ },
372
+ })) as unknown as Dispatcher["query"],
373
+ });
374
+
375
+ const screenWithNav: EntityListScreenDefinition = {
376
+ id: "task-list",
377
+ type: "entityList",
378
+ entity: "task",
379
+ columns: ["title"],
380
+ rowActions: [
381
+ {
382
+ kind: "navigate",
383
+ id: "edit",
384
+ label: "actions.edit",
385
+ screen: "task-edit",
386
+ params: (row) => ({ taskId: row["id"], priority: 5 }),
387
+ },
388
+ ],
389
+ };
390
+
391
+ const { NavProvider } = await import("@cosmicdrift/kumiko-renderer");
392
+ const user = userEvent.setup();
393
+ render(
394
+ <NavProvider value={memoryNav}>
395
+ <DispatcherProvider dispatcher={dispatcher}>
396
+ <KumikoScreen
397
+ schema={{ ...schema, screens: [screenWithNav] }}
398
+ qn="tasks:screen:task-list"
399
+ />
400
+ </DispatcherProvider>
401
+ </NavProvider>,
402
+ );
403
+ await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
404
+
405
+ await user.click(screen.getByTestId("row-r1-action-edit"));
406
+ await waitFor(() => expect(navigateCalls.length).toBe(1));
407
+ expect(navigateCalls[0]).toEqual({ screenId: "task-edit" });
408
+ // params werden zu Strings serialisiert (URL-Layer kennt nur Strings).
409
+ expect(searchParamUpdates).toEqual([{ taskId: "r1", priority: "5" }]);
410
+ });
411
+
412
+ test("entityList rowActions kind=navigate ohne params: setSearchParams wird NICHT gerufen", async () => {
413
+ const navigateCalls: { screenId: string }[] = [];
414
+ const searchParamUpdates: Record<string, string | null>[] = [];
415
+ const memoryNav = {
416
+ route: { screenId: "task-list" },
417
+ navigate: (target: { screenId: string }) => navigateCalls.push(target),
418
+ replace: () => undefined,
419
+ hrefFor: (t: { screenId: string }) => `/${t.screenId}`,
420
+ searchParams: {},
421
+ setSearchParams: (u: Record<string, string | null>) => searchParamUpdates.push(u),
422
+ };
423
+ const dispatcher = makeDispatcher({
424
+ query: (async () => ({
425
+ isSuccess: true,
426
+ data: {
427
+ rows: [{ id: "r1", title: "Alpha", count: 1, done: false }],
428
+ nextCursor: null,
429
+ },
430
+ })) as unknown as Dispatcher["query"],
431
+ });
432
+
433
+ const screenWithNav: EntityListScreenDefinition = {
434
+ id: "task-list",
435
+ type: "entityList",
436
+ entity: "task",
437
+ columns: ["title"],
438
+ rowActions: [{ kind: "navigate", id: "view", label: "actions.view", screen: "task-edit" }],
439
+ };
440
+ const { NavProvider } = await import("@cosmicdrift/kumiko-renderer");
441
+ const user = userEvent.setup();
442
+ render(
443
+ <NavProvider value={memoryNav}>
444
+ <DispatcherProvider dispatcher={dispatcher}>
445
+ <KumikoScreen
446
+ schema={{ ...schema, screens: [screenWithNav] }}
447
+ qn="tasks:screen:task-list"
448
+ />
449
+ </DispatcherProvider>
450
+ </NavProvider>,
451
+ );
452
+ await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
453
+
454
+ await user.click(screen.getByTestId("row-r1-action-view"));
455
+ await waitFor(() => expect(navigateCalls.length).toBe(1));
456
+ expect(searchParamUpdates).toEqual([]);
457
+ });
458
+
459
+ // toolbarActions: Schema-Form (kind: navigate | writeHandler) →
460
+ // Resolved-Form (onTrigger callback). Pinst beide kinds:
461
+ // - navigate dispatch ein nav.navigate({ screenId })
462
+ // - writeHandler dispatched dispatcher.write(handler, payload?())
463
+ test("entityList toolbarActions navigate-kind: Click → nav.navigate", async () => {
464
+ const navigateCalls: { screenId: string }[] = [];
465
+ const dispatcher = makeDispatcher({
466
+ query: (async () => ({
467
+ isSuccess: true,
468
+ data: { rows: [{ id: "r1", title: "x", count: 0, done: false }], nextCursor: null },
469
+ })) as unknown as Dispatcher["query"],
470
+ });
471
+ const memoryNav = {
472
+ route: { screenId: "task-list" },
473
+ navigate: (target: { screenId: string }) => navigateCalls.push(target),
474
+ replace: () => undefined,
475
+ hrefFor: (t: { screenId: string }) => `/${t.screenId}`,
476
+ searchParams: {},
477
+ setSearchParams: () => undefined,
478
+ };
479
+ const screenWithToolbar: EntityListScreenDefinition = {
480
+ id: "task-list",
481
+ type: "entityList",
482
+ entity: "task",
483
+ columns: ["title"],
484
+ toolbarActions: [
485
+ { kind: "navigate", id: "open", label: "actions.open", screen: "task-edit" },
486
+ ],
487
+ };
488
+
489
+ const { NavProvider } = await import("@cosmicdrift/kumiko-renderer");
490
+ const user = userEvent.setup();
491
+ render(
492
+ <NavProvider value={memoryNav}>
493
+ <DispatcherProvider dispatcher={dispatcher}>
494
+ <KumikoScreen
495
+ schema={{ ...schema, screens: [screenWithToolbar] }}
496
+ qn="tasks:screen:task-list"
497
+ />
498
+ </DispatcherProvider>
499
+ </NavProvider>,
500
+ );
501
+ await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
502
+
503
+ await user.click(screen.getByTestId("render-list-toolbar-action-open"));
504
+ expect(navigateCalls).toEqual([{ screenId: "task-edit" }]);
505
+ });
506
+
507
+ test("entityList toolbarActions writeHandler-kind: Click → dispatcher.write", async () => {
508
+ const writeCalls: { type: string; payload: unknown }[] = [];
509
+ const dispatcher = makeDispatcher({
510
+ query: (async () => ({
511
+ isSuccess: true,
512
+ data: { rows: [{ id: "r1", title: "x", count: 0, done: false }], nextCursor: null },
513
+ })) as unknown as Dispatcher["query"],
514
+ write: (async (type: string, payload: unknown) => {
515
+ writeCalls.push({ type, payload });
516
+ return { isSuccess: true, data: {} };
517
+ }) as unknown as Dispatcher["write"],
518
+ });
519
+ const screenWithToolbar: EntityListScreenDefinition = {
520
+ id: "task-list",
521
+ type: "entityList",
522
+ entity: "task",
523
+ columns: ["title"],
524
+ toolbarActions: [
525
+ {
526
+ kind: "writeHandler",
527
+ id: "sync",
528
+ label: "actions.sync",
529
+ handler: "tasks:write:task:sync",
530
+ payload: () => ({ all: true }),
531
+ },
532
+ ],
533
+ };
534
+
535
+ const user = userEvent.setup();
536
+ render(
537
+ <DispatcherProvider dispatcher={dispatcher}>
538
+ <KumikoScreen
539
+ schema={{ ...schema, screens: [screenWithToolbar] }}
540
+ qn="tasks:screen:task-list"
541
+ />
542
+ </DispatcherProvider>,
543
+ );
544
+ await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
545
+
546
+ await user.click(screen.getByTestId("render-list-toolbar-action-sync"));
547
+ await waitFor(() => expect(writeCalls.length).toBe(1));
548
+ expect(writeCalls[0]).toEqual({ type: "tasks:write:task:sync", payload: { all: true } });
549
+ });
550
+
551
+ // Tier 2.7c: Screen-Level filter wird vom Schema in den Query-
552
+ // Payload propagiert. Drei Buckets ("scheduled" / "active" / "done")
553
+ // teilen sich denselben Query-Handler — der Filter unterscheidet
554
+ // welche Rows kommen.
555
+ test("entityList screen-filter: schema.filter landet im query-payload", async () => {
556
+ const queryCalls: { type: string; payload: unknown }[] = [];
557
+ const dispatcher = makeDispatcher({
558
+ query: (async (type: string, payload: unknown) => {
559
+ queryCalls.push({ type, payload });
560
+ return {
561
+ isSuccess: true,
562
+ data: { rows: [], nextCursor: null },
563
+ };
564
+ }) as unknown as Dispatcher["query"],
565
+ });
566
+
567
+ const filteredScreen: EntityListScreenDefinition = {
568
+ id: "task-list",
569
+ type: "entityList",
570
+ entity: "task",
571
+ columns: ["title"],
572
+ filter: { field: "status", op: "eq", value: "scheduled" },
573
+ };
574
+
575
+ render(
576
+ <DispatcherProvider dispatcher={dispatcher}>
577
+ <KumikoScreen
578
+ schema={{ ...schema, screens: [filteredScreen] }}
579
+ qn="tasks:screen:task-list"
580
+ />
581
+ </DispatcherProvider>,
582
+ );
583
+ await waitFor(() => expect(queryCalls.length).toBeGreaterThan(0));
584
+
585
+ const firstCall = queryCalls[0];
586
+ expect(firstCall?.type).toBe("tasks:query:task:list");
587
+ const payload = firstCall?.payload as { filter?: unknown };
588
+ expect(payload.filter).toEqual({ field: "status", op: "eq", value: "scheduled" });
589
+ });
590
+
591
+ // Regression-Guard: Default-Pfad (kein screen.filter) darf KEIN
592
+ // filter-Feld in den queryPayload schicken. Sonst würde Zod-Strict
593
+ // ein leeres `filter: undefined` als 400 abweisen, oder ein
594
+ // "match-none"-Default-Drift entstehen.
595
+ test("entityList ohne screen.filter: queryPayload hat kein filter-Feld", async () => {
596
+ const queryCalls: { type: string; payload: unknown }[] = [];
597
+ const dispatcher = makeDispatcher({
598
+ query: (async (type: string, payload: unknown) => {
599
+ queryCalls.push({ type, payload });
600
+ return {
601
+ isSuccess: true,
602
+ data: { rows: [], nextCursor: null },
603
+ };
604
+ }) as unknown as Dispatcher["query"],
605
+ });
606
+
607
+ render(
608
+ <DispatcherProvider dispatcher={dispatcher}>
609
+ <KumikoScreen schema={schema} qn="tasks:screen:task-list" />
610
+ </DispatcherProvider>,
611
+ );
612
+ await waitFor(() => expect(queryCalls.length).toBeGreaterThan(0));
613
+
614
+ const payload = queryCalls[0]?.payload as { filter?: unknown };
615
+ expect("filter" in payload).toBe(false);
616
+ });
617
+
618
+ test("entityList rowActions visible-filter: hidden Action erscheint nicht im DOM", async () => {
619
+ const dispatcher = makeDispatcher({
620
+ query: (async () => ({
621
+ isSuccess: true,
622
+ data: {
623
+ rows: [
624
+ { id: "r1", title: "Open", status: "scheduled", count: 0, done: false },
625
+ { id: "r2", title: "Done", status: "completed", count: 0, done: true },
626
+ ],
627
+ nextCursor: null,
628
+ },
629
+ })) as unknown as Dispatcher["query"],
630
+ });
631
+
632
+ const screenWithVisible: EntityListScreenDefinition = {
633
+ id: "task-list",
634
+ type: "entityList",
635
+ entity: "task",
636
+ columns: ["title"],
637
+ rowActions: [
638
+ {
639
+ id: "start",
640
+ label: "actions.start",
641
+ handler: "tasks:write:task:start",
642
+ // Nur sichtbar bei status===scheduled
643
+ visible: (row: unknown) => (row as { status?: string }).status === "scheduled",
644
+ },
645
+ ],
646
+ };
647
+
648
+ render(
649
+ <DispatcherProvider dispatcher={dispatcher}>
650
+ <KumikoScreen
651
+ schema={{ ...schema, screens: [screenWithVisible] }}
652
+ qn="tasks:screen:task-list"
653
+ />
654
+ </DispatcherProvider>,
655
+ );
656
+ await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
657
+
658
+ expect(screen.queryByTestId("row-r1-action-start")).not.toBeNull();
659
+ expect(screen.queryByTestId("row-r2-action-start")).toBeNull();
660
+ });
661
+
662
+ // --- actionForm (Tier 2.7d) ---
663
+ // Non-CRUD Write-Handler-driven Form. Schema deklariert handler-QN +
664
+ // inline fields; Renderer baut darauf den selben RenderEdit-Stack
665
+ // wie entityEdit, aber Submit ruft den deklarierten handler statt
666
+ // <feature>:write:<entity>:create.
667
+ test("actionForm: rendert Form-Felder + Submit triggert dispatcher.write(handler, values)", async () => {
668
+ const writeCalls: { type: string; payload: unknown }[] = [];
669
+ const dispatcher = makeDispatcher({
670
+ write: (async (type: string, payload: unknown) => {
671
+ writeCalls.push({ type, payload });
672
+ return { isSuccess: true, data: { id: "new-id" } };
673
+ }) as unknown as Dispatcher["write"],
674
+ });
675
+
676
+ const actionScreen: ActionFormScreenDefinition = {
677
+ id: "quick-add",
678
+ type: "actionForm",
679
+ handler: "tasks:write:task:quick-add",
680
+ fields: {
681
+ title: { type: "text", required: true },
682
+ priority: { type: "number", default: 1 },
683
+ },
684
+ layout: { sections: [{ title: "Basics", fields: ["title", "priority"] }] },
685
+ };
686
+
687
+ render(
688
+ <DispatcherProvider dispatcher={dispatcher}>
689
+ <KumikoScreen schema={{ ...schema, screens: [actionScreen] }} qn="tasks:screen:quick-add" />
690
+ </DispatcherProvider>,
691
+ );
692
+
693
+ expect(screen.getByTestId("render-edit-form")).toBeTruthy();
694
+ const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
695
+ expect(titleInput).toBeTruthy();
696
+ fireEvent.change(titleInput, { target: { value: "New Task" } });
697
+
698
+ fireEvent.click(screen.getByTestId("render-edit-submit"));
699
+ await waitFor(() => expect(writeCalls.length).toBe(1));
700
+ expect(writeCalls[0]?.type).toBe("tasks:write:task:quick-add");
701
+ // payloadMode="values" — alle Form-Werte landen im Payload, nicht
702
+ // nur die geänderten. Defaults (priority=1) bleiben drin.
703
+ expect(writeCalls[0]?.payload).toEqual({ title: "New Task", priority: 1 });
704
+ });
705
+
706
+ test("actionForm mit redirect: nach success → nav.navigate({screenId: redirect})", async () => {
707
+ const navigateCalls: { screenId: string }[] = [];
708
+ const dispatcher = makeDispatcher({
709
+ write: (async () => ({
710
+ isSuccess: true,
711
+ data: { id: "x" },
712
+ })) as unknown as Dispatcher["write"],
713
+ });
714
+ const memoryNav = {
715
+ route: { screenId: "quick-add" },
716
+ navigate: (target: { screenId: string }) => navigateCalls.push(target),
717
+ replace: () => undefined,
718
+ hrefFor: (t: { screenId: string }) => `/${t.screenId}`,
719
+ searchParams: {},
720
+ setSearchParams: () => undefined,
721
+ };
722
+ const actionScreen: ActionFormScreenDefinition = {
723
+ id: "quick-add",
724
+ type: "actionForm",
725
+ handler: "tasks:write:task:quick-add",
726
+ fields: { title: { type: "text", required: true } },
727
+ layout: { sections: [{ title: "x", fields: ["title"] }] },
728
+ redirect: "task-list",
729
+ };
730
+
731
+ const { NavProvider } = await import("@cosmicdrift/kumiko-renderer");
732
+ render(
733
+ <NavProvider value={memoryNav}>
734
+ <DispatcherProvider dispatcher={dispatcher}>
735
+ <KumikoScreen
736
+ schema={{ ...schema, screens: [actionScreen, listScreen] }}
737
+ qn="tasks:screen:quick-add"
738
+ />
739
+ </DispatcherProvider>
740
+ </NavProvider>,
741
+ );
742
+
743
+ const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
744
+ fireEvent.change(titleInput, { target: { value: "go" } });
745
+ fireEvent.click(screen.getByTestId("render-edit-submit"));
746
+ await waitFor(() => expect(navigateCalls.length).toBe(1));
747
+ expect(navigateCalls[0]).toEqual({ screenId: "task-list" });
748
+ });
749
+
750
+ // Tier 2.7e-2: URL-Search-Params füllen die actionForm initial values.
751
+ // Use-case: rowAction kind=navigate setzt `?taskId=r1`, das actionForm
752
+ // sieht es beim Mount und pre-fillt das title-Feld.
753
+ test("actionForm initial values: searchParams überschreiben Field-Defaults", async () => {
754
+ const memoryNav = {
755
+ route: { screenId: "approve" },
756
+ navigate: () => undefined,
757
+ replace: () => undefined,
758
+ hrefFor: () => "/x",
759
+ searchParams: { title: "Pre-filled", priority: "9", isDone: "true" },
760
+ setSearchParams: () => undefined,
761
+ };
762
+ const dispatcher = makeDispatcher();
763
+ const actionScreen: ActionFormScreenDefinition = {
764
+ id: "approve",
765
+ type: "actionForm",
766
+ handler: "tasks:write:task:approve",
767
+ fields: {
768
+ title: { type: "text", default: "default-title" },
769
+ priority: { type: "number", default: 1 },
770
+ isDone: { type: "boolean", default: false },
771
+ },
772
+ layout: {
773
+ sections: [{ title: "x", fields: ["title", "priority", "isDone"] }],
774
+ },
775
+ };
776
+
777
+ const { NavProvider } = await import("@cosmicdrift/kumiko-renderer");
778
+ render(
779
+ <NavProvider value={memoryNav}>
780
+ <DispatcherProvider dispatcher={dispatcher}>
781
+ <KumikoScreen schema={{ ...schema, screens: [actionScreen] }} qn="tasks:screen:approve" />
782
+ </DispatcherProvider>
783
+ </NavProvider>,
784
+ );
785
+
786
+ const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
787
+ expect(titleInput.value).toBe("Pre-filled");
788
+ // Number-coercion: "9" → 9. Erfolgreiche Coercion bedeutet das
789
+ // Number-Input zeigt "9" (nicht den default 1).
790
+ const priorityInput = screen
791
+ .getByTestId("field-priority")
792
+ .querySelector("input") as HTMLInputElement;
793
+ expect(priorityInput.value).toBe("9");
794
+ });
795
+
796
+ test("actionForm initial values: searchParam mit fehlerhafter Number → Default-Fallback", async () => {
797
+ const memoryNav = {
798
+ route: { screenId: "approve" },
799
+ navigate: () => undefined,
800
+ replace: () => undefined,
801
+ hrefFor: () => "/x",
802
+ searchParams: { priority: "not-a-number" },
803
+ setSearchParams: () => undefined,
804
+ };
805
+ const dispatcher = makeDispatcher();
806
+ const actionScreen: ActionFormScreenDefinition = {
807
+ id: "approve",
808
+ type: "actionForm",
809
+ handler: "tasks:write:task:approve",
810
+ fields: { priority: { type: "number", default: 7 } },
811
+ layout: { sections: [{ title: "x", fields: ["priority"] }] },
812
+ };
813
+ const { NavProvider } = await import("@cosmicdrift/kumiko-renderer");
814
+ render(
815
+ <NavProvider value={memoryNav}>
816
+ <DispatcherProvider dispatcher={dispatcher}>
817
+ <KumikoScreen schema={{ ...schema, screens: [actionScreen] }} qn="tasks:screen:approve" />
818
+ </DispatcherProvider>
819
+ </NavProvider>,
820
+ );
821
+ const priorityInput = screen
822
+ .getByTestId("field-priority")
823
+ .querySelector("input") as HTMLInputElement;
824
+ expect(priorityInput.value).toBe("7"); // Fallback auf default
825
+ });
826
+
827
+ test("actionForm submitLabel: i18n-Key landet auf dem Submit-Button (übersteuert default)", () => {
828
+ const dispatcher = makeDispatcher();
829
+ const actionScreen: ActionFormScreenDefinition = {
830
+ id: "approve",
831
+ type: "actionForm",
832
+ handler: "tasks:write:task:approve",
833
+ fields: { note: { type: "text" } },
834
+ layout: { sections: [{ title: "x", fields: ["note"] }] },
835
+ submitLabel: "actions.approve",
836
+ };
837
+
838
+ render(
839
+ <DispatcherProvider dispatcher={dispatcher}>
840
+ <KumikoScreen schema={{ ...schema, screens: [actionScreen] }} qn="tasks:screen:approve" />
841
+ </DispatcherProvider>,
842
+ );
843
+
844
+ // Test-utils mountet einen identity-translator als Fallback —
845
+ // unbekannte Keys returnen den Key selbst, also rendert "actions.approve".
846
+ expect(screen.getByTestId("render-edit-submit").textContent).toBe("actions.approve");
847
+ });
848
+
849
+ test("actionForm ohne redirect: nach success bleibt der User auf der Form (kein navigate)", async () => {
850
+ const navigateCalls: { screenId: string }[] = [];
851
+ const writeCalls: { type: string; payload: unknown }[] = [];
852
+ const dispatcher = makeDispatcher({
853
+ write: (async (type: string, payload: unknown) => {
854
+ writeCalls.push({ type, payload });
855
+ return { isSuccess: true, data: { id: "x" } };
856
+ }) as unknown as Dispatcher["write"],
857
+ });
858
+ const memoryNav = {
859
+ route: { screenId: "quick-add" },
860
+ navigate: (target: { screenId: string }) => navigateCalls.push(target),
861
+ replace: () => undefined,
862
+ hrefFor: (t: { screenId: string }) => `/${t.screenId}`,
863
+ searchParams: {},
864
+ setSearchParams: () => undefined,
865
+ };
866
+ const actionScreen: ActionFormScreenDefinition = {
867
+ id: "quick-add",
868
+ type: "actionForm",
869
+ handler: "tasks:write:task:quick-add",
870
+ fields: { title: { type: "text", required: true } },
871
+ layout: { sections: [{ title: "x", fields: ["title"] }] },
872
+ // redirect bewusst NICHT gesetzt
873
+ };
874
+
875
+ const { NavProvider } = await import("@cosmicdrift/kumiko-renderer");
876
+ render(
877
+ <NavProvider value={memoryNav}>
878
+ <DispatcherProvider dispatcher={dispatcher}>
879
+ <KumikoScreen
880
+ schema={{ ...schema, screens: [actionScreen] }}
881
+ qn="tasks:screen:quick-add"
882
+ />
883
+ </DispatcherProvider>
884
+ </NavProvider>,
885
+ );
886
+
887
+ const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
888
+ fireEvent.change(titleInput, { target: { value: "stay" } });
889
+ fireEvent.click(screen.getByTestId("render-edit-submit"));
890
+ // Auf den Write-Call warten — sonst racet der Test gegen den
891
+ // async submit und prüft navigate-Calls bevor handleSubmitted
892
+ // überhaupt gerufen wurde (waitFor auf "render-edit-form" wäre
893
+ // ein no-op weil die Form sowieso schon mounted ist).
894
+ await waitFor(() => expect(writeCalls.length).toBe(1));
895
+ expect(navigateCalls).toEqual([]);
896
+ });
897
+
898
+ test("custom screen type → placeholder (M4 wires r.uiComponent)", () => {
899
+ const customSchema: FeatureSchema = {
900
+ featureName: "tasks",
901
+ entities: { task: taskEntity },
902
+ screens: [
903
+ {
904
+ id: "dashboard",
905
+ type: "custom",
906
+ renderer: { react: "Dashboard" },
907
+ },
908
+ ],
909
+ };
910
+ render(
911
+ <DispatcherProvider dispatcher={makeDispatcher()}>
912
+ <KumikoScreen schema={customSchema} qn="tasks:screen:dashboard" />
913
+ </DispatcherProvider>,
914
+ );
915
+ expect(screen.getByTestId("kumiko-screen-custom-placeholder")).toBeTruthy();
916
+ });
917
+
918
+ // ------------------------------------------------------------------
919
+ // Auto-Navigate Targets — die drei Hooks im kumiko-screen-Renderer
920
+ // (useNavigateToCreateFor, useNavigateToListAfter, default
921
+ // onRowClick in create-app) ziehen `screenId` aus `schema.screens[].id`
922
+ // und reichen sie an `nav.navigate({ screenId })` durch. Heute hält
923
+ // die Registry SHORT-form-ids in `feature.screens` (siehe
924
+ // packages/framework/src/engine/registry.ts: feature.screens[shortId]
925
+ // = def). Falls dieser Vertrag jemals kippt (Registry stempelt QN-
926
+ // form ein), strippt `lastSegment` defensiv den Prefix — die Tests
927
+ // pinnen beide Pfade.
928
+ // ------------------------------------------------------------------
929
+
930
+ test("entityList + Neu-Button → navigiert mit screenId aus Schema", async () => {
931
+ const navigateCalls: { screenId: string }[] = [];
932
+ const memoryNav = {
933
+ route: { screenId: "task-list" },
934
+ navigate: (target: { screenId: string }) => navigateCalls.push(target),
935
+ replace: () => undefined,
936
+ hrefFor: (t: { screenId: string }) => `/${t.screenId}`,
937
+ searchParams: {},
938
+ setSearchParams: () => undefined,
939
+ };
940
+ const dispatcher = makeDispatcher({
941
+ query: (async () => ({
942
+ isSuccess: true,
943
+ data: { rows: [], nextCursor: null },
944
+ })) as unknown as Dispatcher["query"],
945
+ });
946
+
947
+ const { NavProvider } = await import("@cosmicdrift/kumiko-renderer");
948
+ render(
949
+ <NavProvider value={memoryNav}>
950
+ <DispatcherProvider dispatcher={dispatcher}>
951
+ <KumikoScreen schema={schema} qn="tasks:screen:task-list" />
952
+ </DispatcherProvider>
953
+ </NavProvider>,
954
+ );
955
+ await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
956
+
957
+ fireEvent.click(screen.getByTestId("render-list-create"));
958
+ await waitFor(() => expect(navigateCalls.length).toBe(1));
959
+ // Short-Form-id, nicht QN — sonst würde der Browser auf
960
+ // "/tasks:screen:task-edit" landen und der Re-Lookup würde
961
+ // doppelt-qualifizieren.
962
+ expect(navigateCalls[0]).toEqual({ screenId: "task-edit" });
963
+ });
964
+
965
+ test("Auto-Navigate ist defensiv: schema.screens.id mit Doppel-Punkt-Prefix wird gestrippt", async () => {
966
+ // Defense-in-Depth: falls die Registry irgendwann QN-form-ids in
967
+ // schema.screens stamped, würde useNavigateToCreateFor ohne
968
+ // lastSegment einen QN als screenId weiterreichen → URL doppelt-
969
+ // qualifiziert. Test simuliert diesen hypothetischen Fall.
970
+ const navigateCalls: { screenId: string }[] = [];
971
+ const memoryNav = {
972
+ route: { screenId: "task-list" },
973
+ navigate: (target: { screenId: string }) => navigateCalls.push(target),
974
+ replace: () => undefined,
975
+ hrefFor: (t: { screenId: string }) => `/${t.screenId}`,
976
+ searchParams: {},
977
+ setSearchParams: () => undefined,
978
+ };
979
+ const dispatcher = makeDispatcher({
980
+ query: (async () => ({
981
+ isSuccess: true,
982
+ data: { rows: [], nextCursor: null },
983
+ })) as unknown as Dispatcher["query"],
984
+ });
985
+
986
+ // Hypothetische QN-form Edit-id; List bleibt short, sonst findet
987
+ // KumikoScreen seine eigene List-Sicht nicht (qualifyScreenId
988
+ // arbeitet immer feature-prefix-style).
989
+ const editScreenQn: EntityEditScreenDefinition = {
990
+ ...editScreen,
991
+ id: "tasks:screen:task-edit",
992
+ };
993
+ const mixedSchema: FeatureSchema = {
994
+ ...schema,
995
+ screens: [editScreenQn, listScreen],
996
+ };
997
+
998
+ const { NavProvider } = await import("@cosmicdrift/kumiko-renderer");
999
+ render(
1000
+ <NavProvider value={memoryNav}>
1001
+ <DispatcherProvider dispatcher={dispatcher}>
1002
+ <KumikoScreen schema={mixedSchema} qn="tasks:screen:task-list" />
1003
+ </DispatcherProvider>
1004
+ </NavProvider>,
1005
+ );
1006
+ await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
1007
+
1008
+ fireEvent.click(screen.getByTestId("render-list-create"));
1009
+ await waitFor(() => expect(navigateCalls.length).toBe(1));
1010
+ // lastSegment hat den QN-Prefix gestrippt — ohne den Fix würde
1011
+ // hier "tasks:screen:task-edit" stehen.
1012
+ expect(navigateCalls[0]).toEqual({ screenId: "task-edit" });
1013
+ });
1014
+ });