@cosmicdrift/kumiko-renderer-web 1.0.0 → 2.0.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 (79) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/action-menu.test.tsx +75 -0
  3. package/src/__tests__/breadcrumb.test.tsx +50 -0
  4. package/src/__tests__/create-app.test.tsx +171 -1
  5. package/src/__tests__/dashboard-screen.test.tsx +393 -0
  6. package/src/__tests__/default-app-shell.test.tsx +47 -1
  7. package/src/__tests__/form-action-bar.test.tsx +9 -0
  8. package/src/__tests__/kumiko-screen.test.tsx +155 -1
  9. package/src/__tests__/lightbox.test.tsx +46 -0
  10. package/src/__tests__/nav-tree.test.tsx +122 -0
  11. package/src/__tests__/primitives.test.tsx +32 -1
  12. package/src/__tests__/profile-menu.test.tsx +29 -0
  13. package/src/__tests__/projection-detail.test.tsx +101 -0
  14. package/src/__tests__/projection-list-actions.test.tsx +131 -0
  15. package/src/__tests__/render-list.test.tsx +11 -3
  16. package/src/__tests__/test-utils.tsx +1 -0
  17. package/src/__tests__/toast.test.tsx +9 -9
  18. package/src/__tests__/use-mutation.test.tsx +86 -0
  19. package/src/__tests__/workspace-shell.test.tsx +102 -47
  20. package/src/__tests__/xss-safety.test.tsx +62 -0
  21. package/src/app/__tests__/first-open-screen-qn.test.ts +77 -0
  22. package/src/app/browser-locale.ts +1 -1
  23. package/src/app/create-app.tsx +96 -29
  24. package/src/app/dashboard-body.tsx +454 -0
  25. package/src/index.ts +75 -1
  26. package/src/layout/__tests__/nav-registry.test.ts +105 -0
  27. package/src/layout/__tests__/shell-breadcrumb.test.ts +82 -0
  28. package/src/layout/default-app-shell.tsx +9 -71
  29. package/src/layout/nav-tree.tsx +167 -37
  30. package/src/layout/shell-breadcrumb.ts +46 -0
  31. package/src/layout/shell-header.tsx +114 -0
  32. package/src/layout/workspace-shell.tsx +45 -18
  33. package/src/primitives/__tests__/data-table-logic.test.ts +27 -0
  34. package/src/primitives/__tests__/input-testid.test.tsx +45 -0
  35. package/src/primitives/__tests__/money-input.test.tsx +13 -1
  36. package/src/primitives/dialog.tsx +47 -76
  37. package/src/primitives/index.tsx +215 -37
  38. package/src/primitives/layout.tsx +39 -0
  39. package/src/primitives/lightbox.tsx +39 -0
  40. package/src/primitives/modal-shell.tsx +62 -0
  41. package/src/primitives/money-input.tsx +20 -10
  42. package/src/primitives/toast.tsx +14 -9
  43. package/src/sse/live-events.ts +6 -1
  44. package/src/styles.css +25 -0
  45. package/src/tokens.ts +4 -0
  46. package/src/ui/__tests__/avatar.test.tsx +66 -0
  47. package/src/ui/__tests__/sheet.test.tsx +102 -0
  48. package/src/version/__tests__/update-checker.test.tsx +115 -0
  49. package/src/widgets/__tests__/ai-text-field.test.tsx +267 -0
  50. package/src/widgets/__tests__/drawer.test.tsx +35 -0
  51. package/src/widgets/__tests__/form-kit.test.tsx +368 -0
  52. package/src/widgets/__tests__/infinity-list.test.tsx +132 -0
  53. package/src/widgets/__tests__/query-table.test.tsx +118 -0
  54. package/src/widgets/__tests__/result-panel.test.tsx +117 -0
  55. package/src/widgets/__tests__/widgets.test.tsx +243 -0
  56. package/src/widgets/ai-text-field.tsx +370 -0
  57. package/src/widgets/charts.tsx +228 -0
  58. package/src/widgets/collapsible-section.tsx +42 -0
  59. package/src/widgets/detail-list.tsx +43 -0
  60. package/src/widgets/drawer.tsx +49 -0
  61. package/src/widgets/feed-list.tsx +35 -0
  62. package/src/widgets/form-fields.tsx +326 -0
  63. package/src/widgets/index.ts +59 -0
  64. package/src/widgets/infinity-list.tsx +112 -0
  65. package/src/widgets/mode-switch.tsx +41 -0
  66. package/src/widgets/progress-bar.tsx +27 -0
  67. package/src/widgets/progress-list.tsx +38 -0
  68. package/src/widgets/query-table.tsx +103 -0
  69. package/src/widgets/result-panel.tsx +251 -0
  70. package/src/widgets/section-card.tsx +30 -0
  71. package/src/widgets/stat.tsx +181 -0
  72. package/src/widgets/states.tsx +89 -0
  73. package/src/widgets/status-badge.tsx +51 -0
  74. package/src/widgets/use-draft.ts +40 -0
  75. package/src/ui/card.tsx +0 -93
  76. package/src/ui/collapsible.tsx +0 -34
  77. package/src/ui/dropdown-menu.tsx +0 -258
  78. package/src/ui/select.tsx +0 -191
  79. /package/src/__tests__/{download.test.ts → download.test.tsx} +0 -0
@@ -0,0 +1,267 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+ import { DispatcherProvider } from "@cosmicdrift/kumiko-renderer";
3
+ import { type ReactElement, useState } from "react";
4
+ import {
5
+ createMockDispatcher,
6
+ fireEvent,
7
+ render,
8
+ screen,
9
+ waitFor,
10
+ } from "../../__tests__/test-utils";
11
+ import { AiTextArea, AiTextField, type AiTextFieldProps } from "../ai-text-field";
12
+
13
+ function renderWithDispatcher(
14
+ ui: ReactElement,
15
+ query: NonNullable<Parameters<typeof createMockDispatcher>[0]>["query"],
16
+ ) {
17
+ const dispatcher = createMockDispatcher({ query });
18
+ return render(<DispatcherProvider dispatcher={dispatcher}>{ui}</DispatcherProvider>);
19
+ }
20
+
21
+ // Real controlled-component loop: onChange feeds back into `value` so
22
+ // Tab-accept sees the actually-typed text, not a stale prop. Plain
23
+ // `mock()`-only setups (no state) can't exercise this — that's a test
24
+ // bug we hit and fixed once already (see git history), keep it this way.
25
+ function ControlledAiTextField(
26
+ props: Omit<AiTextFieldProps, "value" | "onChange"> & {
27
+ readonly initialValue: string;
28
+ readonly onChangeSpy?: (v: string) => void;
29
+ },
30
+ ) {
31
+ const { initialValue, onChangeSpy, ...rest } = props;
32
+ const [value, setValue] = useState(initialValue);
33
+ return (
34
+ <AiTextField
35
+ {...rest}
36
+ value={value}
37
+ onChange={(v) => {
38
+ setValue(v);
39
+ onChangeSpy?.(v);
40
+ }}
41
+ />
42
+ );
43
+ }
44
+
45
+ describe("AiTextField", () => {
46
+ test("plain typing calls onChange like a normal text field", () => {
47
+ const onChangeSpy = mock((_v: string) => {});
48
+ renderWithDispatcher(
49
+ <ControlledAiTextField
50
+ label="Title"
51
+ id="title"
52
+ name="title"
53
+ initialValue=""
54
+ onChangeSpy={onChangeSpy}
55
+ actions={[]}
56
+ completion={false}
57
+ testId="title"
58
+ />,
59
+ (async () => ({
60
+ isSuccess: true,
61
+ data: { type: "text", text: "", usage: { inputTokens: 0, outputTokens: 0 } },
62
+ })) as never,
63
+ );
64
+ fireEvent.change(screen.getByTestId("title-input"), { target: { value: "hi" } });
65
+ expect(onChangeSpy).toHaveBeenCalledWith("hi");
66
+ });
67
+
68
+ test("ghost-text: shows suggestion, Tab accepts it appended to the typed text", async () => {
69
+ const onChangeSpy = mock((_v: string) => {});
70
+ renderWithDispatcher(
71
+ <ControlledAiTextField
72
+ label="Title"
73
+ id="title"
74
+ name="title"
75
+ initialValue=""
76
+ onChangeSpy={onChangeSpy}
77
+ actions={[]}
78
+ completionDebounceMs={5}
79
+ testId="title"
80
+ />,
81
+ (async () => ({
82
+ isSuccess: true,
83
+ data: { type: "text", text: "fox", usage: { inputTokens: 1, outputTokens: 1 } },
84
+ })) as never,
85
+ );
86
+
87
+ const input = screen.getByTestId("title-input");
88
+ fireEvent.change(input, { target: { value: "the quick brown " } });
89
+
90
+ await waitFor(() => expect(screen.queryByText("fox")).toBeTruthy(), { timeout: 1000 });
91
+
92
+ fireEvent.keyDown(input, { key: "Tab" });
93
+ expect(onChangeSpy).toHaveBeenLastCalledWith("the quick brown fox");
94
+ });
95
+
96
+ test("ghost-text: Esc discards the suggestion without calling onChange", async () => {
97
+ const onChangeSpy = mock((_v: string) => {});
98
+ renderWithDispatcher(
99
+ <ControlledAiTextField
100
+ label="Title"
101
+ id="title"
102
+ name="title"
103
+ initialValue=""
104
+ onChangeSpy={onChangeSpy}
105
+ actions={[]}
106
+ completionDebounceMs={5}
107
+ testId="title"
108
+ />,
109
+ (async () => ({
110
+ isSuccess: true,
111
+ data: { type: "text", text: "there", usage: { inputTokens: 1, outputTokens: 1 } },
112
+ })) as never,
113
+ );
114
+
115
+ const input = screen.getByTestId("title-input");
116
+ fireEvent.change(input, { target: { value: "hi " } });
117
+ await waitFor(() => expect(screen.queryByText("there")).toBeTruthy(), { timeout: 1000 });
118
+
119
+ onChangeSpy.mockClear();
120
+ fireEvent.keyDown(input, { key: "Escape" });
121
+ expect(screen.queryByText("there")).toBeNull();
122
+ expect(onChangeSpy).not.toHaveBeenCalled();
123
+ });
124
+
125
+ test("feature_disabled → toolbar hides, field stays usable (graceful degradation)", async () => {
126
+ const onChangeSpy = mock((_v: string) => {});
127
+ renderWithDispatcher(
128
+ <ControlledAiTextField
129
+ label="Title"
130
+ id="title"
131
+ name="title"
132
+ initialValue=""
133
+ onChangeSpy={onChangeSpy}
134
+ completionDebounceMs={5}
135
+ testId="title"
136
+ />,
137
+ (async () => ({
138
+ isSuccess: false,
139
+ error: { code: "feature_disabled", message: "off", i18nKey: "errors.feature.disabled" },
140
+ })) as never,
141
+ );
142
+
143
+ fireEvent.change(screen.getByTestId("title-input"), { target: { value: "hi " } });
144
+ await waitFor(() => expect(screen.queryByRole("button")).toBeNull(), { timeout: 1000 });
145
+ expect(screen.getByTestId("title-input")).toBeTruthy();
146
+ });
147
+
148
+ test("correct action: opens dialog, runs immediately, apply calls onChange with the result", async () => {
149
+ const onChangeSpy = mock((_v: string) => {});
150
+ renderWithDispatcher(
151
+ <ControlledAiTextField
152
+ label="Title"
153
+ id="title"
154
+ name="title"
155
+ initialValue="teh cat sat"
156
+ onChangeSpy={onChangeSpy}
157
+ completion={false}
158
+ actions={["correct"]}
159
+ testId="title"
160
+ />,
161
+ (async (_type: string, payload: unknown) => {
162
+ const mode = (payload as { mode: string }).mode;
163
+ if (mode !== "correct") throw new Error(`unexpected mode ${mode}`);
164
+ return {
165
+ isSuccess: true,
166
+ data: { type: "text", text: "the cat sat", usage: { inputTokens: 3, outputTokens: 3 } },
167
+ };
168
+ }) as never,
169
+ );
170
+
171
+ fireEvent.click(screen.getByRole("button", { name: "Correct" }));
172
+ await waitFor(() => expect(screen.queryByText("the cat sat")).toBeTruthy());
173
+
174
+ fireEvent.click(screen.getByRole("button", { name: "Confirm" }));
175
+ expect(onChangeSpy).toHaveBeenCalledWith("the cat sat");
176
+ });
177
+ });
178
+
179
+ describe("AiTextField ghost-text overlay — scroll sync", () => {
180
+ test("Tab/Escape aside: overlay mirrors the input's scrollLeft on an interactive scroll", async () => {
181
+ renderWithDispatcher(
182
+ <ControlledAiTextField
183
+ label="Title"
184
+ id="title"
185
+ name="title"
186
+ initialValue=""
187
+ actions={[]}
188
+ completionDebounceMs={5}
189
+ testId="title"
190
+ />,
191
+ (async () => ({
192
+ isSuccess: true,
193
+ data: { type: "text", text: "fox", usage: { inputTokens: 1, outputTokens: 1 } },
194
+ })) as never,
195
+ );
196
+
197
+ const input = screen.getByTestId("title-input") as HTMLInputElement;
198
+ fireEvent.change(input, { target: { value: "the quick brown " } });
199
+ await waitFor(() => expect(screen.queryByText("fox")).toBeTruthy(), { timeout: 1000 });
200
+
201
+ const overlay = input.parentElement?.querySelector('[aria-hidden="true"]') as HTMLDivElement;
202
+ expect(overlay).toBeTruthy();
203
+
204
+ // Interactive scroll (e.g. arrow-key caret move) — caught by the
205
+ // onScroll handler.
206
+ Object.defineProperty(input, "scrollLeft", { value: 50, writable: true });
207
+ fireEvent.scroll(input);
208
+ expect(overlay.scrollLeft).toBe(50);
209
+ });
210
+
211
+ test("overlay resyncs on typing even without a `scroll` event (Chrome doesn't fire one for caret-follow auto-scroll)", async () => {
212
+ renderWithDispatcher(
213
+ <ControlledAiTextField
214
+ label="Title"
215
+ id="title"
216
+ name="title"
217
+ initialValue=""
218
+ actions={[]}
219
+ completionDebounceMs={5}
220
+ testId="title"
221
+ />,
222
+ (async () => ({
223
+ isSuccess: true,
224
+ data: { type: "text", text: "fox", usage: { inputTokens: 1, outputTokens: 1 } },
225
+ })) as never,
226
+ );
227
+
228
+ const input = screen.getByTestId("title-input") as HTMLInputElement;
229
+ fireEvent.change(input, { target: { value: "the quick brown " } });
230
+ await waitFor(() => expect(screen.queryByText("fox")).toBeTruthy(), { timeout: 1000 });
231
+
232
+ const overlay = input.parentElement?.querySelector('[aria-hidden="true"]') as HTMLDivElement;
233
+
234
+ // Simulate the browser having auto-scrolled the input to reveal the
235
+ // caret WITHOUT firing a `scroll` event — the real Chrome behavior this
236
+ // bug hinged on. Only the value/suggestion-keyed layout effect (not
237
+ // onScroll) can catch this.
238
+ Object.defineProperty(input, "scrollLeft", { value: 120, writable: true });
239
+ fireEvent.change(input, { target: { value: "the quick brown f" } });
240
+ await waitFor(() => expect(screen.queryByText("fox")).toBeTruthy(), { timeout: 1000 });
241
+
242
+ expect(overlay.scrollLeft).toBe(120);
243
+ });
244
+ });
245
+
246
+ describe("AiTextArea", () => {
247
+ test("renders a textarea with the given rows", () => {
248
+ const onChange = mock((_v: string) => {});
249
+ renderWithDispatcher(
250
+ <AiTextArea
251
+ label="Body"
252
+ id="body"
253
+ name="body"
254
+ value=""
255
+ onChange={onChange}
256
+ rows={6}
257
+ actions={[]}
258
+ completion={false}
259
+ testId="body"
260
+ />,
261
+ (async () => ({ isSuccess: true, data: {} })) as never,
262
+ );
263
+ const el = screen.getByTestId("body-input") as HTMLTextAreaElement;
264
+ expect(el.tagName).toBe("TEXTAREA");
265
+ expect(el.getAttribute("rows")).toBe("6");
266
+ });
267
+ });
@@ -0,0 +1,35 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+ import { fireEvent, render, screen } from "../../__tests__/test-utils";
3
+ import { Drawer } from "../drawer";
4
+
5
+ describe("Drawer", () => {
6
+ test("rendert Titel + Inhalt wenn open", () => {
7
+ render(
8
+ <Drawer open={true} onOpenChange={() => {}} title="Mail" testId="drawer">
9
+ <div>Body</div>
10
+ </Drawer>,
11
+ );
12
+ expect(screen.getByText("Mail")).toBeTruthy();
13
+ expect(screen.getByText("Body")).toBeTruthy();
14
+ });
15
+
16
+ test("open=false rendert nichts", () => {
17
+ render(
18
+ <Drawer open={false} onOpenChange={() => {}} title="Mail">
19
+ <div>Body</div>
20
+ </Drawer>,
21
+ );
22
+ expect(screen.queryByText("Body")).toBeNull();
23
+ });
24
+
25
+ test("Escape ruft onOpenChange(false)", () => {
26
+ const onOpenChange = mock((_open: boolean) => {});
27
+ render(
28
+ <Drawer open={true} onOpenChange={onOpenChange} title="Mail" testId="drawer">
29
+ <div>Body</div>
30
+ </Drawer>,
31
+ );
32
+ fireEvent.keyDown(screen.getByTestId("drawer"), { key: "Escape" });
33
+ expect(onOpenChange).toHaveBeenCalledWith(false);
34
+ });
35
+ });
@@ -0,0 +1,368 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+ import { usePrimitives } from "@cosmicdrift/kumiko-renderer";
3
+ import type { ReactNode } from "react";
4
+ import { act, fireEvent, render, renderHook, screen } from "../../__tests__/test-utils";
5
+ import { DetailList } from "../detail-list";
6
+ import {
7
+ BooleanField,
8
+ DateField,
9
+ FileField,
10
+ MoneyField,
11
+ NumberField,
12
+ PercentField,
13
+ RangeField,
14
+ SelectField,
15
+ TextareaField,
16
+ TextField,
17
+ } from "../form-fields";
18
+ import { ComparisonTable, ResultPanel, ResultTable } from "../result-panel";
19
+ import { useDraft } from "../use-draft";
20
+
21
+ describe("useDraft", () => {
22
+ test("field() liefert verdrahtete Props, onChange patcht den Draft", () => {
23
+ const { result } = renderHook(() => useDraft<{ sum: number | undefined }>({ sum: 100 }));
24
+ expect(result.current.field("sum")).toMatchObject({ id: "sum", name: "sum", value: 100 });
25
+ act(() => result.current.field("sum").onChange(250));
26
+ expect(result.current.draft.sum).toBe(250);
27
+ });
28
+
29
+ test("reset stellt die Defaults wieder her", () => {
30
+ const { result } = renderHook(() => useDraft<{ a: number | undefined }>({ a: 1 }));
31
+ act(() => result.current.patch({ a: 9 }));
32
+ expect(result.current.draft.a).toBe(9);
33
+ act(() => result.current.reset());
34
+ expect(result.current.draft.a).toBe(1);
35
+ });
36
+
37
+ test("idPrefix verhindert doppelte DOM-ids bei mehreren useDraft-Formularen mit gleichem Feldnamen", () => {
38
+ const a = renderHook(() =>
39
+ useDraft<{ sum: number | undefined }>({ sum: 1 }, { idPrefix: "a" }),
40
+ );
41
+ const b = renderHook(() =>
42
+ useDraft<{ sum: number | undefined }>({ sum: 2 }, { idPrefix: "b" }),
43
+ );
44
+ expect(a.result.current.field("sum").id).toBe("a-sum");
45
+ expect(b.result.current.field("sum").id).toBe("b-sum");
46
+ // name bleibt der reine Key — nur die DOM-id wird prefixt.
47
+ expect(a.result.current.field("sum").name).toBe("sum");
48
+ });
49
+ });
50
+
51
+ describe("NumberField", () => {
52
+ test("rendert Label und meldet Zahl bei Eingabe", () => {
53
+ const onChange = mock();
54
+ render(<NumberField label="Summe" id="sum" name="sum" value={300} onChange={onChange} />);
55
+ expect(screen.getByText("Summe")).toBeTruthy();
56
+ fireEvent.change(screen.getByRole("spinbutton"), { target: { value: "42" } });
57
+ expect(onChange).toHaveBeenCalledWith(42);
58
+ });
59
+
60
+ test("leeres Feld meldet undefined", () => {
61
+ const onChange = mock();
62
+ render(<NumberField label="X" id="x" name="x" value={5} onChange={onChange} />);
63
+ fireEvent.change(screen.getByRole("spinbutton"), { target: { value: "" } });
64
+ expect(onChange).toHaveBeenCalledWith(undefined);
65
+ });
66
+
67
+ test("MoneyField/PercentField rendern als beschriftetes Zahlenfeld ohne Einheit-Badge", () => {
68
+ const noop = (): void => {};
69
+ const { rerender } = render(
70
+ <MoneyField label="Betrag (€)" id="b" name="b" value={1} onChange={noop} />,
71
+ );
72
+ expect(screen.getByText("Betrag (€)")).toBeTruthy();
73
+ expect(screen.getByRole("spinbutton")).toBeTruthy();
74
+ expect(screen.queryByText("€")).toBeNull(); // Einheit lebt im Label, kein separates Badge
75
+ rerender(<PercentField label="Zins (%)" id="z" name="z" value={1} onChange={noop} />);
76
+ expect(screen.queryByText("%")).toBeNull();
77
+ });
78
+ });
79
+
80
+ describe("DetailList emphasize", () => {
81
+ test("emphasize hebt Label und Wert hervor", () => {
82
+ render(
83
+ <DetailList
84
+ testId="dl"
85
+ rows={[
86
+ { label: "Summe", value: "100" },
87
+ { label: "Effektiv", value: "3,1 %", emphasize: true },
88
+ ]}
89
+ />,
90
+ );
91
+ expect(screen.getByText("Effektiv").className).toContain("font-semibold");
92
+ expect(screen.getByText("Summe").className).not.toContain("font-semibold");
93
+ });
94
+ });
95
+
96
+ describe("ResultPanel", () => {
97
+ test("empty zeigt den Platzhalter, keine Liste", () => {
98
+ render(<ResultPanel title="Ergebnis" empty emptyText="Werte eingeben" />);
99
+ expect(screen.getByText("Werte eingeben")).toBeTruthy();
100
+ });
101
+
102
+ test("gefüllt zeigt Kennzahlen und children", () => {
103
+ render(
104
+ <ResultPanel title="Ergebnis" rows={[{ label: "Rate", value: "890 €" }]}>
105
+ <span>extra</span>
106
+ </ResultPanel>,
107
+ );
108
+ expect(screen.getByText("Rate")).toBeTruthy();
109
+ expect(screen.getByText("extra")).toBeTruthy();
110
+ });
111
+ });
112
+
113
+ describe("ResultTable", () => {
114
+ test("rendert Header, Zeilen und rechtsbündige Zahlenspalte", () => {
115
+ render(
116
+ <ResultTable
117
+ testId="rt"
118
+ columns={[
119
+ { header: "Tranche", cell: (r: { name: string; sum: string }) => r.name },
120
+ { header: "Summe", align: "right", cell: (r) => r.sum },
121
+ ]}
122
+ rows={[{ name: "Bank", sum: "300.000 €" }]}
123
+ rowKey={(r) => r.name}
124
+ />,
125
+ );
126
+ expect(screen.getByText("Tranche")).toBeTruthy();
127
+ expect(screen.getByText("Bank")).toBeTruthy();
128
+ expect(screen.getByText("Summe").className).toContain("text-right");
129
+ expect(screen.getByText("300.000 €").className).toContain("tabular-nums");
130
+ });
131
+ });
132
+
133
+ describe("Feld-Widgets (Select/Date/Text/Boolean/Textarea)", () => {
134
+ const noopStr = (): void => {};
135
+
136
+ test("SelectField rendert Label und den aktuell gewählten Wert", () => {
137
+ render(
138
+ <SelectField
139
+ label="Land"
140
+ id="l"
141
+ name="l"
142
+ value="NW"
143
+ onChange={noopStr}
144
+ options={[
145
+ { value: "NW", label: "Nordrhein-Westfalen" },
146
+ { value: "BY", label: "Bayern" },
147
+ ]}
148
+ />,
149
+ );
150
+ expect(screen.getByText("Land")).toBeTruthy();
151
+ expect(screen.getByText("Nordrhein-Westfalen")).toBeTruthy();
152
+ });
153
+
154
+ test("TextField meldet Eingabe", () => {
155
+ const onChange = mock();
156
+ render(<TextField label="Name" id="n" name="n" value="A" onChange={onChange} />);
157
+ fireEvent.change(screen.getByRole("textbox"), { target: { value: "Neu" } });
158
+ expect(onChange).toHaveBeenCalledWith("Neu");
159
+ });
160
+
161
+ test("DateField rendert mit Wert", () => {
162
+ render(
163
+ <DateField
164
+ label="Datum"
165
+ id="d"
166
+ name="d"
167
+ value="2026-07-10"
168
+ onChange={noopStr}
169
+ max="2030-01-01"
170
+ />,
171
+ );
172
+ expect(screen.getByText("Datum")).toBeTruthy();
173
+ });
174
+
175
+ test("BooleanField meldet Umschalten", () => {
176
+ const onChange = mock();
177
+ render(<BooleanField label="Aktiv" id="b" name="b" value={false} onChange={onChange} />);
178
+ fireEvent.click(screen.getByRole("checkbox"));
179
+ expect(onChange).toHaveBeenCalledWith(true);
180
+ });
181
+
182
+ test("TextareaField meldet Eingabe", () => {
183
+ const onChange = mock();
184
+ render(<TextareaField label="Notiz" id="t" name="t" value="" onChange={onChange} rows={3} />);
185
+ fireEvent.change(screen.getByRole("textbox"), { target: { value: "Text" } });
186
+ expect(onChange).toHaveBeenCalledWith("Text");
187
+ });
188
+ });
189
+
190
+ describe("ResultPanel footer", () => {
191
+ test("rendert den footer-Slot", () => {
192
+ render(
193
+ <ResultPanel
194
+ title="R"
195
+ rows={[{ label: "X", value: "1" }]}
196
+ footer={<button type="button">Los</button>}
197
+ >
198
+ <span>body</span>
199
+ </ResultPanel>,
200
+ );
201
+ expect(screen.getByRole("button", { name: "Los" })).toBeTruthy();
202
+ });
203
+ });
204
+
205
+ describe("RangeField", () => {
206
+ test("meldet den Slider-Wert als Zahl", () => {
207
+ const onChange = mock();
208
+ render(
209
+ <RangeField
210
+ label="Abruf"
211
+ id="r"
212
+ name="r"
213
+ value={20}
214
+ min={0}
215
+ max={100}
216
+ step={5}
217
+ onChange={onChange}
218
+ />,
219
+ );
220
+ expect(screen.getByText("Abruf")).toBeTruthy();
221
+ fireEvent.change(screen.getByRole("slider"), { target: { value: "45" } });
222
+ expect(onChange).toHaveBeenCalledWith(45);
223
+ });
224
+ });
225
+
226
+ describe("FileField", () => {
227
+ test("rendert Label", () => {
228
+ render(
229
+ <FileField label="Bild" id="f" name="f" value={null} onChange={() => {}} variant="image" />,
230
+ );
231
+ expect(screen.getByText("Bild")).toBeTruthy();
232
+ });
233
+
234
+ test('variant="image" mit gesetzter FileRef zeigt Preview + Remove-Button', () => {
235
+ render(
236
+ <FileField
237
+ label="Bild"
238
+ id="f3"
239
+ name="f3"
240
+ value="file-abc"
241
+ onChange={() => {}}
242
+ variant="image"
243
+ />,
244
+ );
245
+ const img = document.querySelector("img") as HTMLImageElement;
246
+ expect(img.src).toContain("/api/files/file-abc");
247
+ expect(screen.getByRole("button", { name: "Remove" })).toBeTruthy();
248
+ });
249
+
250
+ test("Remove-Button ruft onChange(null)", () => {
251
+ const onChange = mock();
252
+ render(
253
+ <FileField
254
+ label="Bild"
255
+ id="f4"
256
+ name="f4"
257
+ value="file-abc"
258
+ onChange={onChange}
259
+ variant="image"
260
+ />,
261
+ );
262
+ fireEvent.click(screen.getByRole("button", { name: "Remove" }));
263
+ expect(onChange).toHaveBeenCalledWith(null);
264
+ });
265
+
266
+ test("entityType/fieldName reichen bis in den /api/files-Upload-Request durch", async () => {
267
+ const originalFetch = global.fetch;
268
+ const fetchSpy = mock(
269
+ async (_url: string, _init?: RequestInit) => new Response(JSON.stringify({ id: "file-1" })),
270
+ );
271
+ global.fetch = fetchSpy as unknown as typeof fetch;
272
+ try {
273
+ render(
274
+ <FileField
275
+ label="Anhang"
276
+ id="f2"
277
+ name="f2"
278
+ value={null}
279
+ onChange={() => {}}
280
+ entityType="property"
281
+ fieldName="brochure"
282
+ />,
283
+ );
284
+ const input = document.getElementById("f2") as HTMLInputElement;
285
+ const file = new File(["x"], "brochure.pdf", { type: "application/pdf" });
286
+ await act(async () => {
287
+ fireEvent.change(input, { target: { files: [file] } });
288
+ });
289
+ expect(fetchSpy).toHaveBeenCalled();
290
+ const body = fetchSpy.mock.calls[0]?.[1]?.body as FormData;
291
+ expect(body.get("entityType")).toBe("property");
292
+ expect(body.get("fieldName")).toBe("brochure");
293
+ } finally {
294
+ global.fetch = originalFetch;
295
+ }
296
+ });
297
+ });
298
+
299
+ describe("Button size", () => {
300
+ function SizedButton({ size }: { readonly size: "sm" | "md" | "icon" }): ReactNode {
301
+ const { Button } = usePrimitives();
302
+ return (
303
+ <Button size={size} onClick={() => {}}>
304
+ X
305
+ </Button>
306
+ );
307
+ }
308
+ test("size=sm rendert die kompakte shadcn-Größe (h-8)", () => {
309
+ render(<SizedButton size="sm" />);
310
+ expect(screen.getByRole("button", { name: "X" }).className).toContain("h-8");
311
+ });
312
+
313
+ test("ariaLabel gibt icon-only-Buttons einen zugänglichen Namen", () => {
314
+ function IconBtn(): ReactNode {
315
+ const { Button } = usePrimitives();
316
+ return (
317
+ <Button size="icon" ariaLabel="Entfernen" onClick={() => {}}>
318
+
319
+ </Button>
320
+ );
321
+ }
322
+ render(<IconBtn />);
323
+ expect(screen.getByRole("button", { name: "Entfernen" })).toBeTruthy();
324
+ });
325
+
326
+ test('width="full" streckt den Button (w-full)', () => {
327
+ function WideBtn(): ReactNode {
328
+ const { Button } = usePrimitives();
329
+ return (
330
+ <Button width="full" onClick={() => {}}>
331
+ Upgrade
332
+ </Button>
333
+ );
334
+ }
335
+ render(<WideBtn />);
336
+ expect(screen.getByRole("button", { name: "Upgrade" }).className).toContain("w-full");
337
+ });
338
+ });
339
+
340
+ describe("ComparisonTable", () => {
341
+ test("transponierte Matrix mit Best-Highlight je Kennzahl", () => {
342
+ const cols = [
343
+ { name: "A", rate: 900 },
344
+ { name: "B", rate: 850 },
345
+ ];
346
+ render(
347
+ <ComparisonTable
348
+ testId="cmp"
349
+ columns={cols}
350
+ columnHeader={(c) => c.name}
351
+ columnKey={(c) => c.name}
352
+ metricLabel="Kennzahl"
353
+ metrics={[
354
+ {
355
+ label: "Rate",
356
+ value: (c: { name: string; rate: number }) => `${c.rate} €`,
357
+ bestIndex: (cs) => ((cs[0]?.rate ?? 0) <= (cs[1]?.rate ?? 0) ? 0 : 1),
358
+ },
359
+ ]}
360
+ />,
361
+ );
362
+ expect(screen.getByText("Kennzahl")).toBeTruthy();
363
+ expect(screen.getByText("A")).toBeTruthy();
364
+ // B (850) ist günstiger → hervorgehoben (font-semibold text-primary span)
365
+ expect(screen.getByText("850 €").className).toContain("text-primary");
366
+ expect(screen.getByText("900 €").className).not.toContain("text-primary");
367
+ });
368
+ });