@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,936 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
//
|
|
3
|
+
// Default-Primitives für Web-Renderer. Tests pinnen den Vertrag, den die
|
|
4
|
+
// Renderer-Komponenten (RenderEdit, RenderList, KumikoScreen) an die
|
|
5
|
+
// Primitives weiterreichen — vor allem Accessibility-Zusagen
|
|
6
|
+
// (role="alert" bei Error-Varianten), Event-Shape-Mapping (Input
|
|
7
|
+
// liefert pro kind unterschiedliche JS-Typen zurück statt des rohen
|
|
8
|
+
// ChangeEvent) und das testId-Forwarding, von dem die E2E-Tests
|
|
9
|
+
// abhängen werden.
|
|
10
|
+
|
|
11
|
+
import userEvent from "@testing-library/user-event";
|
|
12
|
+
import { describe, expect, test, vi } from "vitest";
|
|
13
|
+
import { defaultPrimitives } from "../primitives";
|
|
14
|
+
import { fireEvent, render, screen } from "./test-utils";
|
|
15
|
+
|
|
16
|
+
const { Button, Banner, Field, Input, DataTable, Form, Text, Heading, Dialog } = defaultPrimitives;
|
|
17
|
+
|
|
18
|
+
describe("Button", () => {
|
|
19
|
+
test("disabled: attribute gesetzt + Tailwind-Klassen für pointer-events/opacity", () => {
|
|
20
|
+
render(
|
|
21
|
+
<Button disabled testId="btn">
|
|
22
|
+
Save
|
|
23
|
+
</Button>,
|
|
24
|
+
);
|
|
25
|
+
const btn = screen.getByTestId("btn") as HTMLButtonElement;
|
|
26
|
+
expect(btn.disabled).toBe(true);
|
|
27
|
+
// Visuelles Feedback kommt aus Tailwind-Klassen (shadcn-Pattern).
|
|
28
|
+
expect(btn.className).toContain("disabled:pointer-events-none");
|
|
29
|
+
expect(btn.className).toContain("disabled:opacity-50");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("onClick fires on click", () => {
|
|
33
|
+
const onClick = vi.fn();
|
|
34
|
+
render(
|
|
35
|
+
<Button onClick={onClick} testId="btn">
|
|
36
|
+
Go
|
|
37
|
+
</Button>,
|
|
38
|
+
);
|
|
39
|
+
fireEvent.click(screen.getByTestId("btn"));
|
|
40
|
+
expect(onClick).toHaveBeenCalledTimes(1);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("loading: rendert Spinner statt Children + ist disabled", () => {
|
|
44
|
+
const onClick = vi.fn();
|
|
45
|
+
render(
|
|
46
|
+
<Button loading onClick={onClick} testId="btn">
|
|
47
|
+
Save
|
|
48
|
+
</Button>,
|
|
49
|
+
);
|
|
50
|
+
const btn = screen.getByTestId("btn") as HTMLButtonElement;
|
|
51
|
+
expect(btn.disabled).toBe(true);
|
|
52
|
+
expect(btn.dataset["loading"]).toBe("true");
|
|
53
|
+
// Children verschwinden während loading; Spinner ist ein <svg>.
|
|
54
|
+
expect(btn.textContent).not.toContain("Save");
|
|
55
|
+
expect(btn.querySelector("svg")).not.toBeNull();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("Banner", () => {
|
|
60
|
+
test('variant="error" sets role="alert" (a11y)', () => {
|
|
61
|
+
render(
|
|
62
|
+
<Banner variant="error" testId="b">
|
|
63
|
+
Something broke
|
|
64
|
+
</Banner>,
|
|
65
|
+
);
|
|
66
|
+
expect(screen.getByTestId("b").getAttribute("role")).toBe("alert");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('variant="info" has no alert role', () => {
|
|
70
|
+
render(
|
|
71
|
+
<Banner variant="info" testId="b">
|
|
72
|
+
Hi
|
|
73
|
+
</Banner>,
|
|
74
|
+
);
|
|
75
|
+
expect(screen.getByTestId("b").getAttribute("role")).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("actions prop renders in actions slot", () => {
|
|
79
|
+
render(
|
|
80
|
+
<Banner variant="info" testId="b" actions={<span>undo</span>}>
|
|
81
|
+
Saved
|
|
82
|
+
</Banner>,
|
|
83
|
+
);
|
|
84
|
+
const slot = screen.getByTestId("b").querySelector('[data-slot="actions"]');
|
|
85
|
+
expect(slot?.textContent).toBe("undo");
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("Field", () => {
|
|
90
|
+
test("required fügt einen Stern ans Label an", () => {
|
|
91
|
+
render(
|
|
92
|
+
<Field id="f1" label="Name" required testId="f">
|
|
93
|
+
<input />
|
|
94
|
+
</Field>,
|
|
95
|
+
);
|
|
96
|
+
// shadcn-Field rendert den Mark als <span>* mit text-destructive.
|
|
97
|
+
const label = screen.getByTestId("f").querySelector("label");
|
|
98
|
+
expect(label?.textContent).toContain("*");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("issues render inside role=alert with per-testId suffix", () => {
|
|
102
|
+
render(
|
|
103
|
+
<Field
|
|
104
|
+
id="f1"
|
|
105
|
+
label="Email"
|
|
106
|
+
testId="f"
|
|
107
|
+
issues={[{ path: "email", code: "invalid", i18nKey: "Email invalid" }]}
|
|
108
|
+
>
|
|
109
|
+
<input />
|
|
110
|
+
</Field>,
|
|
111
|
+
);
|
|
112
|
+
const alert = screen.getByTestId("f-errors");
|
|
113
|
+
expect(alert.getAttribute("role")).toBe("alert");
|
|
114
|
+
expect(alert.textContent).toContain("Email invalid");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("no issues → no alert element", () => {
|
|
118
|
+
render(
|
|
119
|
+
<Field id="f1" label="Email" testId="f">
|
|
120
|
+
<input />
|
|
121
|
+
</Field>,
|
|
122
|
+
);
|
|
123
|
+
expect(screen.queryByTestId("f-errors")).toBeNull();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("Input kind mapping", () => {
|
|
128
|
+
test('kind="text": onChange receives string', () => {
|
|
129
|
+
const onChange = vi.fn();
|
|
130
|
+
render(<Input id="i" name="i" kind="text" value="" onChange={onChange} />);
|
|
131
|
+
fireEvent.change(screen.getByRole("textbox"), { target: { value: "hello" } });
|
|
132
|
+
expect(onChange).toHaveBeenCalledWith("hello");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('kind="number": "" → undefined, numeric → number', () => {
|
|
136
|
+
const onChange = vi.fn();
|
|
137
|
+
render(<Input id="i" name="i" kind="number" value={0} onChange={onChange} />);
|
|
138
|
+
const input = screen.getByRole("spinbutton");
|
|
139
|
+
fireEvent.change(input, { target: { value: "42" } });
|
|
140
|
+
expect(onChange).toHaveBeenLastCalledWith(42);
|
|
141
|
+
fireEvent.change(input, { target: { value: "" } });
|
|
142
|
+
expect(onChange).toHaveBeenLastCalledWith(undefined);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('kind="boolean": onChange receives checked', () => {
|
|
146
|
+
const onChange = vi.fn();
|
|
147
|
+
render(<Input id="i" name="i" kind="boolean" value={false} onChange={onChange} />);
|
|
148
|
+
fireEvent.click(screen.getByRole("checkbox"));
|
|
149
|
+
expect(onChange).toHaveBeenCalledWith(true);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('kind="date": Trigger zeigt formatiertes Datum, kein nativer date-Input', () => {
|
|
153
|
+
// Default-DateInput nutzt Radix-Popover + DayPicker statt native
|
|
154
|
+
// <input type="date">. Trigger ist ein Button mit dem formatierten
|
|
155
|
+
// Datum als sichtbarem Text.
|
|
156
|
+
const onChange = vi.fn();
|
|
157
|
+
render(
|
|
158
|
+
<Input id="i" name="i" kind="date" value="2026-04-23" onChange={onChange} locale="de-DE" />,
|
|
159
|
+
);
|
|
160
|
+
expect(document.querySelector('input[type="date"]')).toBeNull();
|
|
161
|
+
const trigger = screen.getByRole("button");
|
|
162
|
+
expect(trigger.textContent).toContain("23. April 2026");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("hasError=true sets aria-invalid", () => {
|
|
166
|
+
render(<Input id="i" name="i" kind="text" value="" hasError onChange={() => {}} />);
|
|
167
|
+
expect(screen.getByRole("textbox").getAttribute("aria-invalid")).toBe("true");
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe("DataTable", () => {
|
|
172
|
+
test("empty rows render empty-state slot with derived testId", () => {
|
|
173
|
+
render(
|
|
174
|
+
<DataTable
|
|
175
|
+
columns={[{ field: "name", label: "Name", type: "string", sortable: false }]}
|
|
176
|
+
rows={[]}
|
|
177
|
+
testId="t"
|
|
178
|
+
/>,
|
|
179
|
+
);
|
|
180
|
+
// getByTestId throws if missing — existence assertion is implicit.
|
|
181
|
+
expect(screen.getByTestId("t-empty")).not.toBeNull();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("rows + cells get individual testIds for E2E hooks", () => {
|
|
185
|
+
render(
|
|
186
|
+
<DataTable
|
|
187
|
+
columns={[
|
|
188
|
+
{ field: "name", label: "Name", type: "string", sortable: false },
|
|
189
|
+
{ field: "active", label: "Active", type: "boolean", sortable: false },
|
|
190
|
+
]}
|
|
191
|
+
rows={[{ id: "r1", values: { name: "Alice", active: true } }]}
|
|
192
|
+
testId="t"
|
|
193
|
+
/>,
|
|
194
|
+
);
|
|
195
|
+
expect(screen.getByTestId("row-r1")).not.toBeNull();
|
|
196
|
+
expect(screen.getByTestId("cell-r1-name").textContent).toBe("Alice");
|
|
197
|
+
expect(screen.getByTestId("cell-r1-active").textContent).toBe("✓");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("onRowClick fires with the clicked row", () => {
|
|
201
|
+
const onRowClick = vi.fn();
|
|
202
|
+
const row = { id: "r1", values: { name: "Alice" } };
|
|
203
|
+
render(
|
|
204
|
+
<DataTable
|
|
205
|
+
columns={[{ field: "name", label: "Name", type: "string", sortable: false }]}
|
|
206
|
+
rows={[row]}
|
|
207
|
+
onRowClick={onRowClick}
|
|
208
|
+
/>,
|
|
209
|
+
);
|
|
210
|
+
fireEvent.click(screen.getByTestId("row-r1"));
|
|
211
|
+
expect(onRowClick).toHaveBeenCalledWith(row);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// Sort-Header pinnt das 3-State-Toggle-Verhalten + Visual-Indicator
|
|
215
|
+
// + aria-sort. Renderer-Vertrag mit dem Caller (RenderList): jede
|
|
216
|
+
// sortable-Column liefert beim Click den nächsten Sort-State zurück
|
|
217
|
+
// — Caller setzt damit URL-State und re-fetcht.
|
|
218
|
+
describe("Sort-Header", () => {
|
|
219
|
+
const sortableCols = [
|
|
220
|
+
{ field: "name", label: "Name", type: "string", sortable: true },
|
|
221
|
+
{ field: "createdAt", label: "Created", type: "timestamp", sortable: true },
|
|
222
|
+
{ field: "id", label: "ID", type: "string", sortable: false },
|
|
223
|
+
] as const;
|
|
224
|
+
// Mindestens eine Row, sonst rendert der DefaultDataTable den
|
|
225
|
+
// Empty-State-Branch und das thead-Markup ist gar nicht im DOM.
|
|
226
|
+
// Sort-Header lebt im thead, das brauchen wir hier.
|
|
227
|
+
const oneRow = [{ id: "r1", values: { name: "A", createdAt: "2026-01-01", id: "r1" } }];
|
|
228
|
+
|
|
229
|
+
test("ohne onSortChange: Header bleibt plain (kein Button, kein cursor)", () => {
|
|
230
|
+
render(<DataTable columns={sortableCols} rows={oneRow} />);
|
|
231
|
+
// Plain th.textContent enthält das Label. KEIN Button drinnen.
|
|
232
|
+
expect(screen.getByTestId("column-name").querySelector("button")).toBeNull();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("mit onSortChange: sortable-Column rendert Button + ArrowUpDown-Icon", () => {
|
|
236
|
+
render(<DataTable columns={sortableCols} rows={oneRow} onSortChange={vi.fn()} />);
|
|
237
|
+
const header = screen.getByTestId("column-name");
|
|
238
|
+
expect(header.querySelector("button")).not.toBeNull();
|
|
239
|
+
// Default-Icon (kein active sort) ist ArrowUpDown — Lucide rendert
|
|
240
|
+
// svg ohne expliziten name; wir prüfen aria-sort='none'.
|
|
241
|
+
expect(header.getAttribute("aria-sort")).toBe("none");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("non-sortable Column rendert KEINEN Button (auch mit onSortChange)", () => {
|
|
245
|
+
render(<DataTable columns={sortableCols} rows={oneRow} onSortChange={vi.fn()} />);
|
|
246
|
+
expect(screen.getByTestId("column-id").querySelector("button")).toBeNull();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("aria-sort=ascending wenn sort.field passt + dir=asc", () => {
|
|
250
|
+
render(
|
|
251
|
+
<DataTable
|
|
252
|
+
columns={sortableCols}
|
|
253
|
+
rows={oneRow}
|
|
254
|
+
sort={{ field: "name", dir: "asc" }}
|
|
255
|
+
onSortChange={vi.fn()}
|
|
256
|
+
/>,
|
|
257
|
+
);
|
|
258
|
+
expect(screen.getByTestId("column-name").getAttribute("aria-sort")).toBe("ascending");
|
|
259
|
+
expect(screen.getByTestId("column-createdAt").getAttribute("aria-sort")).toBe("none");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("aria-sort=descending wenn dir=desc", () => {
|
|
263
|
+
render(
|
|
264
|
+
<DataTable
|
|
265
|
+
columns={sortableCols}
|
|
266
|
+
rows={oneRow}
|
|
267
|
+
sort={{ field: "name", dir: "desc" }}
|
|
268
|
+
onSortChange={vi.fn()}
|
|
269
|
+
/>,
|
|
270
|
+
);
|
|
271
|
+
expect(screen.getByTestId("column-name").getAttribute("aria-sort")).toBe("descending");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("Click ohne aktiven Sort: onSortChange({field, dir:'asc'})", () => {
|
|
275
|
+
const onSortChange = vi.fn();
|
|
276
|
+
render(<DataTable columns={sortableCols} rows={oneRow} onSortChange={onSortChange} />);
|
|
277
|
+
fireEvent.click(screen.getByTestId("column-name").querySelector("button") as HTMLElement);
|
|
278
|
+
expect(onSortChange).toHaveBeenCalledWith({ field: "name", dir: "asc" });
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("Click mit aktivem asc: onSortChange({field, dir:'desc'})", () => {
|
|
282
|
+
const onSortChange = vi.fn();
|
|
283
|
+
render(
|
|
284
|
+
<DataTable
|
|
285
|
+
columns={sortableCols}
|
|
286
|
+
rows={oneRow}
|
|
287
|
+
sort={{ field: "name", dir: "asc" }}
|
|
288
|
+
onSortChange={onSortChange}
|
|
289
|
+
/>,
|
|
290
|
+
);
|
|
291
|
+
fireEvent.click(screen.getByTestId("column-name").querySelector("button") as HTMLElement);
|
|
292
|
+
expect(onSortChange).toHaveBeenCalledWith({ field: "name", dir: "desc" });
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("Click mit aktivem desc: onSortChange(null) (3-State zurück zu unsorted)", () => {
|
|
296
|
+
const onSortChange = vi.fn();
|
|
297
|
+
render(
|
|
298
|
+
<DataTable
|
|
299
|
+
columns={sortableCols}
|
|
300
|
+
rows={oneRow}
|
|
301
|
+
sort={{ field: "name", dir: "desc" }}
|
|
302
|
+
onSortChange={onSortChange}
|
|
303
|
+
/>,
|
|
304
|
+
);
|
|
305
|
+
fireEvent.click(screen.getByTestId("column-name").querySelector("button") as HTMLElement);
|
|
306
|
+
expect(onSortChange).toHaveBeenCalledWith(null);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("Click auf andere Spalte (sort=null für die): startet bei asc", () => {
|
|
310
|
+
const onSortChange = vi.fn();
|
|
311
|
+
render(
|
|
312
|
+
<DataTable
|
|
313
|
+
columns={sortableCols}
|
|
314
|
+
rows={oneRow}
|
|
315
|
+
sort={{ field: "name", dir: "desc" }}
|
|
316
|
+
onSortChange={onSortChange}
|
|
317
|
+
/>,
|
|
318
|
+
);
|
|
319
|
+
fireEvent.click(
|
|
320
|
+
screen.getByTestId("column-createdAt").querySelector("button") as HTMLElement,
|
|
321
|
+
);
|
|
322
|
+
// Andere Spalte: dort gibt's keinen aktiven Sort, also asc.
|
|
323
|
+
expect(onSortChange).toHaveBeenCalledWith({ field: "createdAt", dir: "asc" });
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// Pager: Window-of-7 Logik + 3 State-Pfade (first/middle/last page),
|
|
328
|
+
// disabled-Edges, Click-Callback. Server-Wiring (offset etc.) liegt
|
|
329
|
+
// im KumikoScreen — hier nur das UI.
|
|
330
|
+
describe("Pager", () => {
|
|
331
|
+
const cols = [{ field: "name", label: "Name", type: "string", sortable: false }] as const;
|
|
332
|
+
const oneRow = [{ id: "r1", values: { name: "A" } }];
|
|
333
|
+
|
|
334
|
+
test("ohne pager-prop: kein Pager im DOM", () => {
|
|
335
|
+
render(<DataTable columns={cols} rows={oneRow} testId="dt" />);
|
|
336
|
+
expect(screen.queryByTestId("dt-pager")).toBeNull();
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("pager mit total=0: kein Pager (nichts zu paginieren)", () => {
|
|
340
|
+
render(
|
|
341
|
+
<DataTable
|
|
342
|
+
columns={cols}
|
|
343
|
+
rows={[]}
|
|
344
|
+
testId="dt"
|
|
345
|
+
pager={{ page: 1, limit: 50, total: 0, onPageChange: vi.fn() }}
|
|
346
|
+
/>,
|
|
347
|
+
);
|
|
348
|
+
expect(screen.queryByTestId("dt-pager")).toBeNull();
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("page=1: Prev-Button disabled, Next aktiv", () => {
|
|
352
|
+
render(
|
|
353
|
+
<DataTable
|
|
354
|
+
columns={cols}
|
|
355
|
+
rows={oneRow}
|
|
356
|
+
testId="dt"
|
|
357
|
+
pager={{ page: 1, limit: 50, total: 3000, onPageChange: vi.fn() }}
|
|
358
|
+
/>,
|
|
359
|
+
);
|
|
360
|
+
expect((screen.getByTestId("dt-pager-prev") as HTMLButtonElement).disabled).toBe(true);
|
|
361
|
+
expect((screen.getByTestId("dt-pager-next") as HTMLButtonElement).disabled).toBe(false);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("page=last: Next-Button disabled", () => {
|
|
365
|
+
render(
|
|
366
|
+
<DataTable
|
|
367
|
+
columns={cols}
|
|
368
|
+
rows={oneRow}
|
|
369
|
+
testId="dt"
|
|
370
|
+
pager={{ page: 60, limit: 50, total: 3000, onPageChange: vi.fn() }}
|
|
371
|
+
/>,
|
|
372
|
+
);
|
|
373
|
+
expect((screen.getByTestId("dt-pager-next") as HTMLButtonElement).disabled).toBe(true);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test("Click auf Page-Button: onPageChange feuert mit der Seite", () => {
|
|
377
|
+
const onPageChange = vi.fn();
|
|
378
|
+
render(
|
|
379
|
+
<DataTable
|
|
380
|
+
columns={cols}
|
|
381
|
+
rows={oneRow}
|
|
382
|
+
testId="dt"
|
|
383
|
+
pager={{ page: 1, limit: 50, total: 3000, onPageChange }}
|
|
384
|
+
/>,
|
|
385
|
+
);
|
|
386
|
+
fireEvent.click(screen.getByTestId("dt-pager-page-2"));
|
|
387
|
+
expect(onPageChange).toHaveBeenCalledWith(2);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test("Click auf Prev von page=3: onPageChange(2)", () => {
|
|
391
|
+
const onPageChange = vi.fn();
|
|
392
|
+
render(
|
|
393
|
+
<DataTable
|
|
394
|
+
columns={cols}
|
|
395
|
+
rows={oneRow}
|
|
396
|
+
testId="dt"
|
|
397
|
+
pager={{ page: 3, limit: 50, total: 3000, onPageChange }}
|
|
398
|
+
/>,
|
|
399
|
+
);
|
|
400
|
+
fireEvent.click(screen.getByTestId("dt-pager-prev"));
|
|
401
|
+
expect(onPageChange).toHaveBeenCalledWith(2);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test("aria-current='page' auf der aktiven Seite", () => {
|
|
405
|
+
render(
|
|
406
|
+
<DataTable
|
|
407
|
+
columns={cols}
|
|
408
|
+
rows={oneRow}
|
|
409
|
+
testId="dt"
|
|
410
|
+
pager={{ page: 5, limit: 50, total: 3000, onPageChange: vi.fn() }}
|
|
411
|
+
/>,
|
|
412
|
+
);
|
|
413
|
+
expect(screen.getByTestId("dt-pager-page-5").getAttribute("aria-current")).toBe("page");
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test("totalPages ≤ 7: alle Seiten ohne Ellipse", () => {
|
|
417
|
+
render(
|
|
418
|
+
<DataTable
|
|
419
|
+
columns={cols}
|
|
420
|
+
rows={oneRow}
|
|
421
|
+
testId="dt"
|
|
422
|
+
pager={{ page: 1, limit: 50, total: 200, onPageChange: vi.fn() }}
|
|
423
|
+
/>,
|
|
424
|
+
);
|
|
425
|
+
// total=200, limit=50 → 4 Seiten, kein Window
|
|
426
|
+
expect(screen.queryByTestId("dt-pager-page-1")).not.toBeNull();
|
|
427
|
+
expect(screen.queryByTestId("dt-pager-page-4")).not.toBeNull();
|
|
428
|
+
// Kein Ellipsis-Glyph im DOM
|
|
429
|
+
expect(screen.queryByText("…")).toBeNull();
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test("totalPages > 7 und page in der Mitte: Ellipsen außen", () => {
|
|
433
|
+
render(
|
|
434
|
+
<DataTable
|
|
435
|
+
columns={cols}
|
|
436
|
+
rows={oneRow}
|
|
437
|
+
testId="dt"
|
|
438
|
+
pager={{ page: 30, limit: 50, total: 3000, onPageChange: vi.fn() }}
|
|
439
|
+
/>,
|
|
440
|
+
);
|
|
441
|
+
// Window: 1 ... 28 29 [30] 31 32 ... 60
|
|
442
|
+
expect(screen.getAllByText("…")).toHaveLength(2);
|
|
443
|
+
expect(screen.queryByTestId("dt-pager-page-1")).not.toBeNull();
|
|
444
|
+
expect(screen.queryByTestId("dt-pager-page-60")).not.toBeNull();
|
|
445
|
+
expect(screen.queryByTestId("dt-pager-page-30")).not.toBeNull();
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
// Infinite-Scroll Sentinel: rendert sentinel-div, zeigt Spinner wenn
|
|
450
|
+
// loadingMore, "End of list" wenn !hasMore. IntersectionObserver
|
|
451
|
+
// selbst ist in jsdom unmocked — wir testen nur die Marker, der
|
|
452
|
+
// Observer-Fire-Pfad ist im KumikoScreen.EntityListBody.
|
|
453
|
+
describe("InfiniteSentinel", () => {
|
|
454
|
+
const cols = [{ field: "name", label: "Name", type: "string", sortable: false }] as const;
|
|
455
|
+
const oneRow = [{ id: "r1", values: { name: "A" } }];
|
|
456
|
+
|
|
457
|
+
test("ohne onReachEnd: kein Sentinel im DOM", () => {
|
|
458
|
+
render(<DataTable columns={cols} rows={oneRow} testId="dt" />);
|
|
459
|
+
expect(screen.queryByTestId("dt-sentinel")).toBeNull();
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
test("mit onReachEnd + hasMore=true + loadingMore=false: leerer Sentinel", () => {
|
|
463
|
+
render(
|
|
464
|
+
<DataTable
|
|
465
|
+
columns={cols}
|
|
466
|
+
rows={oneRow}
|
|
467
|
+
testId="dt"
|
|
468
|
+
onReachEnd={vi.fn()}
|
|
469
|
+
loadingMore={false}
|
|
470
|
+
hasMore={true}
|
|
471
|
+
/>,
|
|
472
|
+
);
|
|
473
|
+
const sentinel = screen.getByTestId("dt-sentinel");
|
|
474
|
+
// Weder End-Marker noch Spinner — der Sentinel wartet auf den
|
|
475
|
+
// Observer-Fire (Pre-Fetch via rootMargin: 200px).
|
|
476
|
+
expect(sentinel.querySelector("svg")).toBeNull();
|
|
477
|
+
expect(screen.queryByTestId("dt-sentinel-end")).toBeNull();
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test("loadingMore=true: Spinner sichtbar", () => {
|
|
481
|
+
render(
|
|
482
|
+
<DataTable
|
|
483
|
+
columns={cols}
|
|
484
|
+
rows={oneRow}
|
|
485
|
+
testId="dt"
|
|
486
|
+
onReachEnd={vi.fn()}
|
|
487
|
+
loadingMore={true}
|
|
488
|
+
hasMore={true}
|
|
489
|
+
/>,
|
|
490
|
+
);
|
|
491
|
+
expect(screen.getByTestId("dt-sentinel").querySelector("svg")).not.toBeNull();
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
test("hasMore=false: 'End of list' Marker statt Sentinel-Wirkung", () => {
|
|
495
|
+
render(
|
|
496
|
+
<DataTable
|
|
497
|
+
columns={cols}
|
|
498
|
+
rows={oneRow}
|
|
499
|
+
testId="dt"
|
|
500
|
+
onReachEnd={vi.fn()}
|
|
501
|
+
loadingMore={false}
|
|
502
|
+
hasMore={false}
|
|
503
|
+
/>,
|
|
504
|
+
);
|
|
505
|
+
expect(screen.getByTestId("dt-sentinel-end")).not.toBeNull();
|
|
506
|
+
expect(screen.getByTestId("dt-sentinel-end").textContent).toContain("End of list");
|
|
507
|
+
});
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
// RowActions: pinst die Inline-vs-Kebab-Entscheidung, Confirm-Dialog
|
|
511
|
+
// bei style=danger, Visibility-Filter pro Row und onTrigger-Wiring.
|
|
512
|
+
describe("RowActions", () => {
|
|
513
|
+
const cols = [{ field: "name", label: "Name", type: "string", sortable: false }] as const;
|
|
514
|
+
const rows = [
|
|
515
|
+
{ id: "r1", values: { id: "r1", name: "Alpha" } },
|
|
516
|
+
{ id: "r2", values: { id: "r2", name: "Beta" } },
|
|
517
|
+
];
|
|
518
|
+
|
|
519
|
+
test("ohne rowActions: keine Actions-Spalte im Header", () => {
|
|
520
|
+
render(<DataTable columns={cols} rows={rows} testId="dt" />);
|
|
521
|
+
expect(screen.queryByTestId("column-actions")).toBeNull();
|
|
522
|
+
expect(screen.queryByTestId("cell-r1-actions")).toBeNull();
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
test("mit rowActions: Actions-Spalte gerendert", () => {
|
|
526
|
+
render(
|
|
527
|
+
<DataTable
|
|
528
|
+
columns={cols}
|
|
529
|
+
rows={rows}
|
|
530
|
+
testId="dt"
|
|
531
|
+
rowActions={[{ id: "edit", label: "Edit", onTrigger: vi.fn() }]}
|
|
532
|
+
/>,
|
|
533
|
+
);
|
|
534
|
+
expect(screen.queryByTestId("column-actions")).not.toBeNull();
|
|
535
|
+
expect(screen.queryByTestId("cell-r1-actions")).not.toBeNull();
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
test("≤2 Actions: Inline-Buttons (kein Kebab)", () => {
|
|
539
|
+
render(
|
|
540
|
+
<DataTable
|
|
541
|
+
columns={cols}
|
|
542
|
+
rows={rows}
|
|
543
|
+
testId="dt"
|
|
544
|
+
rowActions={[
|
|
545
|
+
{ id: "edit", label: "Edit", onTrigger: vi.fn() },
|
|
546
|
+
{ id: "delete", label: "Delete", style: "danger", onTrigger: vi.fn() },
|
|
547
|
+
]}
|
|
548
|
+
/>,
|
|
549
|
+
);
|
|
550
|
+
expect(screen.queryByTestId("row-r1-action-edit")).not.toBeNull();
|
|
551
|
+
expect(screen.queryByTestId("row-r1-actions-menu")).toBeNull();
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
test(">2 Actions: Kebab-Dropdown statt Inline-Buttons", () => {
|
|
555
|
+
render(
|
|
556
|
+
<DataTable
|
|
557
|
+
columns={cols}
|
|
558
|
+
rows={rows}
|
|
559
|
+
testId="dt"
|
|
560
|
+
rowActions={[
|
|
561
|
+
{ id: "a", label: "A", onTrigger: vi.fn() },
|
|
562
|
+
{ id: "b", label: "B", onTrigger: vi.fn() },
|
|
563
|
+
{ id: "c", label: "C", onTrigger: vi.fn() },
|
|
564
|
+
]}
|
|
565
|
+
/>,
|
|
566
|
+
);
|
|
567
|
+
expect(screen.queryByTestId("row-r1-actions-menu")).not.toBeNull();
|
|
568
|
+
// Inline-Buttons der Kebab-Items sind nicht direkt im DOM —
|
|
569
|
+
// Radix portal'd Content ist erst nach Click sichtbar.
|
|
570
|
+
expect(screen.queryByTestId("row-r1-action-a")).toBeNull();
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
test("Kebab: Click auf Trigger öffnet Dropdown mit allen Items", async () => {
|
|
574
|
+
const user = userEvent.setup();
|
|
575
|
+
render(
|
|
576
|
+
<DataTable
|
|
577
|
+
columns={cols}
|
|
578
|
+
rows={rows}
|
|
579
|
+
testId="dt"
|
|
580
|
+
rowActions={[
|
|
581
|
+
{ id: "a", label: "Archive", onTrigger: vi.fn() },
|
|
582
|
+
{ id: "b", label: "Duplicate", onTrigger: vi.fn() },
|
|
583
|
+
{ id: "c", label: "Export", onTrigger: vi.fn() },
|
|
584
|
+
]}
|
|
585
|
+
/>,
|
|
586
|
+
);
|
|
587
|
+
await user.click(screen.getByTestId("row-r1-actions-menu"));
|
|
588
|
+
expect(screen.queryByTestId("row-r1-action-a")).not.toBeNull();
|
|
589
|
+
expect(screen.queryByTestId("row-r1-action-b")).not.toBeNull();
|
|
590
|
+
expect(screen.queryByTestId("row-r1-action-c")).not.toBeNull();
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
test("Kebab: Click auf Item ohne confirm → onTrigger feuert direkt", async () => {
|
|
594
|
+
const user = userEvent.setup();
|
|
595
|
+
const onTrigger = vi.fn();
|
|
596
|
+
render(
|
|
597
|
+
<DataTable
|
|
598
|
+
columns={cols}
|
|
599
|
+
rows={rows}
|
|
600
|
+
testId="dt"
|
|
601
|
+
rowActions={[
|
|
602
|
+
{ id: "a", label: "Archive", onTrigger },
|
|
603
|
+
{ id: "b", label: "Duplicate", onTrigger: vi.fn() },
|
|
604
|
+
{ id: "c", label: "Export", onTrigger: vi.fn() },
|
|
605
|
+
]}
|
|
606
|
+
/>,
|
|
607
|
+
);
|
|
608
|
+
await user.click(screen.getByTestId("row-r1-actions-menu"));
|
|
609
|
+
await user.click(screen.getByTestId("row-r1-action-a"));
|
|
610
|
+
// micro-task warten (onTrigger ist async im Hook)
|
|
611
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
612
|
+
expect(onTrigger).toHaveBeenCalledWith(rows[0]);
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
test("Kebab: Click auf Danger-Item → Confirm-Dialog statt direkt-Trigger", async () => {
|
|
616
|
+
const user = userEvent.setup();
|
|
617
|
+
const onTrigger = vi.fn();
|
|
618
|
+
render(
|
|
619
|
+
<DataTable
|
|
620
|
+
columns={cols}
|
|
621
|
+
rows={rows}
|
|
622
|
+
testId="dt"
|
|
623
|
+
rowActions={[
|
|
624
|
+
{ id: "a", label: "Archive", onTrigger: vi.fn() },
|
|
625
|
+
{ id: "b", label: "Duplicate", onTrigger: vi.fn() },
|
|
626
|
+
{ id: "delete", label: "Delete", style: "danger", onTrigger },
|
|
627
|
+
]}
|
|
628
|
+
/>,
|
|
629
|
+
);
|
|
630
|
+
await user.click(screen.getByTestId("row-r1-actions-menu"));
|
|
631
|
+
await user.click(screen.getByTestId("row-r1-action-delete"));
|
|
632
|
+
// Trigger NICHT direkt — der Dialog muss zuerst öffnen.
|
|
633
|
+
expect(onTrigger).not.toHaveBeenCalled();
|
|
634
|
+
expect(screen.queryByTestId("row-r1-action-delete-dialog")).not.toBeNull();
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
test("confirmLabel separat vom label: Dialog-Button zeigt confirmLabel", async () => {
|
|
638
|
+
const user = userEvent.setup();
|
|
639
|
+
render(
|
|
640
|
+
<DataTable
|
|
641
|
+
columns={cols}
|
|
642
|
+
rows={rows}
|
|
643
|
+
testId="dt"
|
|
644
|
+
rowActions={[
|
|
645
|
+
{
|
|
646
|
+
id: "cancel-sub",
|
|
647
|
+
label: "Mark Subscription as Cancelled",
|
|
648
|
+
style: "danger",
|
|
649
|
+
confirmLabel: "Cancel Subscription",
|
|
650
|
+
confirm: "This is permanent.",
|
|
651
|
+
onTrigger: vi.fn(),
|
|
652
|
+
},
|
|
653
|
+
]}
|
|
654
|
+
/>,
|
|
655
|
+
);
|
|
656
|
+
await user.click(screen.getByTestId("row-r1-action-cancel-sub"));
|
|
657
|
+
const dialog = screen.getByTestId("row-r1-action-cancel-sub-dialog");
|
|
658
|
+
// Confirm-Button im Dialog hat confirmLabel, nicht das volle label
|
|
659
|
+
const confirmBtn = dialog.querySelector('[data-testid$="confirm"]');
|
|
660
|
+
expect(confirmBtn?.textContent).toContain("Cancel Subscription");
|
|
661
|
+
expect(confirmBtn?.textContent).not.toContain("Mark Subscription");
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
test("Click auf Action ohne confirm: onTrigger wird mit Row gerufen", async () => {
|
|
665
|
+
const user = userEvent.setup();
|
|
666
|
+
const onTrigger = vi.fn();
|
|
667
|
+
render(
|
|
668
|
+
<DataTable
|
|
669
|
+
columns={cols}
|
|
670
|
+
rows={rows}
|
|
671
|
+
testId="dt"
|
|
672
|
+
rowActions={[{ id: "edit", label: "Edit", onTrigger }]}
|
|
673
|
+
/>,
|
|
674
|
+
);
|
|
675
|
+
await user.click(screen.getByTestId("row-r1-action-edit"));
|
|
676
|
+
expect(onTrigger).toHaveBeenCalledWith(rows[0]);
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
test("style=danger: erzwingt Confirm-Dialog vor onTrigger", async () => {
|
|
680
|
+
const onTrigger = vi.fn();
|
|
681
|
+
render(
|
|
682
|
+
<DataTable
|
|
683
|
+
columns={cols}
|
|
684
|
+
rows={rows}
|
|
685
|
+
testId="dt"
|
|
686
|
+
rowActions={[{ id: "delete", label: "Delete", style: "danger", onTrigger }]}
|
|
687
|
+
/>,
|
|
688
|
+
);
|
|
689
|
+
fireEvent.click(screen.getByTestId("row-r1-action-delete"));
|
|
690
|
+
// Click triggered den Dialog, NICHT direkt onTrigger.
|
|
691
|
+
expect(onTrigger).not.toHaveBeenCalled();
|
|
692
|
+
// Dialog muss im DOM sein — wir checken den testId-Suffix.
|
|
693
|
+
expect(screen.queryByTestId("row-r1-action-delete-dialog")).not.toBeNull();
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
test("isVisible=false: Action erscheint nicht in der Cell", () => {
|
|
697
|
+
render(
|
|
698
|
+
<DataTable
|
|
699
|
+
columns={cols}
|
|
700
|
+
rows={rows}
|
|
701
|
+
testId="dt"
|
|
702
|
+
rowActions={[
|
|
703
|
+
{
|
|
704
|
+
id: "archive",
|
|
705
|
+
label: "Archive",
|
|
706
|
+
onTrigger: vi.fn(),
|
|
707
|
+
// Nur für r1 sichtbar
|
|
708
|
+
isVisible: (row) => row.id === "r1",
|
|
709
|
+
},
|
|
710
|
+
]}
|
|
711
|
+
/>,
|
|
712
|
+
);
|
|
713
|
+
expect(screen.queryByTestId("row-r1-action-archive")).not.toBeNull();
|
|
714
|
+
expect(screen.queryByTestId("row-r2-action-archive")).toBeNull();
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
test("Click auf Action-Cell propagiert NICHT auf onRowClick", async () => {
|
|
718
|
+
const user = userEvent.setup();
|
|
719
|
+
const onRowClick = vi.fn();
|
|
720
|
+
const onTrigger = vi.fn();
|
|
721
|
+
render(
|
|
722
|
+
<DataTable
|
|
723
|
+
columns={cols}
|
|
724
|
+
rows={rows}
|
|
725
|
+
testId="dt"
|
|
726
|
+
onRowClick={onRowClick}
|
|
727
|
+
rowActions={[{ id: "edit", label: "Edit", onTrigger }]}
|
|
728
|
+
/>,
|
|
729
|
+
);
|
|
730
|
+
await user.click(screen.getByTestId("row-r1-action-edit"));
|
|
731
|
+
// onTrigger feuert, onRowClick MUSS NICHT — sonst würde der User
|
|
732
|
+
// beim Action-Click gleichzeitig zum Edit-Screen navigieren.
|
|
733
|
+
expect(onRowClick).not.toHaveBeenCalled();
|
|
734
|
+
});
|
|
735
|
+
});
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
describe("Form", () => {
|
|
739
|
+
test("submit calls onSubmit and prevents default navigation", () => {
|
|
740
|
+
const onSubmit = vi.fn();
|
|
741
|
+
render(
|
|
742
|
+
<Form onSubmit={onSubmit} testId="form">
|
|
743
|
+
<button type="submit">Go</button>
|
|
744
|
+
</Form>,
|
|
745
|
+
);
|
|
746
|
+
const form = screen.getByTestId("form") as HTMLFormElement;
|
|
747
|
+
const submitEvent = new Event("submit", { bubbles: true, cancelable: true });
|
|
748
|
+
fireEvent(form, submitEvent);
|
|
749
|
+
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
750
|
+
expect(submitEvent.defaultPrevented).toBe(true);
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
test("title slot rendert in der Action-Bar links neben den Actions", () => {
|
|
754
|
+
render(
|
|
755
|
+
<Form
|
|
756
|
+
onSubmit={() => undefined}
|
|
757
|
+
title="Eintrag bearbeiten"
|
|
758
|
+
actions={<button type="submit">Save</button>}
|
|
759
|
+
testId="form"
|
|
760
|
+
>
|
|
761
|
+
<div>content</div>
|
|
762
|
+
</Form>,
|
|
763
|
+
);
|
|
764
|
+
const actionsBar = screen.getByTestId("form-actions");
|
|
765
|
+
expect(actionsBar.textContent).toContain("Eintrag bearbeiten");
|
|
766
|
+
expect(actionsBar.textContent).toContain("Save");
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
test("ohne title und actions: keine Action-Bar gerendert", () => {
|
|
770
|
+
render(
|
|
771
|
+
<Form onSubmit={() => undefined} testId="form">
|
|
772
|
+
<div>content</div>
|
|
773
|
+
</Form>,
|
|
774
|
+
);
|
|
775
|
+
expect(screen.queryByTestId("form-actions")).toBeNull();
|
|
776
|
+
});
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
describe("Banner padded", () => {
|
|
780
|
+
test("padded=true wraps in p-6 container für Page-State-Use", () => {
|
|
781
|
+
const { container } = render(
|
|
782
|
+
<Banner padded variant="info" testId="banner">
|
|
783
|
+
Loading…
|
|
784
|
+
</Banner>,
|
|
785
|
+
);
|
|
786
|
+
const banner = screen.getByTestId("banner");
|
|
787
|
+
// Outer wrapper hat p-6, banner innen
|
|
788
|
+
const wrapper = banner.parentElement;
|
|
789
|
+
expect(wrapper?.className).toContain("p-6");
|
|
790
|
+
expect(container.firstChild).toBe(wrapper);
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
test("padded=undefined rendert ohne Wrapper (inline-Use)", () => {
|
|
794
|
+
const { container } = render(
|
|
795
|
+
<Banner variant="info" testId="banner">
|
|
796
|
+
Inline
|
|
797
|
+
</Banner>,
|
|
798
|
+
);
|
|
799
|
+
expect(container.firstChild).toBe(screen.getByTestId("banner"));
|
|
800
|
+
});
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
describe("Heading variants", () => {
|
|
804
|
+
test('variant="page" renders h1', () => {
|
|
805
|
+
render(
|
|
806
|
+
<Heading variant="page" testId="h">
|
|
807
|
+
Items
|
|
808
|
+
</Heading>,
|
|
809
|
+
);
|
|
810
|
+
expect(screen.getByTestId("h").tagName).toBe("H1");
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
test('variant="section" renders h2 mit uppercase styling', () => {
|
|
814
|
+
render(
|
|
815
|
+
<Heading variant="section" testId="h">
|
|
816
|
+
Basics
|
|
817
|
+
</Heading>,
|
|
818
|
+
);
|
|
819
|
+
const h = screen.getByTestId("h");
|
|
820
|
+
expect(h.tagName).toBe("H2");
|
|
821
|
+
expect(h.className).toContain("uppercase");
|
|
822
|
+
});
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
describe("DataTable toolbar slots", () => {
|
|
826
|
+
test("toolbarTitle + toolbarStart + toolbarEnd rendern in einer Zeile", () => {
|
|
827
|
+
render(
|
|
828
|
+
<DataTable
|
|
829
|
+
columns={[]}
|
|
830
|
+
rows={[]}
|
|
831
|
+
toolbarTitle="Items"
|
|
832
|
+
toolbarStart={<input data-testid="search" />}
|
|
833
|
+
toolbarEnd={
|
|
834
|
+
<button type="button" data-testid="create">
|
|
835
|
+
+ Neu
|
|
836
|
+
</button>
|
|
837
|
+
}
|
|
838
|
+
testId="dt"
|
|
839
|
+
/>,
|
|
840
|
+
);
|
|
841
|
+
const toolbar = screen.getByTestId("dt-toolbar");
|
|
842
|
+
expect(toolbar.textContent).toContain("Items");
|
|
843
|
+
expect(toolbar.querySelector('[data-testid="search"]')).not.toBeNull();
|
|
844
|
+
expect(toolbar.querySelector('[data-testid="create"]')).not.toBeNull();
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
test("ohne toolbar slots wird kein Toolbar-Container gerendert", () => {
|
|
848
|
+
render(<DataTable columns={[]} rows={[]} testId="dt" />);
|
|
849
|
+
expect(screen.queryByTestId("dt-toolbar")).toBeNull();
|
|
850
|
+
});
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
describe("Dialog", () => {
|
|
854
|
+
test("open=true rendert Dialog mit Title und Confirm/Cancel Buttons", () => {
|
|
855
|
+
const onConfirm = vi.fn();
|
|
856
|
+
const onOpenChange = vi.fn();
|
|
857
|
+
render(
|
|
858
|
+
<Dialog
|
|
859
|
+
open
|
|
860
|
+
onOpenChange={onOpenChange}
|
|
861
|
+
title="Wirklich löschen?"
|
|
862
|
+
onConfirm={onConfirm}
|
|
863
|
+
testId="confirm"
|
|
864
|
+
/>,
|
|
865
|
+
);
|
|
866
|
+
expect(screen.getByText("Wirklich löschen?")).toBeTruthy();
|
|
867
|
+
expect(screen.getByTestId("confirm-confirm")).toBeTruthy();
|
|
868
|
+
expect(screen.getByTestId("confirm-cancel")).toBeTruthy();
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
test("open=false rendert nichts (Portal leer)", () => {
|
|
872
|
+
render(
|
|
873
|
+
<Dialog
|
|
874
|
+
open={false}
|
|
875
|
+
onOpenChange={() => undefined}
|
|
876
|
+
title="Hidden"
|
|
877
|
+
onConfirm={() => undefined}
|
|
878
|
+
testId="hidden-dialog"
|
|
879
|
+
/>,
|
|
880
|
+
);
|
|
881
|
+
expect(screen.queryByTestId("hidden-dialog")).toBeNull();
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
test("Confirm-Button feuert onConfirm und schließt den Dialog", async () => {
|
|
885
|
+
const user = userEvent.setup();
|
|
886
|
+
const onConfirm = vi.fn();
|
|
887
|
+
const onOpenChange = vi.fn();
|
|
888
|
+
render(
|
|
889
|
+
<Dialog
|
|
890
|
+
open
|
|
891
|
+
onOpenChange={onOpenChange}
|
|
892
|
+
title="Bestätigen?"
|
|
893
|
+
onConfirm={onConfirm}
|
|
894
|
+
testId="dlg"
|
|
895
|
+
/>,
|
|
896
|
+
);
|
|
897
|
+
// userEvent.click wartet auf React-State-Updates die durch Radix-
|
|
898
|
+
// Lifecycle (Presence/FocusScope/DismissableLayer) ausgelöst werden
|
|
899
|
+
// — fireEvent.click würde dieselben Updates uneingewickelt lassen
|
|
900
|
+
// und mit ~13 act()-Warnings spammen.
|
|
901
|
+
await user.click(screen.getByTestId("dlg-confirm"));
|
|
902
|
+
expect(onConfirm).toHaveBeenCalledTimes(1);
|
|
903
|
+
expect(onOpenChange).toHaveBeenCalledWith(false);
|
|
904
|
+
});
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
describe("Text variants", () => {
|
|
908
|
+
test('variant="code" renders <code>', () => {
|
|
909
|
+
render(
|
|
910
|
+
<Text variant="code" testId="t">
|
|
911
|
+
x
|
|
912
|
+
</Text>,
|
|
913
|
+
);
|
|
914
|
+
expect(screen.getByTestId("t").tagName).toBe("CODE");
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
test('variant="small" renders <small>', () => {
|
|
918
|
+
render(
|
|
919
|
+
<Text variant="small" testId="t">
|
|
920
|
+
x
|
|
921
|
+
</Text>,
|
|
922
|
+
);
|
|
923
|
+
expect(screen.getByTestId("t").tagName).toBe("SMALL");
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
test('variant="required-mark" renders data-required span', () => {
|
|
927
|
+
render(
|
|
928
|
+
<Text variant="required-mark" testId="t">
|
|
929
|
+
*
|
|
930
|
+
</Text>,
|
|
931
|
+
);
|
|
932
|
+
const el = screen.getByTestId("t");
|
|
933
|
+
expect(el.tagName).toBe("SPAN");
|
|
934
|
+
expect(el.hasAttribute("data-required")).toBe(true);
|
|
935
|
+
});
|
|
936
|
+
});
|