@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.
- package/package.json +63 -0
- package/src/__tests__/avatar.test.tsx +34 -0
- package/src/__tests__/combobox.test.tsx +240 -0
- package/src/__tests__/config-edit.test.tsx +172 -0
- package/src/__tests__/create-app.test.tsx +261 -0
- package/src/__tests__/date-input.test.tsx +91 -0
- package/src/__tests__/default-app-shell.test.tsx +60 -0
- package/src/__tests__/dispatcher-context.test.tsx +101 -0
- package/src/__tests__/dispatcher-status-wiring.test.tsx +119 -0
- package/src/__tests__/kumiko-screen.test.tsx +1014 -0
- package/src/__tests__/language-switcher.test.tsx +100 -0
- package/src/__tests__/money-input.test.tsx +232 -0
- package/src/__tests__/nav-base-path.test.tsx +388 -0
- package/src/__tests__/nav-search-params.test.tsx +88 -0
- package/src/__tests__/nav-tree.test.tsx +183 -0
- package/src/__tests__/nav.test.tsx +253 -0
- package/src/__tests__/primitives.test.tsx +936 -0
- package/src/__tests__/render-edit.test.tsx +178 -0
- package/src/__tests__/render-list-column-renderer.test.tsx +124 -0
- package/src/__tests__/render-list-debounce.test.tsx +128 -0
- package/src/__tests__/render-list.test.tsx +151 -0
- package/src/__tests__/sidebar.test.tsx +59 -0
- package/src/__tests__/test-utils.tsx +144 -0
- package/src/__tests__/theme-toggle.test.tsx +101 -0
- package/src/__tests__/toast.test.tsx +162 -0
- package/src/__tests__/use-form.test.tsx +112 -0
- package/src/__tests__/use-query-live.test.tsx +152 -0
- package/src/__tests__/use-query.test.tsx +88 -0
- package/src/__tests__/use-store.test.tsx +139 -0
- package/src/__tests__/workspace-shell.test.tsx +772 -0
- package/src/app/browser-locale.ts +85 -0
- package/src/app/client-plugin.tsx +63 -0
- package/src/app/create-app.tsx +380 -0
- package/src/app/nav.tsx +226 -0
- package/src/index.ts +137 -0
- package/src/layout/app-layout.tsx +35 -0
- package/src/layout/avatar.tsx +93 -0
- package/src/layout/default-app-shell.tsx +74 -0
- package/src/layout/language-switcher.tsx +101 -0
- package/src/layout/nav-tree.tsx +281 -0
- package/src/layout/profile-menu.tsx +40 -0
- package/src/layout/sidebar.tsx +65 -0
- package/src/layout/theme-toggle.tsx +44 -0
- package/src/layout/topbar.tsx +22 -0
- package/src/layout/workspace-shell.tsx +282 -0
- package/src/layout/workspace-switcher.tsx +62 -0
- package/src/lib/cn.ts +10 -0
- package/src/primitives/action-menu.tsx +111 -0
- package/src/primitives/combobox.tsx +261 -0
- package/src/primitives/date-input.tsx +165 -0
- package/src/primitives/dialog.tsx +119 -0
- package/src/primitives/dropdown-menu.tsx +103 -0
- package/src/primitives/index.tsx +1271 -0
- package/src/primitives/money-input.tsx +192 -0
- package/src/primitives/toast.tsx +166 -0
- package/src/sse/live-events.ts +90 -0
- package/src/styles.css +113 -0
- 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
|
+
});
|