@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.
- package/package.json +4 -4
- package/src/__tests__/action-menu.test.tsx +75 -0
- package/src/__tests__/breadcrumb.test.tsx +50 -0
- package/src/__tests__/create-app.test.tsx +171 -1
- package/src/__tests__/dashboard-screen.test.tsx +393 -0
- package/src/__tests__/default-app-shell.test.tsx +47 -1
- package/src/__tests__/form-action-bar.test.tsx +9 -0
- package/src/__tests__/kumiko-screen.test.tsx +155 -1
- package/src/__tests__/lightbox.test.tsx +46 -0
- package/src/__tests__/nav-tree.test.tsx +122 -0
- package/src/__tests__/primitives.test.tsx +32 -1
- package/src/__tests__/profile-menu.test.tsx +29 -0
- package/src/__tests__/projection-detail.test.tsx +101 -0
- package/src/__tests__/projection-list-actions.test.tsx +131 -0
- package/src/__tests__/render-list.test.tsx +11 -3
- package/src/__tests__/test-utils.tsx +1 -0
- package/src/__tests__/toast.test.tsx +9 -9
- package/src/__tests__/use-mutation.test.tsx +86 -0
- package/src/__tests__/workspace-shell.test.tsx +102 -47
- package/src/__tests__/xss-safety.test.tsx +62 -0
- package/src/app/__tests__/first-open-screen-qn.test.ts +77 -0
- package/src/app/browser-locale.ts +1 -1
- package/src/app/create-app.tsx +96 -29
- package/src/app/dashboard-body.tsx +454 -0
- package/src/index.ts +75 -1
- package/src/layout/__tests__/nav-registry.test.ts +105 -0
- package/src/layout/__tests__/shell-breadcrumb.test.ts +82 -0
- package/src/layout/default-app-shell.tsx +9 -71
- package/src/layout/nav-tree.tsx +167 -37
- package/src/layout/shell-breadcrumb.ts +46 -0
- package/src/layout/shell-header.tsx +114 -0
- package/src/layout/workspace-shell.tsx +45 -18
- package/src/primitives/__tests__/data-table-logic.test.ts +27 -0
- package/src/primitives/__tests__/input-testid.test.tsx +45 -0
- package/src/primitives/__tests__/money-input.test.tsx +13 -1
- package/src/primitives/dialog.tsx +47 -76
- package/src/primitives/index.tsx +215 -37
- package/src/primitives/layout.tsx +39 -0
- package/src/primitives/lightbox.tsx +39 -0
- package/src/primitives/modal-shell.tsx +62 -0
- package/src/primitives/money-input.tsx +20 -10
- package/src/primitives/toast.tsx +14 -9
- package/src/sse/live-events.ts +6 -1
- package/src/styles.css +25 -0
- package/src/tokens.ts +4 -0
- package/src/ui/__tests__/avatar.test.tsx +66 -0
- package/src/ui/__tests__/sheet.test.tsx +102 -0
- package/src/version/__tests__/update-checker.test.tsx +115 -0
- package/src/widgets/__tests__/ai-text-field.test.tsx +267 -0
- package/src/widgets/__tests__/drawer.test.tsx +35 -0
- package/src/widgets/__tests__/form-kit.test.tsx +368 -0
- package/src/widgets/__tests__/infinity-list.test.tsx +132 -0
- package/src/widgets/__tests__/query-table.test.tsx +118 -0
- package/src/widgets/__tests__/result-panel.test.tsx +117 -0
- package/src/widgets/__tests__/widgets.test.tsx +243 -0
- package/src/widgets/ai-text-field.tsx +370 -0
- package/src/widgets/charts.tsx +228 -0
- package/src/widgets/collapsible-section.tsx +42 -0
- package/src/widgets/detail-list.tsx +43 -0
- package/src/widgets/drawer.tsx +49 -0
- package/src/widgets/feed-list.tsx +35 -0
- package/src/widgets/form-fields.tsx +326 -0
- package/src/widgets/index.ts +59 -0
- package/src/widgets/infinity-list.tsx +112 -0
- package/src/widgets/mode-switch.tsx +41 -0
- package/src/widgets/progress-bar.tsx +27 -0
- package/src/widgets/progress-list.tsx +38 -0
- package/src/widgets/query-table.tsx +103 -0
- package/src/widgets/result-panel.tsx +251 -0
- package/src/widgets/section-card.tsx +30 -0
- package/src/widgets/stat.tsx +181 -0
- package/src/widgets/states.tsx +89 -0
- package/src/widgets/status-badge.tsx +51 -0
- package/src/widgets/use-draft.ts +40 -0
- package/src/ui/card.tsx +0 -93
- package/src/ui/collapsible.tsx +0 -34
- package/src/ui/dropdown-menu.tsx +0 -258
- package/src/ui/select.tsx +0 -191
- /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
|
+
});
|