@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,261 @@
1
+ // @vitest-environment jsdom
2
+ import type {
3
+ EntityDefinition,
4
+ EntityEditScreenDefinition,
5
+ EntityListScreenDefinition,
6
+ } from "@cosmicdrift/kumiko-framework/ui-types";
7
+ import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
8
+ import type { ColumnRendererProps, FeatureSchema, NavApi } from "@cosmicdrift/kumiko-renderer";
9
+ import { act, screen, waitFor } from "@testing-library/react";
10
+ import type { ReactNode } from "react";
11
+ import { beforeEach, describe, expect, type MockInstance, test, vi } from "vitest";
12
+ import type { ClientFeatureDefinition } from "../app/client-plugin";
13
+ import { type CreateKumikoAppOptions, createKumikoApp } from "../app/create-app";
14
+ import { createMockDispatcher } from "./test-utils";
15
+
16
+ const taskEntity = {
17
+ fields: {
18
+ title: { type: "text", required: true },
19
+ },
20
+ } as unknown as EntityDefinition;
21
+
22
+ const editScreen: EntityEditScreenDefinition = {
23
+ id: "task-edit",
24
+ type: "entityEdit",
25
+ entity: "task",
26
+ layout: { sections: [{ title: "x", fields: ["title"] }] },
27
+ };
28
+
29
+ const listScreen: EntityListScreenDefinition = {
30
+ id: "task-list",
31
+ type: "entityList",
32
+ entity: "task",
33
+ columns: ["title"],
34
+ };
35
+
36
+ function makeDispatcher(): Dispatcher {
37
+ return createMockDispatcher({
38
+ query: (async () => ({
39
+ isSuccess: true,
40
+ data: { rows: [], nextCursor: null },
41
+ })) as unknown as Dispatcher["query"],
42
+ });
43
+ }
44
+
45
+ function mountRoot(id = "root"): HTMLDivElement {
46
+ const existing = document.getElementById(id);
47
+ if (existing) existing.remove();
48
+ const root = document.createElement("div");
49
+ root.id = id;
50
+ document.body.appendChild(root);
51
+ return root as HTMLDivElement;
52
+ }
53
+
54
+ const baseSchema: FeatureSchema = {
55
+ featureName: "tasks",
56
+ entities: { task: taskEntity },
57
+ screens: [editScreen, listScreen],
58
+ };
59
+
60
+ // createKumikoApp ruft createRoot(...).render(...) direkt auf — React 18+
61
+ // batcht das in einer concurrent-render-phase, deren State-Updates React
62
+ // im Test-Modus als "outside act()" flaggt. Produktions-Code muss nicht in
63
+ // act() wissen; der Test übernimmt das Wrapping an der einzigen
64
+ // Test-eigenen Aufrufstelle. async weil der erste useEffect-Tick in
65
+ // KumikoScreen (useQuery) ebenfalls flushed werden muss.
66
+ async function mountApp(options: CreateKumikoAppOptions): Promise<void> {
67
+ await act(async () => {
68
+ createKumikoApp(options);
69
+ });
70
+ }
71
+
72
+ describe("createKumikoApp", () => {
73
+ // createKumikoApp mounts via createRoot into document.body. Reset
74
+ // between tests so a previous test's mount doesn't leak through
75
+ // and fool the next one into finding stale markup.
76
+ beforeEach(() => {
77
+ while (document.body.firstChild) {
78
+ document.body.removeChild(document.body.firstChild);
79
+ }
80
+ });
81
+
82
+ test("mounts into #root and renders the first screen by default", async () => {
83
+ mountRoot();
84
+ await mountApp({ schema: baseSchema, dispatcher: makeDispatcher() });
85
+ // First screen is entityEdit → form with the title field.
86
+ await waitFor(() => expect(screen.getByTestId("render-edit-form")).toBeTruthy());
87
+ expect(screen.getByTestId("field-title")).toBeTruthy();
88
+ });
89
+
90
+ test("screenQn override: mounts the named screen instead of the first", async () => {
91
+ mountRoot();
92
+ await mountApp({
93
+ schema: baseSchema,
94
+ dispatcher: makeDispatcher(),
95
+ screenQn: "tasks:screen:task-list",
96
+ });
97
+ // findBy* retries for the default timeout — lets the async useQuery
98
+ // settle without us fishing for intermediate loading state.
99
+ expect(await screen.findByTestId("render-list-table-empty")).toBeTruthy();
100
+ });
101
+
102
+ test("rootId override: mounts into a different DOM id", async () => {
103
+ mountRoot("custom-root");
104
+ await mountApp({
105
+ schema: baseSchema,
106
+ rootId: "custom-root",
107
+ dispatcher: makeDispatcher(),
108
+ });
109
+ await waitFor(() => expect(screen.getByTestId("render-edit-form")).toBeTruthy());
110
+ // And the default #root doesn't pick anything up.
111
+ expect(document.getElementById("root")).toBeNull();
112
+ });
113
+
114
+ test("missing #root → throws with a helpful message", () => {
115
+ // No DOM node prepped.
116
+ expect(() => createKumikoApp({ schema: baseSchema, dispatcher: makeDispatcher() })).toThrow(
117
+ /#root not found/,
118
+ );
119
+ });
120
+
121
+ test("empty schema.screens → throws (nothing to render)", () => {
122
+ mountRoot();
123
+ const empty: FeatureSchema = { ...baseSchema, screens: [] };
124
+ expect(() => createKumikoApp({ schema: empty, dispatcher: makeDispatcher() })).toThrow(
125
+ /no screens/,
126
+ );
127
+ });
128
+
129
+ test("clientFeatures.columnRenderers → bei Key-Kollision warnt + last-wins gewinnt", async () => {
130
+ // Zwei Features liefern denselben Renderer-Key — der Merge in
131
+ // create-app warnt und behält den späteren Eintrag (Last-Wins).
132
+ // Beweist dass das bewusste Override-Verhalten nicht silent
133
+ // wegrutscht falls jemand auf "first-wins" refactored.
134
+ const warnSpy: MockInstance<typeof console.warn> = vi
135
+ .spyOn(console, "warn")
136
+ .mockImplementation(() => {});
137
+
138
+ function FirstSwatch({ value }: ColumnRendererProps): ReactNode {
139
+ return <span data-testid="ca-first">{String(value)}</span>;
140
+ }
141
+ function SecondSwatch({ value }: ColumnRendererProps): ReactNode {
142
+ return <span data-testid="ca-second">{String(value)}</span>;
143
+ }
144
+ const colorEntity = {
145
+ fields: { color: { type: "text" } },
146
+ } as unknown as EntityDefinition;
147
+ const conflictSchema: FeatureSchema = {
148
+ featureName: "tasks",
149
+ entities: { task: colorEntity },
150
+ screens: [
151
+ {
152
+ id: "color-list",
153
+ type: "entityList",
154
+ entity: "task",
155
+ columns: [{ field: "color", renderer: { react: { __component: "Swatch" } } }],
156
+ },
157
+ ],
158
+ };
159
+
160
+ mountRoot();
161
+ await mountApp({
162
+ schema: conflictSchema,
163
+ dispatcher: createMockDispatcher({
164
+ query: (async () => ({
165
+ isSuccess: true,
166
+ data: { rows: [{ id: "r1", color: "#ddd" }], nextCursor: null },
167
+ })) as unknown as Dispatcher["query"],
168
+ }),
169
+ clientFeatures: [
170
+ { name: "first", columnRenderers: { Swatch: FirstSwatch } },
171
+ { name: "second", columnRenderers: { Swatch: SecondSwatch } },
172
+ ],
173
+ });
174
+
175
+ // Last-Wins: SecondSwatch ist gemounted, FirstSwatch nicht.
176
+ expect(await screen.findByTestId("ca-second")).toBeTruthy();
177
+ expect(screen.queryByTestId("ca-first")).toBeNull();
178
+ expect(warnSpy).toHaveBeenCalledWith(
179
+ expect.stringContaining('columnRenderer "Swatch" defined by multiple clientFeatures'),
180
+ );
181
+
182
+ warnSpy.mockRestore();
183
+ });
184
+
185
+ test("clientFeatures.columnRenderers → __component-Renderer mounten echtes Component im DOM", async () => {
186
+ // Beweist die Verdrahtung end-to-end: ClientFeatureDefinition.columnRenderers
187
+ // → Provider in create-app → useColumnRenderer im DataTable-Cell → JSX
188
+ // landet im DOM. Schema deklariert die String-Form, Component lebt nur
189
+ // client-seitig.
190
+ function Swatch({ value, column }: ColumnRendererProps): ReactNode {
191
+ return (
192
+ <span data-testid="ca-swatch">
193
+ <span data-testid="ca-swatch-value">{String(value)}</span>
194
+ <span data-testid="ca-swatch-field">{column.field}</span>
195
+ </span>
196
+ );
197
+ }
198
+ const colorEntity = {
199
+ fields: { title: { type: "text" }, color: { type: "text" } },
200
+ } as unknown as EntityDefinition;
201
+ const colorListScreen: EntityListScreenDefinition = {
202
+ id: "color-list",
203
+ type: "entityList",
204
+ entity: "task",
205
+ columns: ["title", { field: "color", renderer: { react: { __component: "Swatch" } } }],
206
+ };
207
+ const colorSchema: FeatureSchema = {
208
+ featureName: "tasks",
209
+ entities: { task: colorEntity },
210
+ screens: [colorListScreen],
211
+ };
212
+ const dispatcher = createMockDispatcher({
213
+ query: (async () => ({
214
+ isSuccess: true,
215
+ data: { rows: [{ id: "r1", title: "Alpha", color: "#a1b2c3" }], nextCursor: null },
216
+ })) as unknown as Dispatcher["query"],
217
+ });
218
+ const clientFeature: ClientFeatureDefinition = {
219
+ name: "tasks",
220
+ columnRenderers: { Swatch },
221
+ };
222
+
223
+ mountRoot();
224
+ await mountApp({
225
+ schema: colorSchema,
226
+ dispatcher,
227
+ clientFeatures: [clientFeature],
228
+ });
229
+
230
+ expect(await screen.findByTestId("ca-swatch")).toBeTruthy();
231
+ expect(screen.getByTestId("ca-swatch-value").textContent).toBe("#a1b2c3");
232
+ expect(screen.getByTestId("ca-swatch-field").textContent).toBe("color");
233
+ });
234
+
235
+ test("navAdapter override: eigener Router steuert den aktiven Screen", async () => {
236
+ // Beweist den Nav-Seam: der Default-Adapter liest location.pathname,
237
+ // dieser Memory-Adapter hardcoded die Route. Wenn swap funktioniert,
238
+ // sehen wir den Listen-Screen statt den Form-Screen, ohne `screenQn`
239
+ // zu setzen und ohne `window.history` zu touchen.
240
+ mountRoot();
241
+ const memoryNav: NavApi = {
242
+ route: { screenId: "task-list" },
243
+ navigate: () => {},
244
+ replace: () => {},
245
+ hrefFor: (target) =>
246
+ target.entityId !== undefined
247
+ ? `/${target.screenId}/${target.entityId}`
248
+ : `/${target.screenId}`,
249
+ searchParams: {},
250
+ setSearchParams: () => {},
251
+ };
252
+ await mountApp({
253
+ schema: baseSchema,
254
+ dispatcher: makeDispatcher(),
255
+ navAdapter: () => memoryNav,
256
+ });
257
+ expect(await screen.findByTestId("render-list-table-empty")).toBeTruthy();
258
+ // Und definitiv NICHT der Edit-Screen (der wäre die Default-Landing).
259
+ expect(screen.queryByTestId("render-edit-form")).toBeNull();
260
+ });
261
+ });
@@ -0,0 +1,91 @@
1
+ // @vitest-environment jsdom
2
+ //
3
+ // DateInput pinnt: Trigger zeigt formatiertes Datum (locale-aware),
4
+ // Popover öffnet das DayPicker, Auswahl gibt ISO-yyyy-mm-dd zurück.
5
+ // Wert-Roundtrip (ISO → Date → ISO) muss tag-stable sein, sonst
6
+ // zeigt der Calendar je nach Timezone den Vortag.
7
+
8
+ import { fireEvent, render, screen } from "@testing-library/react";
9
+ import userEvent from "@testing-library/user-event";
10
+ import { describe, expect, test, vi } from "vitest";
11
+ import { DateInput } from "../primitives/date-input";
12
+
13
+ describe("DateInput", () => {
14
+ test("trigger zeigt formatiertes Datum (de-DE)", () => {
15
+ render(
16
+ <DateInput id="d" name="d" value="2026-04-23" onChange={() => undefined} locale="de-DE" />,
17
+ );
18
+ expect(screen.getByRole("button").textContent).toContain("23. April 2026");
19
+ });
20
+
21
+ test("trigger zeigt formatiertes Datum (en-US)", () => {
22
+ render(
23
+ <DateInput id="d" name="d" value="2026-04-23" onChange={() => undefined} locale="en-US" />,
24
+ );
25
+ expect(screen.getByRole("button").textContent).toContain("April 23, 2026");
26
+ });
27
+
28
+ test('trigger zeigt "—" Placeholder bei leerem Wert', () => {
29
+ render(<DateInput id="d" name="d" value="" onChange={() => undefined} locale="de-DE" />);
30
+ expect(screen.getByRole("button").textContent).toContain("—");
31
+ });
32
+
33
+ test("kein nativer date-input im DOM (Radix-Popover-Pattern, nicht type=date)", () => {
34
+ render(
35
+ <DateInput id="d" name="d" value="2026-04-23" onChange={() => undefined} locale="de-DE" />,
36
+ );
37
+ expect(document.querySelector('input[type="date"]')).toBeNull();
38
+ });
39
+
40
+ test("hasError setzt aria-invalid auf dem Trigger", () => {
41
+ render(
42
+ <DateInput
43
+ id="d"
44
+ name="d"
45
+ value="2026-04-23"
46
+ onChange={() => undefined}
47
+ locale="de-DE"
48
+ hasError
49
+ />,
50
+ );
51
+ expect(screen.getByRole("button").getAttribute("aria-invalid")).toBe("true");
52
+ });
53
+
54
+ test("disabled blockt Trigger-Click", () => {
55
+ render(
56
+ <DateInput
57
+ id="d"
58
+ name="d"
59
+ value="2026-04-23"
60
+ onChange={() => undefined}
61
+ locale="de-DE"
62
+ disabled
63
+ />,
64
+ );
65
+ const trigger = screen.getByRole("button") as HTMLButtonElement;
66
+ expect(trigger.disabled).toBe(true);
67
+ });
68
+
69
+ test("Popover öffnet auf Click und zeigt das DayPicker", async () => {
70
+ const user = userEvent.setup();
71
+ render(
72
+ <DateInput id="d" name="d" value="2026-04-23" onChange={() => undefined} locale="de-DE" />,
73
+ );
74
+ await user.click(screen.getByRole("button"));
75
+ // DayPicker rendert eine grid-Role für den Calendar; Existenz
76
+ // reicht als Smoke-Test, ohne brittle DOM-Schnipsel zu pinnen.
77
+ expect(screen.getByRole("grid")).toBeTruthy();
78
+ });
79
+
80
+ test("Tag-Auswahl im Calendar: onChange feuert ISO yyyy-mm-dd", async () => {
81
+ const user = userEvent.setup();
82
+ const onChange = vi.fn();
83
+ render(<DateInput id="d" name="d" value="2026-04-23" onChange={onChange} locale="en-US" />);
84
+ await user.click(screen.getByRole("button"));
85
+ // react-day-picker rendert jeden Tag als gridcell. Der 25. April
86
+ // 2026 ist ein Samstag — pickbar im sichtbaren Monat.
87
+ const day25 = screen.getByRole("gridcell", { name: /25/ });
88
+ fireEvent.click(day25.querySelector("button") as HTMLButtonElement);
89
+ expect(onChange).toHaveBeenCalledWith("2026-04-25");
90
+ });
91
+ });
@@ -0,0 +1,60 @@
1
+ // @vitest-environment jsdom
2
+ //
3
+ // DefaultAppShell — pinnt dass user-prop an NavTree durchgereicht wird.
4
+ //
5
+ // Prod-Bug 2026-05-02: DefaultAppShell hatte user-prop NICHT, sysadmin
6
+ // sah keine SystemAdmin-only nav-einträge (resolveNavigation behandelte
7
+ // fehlende user als anonymous → alle role-gated navs ausgeblendet). Test
8
+ // pinst dass DefaultAppShell user nun akzeptiert UND durchreicht.
9
+
10
+ import type { FeatureSchema } from "@cosmicdrift/kumiko-renderer";
11
+ import { describe, expect, test } from "vitest";
12
+ import { DefaultAppShell } from "../layout/default-app-shell";
13
+ import { render, screen } from "./test-utils";
14
+
15
+ function makeSchema(): FeatureSchema {
16
+ return {
17
+ featureName: "showcase",
18
+ entities: {},
19
+ screens: [
20
+ { id: "public-screen", type: "entityList", entity: "x", columns: [] },
21
+ { id: "sysadmin-screen", type: "entityList", entity: "x", columns: [] },
22
+ ],
23
+ navs: [
24
+ { id: "public", label: "Public", screen: "public-screen", order: 10 },
25
+ {
26
+ id: "sysadmin",
27
+ label: "Sysadmin",
28
+ screen: "sysadmin-screen",
29
+ order: 20,
30
+ access: { roles: ["SystemAdmin"] },
31
+ },
32
+ ],
33
+ } as FeatureSchema;
34
+ }
35
+
36
+ describe("DefaultAppShell user-prop forwarding", () => {
37
+ test("OHNE user-prop → role-gated nav unsichtbar (= prod-bug-vor-fix)", () => {
38
+ render(
39
+ <DefaultAppShell brand={<span>Brand</span>} schema={makeSchema()}>
40
+ <div>content</div>
41
+ </DefaultAppShell>,
42
+ );
43
+ expect(screen.getByText("Public")).toBeTruthy();
44
+ expect(screen.queryByText("Sysadmin")).toBeNull();
45
+ });
46
+
47
+ test("MIT user-prop SystemAdmin → sysadmin-nav sichtbar", () => {
48
+ render(
49
+ <DefaultAppShell
50
+ brand={<span>Brand</span>}
51
+ schema={makeSchema()}
52
+ user={{ id: "u1", roles: ["SystemAdmin", "User"] }}
53
+ >
54
+ <div>content</div>
55
+ </DefaultAppShell>,
56
+ );
57
+ expect(screen.getByText("Public")).toBeTruthy();
58
+ expect(screen.getByText("Sysadmin")).toBeTruthy();
59
+ });
60
+ });
@@ -0,0 +1,101 @@
1
+ // @vitest-environment jsdom
2
+ import type { Dispatcher, DispatcherStatus } from "@cosmicdrift/kumiko-headless";
3
+ import {
4
+ DispatcherProvider,
5
+ useDispatcher,
6
+ useDispatcherStatus,
7
+ useOptionalDispatcher,
8
+ } from "@cosmicdrift/kumiko-renderer";
9
+ import type { ReactNode } from "react";
10
+ import { describe, expect, test } from "vitest";
11
+ import { act, createMockDispatcher, render, renderHook } from "./test-utils";
12
+
13
+ // Minimal fake dispatcher: write/query/batch throwen, damit klar wird
14
+ // wenn ein Hook unter Test irgendwohin greift wo er nicht hingehört.
15
+ // Status-Mutationen laufen über den exposed setStatus-Helper, der den
16
+ // statusStore direkt schreibt.
17
+ function makeFakeDispatcher(): {
18
+ readonly dispatcher: Dispatcher;
19
+ setStatus(next: DispatcherStatus): void;
20
+ } {
21
+ const dispatcher = createMockDispatcher({
22
+ write: async () => {
23
+ throw new Error("write not used in this test");
24
+ },
25
+ query: async () => {
26
+ throw new Error("query not used in this test");
27
+ },
28
+ batch: async () => {
29
+ throw new Error("batch not used in this test");
30
+ },
31
+ });
32
+ return {
33
+ dispatcher,
34
+ setStatus(next) {
35
+ dispatcher.statusStore.setState(next);
36
+ },
37
+ };
38
+ }
39
+
40
+ function wrapper(dispatcher: Dispatcher) {
41
+ return ({ children }: { children: ReactNode }) => (
42
+ <DispatcherProvider dispatcher={dispatcher}>{children}</DispatcherProvider>
43
+ );
44
+ }
45
+
46
+ describe("DispatcherContext", () => {
47
+ test("useDispatcher returns the provided instance", () => {
48
+ const { dispatcher } = makeFakeDispatcher();
49
+ const { result } = renderHook(() => useDispatcher(), { wrapper: wrapper(dispatcher) });
50
+ expect(result.current).toBe(dispatcher);
51
+ });
52
+
53
+ test("useDispatcher throws outside a provider — the app forgot to wrap root", () => {
54
+ // renderHook surfaces hook-throws as result.current being the error —
55
+ // we read it directly via render() and catch in the component.
56
+ const Probe = (): ReactNode => {
57
+ useDispatcher();
58
+ return null;
59
+ };
60
+ expect(() => render(<Probe />)).toThrow(/no <DispatcherProvider> mounted/);
61
+ });
62
+
63
+ test("useDispatcherStatus reflects current status on mount", () => {
64
+ const { dispatcher, setStatus } = makeFakeDispatcher();
65
+ setStatus("offline");
66
+ const { result } = renderHook(() => useDispatcherStatus(), { wrapper: wrapper(dispatcher) });
67
+ expect(result.current).toBe("offline");
68
+ });
69
+
70
+ test("useDispatcherStatus updates when statusStore changes", () => {
71
+ const { dispatcher, setStatus } = makeFakeDispatcher();
72
+ const { result } = renderHook(() => useDispatcherStatus(), { wrapper: wrapper(dispatcher) });
73
+ expect(result.current).toBe("online");
74
+ act(() => setStatus("offline"));
75
+ expect(result.current).toBe("offline");
76
+ act(() => setStatus("online"));
77
+ expect(result.current).toBe("online");
78
+ });
79
+
80
+ // useOptionalDispatcher: identisch zu useDispatcher AUSSER beim Missing-
81
+ // Provider — dort returnt es undefined statt zu throwen. Genau dafür
82
+ // existiert es: KumikoScreen.EntityListBody braucht den Dispatcher
83
+ // optional (rowActions silent skipping wenn keiner mounted ist), und
84
+ // soll nicht throw'en in Tests die kein Mutation-Wiring brauchen.
85
+ test("useOptionalDispatcher: returns the instance when provider is mounted", () => {
86
+ const { dispatcher } = makeFakeDispatcher();
87
+ const { result } = renderHook(() => useOptionalDispatcher(), {
88
+ wrapper: wrapper(dispatcher),
89
+ });
90
+ expect(result.current).toBe(dispatcher);
91
+ });
92
+
93
+ test("useOptionalDispatcher: returns undefined when no provider mounted (no throw)", () => {
94
+ const Probe = (): ReactNode => {
95
+ const d = useOptionalDispatcher();
96
+ return <span data-testid="d">{d === undefined ? "no-provider" : "found"}</span>;
97
+ };
98
+ const { getByTestId } = render(<Probe />);
99
+ expect(getByTestId("d").textContent).toBe("no-provider");
100
+ });
101
+ });
@@ -0,0 +1,119 @@
1
+ // @vitest-environment jsdom
2
+ //
3
+ // Verdrahtungs-Test: beweist dass die ganze UI-Store-Kette zusammenhält:
4
+ // createLiveDispatcher → dispatcher.statusStore → DispatcherProvider →
5
+ // useDispatcherStatus → useStore → Re-Render
6
+ //
7
+ // Vorher hatte der Dispatcher zwei Wrapper-Methoden (status() +
8
+ // subscribeStatus()), Konsumenten verdrahteten sie manuell mit
9
+ // useSyncExternalStore. Nach dem Refactor ist statusStore ein read-only
10
+ // Store als Property und useDispatcherStatus reduziert sich auf
11
+ // `useStore(dispatcher.statusStore)`. Wenn dieser Test grün läuft, ist
12
+ // die ganze Kette intakt — eine subtile Renaming-Regression in einem
13
+ // der Glieder würde sich hier zeigen statt erst in Prod.
14
+ //
15
+ // Bewusst KEIN .integration.ts: kein Server, kein DB. Wir mocken den
16
+ // fetch-Layer (das ist die System-Grenze für den Live-Dispatcher) und
17
+ // lassen alles darüber echt laufen. Im Sinne von CLAUDE.md ist das ein
18
+ // "Full-Stack des Frontend-Stacks", nicht ein Full-Stack-mit-API.
19
+
20
+ import { createLiveDispatcher } from "@cosmicdrift/kumiko-dispatcher-live";
21
+ import { DispatcherProvider, useDispatcherStatus } from "@cosmicdrift/kumiko-renderer";
22
+ import type { ReactNode } from "react";
23
+ import { describe, expect, test, vi } from "vitest";
24
+ import { act, render, screen, waitFor } from "./test-utils";
25
+
26
+ function StatusProbe(): ReactNode {
27
+ const status = useDispatcherStatus();
28
+ return <span data-testid="status">{status}</span>;
29
+ }
30
+
31
+ describe("UI-Store Verdrahtung: Dispatcher → statusStore → useDispatcherStatus", () => {
32
+ test("initial-status: Probe rendert 'online' nach Provider-Mount", () => {
33
+ const fetch = vi.fn() as unknown as typeof globalThis.fetch;
34
+ const dispatcher = createLiveDispatcher({ fetch, readCsrf: () => "t" });
35
+
36
+ render(
37
+ <DispatcherProvider dispatcher={dispatcher}>
38
+ <StatusProbe />
39
+ </DispatcherProvider>,
40
+ );
41
+
42
+ expect(screen.getByTestId("status").textContent).toBe("online");
43
+ });
44
+
45
+ test("network-fail flippt Probe von 'online' nach 'offline'", async () => {
46
+ const fetch = vi.fn(async () => {
47
+ throw new Error("ECONNREFUSED");
48
+ }) as unknown as typeof globalThis.fetch;
49
+ const dispatcher = createLiveDispatcher({ fetch, readCsrf: () => "t" });
50
+
51
+ render(
52
+ <DispatcherProvider dispatcher={dispatcher}>
53
+ <StatusProbe />
54
+ </DispatcherProvider>,
55
+ );
56
+
57
+ expect(screen.getByTestId("status").textContent).toBe("online");
58
+
59
+ // Echter call löst observeNetworkOutcome(false) → statusStore.setState("offline")
60
+ // → useStore-Subscriber feuert → Probe re-rendert.
61
+ await act(async () => {
62
+ await dispatcher.write("x", {});
63
+ });
64
+
65
+ await waitFor(() => expect(screen.getByTestId("status").textContent).toBe("offline"));
66
+ });
67
+
68
+ test("recovery flippt Probe zurück auf 'online'", async () => {
69
+ let failNext = true;
70
+ const fetch = vi.fn(async () => {
71
+ if (failNext) {
72
+ failNext = false;
73
+ throw new Error("boom");
74
+ }
75
+ return {
76
+ ok: true,
77
+ status: 200,
78
+ async json() {
79
+ return { isSuccess: true, data: {} };
80
+ },
81
+ } as unknown as Response;
82
+ }) as unknown as typeof globalThis.fetch;
83
+ const dispatcher = createLiveDispatcher({ fetch, readCsrf: () => "t" });
84
+
85
+ render(
86
+ <DispatcherProvider dispatcher={dispatcher}>
87
+ <StatusProbe />
88
+ </DispatcherProvider>,
89
+ );
90
+
91
+ await act(async () => {
92
+ await dispatcher.write("x", {}); // → offline
93
+ });
94
+ await waitFor(() => expect(screen.getByTestId("status").textContent).toBe("offline"));
95
+
96
+ await act(async () => {
97
+ await dispatcher.write("x", {}); // → online
98
+ });
99
+ await waitFor(() => expect(screen.getByTestId("status").textContent).toBe("online"));
100
+ });
101
+
102
+ test("statusStore ist read-only auf dem public Dispatcher-Type", () => {
103
+ const fetch = vi.fn() as unknown as typeof globalThis.fetch;
104
+ const dispatcher = createLiveDispatcher({ fetch, readCsrf: () => "t" });
105
+
106
+ // Der Dispatcher-Contract exponiert statusStore als Store<T> (nicht
107
+ // WritableStore). Zur Runtime ist setState da (intern liegt ein
108
+ // WritableStore), aber der public Type versteckt es — UI-Code kann
109
+ // setState NICHT aufrufen ohne Type-Error. Wenn der Public-Type je
110
+ // auf WritableStore aufweicht, fällt der ts-expect-error weg und tsc
111
+ // flagged es.
112
+ expect(typeof dispatcher.statusStore.subscribe).toBe("function");
113
+ expect(typeof dispatcher.statusStore.getSnapshot).toBe("function");
114
+ // Dispatcher.statusStore exposes the read-only Store contract; the
115
+ // mock returns WritableStore so tests can drive transitions, hence
116
+ // setState is callable here. Production resolvers ship Store only.
117
+ void dispatcher.statusStore;
118
+ });
119
+ });