@cosmicdrift/kumiko-renderer-web 1.0.0 → 2.0.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 (79) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/action-menu.test.tsx +75 -0
  3. package/src/__tests__/breadcrumb.test.tsx +50 -0
  4. package/src/__tests__/create-app.test.tsx +171 -1
  5. package/src/__tests__/dashboard-screen.test.tsx +393 -0
  6. package/src/__tests__/default-app-shell.test.tsx +47 -1
  7. package/src/__tests__/form-action-bar.test.tsx +9 -0
  8. package/src/__tests__/kumiko-screen.test.tsx +155 -1
  9. package/src/__tests__/lightbox.test.tsx +46 -0
  10. package/src/__tests__/nav-tree.test.tsx +122 -0
  11. package/src/__tests__/primitives.test.tsx +32 -1
  12. package/src/__tests__/profile-menu.test.tsx +29 -0
  13. package/src/__tests__/projection-detail.test.tsx +101 -0
  14. package/src/__tests__/projection-list-actions.test.tsx +131 -0
  15. package/src/__tests__/render-list.test.tsx +11 -3
  16. package/src/__tests__/test-utils.tsx +1 -0
  17. package/src/__tests__/toast.test.tsx +9 -9
  18. package/src/__tests__/use-mutation.test.tsx +86 -0
  19. package/src/__tests__/workspace-shell.test.tsx +102 -47
  20. package/src/__tests__/xss-safety.test.tsx +62 -0
  21. package/src/app/__tests__/first-open-screen-qn.test.ts +77 -0
  22. package/src/app/browser-locale.ts +1 -1
  23. package/src/app/create-app.tsx +96 -29
  24. package/src/app/dashboard-body.tsx +454 -0
  25. package/src/index.ts +75 -1
  26. package/src/layout/__tests__/nav-registry.test.ts +105 -0
  27. package/src/layout/__tests__/shell-breadcrumb.test.ts +82 -0
  28. package/src/layout/default-app-shell.tsx +9 -71
  29. package/src/layout/nav-tree.tsx +167 -37
  30. package/src/layout/shell-breadcrumb.ts +46 -0
  31. package/src/layout/shell-header.tsx +114 -0
  32. package/src/layout/workspace-shell.tsx +45 -18
  33. package/src/primitives/__tests__/data-table-logic.test.ts +27 -0
  34. package/src/primitives/__tests__/input-testid.test.tsx +45 -0
  35. package/src/primitives/__tests__/money-input.test.tsx +13 -1
  36. package/src/primitives/dialog.tsx +47 -76
  37. package/src/primitives/index.tsx +215 -37
  38. package/src/primitives/layout.tsx +39 -0
  39. package/src/primitives/lightbox.tsx +39 -0
  40. package/src/primitives/modal-shell.tsx +62 -0
  41. package/src/primitives/money-input.tsx +20 -10
  42. package/src/primitives/toast.tsx +14 -9
  43. package/src/sse/live-events.ts +6 -1
  44. package/src/styles.css +25 -0
  45. package/src/tokens.ts +4 -0
  46. package/src/ui/__tests__/avatar.test.tsx +66 -0
  47. package/src/ui/__tests__/sheet.test.tsx +102 -0
  48. package/src/version/__tests__/update-checker.test.tsx +115 -0
  49. package/src/widgets/__tests__/ai-text-field.test.tsx +267 -0
  50. package/src/widgets/__tests__/drawer.test.tsx +35 -0
  51. package/src/widgets/__tests__/form-kit.test.tsx +368 -0
  52. package/src/widgets/__tests__/infinity-list.test.tsx +132 -0
  53. package/src/widgets/__tests__/query-table.test.tsx +118 -0
  54. package/src/widgets/__tests__/result-panel.test.tsx +117 -0
  55. package/src/widgets/__tests__/widgets.test.tsx +243 -0
  56. package/src/widgets/ai-text-field.tsx +370 -0
  57. package/src/widgets/charts.tsx +228 -0
  58. package/src/widgets/collapsible-section.tsx +42 -0
  59. package/src/widgets/detail-list.tsx +43 -0
  60. package/src/widgets/drawer.tsx +49 -0
  61. package/src/widgets/feed-list.tsx +35 -0
  62. package/src/widgets/form-fields.tsx +326 -0
  63. package/src/widgets/index.ts +59 -0
  64. package/src/widgets/infinity-list.tsx +112 -0
  65. package/src/widgets/mode-switch.tsx +41 -0
  66. package/src/widgets/progress-bar.tsx +27 -0
  67. package/src/widgets/progress-list.tsx +38 -0
  68. package/src/widgets/query-table.tsx +103 -0
  69. package/src/widgets/result-panel.tsx +251 -0
  70. package/src/widgets/section-card.tsx +30 -0
  71. package/src/widgets/stat.tsx +181 -0
  72. package/src/widgets/states.tsx +89 -0
  73. package/src/widgets/status-badge.tsx +51 -0
  74. package/src/widgets/use-draft.ts +40 -0
  75. package/src/ui/card.tsx +0 -93
  76. package/src/ui/collapsible.tsx +0 -34
  77. package/src/ui/dropdown-menu.tsx +0 -258
  78. package/src/ui/select.tsx +0 -191
  79. /package/src/__tests__/{download.test.ts → download.test.tsx} +0 -0
@@ -0,0 +1,46 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { defaultPrimitives } from "../primitives";
4
+ import { render, screen } from "./test-utils";
5
+
6
+ const { Lightbox } = defaultPrimitives;
7
+
8
+ describe("Lightbox", () => {
9
+ test("open=true renders image with src and alt", () => {
10
+ render(
11
+ <Lightbox
12
+ open
13
+ onOpenChange={() => undefined}
14
+ src="/demo.png"
15
+ alt="Product screenshot"
16
+ testId="lb"
17
+ />,
18
+ );
19
+ const img = screen.getByRole("img", { name: "Product screenshot" });
20
+ expect(img.getAttribute("src")).toBe("/demo.png");
21
+ expect(screen.getByTestId("lb")).toBeTruthy();
22
+ });
23
+
24
+ test("open=false renders nothing", () => {
25
+ render(
26
+ <Lightbox
27
+ open={false}
28
+ onOpenChange={() => undefined}
29
+ src="/demo.png"
30
+ alt="Hidden"
31
+ testId="lb-hidden"
32
+ />,
33
+ );
34
+ expect(screen.queryByTestId("lb-hidden")).toBeNull();
35
+ });
36
+
37
+ test("close button calls onOpenChange(false)", async () => {
38
+ const user = userEvent.setup();
39
+ const onOpenChange = mock();
40
+ render(
41
+ <Lightbox open onOpenChange={onOpenChange} src="/demo.png" alt="Preview" testId="lb-close" />,
42
+ );
43
+ await user.click(screen.getByLabelText("Close"));
44
+ expect(onOpenChange).toHaveBeenCalledWith(false);
45
+ });
46
+ });
@@ -224,6 +224,66 @@ describe("NavTree", () => {
224
224
  expect(container.querySelectorAll("svg").length).toBe(2);
225
225
  });
226
226
 
227
+ test("tag + key (custom-fields / api-tokens) lösen auf ein Icon auf", () => {
228
+ const schema = {
229
+ featureName: "showcase",
230
+ entities: {},
231
+ screens: [
232
+ { id: "fields", type: "entityList", entity: "x", columns: [] },
233
+ { id: "tokens", type: "entityList", entity: "x", columns: [] },
234
+ ],
235
+ navs: [
236
+ { id: "fields", label: "Felder", screen: "fields", order: 10, icon: "tag" },
237
+ { id: "tokens", label: "Tokens", screen: "tokens", order: 20, icon: "key" },
238
+ ],
239
+ } as FeatureSchema;
240
+ const { container } = render(<NavTree schema={schema} />);
241
+ expect(container.querySelectorAll("svg").length).toBe(2);
242
+ });
243
+
244
+ test("server, mail, download, rocket lösen auf ein Icon auf (Config-/SMTP-Nav)", () => {
245
+ // Config-Settings-Hub leitet Child-Nav-Icons aus dem mask.icon der
246
+ // ConfigKey ab (smtp-host="server", from="mail", subscription="rocket");
247
+ // fehlten sie in NAV_ICONS, rendert das Nav blank statt Icon.
248
+ const schema = {
249
+ featureName: "app",
250
+ entities: {},
251
+ screens: [
252
+ { id: "a", type: "entityList", entity: "x", columns: [] },
253
+ { id: "b", type: "entityList", entity: "x", columns: [] },
254
+ { id: "c", type: "entityList", entity: "x", columns: [] },
255
+ { id: "d", type: "entityList", entity: "x", columns: [] },
256
+ ],
257
+ navs: [
258
+ { id: "a", label: "SMTP", screen: "a", order: 10, icon: "server" },
259
+ { id: "b", label: "From", screen: "b", order: 20, icon: "mail" },
260
+ { id: "c", label: "Export", screen: "c", order: 30, icon: "download" },
261
+ { id: "d", label: "Billing", screen: "d", order: 40, icon: "rocket" },
262
+ ],
263
+ } as FeatureSchema;
264
+ const { container } = render(<NavTree schema={schema} />);
265
+ expect(container.querySelectorAll("svg").length).toBe(4);
266
+ });
267
+
268
+ test("palette, link und share rendern Lucide-Icons (Share-/Branding-Nav)", () => {
269
+ const schema = {
270
+ featureName: "app",
271
+ entities: {},
272
+ screens: [
273
+ { id: "branding", type: "entityList", entity: "x", columns: [] },
274
+ { id: "links", type: "entityList", entity: "x", columns: [] },
275
+ { id: "defaults", type: "entityList", entity: "x", columns: [] },
276
+ ],
277
+ navs: [
278
+ { id: "branding", label: "Branding", screen: "branding", order: 10, icon: "palette" },
279
+ { id: "links", label: "Links", screen: "links", order: 20, icon: "link" },
280
+ { id: "defaults", label: "Defaults", screen: "defaults", order: 30, icon: "share" },
281
+ ],
282
+ } as FeatureSchema;
283
+ const { container } = render(<NavTree schema={schema} />);
284
+ expect(container.querySelectorAll("svg").length).toBe(3);
285
+ });
286
+
227
287
  test("unbekannter icon-Key fällt sauber auf den Dot zurück (kein svg)", () => {
228
288
  const schema = {
229
289
  featureName: "showcase",
@@ -506,3 +566,65 @@ describe("NavTree dynamic provider nodes", () => {
506
566
  expect(active()).toBe(0); // Unmount baut alles ab → kein Leak
507
567
  });
508
568
  });
569
+
570
+ describe("NavTree Suchfeld", () => {
571
+ // Das Suchfeld ist das einzige <input> im Baum; über die Rolle statt den
572
+ // i18n-Placeholder gefunden (i18n-Bundle resolvt im Worktree gegen den
573
+ // Haupt-Checkout, wo der neue Key noch fehlt — in CI greift er).
574
+
575
+ test("Filter blendet Nicht-Treffer aus, hält Treffer + deren Ancestors", () => {
576
+ render(<NavTree schema={makeSchema()} />);
577
+ const input = screen.getByRole("textbox") as HTMLInputElement;
578
+
579
+ fireEvent.change(input, { target: { value: "active" } });
580
+
581
+ // Treffer selbst sichtbar (case-insensitiv).
582
+ expect(screen.getByText("Active")).toBeTruthy();
583
+ // Nicht-Treffer-Geschwister weg.
584
+ expect(screen.queryByText("Backlog")).toBeNull();
585
+ // Ancestors bleiben, damit der Treffer erreichbar bleibt.
586
+ expect(screen.getByText("Items")).toBeTruthy();
587
+ expect(screen.getByText("Data")).toBeTruthy();
588
+ });
589
+
590
+ test("Filter ohne Treffer leert den Baum; Leeren stellt alles wieder her", () => {
591
+ render(<NavTree schema={makeSchema()} />);
592
+ const input = screen.getByRole("textbox") as HTMLInputElement;
593
+
594
+ fireEvent.change(input, { target: { value: "zzz-nichts" } });
595
+ expect(screen.queryByText("Items")).toBeNull();
596
+ expect(screen.queryByText("Data")).toBeNull();
597
+
598
+ fireEvent.change(input, { target: { value: "" } });
599
+ expect(screen.getByText("Backlog")).toBeTruthy();
600
+ expect(screen.getByText("Data")).toBeTruthy();
601
+ });
602
+ });
603
+
604
+ describe("NavTree folder-open Icon", () => {
605
+ test("Folder-Knoten zeigt folder-open wenn expanded, folder wenn collapsed", async () => {
606
+ const provider: TreeChildrenSubscribe = () => (emit) => {
607
+ emit([{ label: "page", icon: "folder", children: [pageLeaf("hero")] }]);
608
+ return () => {};
609
+ };
610
+ let container: HTMLElement | undefined;
611
+ await act(async () => {
612
+ container = renderDynamic({
613
+ schema: dynamicSchema(),
614
+ providers: new Map([["cms:nav:content", provider]]),
615
+ }).container;
616
+ });
617
+ // Default-expanded → offener Ordner, kein geschlossener.
618
+ expect(container?.querySelector(".lucide-folder-open")).toBeTruthy();
619
+ expect(container?.querySelector(".lucide-folder")).toBeNull();
620
+
621
+ // Der einzige aria-gelabelte Chevron gehört dem "page"-Sub-Ordner
622
+ // (der Provider-Container toggelt inline, ohne aria-Label).
623
+ const chevron = screen.getByRole("button", { name: /Expand|Collapse/ });
624
+ await act(async () => {
625
+ fireEvent.click(chevron);
626
+ });
627
+ expect(container?.querySelector(".lucide-folder")).toBeTruthy();
628
+ expect(container?.querySelector(".lucide-folder-open")).toBeNull();
629
+ });
630
+ });
@@ -10,6 +10,7 @@
10
10
  import { describe, expect, mock, test } from "bun:test";
11
11
  import userEvent from "@testing-library/user-event";
12
12
  import { defaultPrimitives } from "../primitives";
13
+ import { PageSection, Stack } from "../primitives/layout";
13
14
  import { fireEvent, render, screen } from "./test-utils";
14
15
 
15
16
  const { Button, Banner, Field, Input, DataTable, Form, Text, Heading, Dialog, Card } =
@@ -979,7 +980,7 @@ describe("Card", () => {
979
980
  <span>body</span>
980
981
  </Card>,
981
982
  );
982
- expect(screen.getByTestId("c").innerHTML).toContain("p-6");
983
+ expect(screen.getByTestId("c").innerHTML).toContain("p-[var(--card-padding)]");
983
984
  });
984
985
 
985
986
  test("padded=false renders body without padding classes", () => {
@@ -1053,3 +1054,33 @@ describe("Card", () => {
1053
1054
  expect(screen.getByTestId("c").innerHTML).not.toContain("grow");
1054
1055
  });
1055
1056
  });
1057
+
1058
+ describe("Stack", () => {
1059
+ test("gap-Variante bildet auf die Tailwind-gap-Klasse ab", () => {
1060
+ render(
1061
+ <Stack testId="s" gap="lg">
1062
+ <span>a</span>
1063
+ </Stack>,
1064
+ );
1065
+ const el = screen.getByTestId("s");
1066
+ expect(el.className).toContain("flex flex-col");
1067
+ expect(el.className).toContain("gap-6");
1068
+ });
1069
+
1070
+ test("default gap = md", () => {
1071
+ render(<Stack testId="s">x</Stack>);
1072
+ expect(screen.getByTestId("s").className).toContain("gap-4");
1073
+ });
1074
+ });
1075
+
1076
+ describe("PageSection", () => {
1077
+ test("wrappt children mit einheitlichem Padding", () => {
1078
+ render(
1079
+ <PageSection testId="p">
1080
+ <span data-testid="child">x</span>
1081
+ </PageSection>,
1082
+ );
1083
+ expect(screen.getByTestId("p").className).toContain("p-6");
1084
+ expect(screen.getByTestId("child")).toBeDefined();
1085
+ });
1086
+ });
@@ -0,0 +1,29 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+ import { render, screen, waitFor } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { ProfileMenu } from "../layout/profile-menu";
5
+
6
+ describe("ProfileMenu", () => {
7
+ test("wraps Avatar trigger and opens ActionMenu items", async () => {
8
+ const user = userEvent.setup();
9
+ const onSelect = mock(() => {});
10
+ render(
11
+ <ProfileMenu
12
+ user={{ id: "u1", label: "Ada Lovelace" }}
13
+ items={[
14
+ { kind: "item", id: "profile", label: "View profile", onSelect },
15
+ { kind: "separator" },
16
+ { kind: "item", id: "out", label: "Sign out", variant: "danger", onSelect: () => {} },
17
+ ]}
18
+ />,
19
+ );
20
+ const trigger = screen.getByTestId("profile-menu-trigger");
21
+ expect(trigger.getAttribute("aria-label")).toBe("Open Ada Lovelace menu");
22
+ await user.click(trigger);
23
+ await waitFor(() => {
24
+ expect(screen.getByTestId("action-menu-item-profile")).toBeTruthy();
25
+ });
26
+ await user.click(screen.getByTestId("action-menu-item-profile"));
27
+ expect(onSelect).toHaveBeenCalled();
28
+ });
29
+ });
@@ -0,0 +1,101 @@
1
+ // Unit-Tests für den projectionDetail-Screen-Type (read-only single-row
2
+ // inspector, kumiko-framework#255). Deckt den Pfad ab, der Integration/E2E
3
+ // nicht prüft (e2e-generator skippt projectionDetail explizit — kein CRUD):
4
+ // - Row wird über idParam gefetcht, Felder zeigen die Query-Response-Werte
5
+ // - jedes Feld ist readOnly, kein Submit-Button (hasEditableSection=false)
6
+ // - fehlende entityId → Error-Banner statt Crash
7
+
8
+ import { describe, expect, test } from "bun:test";
9
+ import type { ProjectionDetailScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
10
+ import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
11
+ import type { FeatureSchema } from "@cosmicdrift/kumiko-renderer";
12
+ import { DispatcherProvider, KumikoScreen } from "@cosmicdrift/kumiko-renderer";
13
+ import { act, createMockDispatcher, render, screen, waitFor } from "./test-utils";
14
+
15
+ const detailScreen: ProjectionDetailScreenDefinition = {
16
+ id: "session-detail",
17
+ type: "projectionDetail",
18
+ query: "sessions:query:user-session:detail",
19
+ idParam: "id",
20
+ layout: {
21
+ sections: [{ title: "Session", fields: ["userId", "createdAt"] }],
22
+ },
23
+ fieldLabels: {
24
+ userId: "sessions.detail.field.userId",
25
+ createdAt: "sessions.detail.field.createdAt",
26
+ },
27
+ };
28
+
29
+ const schema: FeatureSchema = {
30
+ featureName: "sessions",
31
+ entities: {},
32
+ screens: [detailScreen],
33
+ };
34
+
35
+ describe("KumikoScreen / projectionDetail", () => {
36
+ test("fetches the row via idParam and renders its fields read-only, no submit button", async () => {
37
+ const querySpy = (async (_qn: string, payload: unknown) => {
38
+ expect(payload).toEqual({ id: "sess-1" });
39
+ return {
40
+ isSuccess: true,
41
+ data: { userId: "user-42", createdAt: "2026-07-01T00:00:00Z" },
42
+ };
43
+ }) as unknown as Dispatcher["query"];
44
+ const dispatcher: Dispatcher = createMockDispatcher({ query: querySpy });
45
+
46
+ render(
47
+ <DispatcherProvider dispatcher={dispatcher}>
48
+ <KumikoScreen schema={schema} qn="sessions:screen:session-detail" entityId="sess-1" />
49
+ </DispatcherProvider>,
50
+ );
51
+
52
+ await waitFor(() => screen.getByTestId("render-edit-form"));
53
+ const userIdInput = screen.getByTestId("field-userId").querySelector("input");
54
+ expect(userIdInput?.value).toBe("user-42");
55
+ expect(userIdInput?.disabled).toBe(true);
56
+
57
+ // hasEditableSection() reads readOnly on every field — projectionDetail
58
+ // forces it hard in the shim, so RenderEdit must never draw a Save button.
59
+ expect(screen.queryByTestId("render-edit-submit")).toBeNull();
60
+ });
61
+
62
+ test("missing entityId shows an error banner instead of crashing", async () => {
63
+ let resolveQuery: (value: unknown) => void = () => {};
64
+ const dispatcher: Dispatcher = createMockDispatcher({
65
+ query: (() =>
66
+ new Promise((resolve) => {
67
+ resolveQuery = resolve;
68
+ })) as unknown as Dispatcher["query"],
69
+ });
70
+
71
+ render(
72
+ <DispatcherProvider dispatcher={dispatcher}>
73
+ <KumikoScreen schema={schema} qn="sessions:screen:session-detail" />
74
+ </DispatcherProvider>,
75
+ );
76
+
77
+ expect(screen.getByTestId("kumiko-screen-projection-detail-missing-id")).toBeTruthy();
78
+
79
+ // Screen skips the record entirely without entityId, but useQuery's
80
+ // effect still fired (unconditional hook call) — settle it so its async
81
+ // setState doesn't land after the test unmounts.
82
+ await act(async () => {
83
+ resolveQuery({ isSuccess: true, data: {} });
84
+ await Promise.resolve();
85
+ });
86
+ });
87
+
88
+ test("record not found shows an error banner", async () => {
89
+ const dispatcher: Dispatcher = createMockDispatcher({
90
+ query: (async () => ({ isSuccess: true, data: null })) as unknown as Dispatcher["query"],
91
+ });
92
+
93
+ render(
94
+ <DispatcherProvider dispatcher={dispatcher}>
95
+ <KumikoScreen schema={schema} qn="sessions:screen:session-detail" entityId="sess-missing" />
96
+ </DispatcherProvider>,
97
+ );
98
+
99
+ await waitFor(() => screen.getByTestId("kumiko-screen-record-missing"));
100
+ });
101
+ });
@@ -0,0 +1,131 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+ import type { ProjectionListScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
3
+ import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
4
+ import type { FeatureSchema } from "@cosmicdrift/kumiko-renderer";
5
+ import { DispatcherProvider, KumikoScreen } from "@cosmicdrift/kumiko-renderer";
6
+ import { createMockDispatcher, fireEvent, render, screen, waitFor } from "./test-utils";
7
+
8
+ // writeHandler-Row/Toolbar-Actions auf projectionList — der entityList-
9
+ // Dispatch-Pfad gilt jetzt auch hier (vorher v1: nur navigate).
10
+
11
+ const projectionScreen: ProjectionListScreenDefinition = {
12
+ id: "maintenance-list",
13
+ type: "projectionList",
14
+ query: "status:query:maintenance:upcoming",
15
+ columns: [{ field: "name", label: "status:col:name" }],
16
+ rowActions: [
17
+ {
18
+ kind: "writeHandler",
19
+ id: "start",
20
+ label: "status:action:start",
21
+ handler: "status:write:maintenance:start",
22
+ },
23
+ ],
24
+ toolbarActions: [
25
+ {
26
+ kind: "writeHandler",
27
+ id: "sync",
28
+ label: "status:action:sync",
29
+ handler: "status:write:maintenance:sync",
30
+ payload: { source: "manual" },
31
+ },
32
+ ],
33
+ };
34
+
35
+ const schema: FeatureSchema = {
36
+ featureName: "status",
37
+ entities: {},
38
+ screens: [projectionScreen],
39
+ };
40
+
41
+ function makeDispatcher(write: Dispatcher["write"]): Dispatcher {
42
+ return {
43
+ ...createMockDispatcher({
44
+ query: (async () => ({
45
+ isSuccess: true,
46
+ data: { rows: [{ id: "m1", name: "DB-Upgrade" }], nextCursor: null },
47
+ })) as unknown as Dispatcher["query"],
48
+ }),
49
+ write,
50
+ };
51
+ }
52
+
53
+ describe("projectionList writeHandler-Actions", () => {
54
+ test("Row-Action dispatcht den Handler mit Default-Payload {id}", async () => {
55
+ const write = mock(async (_type: string, _payload: unknown) => ({
56
+ isSuccess: true,
57
+ data: {},
58
+ }));
59
+ render(
60
+ <DispatcherProvider dispatcher={makeDispatcher(write as unknown as Dispatcher["write"])}>
61
+ <KumikoScreen schema={schema} qn="status:screen:maintenance-list" />
62
+ </DispatcherProvider>,
63
+ );
64
+ await waitFor(() => expect(screen.getByText("DB-Upgrade")).toBeTruthy());
65
+
66
+ fireEvent.click(screen.getByRole("button", { name: "status:action:start" }));
67
+ await waitFor(() => expect(write).toHaveBeenCalledTimes(1));
68
+ expect(write.mock.calls[0]?.[0]).toBe("status:write:maintenance:start");
69
+ expect(write.mock.calls[0]?.[1]).toEqual({ id: "m1" });
70
+ });
71
+
72
+ test("Toolbar-Action dispatcht den Handler mit deklariertem Payload", async () => {
73
+ const write = mock(async (_type: string, _payload: unknown) => ({
74
+ isSuccess: true,
75
+ data: {},
76
+ }));
77
+ render(
78
+ <DispatcherProvider dispatcher={makeDispatcher(write as unknown as Dispatcher["write"])}>
79
+ <KumikoScreen schema={schema} qn="status:screen:maintenance-list" />
80
+ </DispatcherProvider>,
81
+ );
82
+ await waitFor(() => expect(screen.getByText("DB-Upgrade")).toBeTruthy());
83
+
84
+ fireEvent.click(screen.getByRole("button", { name: "status:action:sync" }));
85
+ await waitFor(() => expect(write).toHaveBeenCalledTimes(1));
86
+ expect(write.mock.calls[0]?.[0]).toBe("status:write:maintenance:sync");
87
+ expect(write.mock.calls[0]?.[1]).toEqual({ source: "manual" });
88
+ });
89
+
90
+ // Prod-Bug 2026-06-07 (siehe useRowActionTrigger): ein verschluckter
91
+ // Write-Fehler sah für den User wie "nichts passiert" aus. Row- UND
92
+ // Toolbar-Action auf projectionList müssen denselben Surfacing-Pfad wie
93
+ // entityList nehmen (Toast statt stiller no-op).
94
+ test("Row-Action-Fehler wird als Toast surfaced, nicht verschluckt", async () => {
95
+ const write = mock(async (_type: string, _payload: unknown) => ({
96
+ isSuccess: false,
97
+ error: { code: "internal_error", httpStatus: 500, message: "maintenance start failed" },
98
+ }));
99
+ const { ToastProvider } = await import("../primitives/toast");
100
+ render(
101
+ <ToastProvider>
102
+ <DispatcherProvider dispatcher={makeDispatcher(write as unknown as Dispatcher["write"])}>
103
+ <KumikoScreen schema={schema} qn="status:screen:maintenance-list" />
104
+ </DispatcherProvider>
105
+ </ToastProvider>,
106
+ );
107
+ await waitFor(() => expect(screen.getByText("DB-Upgrade")).toBeTruthy());
108
+
109
+ fireEvent.click(screen.getByRole("button", { name: "status:action:start" }));
110
+ expect(await screen.findByText("maintenance start failed")).toBeTruthy();
111
+ });
112
+
113
+ test("Toolbar-Action-Fehler wird als Toast surfaced, nicht verschluckt", async () => {
114
+ const write = mock(async (_type: string, _payload: unknown) => ({
115
+ isSuccess: false,
116
+ error: { code: "internal_error", httpStatus: 500, message: "maintenance sync failed" },
117
+ }));
118
+ const { ToastProvider } = await import("../primitives/toast");
119
+ render(
120
+ <ToastProvider>
121
+ <DispatcherProvider dispatcher={makeDispatcher(write as unknown as Dispatcher["write"])}>
122
+ <KumikoScreen schema={schema} qn="status:screen:maintenance-list" />
123
+ </DispatcherProvider>
124
+ </ToastProvider>,
125
+ );
126
+ await waitFor(() => expect(screen.getByText("DB-Upgrade")).toBeTruthy());
127
+
128
+ fireEvent.click(screen.getByRole("button", { name: "status:action:sync" }));
129
+ expect(await screen.findByText("maintenance sync failed")).toBeTruthy();
130
+ });
131
+ });
@@ -1,4 +1,4 @@
1
- import { describe, expect, mock, spyOn, test } from "bun:test";
1
+ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
2
2
  import type {
3
3
  EntityDefinition,
4
4
  EntityListScreenDefinition,
@@ -188,13 +188,21 @@ describe("RenderList — slots.header", () => {
188
188
  });
189
189
 
190
190
  test("slots.header gesetzt, aber Component nicht registriert → kein Crash, kein Header", () => {
191
- const warn = spyOn(console, "warn").mockImplementation(() => {});
191
+ spyOn(console, "warn").mockImplementation(() => {});
192
192
  render(
193
193
  <ExtensionSectionsProvider value={{}}>
194
194
  <RenderList screen={screenWithHeader} entity={taskEntity} rows={[]} featureName="tasks" />
195
195
  </ExtensionSectionsProvider>,
196
196
  );
197
197
  expect(screen.queryByTestId("list-header-slot")).toBeNull();
198
- warn.mockRestore();
199
198
  });
200
199
  });
200
+
201
+ // 573/1: a mid-test assertion throw would skip the trailing warn.mockRestore()
202
+ // above, leaking the spy into every later test — this backstop restores
203
+ // unconditionally regardless of how the test exited.
204
+ afterEach(() => {
205
+ // biome-ignore lint/suspicious/noConsole: reading the spy handle to restore it, not logging
206
+ const warn = console.warn as unknown as { mockRestore?: () => void };
207
+ warn.mockRestore?.();
208
+ });
@@ -157,6 +157,7 @@ export function createMockDispatcher(options: MockDispatcherOptions = {}): Dispa
157
157
  options.batch ??
158
158
  ((async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"]),
159
159
  statusStore,
160
+ async *stream() {},
160
161
  pendingWrites: () => [],
161
162
  pendingFiles: () => [],
162
163
  };
@@ -1,9 +1,9 @@
1
1
  //
2
2
  // ToastProvider + useToast pinnt: toast() rendert Title+Description in
3
- // einem Radix-Toast; mehrere toasts stapeln; Variant=destructive setzt
4
- // die destructive-Klasse; useToast außerhalb des Providers ist no-op
5
- // (kein crash); IDs sind kollisionsfrei auch bei zwei Calls im selben
6
- // Tick (Counter-Race-Bug).
3
+ // einem Radix-Toast; mehrere toasts stapeln; Variant=bad setzt die
4
+ // StatusTone-Klasse; useToast außerhalb des Providers ist no-op (kein
5
+ // crash); IDs sind kollisionsfrei auch bei zwei Calls im selben Tick
6
+ // (Counter-Race-Bug).
7
7
 
8
8
  import { describe, expect, test } from "bun:test";
9
9
  import { act, fireEvent, screen } from "@testing-library/react";
@@ -49,7 +49,7 @@ describe("ToastProvider + useToast", () => {
49
49
  options={[
50
50
  {
51
51
  title: "Konflikt",
52
- variant: "destructive",
52
+ variant: "bad",
53
53
  docsUrl: "https://docs.kumiko.rocks/errors/stale_state",
54
54
  },
55
55
  ]}
@@ -88,18 +88,18 @@ describe("ToastProvider + useToast", () => {
88
88
  expect(screen.queryByRole("link")).toBeNull();
89
89
  });
90
90
 
91
- test("variant=destructive: setzt die destructive-Klasse auf den Root", () => {
91
+ test("variant=bad: setzt die status-bad StatusTone-Klasse auf den Root", () => {
92
92
  render(
93
93
  <ToastProvider>
94
- <ToastTrigger options={[{ title: "Failed", variant: "destructive" }]} />
94
+ <ToastTrigger options={[{ title: "Failed", variant: "bad" }]} />
95
95
  </ToastProvider>,
96
96
  );
97
97
  // Class-Mapping ist der Public-Vertrag mit Tailwind-Tokens. Wir
98
98
  // suchen den nearest Ancestor des Title-Knotens dessen Klassen-
99
- // String "destructive" enthält — Radix-Toast.Root rendert in einem
99
+ // String "status-bad" enthält — Radix-Toast.Root rendert in einem
100
100
  // <li>, aber die genaue Role wechselt je nach priority/type.
101
101
  let node: HTMLElement | null = screen.getByText("Failed");
102
- while (node !== null && !node.className.includes("destructive")) {
102
+ while (node !== null && !node.className.includes("status-bad")) {
103
103
  node = node.parentElement;
104
104
  }
105
105
  expect(node).not.toBeNull();
@@ -0,0 +1,86 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+ import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
3
+ import { DispatcherProvider, useMutation } from "@cosmicdrift/kumiko-renderer";
4
+ import type { ReactNode } from "react";
5
+ import { act, createMockDispatcher, renderHook, waitFor } from "./test-utils";
6
+
7
+ function wrap(dispatcher: Dispatcher) {
8
+ return ({ children }: { children: ReactNode }) => (
9
+ <DispatcherProvider dispatcher={dispatcher}>{children}</DispatcherProvider>
10
+ );
11
+ }
12
+
13
+ describe("useMutation", () => {
14
+ // #902/2: two overlapping calls on one instance (list-row actions sharing
15
+ // it) must not let the earlier-STARTED, later-RESOLVING call clobber the
16
+ // state set by the later-started, earlier-resolving one.
17
+ test("out-of-order resolution: state reflects the most recently started call, not the most recently resolved one", async () => {
18
+ let resolveFirst: (() => void) | undefined;
19
+ const write = mock(async (_type: string, payload: unknown) => {
20
+ if ((payload as { id: string }).id === "row-1") {
21
+ await new Promise<void>((resolve) => {
22
+ resolveFirst = resolve;
23
+ });
24
+ return { isSuccess: true, data: { id: "row-1" } };
25
+ }
26
+ return { isSuccess: true, data: { id: "row-2" } };
27
+ });
28
+ const dispatcher = createMockDispatcher({ write: write as unknown as Dispatcher["write"] });
29
+ const { result } = renderHook(() => useMutation("row:update"), {
30
+ wrapper: wrap(dispatcher),
31
+ });
32
+
33
+ let firstCallDone: Promise<unknown>;
34
+ let secondCallDone: Promise<unknown>;
35
+ act(() => {
36
+ firstCallDone = result.current.mutate({ id: "row-1" });
37
+ });
38
+ await act(async () => {
39
+ secondCallDone = result.current.mutate({ id: "row-2" });
40
+ // let the second (no-delay) call resolve before the first
41
+ await Promise.resolve();
42
+ });
43
+
44
+ await waitFor(() => expect(result.current.data).toEqual({ id: "row-2" }));
45
+ expect(result.current.pending).toBe(false); // row-2 (the latest call) already settled
46
+
47
+ await act(async () => {
48
+ resolveFirst?.();
49
+ await firstCallDone;
50
+ await secondCallDone;
51
+ });
52
+
53
+ // The first call resolving late must not overwrite row-2's state.
54
+ expect(result.current.data).toEqual({ id: "row-2" });
55
+ expect(result.current.pending).toBe(false);
56
+ });
57
+
58
+ test("reset() invalidates a still-pending call's late state update", async () => {
59
+ let resolveWrite: (() => void) | undefined;
60
+ const write = mock(async () => {
61
+ await new Promise<void>((resolve) => {
62
+ resolveWrite = resolve;
63
+ });
64
+ return { isSuccess: true, data: { id: "late" } };
65
+ });
66
+ const dispatcher = createMockDispatcher({ write: write as unknown as Dispatcher["write"] });
67
+ const { result } = renderHook(() => useMutation("row:update"), {
68
+ wrapper: wrap(dispatcher),
69
+ });
70
+
71
+ let mutateDone: Promise<unknown>;
72
+ act(() => {
73
+ mutateDone = result.current.mutate({ id: "x" });
74
+ });
75
+ act(() => {
76
+ result.current.reset();
77
+ });
78
+ await act(async () => {
79
+ resolveWrite?.();
80
+ await mutateDone;
81
+ });
82
+
83
+ expect(result.current.data).toBeNull();
84
+ expect(result.current.pending).toBe(false);
85
+ });
86
+ });