@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,144 @@
1
+ // Test-Utilities für renderer-web-Tests. Wrappt `render()` mit den
2
+ // Providern die Consumer-Komponenten (RenderEdit, RenderList,
3
+ // KumikoScreen) zur Laufzeit erwarten: PrimitivesProvider mit den
4
+ // HTML-Defaults, NavProvider mit einem Stub (route=undefined), und
5
+ // LiveEventsProvider mit einem No-op-Subscriber.
6
+ //
7
+ // Dispatcher wird NICHT automatisch gestellt — Tests die einen
8
+ // brauchen, mounten DispatcherProvider selber (sonst landeten alle
9
+ // Tests mit dem gleichen Stub-Dispatcher, was echte Tests unsichtbar
10
+ // vor-defaultet). Für Nav brauchen die kumiko-screen-Tests nur dass
11
+ // `useNav()` nicht throwt; ein Stub reicht.
12
+ //
13
+ // Tests die ein anderes Setup brauchen (z.B. custom primitives, echte
14
+ // browser-Nav) bauen ihren Wrapper selbst — siehe nav.test.tsx.
15
+
16
+ import {
17
+ createStore,
18
+ type Dispatcher,
19
+ type DispatcherStatus,
20
+ type WritableStore,
21
+ } from "@cosmicdrift/kumiko-headless";
22
+ import {
23
+ createStaticLocaleResolver,
24
+ kumikoDefaultTranslations,
25
+ type LiveEventSubscriber,
26
+ LiveEventsProvider,
27
+ LocaleProvider,
28
+ type NavApi,
29
+ NavProvider,
30
+ PrimitivesProvider,
31
+ TokensProvider,
32
+ } from "@cosmicdrift/kumiko-renderer";
33
+ import { render as _render, type RenderOptions, type RenderResult } from "@testing-library/react";
34
+ import type { ReactElement, ReactNode } from "react";
35
+ import { defaultPrimitives } from "../primitives";
36
+ import { defaultTokens } from "../tokens";
37
+
38
+ // jsdom hat keinen ResizeObserver — cmdk (Combobox-Library) braucht
39
+ // das im Setup. Stub reicht für unsere Tests; wir messen keine
40
+ // Sizes, nur Mount-Lifecycle. Setze als globaler Side-Effect beim
41
+ // Modul-Import damit alle Tests die test-utils laden ihn haben.
42
+ if (typeof globalThis.ResizeObserver === "undefined") {
43
+ globalThis.ResizeObserver = class {
44
+ observe(): void {}
45
+ unobserve(): void {}
46
+ disconnect(): void {}
47
+ } as unknown as typeof ResizeObserver;
48
+ }
49
+
50
+ const stubNav: NavApi = {
51
+ route: undefined,
52
+ navigate: () => {},
53
+ replace: () => {},
54
+ hrefFor: (target) =>
55
+ target.entityId !== undefined
56
+ ? `/${target.screenId}/${target.entityId}`
57
+ : `/${target.screenId}`,
58
+ searchParams: {},
59
+ setSearchParams: () => {},
60
+ };
61
+
62
+ const stubLiveEvents: LiveEventSubscriber = () => () => {};
63
+
64
+ // Stub-Tokens-API für Tests. Mode-Setter ist ein no-op — Tests die
65
+ // Theme-Toggle testen wollen, bauen sich ihren eigenen Wrapper.
66
+ const stubTokens = {
67
+ tokens: defaultTokens,
68
+ mode: "dark" as const,
69
+ setMode: () => {},
70
+ toggleMode: () => {},
71
+ };
72
+
73
+ const stubResolver = createStaticLocaleResolver();
74
+
75
+ function DefaultProviders({ children }: { readonly children: ReactNode }): ReactNode {
76
+ return (
77
+ <TokensProvider value={stubTokens}>
78
+ <LocaleProvider resolver={stubResolver} fallbackBundles={[kumikoDefaultTranslations]}>
79
+ <PrimitivesProvider value={defaultPrimitives}>
80
+ <NavProvider value={stubNav}>
81
+ <LiveEventsProvider value={stubLiveEvents}>{children}</LiveEventsProvider>
82
+ </NavProvider>
83
+ </PrimitivesProvider>
84
+ </LocaleProvider>
85
+ </TokensProvider>
86
+ );
87
+ }
88
+
89
+ export function render(ui: ReactElement, options?: Omit<RenderOptions, "wrapper">): RenderResult {
90
+ return _render(ui, { wrapper: DefaultProviders, ...options });
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Mock-Dispatcher-Helper
95
+ // ---------------------------------------------------------------------------
96
+
97
+ export type MockDispatcherOptions = {
98
+ /** Initialer Status. Default: "online". Tests die Transitions prüfen,
99
+ * greifen den zurückgegebenen `statusStore` ab und mutieren ihn via
100
+ * `statusStore.setState(...)`. */
101
+ readonly initialStatus?: DispatcherStatus;
102
+ /** Override write(). Default: returnt `{ isSuccess: true, data: {} }`. */
103
+ readonly write?: Dispatcher["write"];
104
+ /** Override query(). Default: returnt `{ isSuccess: true, data: {} }`. */
105
+ readonly query?: Dispatcher["query"];
106
+ /** Override batch(). Default: returnt `{ isSuccess: true, results: [] }`. */
107
+ readonly batch?: Dispatcher["batch"];
108
+ };
109
+
110
+ /** Minimal Mock-Dispatcher für renderer-web-Tests. Replaces the
111
+ * hand-rolled `makeDispatcher()` Funktionen, die in fast jedem
112
+ * Test-File identisch waren. Der zurückgegebene `statusStore` ist
113
+ * ein WritableStore, damit Tests Status-Wechsel auslösen können —
114
+ * beim public Dispatcher-Contract ist statusStore read-only, aber
115
+ * Mocks dürfen mehr.
116
+ *
117
+ * Pending queues sind immer `[]` (live-dispatcher-Semantik); Tests
118
+ * die savable-Pending-Verhalten prüfen wollen, mocken den Dispatcher
119
+ * selbst.
120
+ */
121
+ export function createMockDispatcher(options: MockDispatcherOptions = {}): Dispatcher & {
122
+ /** Schreibbarer Zugriff auf den Status-Store für Test-Mutationen.
123
+ * Identisch zu `dispatcher.statusStore` (Aliasing), aber als
124
+ * WritableStore typisiert. */
125
+ readonly statusStore: WritableStore<DispatcherStatus>;
126
+ } {
127
+ const statusStore = createStore<DispatcherStatus>(options.initialStatus ?? "online");
128
+ return {
129
+ write:
130
+ options.write ??
131
+ ((async () => ({ isSuccess: true, data: {} })) as unknown as Dispatcher["write"]),
132
+ query:
133
+ options.query ??
134
+ ((async () => ({ isSuccess: true, data: {} })) as unknown as Dispatcher["query"]),
135
+ batch:
136
+ options.batch ??
137
+ ((async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"]),
138
+ statusStore,
139
+ pendingWrites: () => [],
140
+ pendingFiles: () => [],
141
+ };
142
+ }
143
+
144
+ export * from "@testing-library/react";
@@ -0,0 +1,101 @@
1
+ // @vitest-environment jsdom
2
+ //
3
+ // Tests pinnen den ThemeToggle-Vertrag: Click ruft toggleMode, Icon-
4
+ // Slot defaultet auf Unicode, Custom-Slots werden durchgereicht, das
5
+ // title/aria-label-Paar reagiert auf den aktuellen Mode.
6
+ //
7
+ // Ein Stub-TokensApi reicht — der Hook useTokenController liest nur
8
+ // `mode` + `toggleMode`, der Rest (tokens, setMode) wird vom Toggle
9
+ // nicht angefasst.
10
+
11
+ import { TokensProvider } from "@cosmicdrift/kumiko-renderer";
12
+ import { fireEvent, render, screen } from "@testing-library/react";
13
+ import type { ReactNode } from "react";
14
+ import { describe, expect, test, vi } from "vitest";
15
+ import { ThemeToggle } from "../layout/theme-toggle";
16
+
17
+ type StubApi = {
18
+ mode: "light" | "dark";
19
+ toggleMode: () => void;
20
+ };
21
+
22
+ function makeStub(
23
+ initial: "light" | "dark" = "light",
24
+ ): StubApi & { toggleMode: ReturnType<typeof vi.fn> } {
25
+ const stub = {
26
+ mode: initial,
27
+ toggleMode: vi.fn(),
28
+ } as StubApi & { toggleMode: ReturnType<typeof vi.fn> };
29
+ return stub;
30
+ }
31
+
32
+ function renderWithMode(stub: StubApi, ui: ReactNode) {
33
+ // Cast über unknown — TokensApi hat tokens/setMode die wir hier nicht
34
+ // brauchen, aber der Provider ist generisch typisiert.
35
+ const api = {
36
+ tokens: {} as never,
37
+ mode: stub.mode,
38
+ setMode: () => {},
39
+ toggleMode: stub.toggleMode,
40
+ };
41
+ return render(<TokensProvider value={api}>{ui}</TokensProvider>);
42
+ }
43
+
44
+ describe("ThemeToggle", () => {
45
+ test("rendert Default-Unicode-Icon (☾) im light-Mode", () => {
46
+ const stub = makeStub("light");
47
+ renderWithMode(stub, <ThemeToggle testId="t" />);
48
+ const btn = screen.getByTestId("t");
49
+ expect(btn.textContent).toContain("☾");
50
+ expect(btn.textContent).not.toContain("☀");
51
+ });
52
+
53
+ test("rendert Default-Unicode-Icon (☀) im dark-Mode", () => {
54
+ const stub = makeStub("dark");
55
+ renderWithMode(stub, <ThemeToggle testId="t" />);
56
+ const btn = screen.getByTestId("t");
57
+ expect(btn.textContent).toContain("☀");
58
+ expect(btn.textContent).not.toContain("☾");
59
+ });
60
+
61
+ test("Custom-Icons werden durchgereicht (Icon-Slots überschreiben Defaults)", () => {
62
+ const stub = makeStub("light");
63
+ renderWithMode(
64
+ stub,
65
+ <ThemeToggle
66
+ testId="t"
67
+ lightIcon={<span data-testid="light-svg">L</span>}
68
+ darkIcon={<span data-testid="dark-svg">D</span>}
69
+ />,
70
+ );
71
+ // light-mode → zeigt darkIcon (klick wechselt zu dark)
72
+ expect(screen.queryByTestId("dark-svg")).not.toBeNull();
73
+ expect(screen.queryByTestId("light-svg")).toBeNull();
74
+ });
75
+
76
+ test("Click ruft toggleMode genau einmal", () => {
77
+ const stub = makeStub("light");
78
+ renderWithMode(stub, <ThemeToggle testId="t" />);
79
+ fireEvent.click(screen.getByTestId("t"));
80
+ expect(stub.toggleMode).toHaveBeenCalledTimes(1);
81
+ });
82
+
83
+ test("aria-label und title spiegeln den Mode-Übergang", () => {
84
+ const lightStub = makeStub("light");
85
+ const { unmount } = renderWithMode(
86
+ lightStub,
87
+ <ThemeToggle testId="t" titleInLight="zu dark" titleInDark="zu light" />,
88
+ );
89
+ // Im light-Mode kündigt der Toggle an "wechselt zu dark"
90
+ expect(screen.getByTestId("t").getAttribute("aria-label")).toBe("zu dark");
91
+ expect(screen.getByTestId("t").getAttribute("title")).toBe("zu dark");
92
+ unmount();
93
+
94
+ const darkStub = makeStub("dark");
95
+ renderWithMode(
96
+ darkStub,
97
+ <ThemeToggle testId="t" titleInLight="zu dark" titleInDark="zu light" />,
98
+ );
99
+ expect(screen.getByTestId("t").getAttribute("aria-label")).toBe("zu light");
100
+ });
101
+ });
@@ -0,0 +1,162 @@
1
+ // @vitest-environment jsdom
2
+ //
3
+ // ToastProvider + useToast pinnt: toast() rendert Title+Description in
4
+ // einem Radix-Toast; mehrere toasts stapeln; Variant=destructive setzt
5
+ // die destructive-Klasse; useToast außerhalb des Providers ist no-op
6
+ // (kein crash); IDs sind kollisionsfrei auch bei zwei Calls im selben
7
+ // Tick (Counter-Race-Bug).
8
+
9
+ import { act, fireEvent, render, screen } from "@testing-library/react";
10
+ import { type ReactNode, useEffect } from "react";
11
+ import { describe, expect, test } from "vitest";
12
+ import { type ToastOptions, ToastProvider, useToast } from "../primitives/toast";
13
+
14
+ // Trigger-Component die im Mount toast() aufruft. So testen wir den
15
+ // Hook ohne userEvent-Klick-Pfad und ohne fragile timer.
16
+ function ToastTrigger({ options }: { readonly options: readonly ToastOptions[] }): ReactNode {
17
+ const { toast } = useToast();
18
+ useEffect(() => {
19
+ for (const o of options) toast(o);
20
+ // toast() ist in der ToastApi memoized — keine Re-Trigger-Loops.
21
+ }, [toast, options]);
22
+ return null;
23
+ }
24
+
25
+ describe("ToastProvider + useToast", () => {
26
+ test("toast() pushed Title und Description in den Viewport", () => {
27
+ render(
28
+ <ToastProvider>
29
+ <ToastTrigger options={[{ title: "Saved", description: "Changes applied" }]} />
30
+ </ToastProvider>,
31
+ );
32
+ expect(screen.getByText("Saved")).toBeTruthy();
33
+ expect(screen.getByText("Changes applied")).toBeTruthy();
34
+ });
35
+
36
+ test("toast() ohne description rendert nur den Title", () => {
37
+ render(
38
+ <ToastProvider>
39
+ <ToastTrigger options={[{ title: "Copied" }]} />
40
+ </ToastProvider>,
41
+ );
42
+ expect(screen.getByText("Copied")).toBeTruthy();
43
+ });
44
+
45
+ test("docsUrl: rendert 'Mehr erfahren →' Link mit target=_blank", () => {
46
+ render(
47
+ <ToastProvider>
48
+ <ToastTrigger
49
+ options={[
50
+ {
51
+ title: "Konflikt",
52
+ variant: "destructive",
53
+ docsUrl: "https://docs.kumiko.so/errors/stale_state",
54
+ },
55
+ ]}
56
+ />
57
+ </ToastProvider>,
58
+ );
59
+ const link = screen.getByRole("link", { name: /Mehr erfahren/i });
60
+ expect(link.getAttribute("href")).toBe("https://docs.kumiko.so/errors/stale_state");
61
+ expect(link.getAttribute("target")).toBe("_blank");
62
+ expect(link.getAttribute("rel")).toBe("noopener noreferrer");
63
+ });
64
+
65
+ test("docsLinkLabel override: nutzt vom Caller gegebenen Text", () => {
66
+ render(
67
+ <ToastProvider>
68
+ <ToastTrigger
69
+ options={[
70
+ {
71
+ title: "Conflict",
72
+ docsUrl: "https://docs.kumiko.so/errors/stale_state",
73
+ docsLinkLabel: "Learn more",
74
+ },
75
+ ]}
76
+ />
77
+ </ToastProvider>,
78
+ );
79
+ expect(screen.getByRole("link", { name: /Learn more/i })).toBeTruthy();
80
+ });
81
+
82
+ test("ohne docsUrl: kein Link gerendert", () => {
83
+ render(
84
+ <ToastProvider>
85
+ <ToastTrigger options={[{ title: "Saved", description: "ok" }]} />
86
+ </ToastProvider>,
87
+ );
88
+ expect(screen.queryByRole("link")).toBeNull();
89
+ });
90
+
91
+ test("variant=destructive: setzt die destructive-Klasse auf den Root", () => {
92
+ render(
93
+ <ToastProvider>
94
+ <ToastTrigger options={[{ title: "Failed", variant: "destructive" }]} />
95
+ </ToastProvider>,
96
+ );
97
+ // Class-Mapping ist der Public-Vertrag mit Tailwind-Tokens. Wir
98
+ // suchen den nearest Ancestor des Title-Knotens dessen Klassen-
99
+ // String "destructive" enthält — Radix-Toast.Root rendert in einem
100
+ // <li>, aber die genaue Role wechselt je nach priority/type.
101
+ let node: HTMLElement | null = screen.getByText("Failed");
102
+ while (node !== null && !node.className.includes("destructive")) {
103
+ node = node.parentElement;
104
+ }
105
+ expect(node).not.toBeNull();
106
+ });
107
+
108
+ test("zwei toasts: beide Entries sind im DOM (Stacking)", () => {
109
+ render(
110
+ <ToastProvider>
111
+ <ToastTrigger options={[{ title: "First" }, { title: "Second" }]} />
112
+ </ToastProvider>,
113
+ );
114
+ expect(screen.getByText("First")).toBeTruthy();
115
+ expect(screen.getByText("Second")).toBeTruthy();
116
+ });
117
+
118
+ test("zwei toasts im selben Tick: getrennte React-keys (Counter-Race-Regression)", () => {
119
+ // Vorher hatte ToastProvider einen useState-counter, der wegen
120
+ // Closure-Capture bei Doppel-Calls denselben Wert sah → identische
121
+ // IDs → React-key-Kollision (Warning + UI-Glitch). Der Fix nutzt
122
+ // useRef. Test: zwei toasts + console.error darf kein "duplicate
123
+ // key" loggen.
124
+ const errors: string[] = [];
125
+ /* biome-ignore lint/suspicious/noConsole: test spy auf React's duplicate-key-Warning, die nur über console.error gemeldet wird */
126
+ const original = console.error;
127
+ console.error = (...args: unknown[]): void => {
128
+ errors.push(args.map(String).join(" "));
129
+ };
130
+ try {
131
+ render(
132
+ <ToastProvider>
133
+ <ToastTrigger options={[{ title: "A" }, { title: "B" }]} />
134
+ </ToastProvider>,
135
+ );
136
+ const dup = errors.filter((e) => /duplicate key|Encountered two children/i.test(e));
137
+ expect(dup).toEqual([]);
138
+ } finally {
139
+ console.error = original;
140
+ }
141
+ });
142
+
143
+ test("useToast außerhalb des Providers: no-op, kein Crash", () => {
144
+ // Component die useToast nutzt aber NICHT in <ToastProvider> mounted
145
+ // ist — z.B. ein Test ohne Provider, oder ein Pre-Mount-Code-Path.
146
+ // Soll ohne Throw rendern.
147
+ function Outside(): ReactNode {
148
+ const { toast } = useToast();
149
+ return (
150
+ <button type="button" onClick={() => toast({ title: "Stub" })}>
151
+ trigger
152
+ </button>
153
+ );
154
+ }
155
+ render(<Outside />);
156
+ // Click feuert toast() — sollte einfach durchlaufen ohne Effekt.
157
+ act(() => {
158
+ fireEvent.click(screen.getByRole("button"));
159
+ });
160
+ expect(screen.queryByText("Stub")).toBeNull();
161
+ });
162
+ });
@@ -0,0 +1,112 @@
1
+ // @vitest-environment jsdom
2
+ import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
3
+ import { DispatcherProvider, useForm } from "@cosmicdrift/kumiko-renderer";
4
+ import type { ReactNode } from "react";
5
+ import { describe, expect, test, vi } from "vitest";
6
+ import { z } from "zod";
7
+ import { act, createMockDispatcher, renderHook } from "./test-utils";
8
+
9
+ type Values = { title: string; count?: number };
10
+
11
+ function makeDispatcher(writeFn?: Dispatcher["write"]): Dispatcher {
12
+ return createMockDispatcher({ write: writeFn });
13
+ }
14
+
15
+ function wrap(dispatcher: Dispatcher) {
16
+ return ({ children }: { children: ReactNode }) => (
17
+ <DispatcherProvider dispatcher={dispatcher}>{children}</DispatcherProvider>
18
+ );
19
+ }
20
+
21
+ describe("useForm", () => {
22
+ test("snapshot reflects setField mutations", () => {
23
+ const dispatcher = makeDispatcher();
24
+ const { result } = renderHook(
25
+ () =>
26
+ useForm<Values>({
27
+ initial: { title: "", count: 0 },
28
+ submit: { type: "x:create" },
29
+ }),
30
+ { wrapper: wrap(dispatcher) },
31
+ );
32
+
33
+ expect(result.current.snapshot.values.title).toBe("");
34
+ expect(result.current.snapshot.isDirty).toBe(false);
35
+
36
+ act(() => result.current.controller.setField("title", "hello"));
37
+ expect(result.current.snapshot.values.title).toBe("hello");
38
+ expect(result.current.snapshot.isDirty).toBe(true);
39
+ expect(result.current.snapshot.changes.title).toBe("hello");
40
+ });
41
+
42
+ test("submit dispatches to the context dispatcher when no explicit one is passed", async () => {
43
+ const write = vi.fn(async () => ({ isSuccess: true, data: { id: "123" } }) as never);
44
+ const dispatcher = makeDispatcher(write);
45
+ const { result } = renderHook(
46
+ () =>
47
+ useForm<Values>({
48
+ initial: { title: "", count: 0 },
49
+ submit: { type: "x:create" },
50
+ }),
51
+ { wrapper: wrap(dispatcher) },
52
+ );
53
+
54
+ act(() => result.current.controller.setField("title", "new"));
55
+ let submitResult: unknown;
56
+ await act(async () => {
57
+ submitResult = await result.current.controller.submit();
58
+ });
59
+
60
+ expect(write).toHaveBeenCalledOnce();
61
+ expect(write).toHaveBeenCalledWith("x:create", expect.anything());
62
+ expect((submitResult as { isSuccess: boolean }).isSuccess).toBe(true);
63
+ });
64
+
65
+ test("zod schema failure blocks submit; no network call fires", async () => {
66
+ const write = vi.fn();
67
+ const dispatcher = makeDispatcher(write as unknown as Dispatcher["write"]);
68
+ const schema = z.object({ title: z.string().min(1), count: z.number().optional() });
69
+ const { result } = renderHook(
70
+ () =>
71
+ useForm<Values>({
72
+ initial: { title: "", count: 0 },
73
+ schema,
74
+ submit: { type: "x:create" },
75
+ }),
76
+ { wrapper: wrap(dispatcher) },
77
+ );
78
+
79
+ let submitResult: unknown;
80
+ await act(async () => {
81
+ submitResult = await result.current.controller.submit();
82
+ });
83
+
84
+ expect(write).not.toHaveBeenCalled();
85
+ expect((submitResult as { validationBlocked: boolean }).validationBlocked).toBe(true);
86
+ expect(Object.keys(result.current.snapshot.errors)).toContain("title");
87
+ });
88
+
89
+ test("explicit dispatcher on submit wins over context dispatcher", async () => {
90
+ const contextWrite = vi.fn(async () => ({ isSuccess: true, data: {} }) as never);
91
+ const overrideWrite = vi.fn(async () => ({ isSuccess: true, data: {} }) as never);
92
+ const contextDispatcher = makeDispatcher(contextWrite);
93
+ const overrideDispatcher = makeDispatcher(overrideWrite);
94
+
95
+ const { result } = renderHook(
96
+ () =>
97
+ useForm<Values>({
98
+ initial: { title: "hi", count: 0 },
99
+ submit: { type: "x:create", dispatcher: overrideDispatcher },
100
+ }),
101
+ { wrapper: wrap(contextDispatcher) },
102
+ );
103
+
104
+ act(() => result.current.controller.setField("title", "changed"));
105
+ await act(async () => {
106
+ await result.current.controller.submit();
107
+ });
108
+
109
+ expect(overrideWrite).toHaveBeenCalled();
110
+ expect(contextWrite).not.toHaveBeenCalled();
111
+ });
112
+ });
@@ -0,0 +1,152 @@
1
+ // @vitest-environment jsdom
2
+ import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
3
+ import {
4
+ DispatcherProvider,
5
+ type LiveEvent,
6
+ type LiveEventSubscriber,
7
+ LiveEventsProvider,
8
+ useQuery,
9
+ } from "@cosmicdrift/kumiko-renderer";
10
+ import type { ReactNode } from "react";
11
+ import { describe, expect, test } from "vitest";
12
+ import { act, createMockDispatcher, render, waitFor } from "./test-utils";
13
+
14
+ // Test-Helper: fake LiveEventSubscriber. Sammelt alle Subscriber, das
15
+ // Test kann `inject(type, data)` rufen um die matching listener zu
16
+ // feuern. Ersetzt die alten `__injectLiveEvent`-Seams aus dem
17
+ // Module-Singleton; jetzt lebt der Fake im Provider-Tree — identisch
18
+ // zum Production-Pattern.
19
+ function makeFakeLiveEvents(): {
20
+ subscriber: LiveEventSubscriber;
21
+ inject: (type: string, data: LiveEvent["data"]) => void;
22
+ } {
23
+ const listeners = new Set<{ entity: string; cb: (e: LiveEvent) => void }>();
24
+ return {
25
+ subscriber: (entity, cb) => {
26
+ const entry = { entity, cb };
27
+ listeners.add(entry);
28
+ return () => {
29
+ listeners.delete(entry);
30
+ };
31
+ },
32
+ inject: (type, data) => {
33
+ for (const l of listeners) {
34
+ if (l.entity === data.aggregateType) l.cb({ type, data });
35
+ }
36
+ },
37
+ };
38
+ }
39
+
40
+ function makeDispatcher(queryFn: Dispatcher["query"]): Dispatcher {
41
+ return createMockDispatcher({ query: queryFn });
42
+ }
43
+
44
+ function Probe({ live }: { readonly live: boolean }): React.ReactElement {
45
+ const q = useQuery<{ count: number }>("tasks:query:task:list", {}, { live });
46
+ return <div data-testid="probe">{q.data?.count ?? "loading"}</div>;
47
+ }
48
+
49
+ function Wrapper({
50
+ children,
51
+ dispatcher,
52
+ liveEvents,
53
+ }: {
54
+ readonly children: ReactNode;
55
+ readonly dispatcher: Dispatcher;
56
+ readonly liveEvents: LiveEventSubscriber;
57
+ }): ReactNode {
58
+ return (
59
+ <DispatcherProvider dispatcher={dispatcher}>
60
+ <LiveEventsProvider value={liveEvents}>{children}</LiveEventsProvider>
61
+ </DispatcherProvider>
62
+ );
63
+ }
64
+
65
+ describe("useQuery live-mode", () => {
66
+ test("live=true: injected event triggert refetch", async () => {
67
+ let calls = 0;
68
+ const dispatcher = makeDispatcher((async () => {
69
+ calls += 1;
70
+ return { isSuccess: true, data: { count: calls } };
71
+ }) as unknown as Dispatcher["query"]);
72
+ const fake = makeFakeLiveEvents();
73
+
74
+ const { getByTestId } = render(
75
+ <Wrapper dispatcher={dispatcher} liveEvents={fake.subscriber}>
76
+ <Probe live={true} />
77
+ </Wrapper>,
78
+ );
79
+
80
+ await waitFor(() => expect(getByTestId("probe").textContent).toBe("1"));
81
+
82
+ act(() => {
83
+ fake.inject("task.created", {
84
+ id: "t1",
85
+ aggregateType: "task",
86
+ version: 1,
87
+ payload: {},
88
+ createdAt: "",
89
+ });
90
+ });
91
+
92
+ await waitFor(() => expect(getByTestId("probe").textContent).toBe("2"));
93
+ });
94
+
95
+ test("live=false: injected event wird ignoriert, kein refetch", async () => {
96
+ let calls = 0;
97
+ const dispatcher = makeDispatcher((async () => {
98
+ calls += 1;
99
+ return { isSuccess: true, data: { count: calls } };
100
+ }) as unknown as Dispatcher["query"]);
101
+ const fake = makeFakeLiveEvents();
102
+
103
+ const { getByTestId } = render(
104
+ <Wrapper dispatcher={dispatcher} liveEvents={fake.subscriber}>
105
+ <Probe live={false} />
106
+ </Wrapper>,
107
+ );
108
+
109
+ await waitFor(() => expect(getByTestId("probe").textContent).toBe("1"));
110
+
111
+ fake.inject("task.created", {
112
+ id: "t1",
113
+ aggregateType: "task",
114
+ version: 1,
115
+ payload: {},
116
+ createdAt: "",
117
+ });
118
+
119
+ await new Promise((r) => setTimeout(r, 50));
120
+ expect(getByTestId("probe").textContent).toBe("1");
121
+ expect(calls).toBe(1);
122
+ });
123
+
124
+ test("live=true: nur events für die Query-Entity triggern refetch", async () => {
125
+ let calls = 0;
126
+ const dispatcher = makeDispatcher((async () => {
127
+ calls += 1;
128
+ return { isSuccess: true, data: { count: calls } };
129
+ }) as unknown as Dispatcher["query"]);
130
+ const fake = makeFakeLiveEvents();
131
+
132
+ const { getByTestId } = render(
133
+ <Wrapper dispatcher={dispatcher} liveEvents={fake.subscriber}>
134
+ <Probe live={true} />
135
+ </Wrapper>,
136
+ );
137
+
138
+ await waitFor(() => expect(getByTestId("probe").textContent).toBe("1"));
139
+
140
+ // Event für andere Entity — kein refetch.
141
+ fake.inject("note.created", {
142
+ id: "n1",
143
+ aggregateType: "note",
144
+ version: 1,
145
+ payload: {},
146
+ createdAt: "",
147
+ });
148
+
149
+ await new Promise((r) => setTimeout(r, 50));
150
+ expect(calls).toBe(1);
151
+ });
152
+ });