@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
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@cosmicdrift/kumiko-renderer-web",
3
+ "version": "0.1.0",
4
+ "description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
5
+ "license": "BUSL-1.1",
6
+ "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
7
+ "type": "module",
8
+ "kumiko": {
9
+ "runtime": "client"
10
+ },
11
+ "exports": {
12
+ ".": "./src/index.ts",
13
+ "./styles.css": "./src/styles.css"
14
+ },
15
+ "dependencies": {
16
+ "@cosmicdrift/kumiko-dispatcher-live": "workspace:*",
17
+ "@cosmicdrift/kumiko-headless": "workspace:*",
18
+ "@cosmicdrift/kumiko-renderer": "workspace:*",
19
+ "@radix-ui/react-dialog": "^1.1.6",
20
+ "@radix-ui/react-dropdown-menu": "^2.1.6",
21
+ "@radix-ui/react-label": "^2.1.2",
22
+ "@radix-ui/react-popover": "^1.1.6",
23
+ "@radix-ui/react-select": "^2.1.6",
24
+ "@radix-ui/react-slot": "^1.1.2",
25
+ "@radix-ui/react-toast": "^1.2.6",
26
+ "@radix-ui/react-tooltip": "^1.1.8",
27
+ "class-variance-authority": "^0.7.1",
28
+ "clsx": "^2.1.1",
29
+ "cmdk": "^1.1.1",
30
+ "lucide-react": "^1.11.0",
31
+ "react": "^19.2.0",
32
+ "react-day-picker": "^9.14.0",
33
+ "react-dom": "^19.2.0",
34
+ "tailwind-merge": "^3.0.2"
35
+ },
36
+ "devDependencies": {
37
+ "@tailwindcss/cli": "^4.0.0",
38
+ "@testing-library/react": "^16.3.0",
39
+ "@testing-library/user-event": "^14.6.1",
40
+ "@types/react": "^19.2.0",
41
+ "@types/react-dom": "^19.2.0",
42
+ "jsdom": "^29.1.0",
43
+ "tailwindcss": "^4.0.0"
44
+ },
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "git+https://github.com/cosmicdriftgamestudio/kumiko-framework.git",
48
+ "directory": "packages/renderer-web"
49
+ },
50
+ "homepage": "https://kumiko.so",
51
+ "bugs": {
52
+ "url": "https://github.com/cosmicdriftgamestudio/kumiko-framework/issues"
53
+ },
54
+ "publishConfig": {
55
+ "registry": "https://registry.npmjs.org",
56
+ "access": "public"
57
+ },
58
+ "files": [
59
+ "src",
60
+ "README.md",
61
+ "LICENSE"
62
+ ]
63
+ }
@@ -0,0 +1,34 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, expect, test } from "vitest";
3
+ import { Avatar } from "../layout/avatar";
4
+ import { render, screen } from "./test-utils";
5
+
6
+ describe("Avatar", () => {
7
+ test("Initials aus 'Daniel Hennig' → 'DH'", () => {
8
+ render(<Avatar id="user-1" label="Daniel Hennig" />);
9
+ expect(screen.getByRole("img", { name: "Daniel Hennig" }).textContent).toBe("DH");
10
+ });
11
+
12
+ test("Initials aus single-name 'Daniel' → 'DA' (erste 2 Buchstaben)", () => {
13
+ render(<Avatar id="user-2" label="Daniel" />);
14
+ expect(screen.getByRole("img", { name: "Daniel" }).textContent).toBe("DA");
15
+ });
16
+
17
+ test("Initials aus email 'alice@example.com' → 'AL' (erste 2 Buchstaben)", () => {
18
+ render(<Avatar id="user-3" label="alice@example.com" />);
19
+ expect(screen.getByRole("img", { name: "alice@example.com" }).textContent).toBe("AL");
20
+ });
21
+
22
+ test("empty label → '?'-Fallback", () => {
23
+ render(<Avatar id="user-4" label="" />);
24
+ expect(screen.getByRole("img", { name: "" }).textContent).toBe("?");
25
+ });
26
+
27
+ test("gleiche id → gleiche Color-Klasse (deterministic)", () => {
28
+ const { rerender, container } = render(<Avatar id="stable-id" label="A B" />);
29
+ const colorBefore = container.querySelector("[role='img']")?.className;
30
+ rerender(<Avatar id="stable-id" label="C D" />);
31
+ const colorAfter = container.querySelector("[role='img']")?.className;
32
+ expect(colorBefore).toBe(colorAfter);
33
+ });
34
+ });
@@ -0,0 +1,240 @@
1
+ // @vitest-environment jsdom
2
+ import { fireEvent } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { act } from "react";
5
+ import { describe, expect, test } from "vitest";
6
+ import { ComboboxInput } from "../primitives/combobox";
7
+ import { render, screen } from "./test-utils";
8
+
9
+ // Tier 2.1c: Combobox-Primitive Smoke-Tests. cmdk + Radix-Popover
10
+ // rendern Portals; jsdom resolved sie auf document.body. Wir testen
11
+ // die Trigger-Render-Form (Single + Multi mit Tags) und den Click→
12
+ // Select Roundtrip. Volltext-Filter-Mechanik ist cmdk-internal und
13
+ // dort getestet — unsere Tests pinnen nur das Wiring.
14
+
15
+ describe("ComboboxInput (Tier 2.1c)", () => {
16
+ test("single-mode: Trigger zeigt placeholder wenn value leer", () => {
17
+ render(
18
+ <ComboboxInput
19
+ id="combo"
20
+ name="combo"
21
+ value=""
22
+ onChange={() => {}}
23
+ options={[
24
+ { value: "a", label: "Alpha" },
25
+ { value: "b", label: "Beta" },
26
+ ]}
27
+ placeholder="Pick one"
28
+ />,
29
+ );
30
+ expect(screen.getByText("Pick one")).toBeTruthy();
31
+ });
32
+
33
+ test("single-mode: Trigger zeigt Label des selected value", () => {
34
+ render(
35
+ <ComboboxInput
36
+ id="combo"
37
+ name="combo"
38
+ value="b"
39
+ onChange={() => {}}
40
+ options={[
41
+ { value: "a", label: "Alpha" },
42
+ { value: "b", label: "Beta" },
43
+ ]}
44
+ />,
45
+ );
46
+ expect(screen.getByText("Beta")).toBeTruthy();
47
+ });
48
+
49
+ test("single-mode: Click auf Item → onChange mit value, Popover schließt", async () => {
50
+ const user = userEvent.setup();
51
+ const changes: string[] = [];
52
+ render(
53
+ <ComboboxInput
54
+ id="combo"
55
+ name="combo"
56
+ value=""
57
+ onChange={(v) => changes.push(v as string)}
58
+ options={[
59
+ { value: "a", label: "Alpha" },
60
+ { value: "b", label: "Beta" },
61
+ ]}
62
+ />,
63
+ );
64
+ // Trigger klicken → Popover öffnet, Items rendern
65
+ await user.click(screen.getByTestId("combobox-combo"));
66
+ // cmdk Items haben das role="option" attr
67
+ const beta = await screen.findByText("Beta");
68
+ await user.click(beta);
69
+ expect(changes).toEqual(["b"]);
70
+ });
71
+
72
+ test("multi-mode: leerer state zeigt placeholder", () => {
73
+ render(
74
+ <ComboboxInput
75
+ id="combo"
76
+ name="combo"
77
+ value={[]}
78
+ onChange={() => {}}
79
+ options={[{ value: "a", label: "Alpha" }]}
80
+ multiple
81
+ placeholder="Pick tags"
82
+ />,
83
+ );
84
+ expect(screen.getByText("Pick tags")).toBeTruthy();
85
+ });
86
+
87
+ test("multi-mode: vorhandene values rendern als Tags mit Label", () => {
88
+ render(
89
+ <ComboboxInput
90
+ id="combo"
91
+ name="combo"
92
+ value={["a", "b"]}
93
+ onChange={() => {}}
94
+ options={[
95
+ { value: "a", label: "Alpha" },
96
+ { value: "b", label: "Beta" },
97
+ { value: "c", label: "Gamma" },
98
+ ]}
99
+ multiple
100
+ />,
101
+ );
102
+ expect(screen.getByText("Alpha")).toBeTruthy();
103
+ expect(screen.getByText("Beta")).toBeTruthy();
104
+ // Gamma ist nicht selected → kein Tag im Trigger.
105
+ expect(screen.queryByText("Gamma")).toBeNull();
106
+ });
107
+
108
+ test("multi-mode: Click auf neues Item → onChange mit erweitertem Array", async () => {
109
+ const user = userEvent.setup();
110
+ const changes: (readonly string[])[] = [];
111
+ render(
112
+ <ComboboxInput
113
+ id="combo"
114
+ name="combo"
115
+ value={["a"]}
116
+ onChange={(v) => changes.push(v as readonly string[])}
117
+ options={[
118
+ { value: "a", label: "Alpha" },
119
+ { value: "b", label: "Beta" },
120
+ ]}
121
+ multiple
122
+ />,
123
+ );
124
+ await user.click(screen.getByTestId("combobox-combo"));
125
+ // In Multi-Mode mountet cmdk die Items im Portal — wir suchen
126
+ // das Item per Text. Beim Klick toggled der Combobox die value.
127
+ const items = await screen.findAllByText("Beta");
128
+ // Erstes "Beta" könnte das selected-Tag oben sein; in der List
129
+ // rendert cmdk eine zweite Instanz. Tag ist nicht da (a war
130
+ // selected, b nicht), also ist nur das List-Item da.
131
+ await user.click(items[items.length - 1] as HTMLElement);
132
+ expect(changes).toHaveLength(1);
133
+ expect([...(changes[0] ?? [])].sort()).toEqual(["a", "b"]);
134
+ });
135
+
136
+ test("disabled: Trigger ist disabled", () => {
137
+ render(
138
+ <ComboboxInput id="combo" name="combo" value="" onChange={() => {}} options={[]} disabled />,
139
+ );
140
+ expect((screen.getByTestId("combobox-combo") as HTMLButtonElement).disabled).toBe(true);
141
+ });
142
+
143
+ // Regression aus PublicStatus-Live-Debug: Multi-Mode + Remote-Search
144
+ // (= Reference-Field mit `multiple: true`) — Click auf Item hat im
145
+ // Browser nur Focus gesetzt, nicht onChange ausgelöst. Non-Remote
146
+ // Multi-Mode (Test oben) funktionierte, jeder andere Pfad auch — nur
147
+ // diese Kombination war kaputt.
148
+ test("multi-mode + remote-mode: Click auf Item → onChange mit erweitertem Array", async () => {
149
+ const user = userEvent.setup();
150
+ const changes: (readonly string[])[] = [];
151
+ render(
152
+ <ComboboxInput
153
+ id="combo"
154
+ name="combo"
155
+ value={[]}
156
+ onChange={(v) => changes.push(v as readonly string[])}
157
+ options={[
158
+ { value: "a", label: "Alpha" },
159
+ { value: "b", label: "Beta" },
160
+ ]}
161
+ multiple
162
+ // Remote-Mode: onSearchChange-Prop signalisiert "server-side
163
+ // filter", also das Pattern aus Reference-Inputs in Forms.
164
+ onSearchChange={() => {}}
165
+ />,
166
+ );
167
+ await user.click(screen.getByTestId("combobox-combo"));
168
+ const items = await screen.findAllByText("Beta");
169
+ await user.click(items[items.length - 1] as HTMLElement);
170
+ expect(changes).toHaveLength(1);
171
+ expect(changes[0]).toEqual(["b"]);
172
+ });
173
+
174
+ // Tier 2.7e Remote-Mode: typed-search-API.
175
+ test("remote-mode: render mit onSearchChange + loading mountet ohne crash", () => {
176
+ render(
177
+ <ComboboxInput
178
+ id="combo"
179
+ name="combo"
180
+ value=""
181
+ onChange={() => {}}
182
+ options={[{ value: "a", label: "Alpha" }]}
183
+ onSearchChange={() => {}}
184
+ loading
185
+ />,
186
+ );
187
+ expect(screen.getByTestId("combobox-combo")).toBeTruthy();
188
+ });
189
+
190
+ // Audit-Fix #3: Real-Search-Verhalten ohne fake-Timers (collidieren
191
+ // mit RTL findBy-polling). defaultOpen forciert den Popover-Mount,
192
+ // dann triggern wir change-event direkt am Search-Input und warten
193
+ // auf real-time 350ms damit der 300ms-Debounce durch ist.
194
+ test("remote-mode: Search-Input typing → onSearchChange debounced (300ms)", async () => {
195
+ const searches: string[] = [];
196
+ render(
197
+ <ComboboxInput
198
+ id="combo"
199
+ name="combo"
200
+ value=""
201
+ onChange={() => {}}
202
+ options={[{ value: "a", label: "Alpha" }]}
203
+ onSearchChange={(q) => searches.push(q)}
204
+ defaultOpen
205
+ />,
206
+ );
207
+ const searchInput = await screen.findByRole("combobox");
208
+ await act(async () => {
209
+ fireEvent.change(searchInput, { target: { value: "abc" } });
210
+ });
211
+ // Real-time Debounce-Window — kein fake-timers weil das mit
212
+ // findByRole's polling kollidiert.
213
+ await new Promise((resolve) => setTimeout(resolve, 350));
214
+ expect(searches[searches.length - 1]).toBe("abc");
215
+ });
216
+
217
+ test("remote-mode: loading=true zeigt 'Loading…' im Empty-State", async () => {
218
+ render(
219
+ <ComboboxInput
220
+ id="combo"
221
+ name="combo"
222
+ value=""
223
+ onChange={() => {}}
224
+ options={[]}
225
+ onSearchChange={() => {}}
226
+ loading
227
+ defaultOpen
228
+ />,
229
+ );
230
+ const loadingText = await screen.findByText("Loading…");
231
+ expect(loadingText).toBeTruthy();
232
+ });
233
+
234
+ test("hasError: Trigger hat aria-invalid=true", () => {
235
+ render(
236
+ <ComboboxInput id="combo" name="combo" value="" onChange={() => {}} options={[]} hasError />,
237
+ );
238
+ expect(screen.getByTestId("combobox-combo").getAttribute("aria-invalid")).toBe("true");
239
+ });
240
+ });
@@ -0,0 +1,172 @@
1
+ // @vitest-environment jsdom
2
+ //
3
+ // Unit-Tests für den configEdit-Screen-Type. Decken die Pfade ab die
4
+ // Integration + E2E nur indirekt sehen:
5
+ // - Initial-Load via config:query:values + Pre-Fill aus values[qn]
6
+ // - customSubmit dispatcht pro geändertem Field einen separaten
7
+ // config:write:set Call mit dem qualifizierten Key + scope
8
+ // - Save-Button Greying via controller.rebase nach Success
9
+ // - Loading-State während config:query:values noch läuft
10
+
11
+ import type { ConfigEditScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
12
+ import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
13
+ import type { FeatureSchema } from "@cosmicdrift/kumiko-renderer";
14
+ import { DispatcherProvider, KumikoScreen } from "@cosmicdrift/kumiko-renderer";
15
+ import userEvent from "@testing-library/user-event";
16
+ import { describe, expect, test, vi } from "vitest";
17
+ import { createMockDispatcher, render, screen, waitFor } from "./test-utils";
18
+
19
+ const settingsScreen: ConfigEditScreenDefinition = {
20
+ id: "settings",
21
+ type: "configEdit",
22
+ scope: "tenant",
23
+ configKeys: {
24
+ siteName: "demo:config:site-name",
25
+ maxUploadMb: "demo:config:max-upload-mb",
26
+ },
27
+ fields: {
28
+ siteName: { type: "text", required: true },
29
+ maxUploadMb: { type: "number" },
30
+ // @cast-boundary inline schema-author shape — FieldDefinition union too narrow
31
+ } as ConfigEditScreenDefinition["fields"],
32
+ layout: {
33
+ sections: [{ title: "Basics", fields: ["siteName", "maxUploadMb"] }],
34
+ },
35
+ };
36
+
37
+ const schema: FeatureSchema = {
38
+ featureName: "demo",
39
+ entities: {},
40
+ screens: [settingsScreen],
41
+ };
42
+
43
+ describe("KumikoScreen / configEdit", () => {
44
+ test("loading state while config:query:values is pending", async () => {
45
+ let resolveQuery: (value: unknown) => void = () => {};
46
+ const queryPending = new Promise((resolve) => {
47
+ resolveQuery = resolve;
48
+ });
49
+ const dispatcher: Dispatcher = createMockDispatcher({
50
+ query: (() => queryPending) as unknown as Dispatcher["query"],
51
+ });
52
+
53
+ render(
54
+ <DispatcherProvider dispatcher={dispatcher}>
55
+ <KumikoScreen schema={schema} qn="demo:screen:settings" />
56
+ </DispatcherProvider>,
57
+ );
58
+
59
+ // Solange die query pending ist, rendert ConfigEditBody den
60
+ // Loading-Banner (kein Form, kein vorzeitiges leeres Feld).
61
+ expect(screen.getByTestId("kumiko-screen-loading")).toBeTruthy();
62
+
63
+ // Cleanup: Query auflösen damit der useEffect-Subscriber sauber
64
+ // unmounten kann ohne open-handle-Warning.
65
+ resolveQuery({ isSuccess: true, data: {} });
66
+ await waitFor(() => screen.queryByTestId("render-edit-form"));
67
+ });
68
+
69
+ test("loaded values pre-fill the form (string + numeric coercion)", async () => {
70
+ const dispatcher: Dispatcher = createMockDispatcher({
71
+ query: (async () => ({
72
+ isSuccess: true,
73
+ data: {
74
+ "demo:config:site-name": { value: "Acme", scope: "tenant" },
75
+ "demo:config:max-upload-mb": { value: 25, scope: "tenant" },
76
+ },
77
+ })) as unknown as Dispatcher["query"],
78
+ });
79
+
80
+ render(
81
+ <DispatcherProvider dispatcher={dispatcher}>
82
+ <KumikoScreen schema={schema} qn="demo:screen:settings" />
83
+ </DispatcherProvider>,
84
+ );
85
+
86
+ await waitFor(() => screen.getByTestId("render-edit-form"));
87
+ const siteInput = screen.getByTestId("field-siteName").querySelector("input");
88
+ const maxInput = screen.getByTestId("field-maxUploadMb").querySelector("input");
89
+ expect(siteInput?.value).toBe("Acme");
90
+ expect(maxInput?.value).toBe("25");
91
+ });
92
+
93
+ test("submit dispatches one /api/batch with one command per changed field", async () => {
94
+ const batchSpy = vi.fn(
95
+ async (_commands: ReadonlyArray<{ type: string; payload: unknown }>) => ({
96
+ isSuccess: true as const,
97
+ results: [],
98
+ }),
99
+ );
100
+ const dispatcher: Dispatcher = createMockDispatcher({
101
+ query: (async () => ({
102
+ isSuccess: true,
103
+ data: {
104
+ "demo:config:site-name": { value: "Acme", scope: "tenant" },
105
+ "demo:config:max-upload-mb": { value: 25, scope: "tenant" },
106
+ },
107
+ })) as unknown as Dispatcher["query"],
108
+ batch: batchSpy as unknown as Dispatcher["batch"],
109
+ });
110
+
111
+ const user = userEvent.setup();
112
+ render(
113
+ <DispatcherProvider dispatcher={dispatcher}>
114
+ <KumikoScreen schema={schema} qn="demo:screen:settings" />
115
+ </DispatcherProvider>,
116
+ );
117
+
118
+ await waitFor(() => screen.getByTestId("render-edit-form"));
119
+ const siteInput = screen.getByTestId("field-siteName").querySelector("input");
120
+ if (!siteInput) throw new Error("expected siteName input");
121
+
122
+ // Ändert NUR siteName — der Batch darf nur EIN Command enthalten,
123
+ // nicht beide (unchanged-Field bleibt aus).
124
+ await user.clear(siteInput);
125
+ await user.type(siteInput, "Globex");
126
+ await user.click(screen.getByTestId("render-edit-submit"));
127
+
128
+ await waitFor(() => expect(batchSpy).toHaveBeenCalled());
129
+ expect(batchSpy).toHaveBeenCalledTimes(1);
130
+ const commands = batchSpy.mock.calls[0]?.[0];
131
+ if (!commands) throw new Error("batchSpy not called");
132
+ expect(commands).toHaveLength(1);
133
+ expect(commands[0]).toEqual({
134
+ type: "config:write:set",
135
+ payload: { key: "demo:config:site-name", value: "Globex", scope: "tenant" },
136
+ });
137
+ });
138
+
139
+ test("save-button disabled after successful submit (rebase fired)", async () => {
140
+ const dispatcher: Dispatcher = createMockDispatcher({
141
+ query: (async () => ({
142
+ isSuccess: true,
143
+ data: { "demo:config:site-name": { value: "Acme", scope: "tenant" } },
144
+ })) as unknown as Dispatcher["query"],
145
+ batch: (async () => ({
146
+ isSuccess: true,
147
+ results: [],
148
+ })) as unknown as Dispatcher["batch"],
149
+ });
150
+
151
+ const user = userEvent.setup();
152
+ render(
153
+ <DispatcherProvider dispatcher={dispatcher}>
154
+ <KumikoScreen schema={schema} qn="demo:screen:settings" />
155
+ </DispatcherProvider>,
156
+ );
157
+
158
+ await waitFor(() => screen.getByTestId("render-edit-form"));
159
+ const siteInput = screen.getByTestId("field-siteName").querySelector("input");
160
+ if (!siteInput) throw new Error("expected siteName input");
161
+
162
+ await user.clear(siteInput);
163
+ await user.type(siteInput, "Globex");
164
+ const submit = screen.getByTestId("render-edit-submit") as HTMLButtonElement;
165
+ await user.click(submit);
166
+
167
+ // After rebase, draft == server-snapshot, isUnchanged=true,
168
+ // Button wird disabled. Ohne customSubmit-rebase-Wiring blieb
169
+ // dieser State stale (regression-guard).
170
+ await waitFor(() => expect(submit.disabled).toBe(true));
171
+ });
172
+ });