@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,132 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
3
|
+
import { DispatcherProvider } from "@cosmicdrift/kumiko-renderer";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
import {
|
|
6
|
+
createMockDispatcher,
|
|
7
|
+
fireEvent,
|
|
8
|
+
render,
|
|
9
|
+
screen,
|
|
10
|
+
waitFor,
|
|
11
|
+
} from "../../__tests__/test-utils";
|
|
12
|
+
import { InfinityList } from "../infinity-list";
|
|
13
|
+
|
|
14
|
+
// jsdom has no IntersectionObserver — stub stores the callback per observer
|
|
15
|
+
// so tests can fire the "sentinel became visible" event manually instead of
|
|
16
|
+
// simulating real scrolling.
|
|
17
|
+
const observers: ((entries: readonly { isIntersecting: boolean }[]) => void)[] = [];
|
|
18
|
+
globalThis.IntersectionObserver = class {
|
|
19
|
+
constructor(cb: (entries: readonly { isIntersecting: boolean }[]) => void) {
|
|
20
|
+
observers.push(cb);
|
|
21
|
+
}
|
|
22
|
+
observe(): void {}
|
|
23
|
+
unobserve(): void {}
|
|
24
|
+
disconnect(): void {}
|
|
25
|
+
} as unknown as typeof IntersectionObserver;
|
|
26
|
+
|
|
27
|
+
function fireIntersect(): void {
|
|
28
|
+
observers[observers.length - 1]?.([{ isIntersecting: true }]);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function renderWithDispatcher(ui: ReactNode, dispatcher: Dispatcher) {
|
|
32
|
+
return render(<DispatcherProvider dispatcher={dispatcher}>{ui}</DispatcherProvider>);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type Row = { readonly id: string; readonly subject: string };
|
|
36
|
+
type Page = { readonly rows: readonly Row[]; readonly nextCursor: string | null };
|
|
37
|
+
|
|
38
|
+
function list(query: string) {
|
|
39
|
+
return (
|
|
40
|
+
<InfinityList<Page, Row>
|
|
41
|
+
query={query}
|
|
42
|
+
rows={(data) => data.rows}
|
|
43
|
+
nextCursor={(data) => data.nextCursor}
|
|
44
|
+
rowId={(row) => row.id}
|
|
45
|
+
renderRow={(row) => <span>{row.subject}</span>}
|
|
46
|
+
testId="inbox"
|
|
47
|
+
/>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("InfinityList", () => {
|
|
52
|
+
test("rendert die erste Seite", async () => {
|
|
53
|
+
const dispatcher = createMockDispatcher({
|
|
54
|
+
query: (async () => ({
|
|
55
|
+
isSuccess: true,
|
|
56
|
+
data: { rows: [{ id: "m1", subject: "Hallo" }], nextCursor: null },
|
|
57
|
+
})) as unknown as Dispatcher["query"],
|
|
58
|
+
});
|
|
59
|
+
renderWithDispatcher(list("inbox:query:message:list"), dispatcher);
|
|
60
|
+
await waitFor(() => expect(screen.getByText("Hallo")).toBeTruthy());
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("lädt die nächste Seite nach, sobald der Sentinel sichtbar wird", async () => {
|
|
64
|
+
let calls = 0;
|
|
65
|
+
const dispatcher = createMockDispatcher({
|
|
66
|
+
query: (async () => {
|
|
67
|
+
calls += 1;
|
|
68
|
+
if (calls === 1) {
|
|
69
|
+
return {
|
|
70
|
+
isSuccess: true,
|
|
71
|
+
data: { rows: [{ id: "m1", subject: "Erste" }], nextCursor: "c1" },
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
isSuccess: true,
|
|
76
|
+
data: { rows: [{ id: "m2", subject: "Zweite" }], nextCursor: null },
|
|
77
|
+
};
|
|
78
|
+
}) as unknown as Dispatcher["query"],
|
|
79
|
+
});
|
|
80
|
+
renderWithDispatcher(list("inbox:query:message:list"), dispatcher);
|
|
81
|
+
await waitFor(() => expect(screen.getByText("Erste")).toBeTruthy());
|
|
82
|
+
fireIntersect();
|
|
83
|
+
await waitFor(() => expect(screen.getByText("Zweite")).toBeTruthy());
|
|
84
|
+
expect(screen.getByText("Erste")).toBeTruthy();
|
|
85
|
+
expect(calls).toBe(2);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("Fehler → ErrorState, Retry lädt neu", async () => {
|
|
89
|
+
let calls = 0;
|
|
90
|
+
const dispatcher = createMockDispatcher({
|
|
91
|
+
query: (async () => {
|
|
92
|
+
calls += 1;
|
|
93
|
+
if (calls === 1) {
|
|
94
|
+
return {
|
|
95
|
+
isSuccess: false,
|
|
96
|
+
error: { code: "internal", message: "kaputt", i18nKey: "errors.internal" },
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
isSuccess: true,
|
|
101
|
+
data: { rows: [{ id: "m1", subject: "Hallo" }], nextCursor: null },
|
|
102
|
+
};
|
|
103
|
+
}) as unknown as Dispatcher["query"],
|
|
104
|
+
});
|
|
105
|
+
renderWithDispatcher(list("inbox:query:message:list"), dispatcher);
|
|
106
|
+
await waitFor(() => expect(screen.getByRole("button")).toBeTruthy());
|
|
107
|
+
fireEvent.click(screen.getByRole("button"));
|
|
108
|
+
await waitFor(() => expect(screen.getByText("Hallo")).toBeTruthy());
|
|
109
|
+
expect(calls).toBe(2);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("leeres Result rendert Empty-State", async () => {
|
|
113
|
+
const dispatcher = createMockDispatcher({
|
|
114
|
+
query: (async () => ({
|
|
115
|
+
isSuccess: true,
|
|
116
|
+
data: { rows: [], nextCursor: null },
|
|
117
|
+
})) as unknown as Dispatcher["query"],
|
|
118
|
+
});
|
|
119
|
+
renderWithDispatcher(
|
|
120
|
+
<InfinityList<Page, Row>
|
|
121
|
+
query="inbox:query:message:list"
|
|
122
|
+
rows={(data) => data.rows}
|
|
123
|
+
nextCursor={(data) => data.nextCursor}
|
|
124
|
+
rowId={(row) => row.id}
|
|
125
|
+
renderRow={(row) => <span>{row.subject}</span>}
|
|
126
|
+
emptyState={<span>Keine Nachrichten</span>}
|
|
127
|
+
/>,
|
|
128
|
+
dispatcher,
|
|
129
|
+
);
|
|
130
|
+
await waitFor(() => expect(screen.getByText("Keine Nachrichten")).toBeTruthy());
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
3
|
+
import { DispatcherProvider } from "@cosmicdrift/kumiko-renderer";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
import {
|
|
6
|
+
createMockDispatcher,
|
|
7
|
+
fireEvent,
|
|
8
|
+
render,
|
|
9
|
+
screen,
|
|
10
|
+
waitFor,
|
|
11
|
+
} from "../../__tests__/test-utils";
|
|
12
|
+
import { QueryTable } from "../query-table";
|
|
13
|
+
|
|
14
|
+
function renderWithDispatcher(ui: ReactNode, dispatcher: Dispatcher) {
|
|
15
|
+
return render(<DispatcherProvider dispatcher={dispatcher}>{ui}</DispatcherProvider>);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const COLUMNS = [
|
|
19
|
+
{ field: "name", label: "Name" },
|
|
20
|
+
{ field: "plan", label: "Plan" },
|
|
21
|
+
] as const;
|
|
22
|
+
|
|
23
|
+
describe("QueryTable", () => {
|
|
24
|
+
test("rendert Query-Result als DataTable-Rows", async () => {
|
|
25
|
+
const dispatcher = createMockDispatcher({
|
|
26
|
+
query: (async () => ({
|
|
27
|
+
isSuccess: true,
|
|
28
|
+
data: [
|
|
29
|
+
{ id: "t1", name: "Acme", plan: "pro" },
|
|
30
|
+
{ id: "t2", name: "Beta GmbH", plan: "free" },
|
|
31
|
+
],
|
|
32
|
+
})) as unknown as Dispatcher["query"],
|
|
33
|
+
});
|
|
34
|
+
renderWithDispatcher(
|
|
35
|
+
<QueryTable query="tenant:query:tenant:list" columns={COLUMNS} testId="tbl" />,
|
|
36
|
+
dispatcher,
|
|
37
|
+
);
|
|
38
|
+
await waitFor(() => expect(screen.getByText("Acme")).toBeTruthy());
|
|
39
|
+
expect(screen.getByText("Beta GmbH")).toBeTruthy();
|
|
40
|
+
expect(screen.getByText("Name")).toBeTruthy();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("Row-Click liefert die geklickte Row", async () => {
|
|
44
|
+
const onRowClick = mock((_row: { id: string }) => {});
|
|
45
|
+
const dispatcher = createMockDispatcher({
|
|
46
|
+
query: (async () => ({
|
|
47
|
+
isSuccess: true,
|
|
48
|
+
data: [{ id: "t1", name: "Acme", plan: "pro" }],
|
|
49
|
+
})) as unknown as Dispatcher["query"],
|
|
50
|
+
});
|
|
51
|
+
renderWithDispatcher(
|
|
52
|
+
<QueryTable query="tenant:query:tenant:list" columns={COLUMNS} onRowClick={onRowClick} />,
|
|
53
|
+
dispatcher,
|
|
54
|
+
);
|
|
55
|
+
await waitFor(() => expect(screen.getByText("Acme")).toBeTruthy());
|
|
56
|
+
fireEvent.click(screen.getByText("Acme"));
|
|
57
|
+
expect(onRowClick).toHaveBeenCalledTimes(1);
|
|
58
|
+
expect(onRowClick.mock.calls[0]?.[0]?.id).toBe("t1");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("Fehler → ErrorState, Retry refetcht", async () => {
|
|
62
|
+
let calls = 0;
|
|
63
|
+
const dispatcher = createMockDispatcher({
|
|
64
|
+
query: (async () => {
|
|
65
|
+
calls += 1;
|
|
66
|
+
if (calls === 1) {
|
|
67
|
+
return {
|
|
68
|
+
isSuccess: false,
|
|
69
|
+
error: { code: "internal", message: "kaputt", i18nKey: "errors.internal" },
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
return { isSuccess: true, data: [{ id: "t1", name: "Acme", plan: "pro" }] };
|
|
73
|
+
}) as unknown as Dispatcher["query"],
|
|
74
|
+
});
|
|
75
|
+
renderWithDispatcher(
|
|
76
|
+
<QueryTable query="tenant:query:tenant:list" columns={COLUMNS} testId="tbl" />,
|
|
77
|
+
dispatcher,
|
|
78
|
+
);
|
|
79
|
+
await waitFor(() => expect(screen.getByRole("button")).toBeTruthy());
|
|
80
|
+
fireEvent.click(screen.getByRole("button"));
|
|
81
|
+
await waitFor(() => expect(screen.getByText("Acme")).toBeTruthy());
|
|
82
|
+
expect(calls).toBe(2);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("leeres Result rendert Empty-State", async () => {
|
|
86
|
+
const dispatcher = createMockDispatcher({
|
|
87
|
+
query: (async () => ({ isSuccess: true, data: [] })) as unknown as Dispatcher["query"],
|
|
88
|
+
});
|
|
89
|
+
const { container } = renderWithDispatcher(
|
|
90
|
+
<QueryTable
|
|
91
|
+
query="tenant:query:tenant:list"
|
|
92
|
+
columns={COLUMNS}
|
|
93
|
+
emptyState={<span>Keine Tenants</span>}
|
|
94
|
+
/>,
|
|
95
|
+
dispatcher,
|
|
96
|
+
);
|
|
97
|
+
await waitFor(() => expect(screen.getByText("Keine Tenants")).toBeTruthy());
|
|
98
|
+
expect(container.querySelectorAll("tbody tr").length).toBe(0);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("rows-Selector zieht Rows aus verschachteltem Result", async () => {
|
|
102
|
+
const dispatcher = createMockDispatcher({
|
|
103
|
+
query: (async () => ({
|
|
104
|
+
isSuccess: true,
|
|
105
|
+
data: { items: [{ id: "x", name: "Nested", plan: "pro" }], total: 1 },
|
|
106
|
+
})) as unknown as Dispatcher["query"],
|
|
107
|
+
});
|
|
108
|
+
renderWithDispatcher(
|
|
109
|
+
<QueryTable<{ items: readonly Record<string, unknown>[]; total: number }>
|
|
110
|
+
query="tenant:query:tenant:list"
|
|
111
|
+
columns={COLUMNS}
|
|
112
|
+
rows={(data) => data.items}
|
|
113
|
+
/>,
|
|
114
|
+
dispatcher,
|
|
115
|
+
);
|
|
116
|
+
await waitFor(() => expect(screen.getByText("Nested")).toBeTruthy());
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { render, screen } from "../../__tests__/test-utils";
|
|
3
|
+
import { ComparisonTable, ResultTable } from "../result-panel";
|
|
4
|
+
|
|
5
|
+
const COLUMNS = [
|
|
6
|
+
{ header: "Jahr", cell: (row: { year: number; total: number }) => row.year },
|
|
7
|
+
{
|
|
8
|
+
header: "Summe",
|
|
9
|
+
align: "right" as const,
|
|
10
|
+
cell: (row: { year: number; total: number }) => row.total,
|
|
11
|
+
},
|
|
12
|
+
];
|
|
13
|
+
const ROWS = [
|
|
14
|
+
{ year: 2026, total: 100 },
|
|
15
|
+
{ year: 2027, total: 200 },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
describe("ResultTable", () => {
|
|
19
|
+
test("card=true wrapt in den gerundeten Border-Container mit bg-muted-Header", () => {
|
|
20
|
+
const { container } = render(
|
|
21
|
+
<ResultTable columns={COLUMNS} rows={ROWS} rowKey={(r) => String(r.year)} card testId="rt" />,
|
|
22
|
+
);
|
|
23
|
+
expect(container.querySelector(".rounded-lg.border.bg-card")).toBeTruthy();
|
|
24
|
+
expect(container.querySelector("thead.bg-muted")).toBeTruthy();
|
|
25
|
+
expect(screen.getByText("2026")).toBeTruthy();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("ohne card kein Wrapper (bare default)", () => {
|
|
29
|
+
const { container } = render(
|
|
30
|
+
<ResultTable columns={COLUMNS} rows={ROWS} rowKey={(r) => String(r.year)} testId="rt" />,
|
|
31
|
+
);
|
|
32
|
+
expect(container.querySelector(".rounded-lg.border.bg-card")).toBeNull();
|
|
33
|
+
expect(container.querySelector("thead.bg-muted")).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("footer rows render as table rows spanning all-but-last column, value under the last column", () => {
|
|
37
|
+
const { container } = render(
|
|
38
|
+
<ResultTable
|
|
39
|
+
columns={COLUMNS}
|
|
40
|
+
rows={ROWS}
|
|
41
|
+
rowKey={(r) => String(r.year)}
|
|
42
|
+
testId="rt"
|
|
43
|
+
footer={[
|
|
44
|
+
{ label: "Subtotal", value: "300" },
|
|
45
|
+
{ label: "Total", value: "300", emphasize: true },
|
|
46
|
+
]}
|
|
47
|
+
/>,
|
|
48
|
+
);
|
|
49
|
+
const footerRows = container.querySelectorAll("tbody tr");
|
|
50
|
+
// 2 data rows + 2 footer rows.
|
|
51
|
+
expect(footerRows).toHaveLength(4);
|
|
52
|
+
const totalRow = footerRows[3];
|
|
53
|
+
expect(totalRow?.querySelector("td[colspan]")?.textContent).toBe("Total");
|
|
54
|
+
expect(totalRow?.querySelectorAll("td")[1]?.textContent).toBe("300");
|
|
55
|
+
expect(totalRow?.className).toContain("font-semibold");
|
|
56
|
+
expect(screen.getByText("Subtotal")).toBeTruthy();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("single-column table: footer value still renders (not dropped)", () => {
|
|
60
|
+
const singleColumn = [{ header: "Jahr", cell: (row: { year: number }) => row.year }];
|
|
61
|
+
const { container } = render(
|
|
62
|
+
<ResultTable
|
|
63
|
+
columns={singleColumn}
|
|
64
|
+
rows={ROWS}
|
|
65
|
+
rowKey={(r) => String(r.year)}
|
|
66
|
+
testId="rt"
|
|
67
|
+
footer={[{ label: "Total", value: "300", emphasize: true }]}
|
|
68
|
+
/>,
|
|
69
|
+
);
|
|
70
|
+
const footerRows = container.querySelectorAll("tbody tr");
|
|
71
|
+
expect(footerRows).toHaveLength(3);
|
|
72
|
+
const totalRow = footerRows[2];
|
|
73
|
+
// Single <td> carries both label and value — colSpan is dropped, not
|
|
74
|
+
// set to 0, since there's only one column to span.
|
|
75
|
+
expect(totalRow?.querySelectorAll("td")).toHaveLength(1);
|
|
76
|
+
expect(totalRow?.querySelector("td")?.hasAttribute("colspan")).toBe(false);
|
|
77
|
+
expect(screen.getByText("Total")).toBeTruthy();
|
|
78
|
+
expect(screen.getByText("300")).toBeTruthy();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("ComparisonTable", () => {
|
|
83
|
+
const cols = ["A", "B"];
|
|
84
|
+
const metrics = [{ label: "Zins", value: (col: string) => `${col}-Zins` }];
|
|
85
|
+
|
|
86
|
+
test("card=true wrapt in den gerundeten Border-Container mit bg-muted-Header", () => {
|
|
87
|
+
const { container } = render(
|
|
88
|
+
<ComparisonTable
|
|
89
|
+
columns={cols}
|
|
90
|
+
columnHeader={(c) => c}
|
|
91
|
+
columnKey={(c) => c}
|
|
92
|
+
metrics={metrics}
|
|
93
|
+
metricLabel="Kennzahl"
|
|
94
|
+
card
|
|
95
|
+
testId="ct"
|
|
96
|
+
/>,
|
|
97
|
+
);
|
|
98
|
+
expect(container.querySelector(".rounded-lg.border.bg-card")).toBeTruthy();
|
|
99
|
+
expect(container.querySelector("thead.bg-muted")).toBeTruthy();
|
|
100
|
+
expect(screen.getByText("A-Zins")).toBeTruthy();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("ohne card kein Wrapper (bare default)", () => {
|
|
104
|
+
const { container } = render(
|
|
105
|
+
<ComparisonTable
|
|
106
|
+
columns={cols}
|
|
107
|
+
columnHeader={(c) => c}
|
|
108
|
+
columnKey={(c) => c}
|
|
109
|
+
metrics={metrics}
|
|
110
|
+
metricLabel="Kennzahl"
|
|
111
|
+
testId="ct"
|
|
112
|
+
/>,
|
|
113
|
+
);
|
|
114
|
+
expect(container.querySelector(".rounded-lg.border.bg-card")).toBeNull();
|
|
115
|
+
expect(container.querySelector("thead.bg-muted")).toBeNull();
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { fireEvent, render, screen } from "../../__tests__/test-utils";
|
|
3
|
+
import { StatusBarChart, smoothPath, TimeseriesChart } from "../charts";
|
|
4
|
+
import { CollapsibleSection } from "../collapsible-section";
|
|
5
|
+
import { DetailList } from "../detail-list";
|
|
6
|
+
import { ModeSwitch } from "../mode-switch";
|
|
7
|
+
import { ProgressBar } from "../progress-bar";
|
|
8
|
+
import { SectionCard } from "../section-card";
|
|
9
|
+
import { MiniStat, StatCard } from "../stat";
|
|
10
|
+
import { EmptyState } from "../states";
|
|
11
|
+
import { StatusBadge } from "../status-badge";
|
|
12
|
+
|
|
13
|
+
describe("StatusBadge", () => {
|
|
14
|
+
test("rendert Label mit Tone-Klassen", () => {
|
|
15
|
+
render(
|
|
16
|
+
<StatusBadge tone="ok" testId="badge">
|
|
17
|
+
Operational
|
|
18
|
+
</StatusBadge>,
|
|
19
|
+
);
|
|
20
|
+
const badge = screen.getByTestId("badge");
|
|
21
|
+
expect(badge.textContent).toBe("Operational");
|
|
22
|
+
expect(badge.className).toContain("text-status-ok");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("muted nutzt die neutralen Theme-Tokens", () => {
|
|
26
|
+
render(
|
|
27
|
+
<StatusBadge tone="muted" testId="badge">
|
|
28
|
+
Resolved
|
|
29
|
+
</StatusBadge>,
|
|
30
|
+
);
|
|
31
|
+
expect(screen.getByTestId("badge").className).toContain("text-muted-foreground");
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("ProgressBar", () => {
|
|
36
|
+
test("clampt value auf 0..1 und setzt aria", () => {
|
|
37
|
+
render(<ProgressBar value={1.7} testId="bar" />);
|
|
38
|
+
expect(screen.getByTestId("bar").getAttribute("aria-valuenow")).toBe("100");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("negative Werte werden 0", () => {
|
|
42
|
+
render(<ProgressBar value={-3} testId="bar" />);
|
|
43
|
+
expect(screen.getByTestId("bar").getAttribute("aria-valuenow")).toBe("0");
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("ModeSwitch", () => {
|
|
48
|
+
test("markiert aktive Option und feuert onChange", () => {
|
|
49
|
+
const onChange = mock((_v: string) => {});
|
|
50
|
+
render(
|
|
51
|
+
<ModeSwitch
|
|
52
|
+
value="a"
|
|
53
|
+
options={[
|
|
54
|
+
{ value: "a", label: "Modus A" },
|
|
55
|
+
{ value: "b", label: "Modus B" },
|
|
56
|
+
]}
|
|
57
|
+
onChange={onChange}
|
|
58
|
+
/>,
|
|
59
|
+
);
|
|
60
|
+
const active = screen.getByRole("button", { name: "Modus A" });
|
|
61
|
+
expect(active.getAttribute("aria-pressed")).toBe("true");
|
|
62
|
+
fireEvent.click(screen.getByRole("button", { name: "Modus B" }));
|
|
63
|
+
expect(onChange).toHaveBeenCalledWith("b");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("#902: Buttons sind als zusammengehörige Gruppe gekennzeichnet", () => {
|
|
67
|
+
render(
|
|
68
|
+
<ModeSwitch
|
|
69
|
+
value="a"
|
|
70
|
+
options={[
|
|
71
|
+
{ value: "a", label: "Modus A" },
|
|
72
|
+
{ value: "b", label: "Modus B" },
|
|
73
|
+
]}
|
|
74
|
+
onChange={() => {}}
|
|
75
|
+
/>,
|
|
76
|
+
);
|
|
77
|
+
expect(screen.getByRole("group")).toBeTruthy();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("CollapsibleSection", () => {
|
|
82
|
+
test("async geflipptes defaultOpen öffnet nachträglich", () => {
|
|
83
|
+
const { rerender, container } = render(
|
|
84
|
+
<CollapsibleSection title="Erweitert" defaultOpen={false}>
|
|
85
|
+
<span>Inhalt</span>
|
|
86
|
+
</CollapsibleSection>,
|
|
87
|
+
);
|
|
88
|
+
const details = container.querySelector("details");
|
|
89
|
+
expect(details?.open).toBe(false);
|
|
90
|
+
rerender(
|
|
91
|
+
<CollapsibleSection title="Erweitert" defaultOpen={true}>
|
|
92
|
+
<span>Inhalt</span>
|
|
93
|
+
</CollapsibleSection>,
|
|
94
|
+
);
|
|
95
|
+
expect(container.querySelector("details")?.open).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("DetailList", () => {
|
|
100
|
+
test("rendert Label/Wert-Paare als dl", () => {
|
|
101
|
+
render(<DetailList rows={[{ label: "Name", value: "Acme" }]} testId="dl" />);
|
|
102
|
+
expect(screen.getByText("Name").tagName).toBe("DT");
|
|
103
|
+
expect(screen.getByText("Acme").tagName).toBe("DD");
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("SectionCard", () => {
|
|
108
|
+
test("rendert Titel, Action-Slot und Children über das Card-Primitive", () => {
|
|
109
|
+
render(
|
|
110
|
+
<SectionCard title="Verlauf" action={<button type="button">Range</button>}>
|
|
111
|
+
<span>Body</span>
|
|
112
|
+
</SectionCard>,
|
|
113
|
+
);
|
|
114
|
+
expect(screen.getByText("Verlauf")).toBeTruthy();
|
|
115
|
+
expect(screen.getByRole("button", { name: "Range" })).toBeTruthy();
|
|
116
|
+
expect(screen.getByText("Body")).toBeTruthy();
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("StatCard", () => {
|
|
121
|
+
test("rendert Label, Wert, Delta und Sub-Zeile", () => {
|
|
122
|
+
render(
|
|
123
|
+
<StatCard
|
|
124
|
+
label="Restschuld"
|
|
125
|
+
value="123.456 €"
|
|
126
|
+
sub="nach 10 Jahren"
|
|
127
|
+
delta={{ value: "2,1 %", direction: "down" }}
|
|
128
|
+
/>,
|
|
129
|
+
);
|
|
130
|
+
expect(screen.getByText("Restschuld")).toBeTruthy();
|
|
131
|
+
expect(screen.getByText("123.456 €")).toBeTruthy();
|
|
132
|
+
expect(screen.getByText(/2,1 %/)).toBeTruthy();
|
|
133
|
+
expect(screen.getByText("nach 10 Jahren")).toBeTruthy();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("accentColor färbt den Icon-Chip inline", () => {
|
|
137
|
+
const { container } = render(
|
|
138
|
+
<StatCard
|
|
139
|
+
icon={<svg aria-hidden="true" />}
|
|
140
|
+
label="Zins"
|
|
141
|
+
value="3,1 %"
|
|
142
|
+
accentColor="#123456"
|
|
143
|
+
/>,
|
|
144
|
+
);
|
|
145
|
+
const chip = container.querySelector("span[style]");
|
|
146
|
+
// happy-dom parst color-mix()-backgroundColor nicht — color reicht als Beleg.
|
|
147
|
+
expect(chip?.getAttribute("style") ?? "").toContain("#123456");
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe("MiniStat", () => {
|
|
152
|
+
test("emphasize hebt die Kachel mit Ring hervor", () => {
|
|
153
|
+
render(<MiniStat label="Rate" value="890 €" emphasize testId="mini" />);
|
|
154
|
+
expect(screen.getByTestId("mini").className).toContain("ring-1");
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("EmptyState", () => {
|
|
159
|
+
test("rendert Titel, Beschreibung und CTA", () => {
|
|
160
|
+
render(
|
|
161
|
+
<EmptyState
|
|
162
|
+
title="Noch keine Monitore"
|
|
163
|
+
description="Lege den ersten an."
|
|
164
|
+
action={<button type="button">Neu</button>}
|
|
165
|
+
/>,
|
|
166
|
+
);
|
|
167
|
+
expect(screen.getByText("Noch keine Monitore")).toBeTruthy();
|
|
168
|
+
expect(screen.getByRole("button", { name: "Neu" })).toBeTruthy();
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe("smoothPath", () => {
|
|
173
|
+
test("leer → leerer Pfad, ein Punkt → Move + Line auf sich selbst", () => {
|
|
174
|
+
expect(smoothPath([])).toBe("");
|
|
175
|
+
expect(smoothPath([{ x: 1, y: 2 }])).toBe("M 1.0 2.0 L 1.0 2.0");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("glättet über Quadratic-Midpoints und endet am letzten Punkt", () => {
|
|
179
|
+
const d = smoothPath([
|
|
180
|
+
{ x: 0, y: 0 },
|
|
181
|
+
{ x: 10, y: 20 },
|
|
182
|
+
{ x: 20, y: 0 },
|
|
183
|
+
]);
|
|
184
|
+
expect(d.startsWith("M 0.0 0.0")).toBe(true);
|
|
185
|
+
expect(d).toContain("Q 10.0 20.0, 15.0 10.0");
|
|
186
|
+
expect(d.endsWith("L 20.0 0.0")).toBe(true);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe("StatusBarChart", () => {
|
|
191
|
+
test("rendert einen Bar pro Entry mit aria-Label", () => {
|
|
192
|
+
const { container } = render(
|
|
193
|
+
<StatusBarChart
|
|
194
|
+
ariaLabel="Uptime 90 Tage"
|
|
195
|
+
entries={[
|
|
196
|
+
{ key: "d1", level: 1, tone: "ok" },
|
|
197
|
+
{ key: "d2", level: 0.5, tone: "bad" },
|
|
198
|
+
]}
|
|
199
|
+
startLabel="90 Tage"
|
|
200
|
+
endLabel="heute"
|
|
201
|
+
/>,
|
|
202
|
+
);
|
|
203
|
+
expect(screen.getByRole("img", { name: "Uptime 90 Tage" })).toBeTruthy();
|
|
204
|
+
// 2 Entries × (Gradient-Bar + Tick) + 1 Last-Highlight-Stripe = 5 rects
|
|
205
|
+
expect(container.querySelectorAll("rect").length).toBe(5);
|
|
206
|
+
expect(screen.getByText("heute")).toBeTruthy();
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe("TimeseriesChart", () => {
|
|
211
|
+
test("unter 2 Messwerten rendert emptyContent statt Chart", () => {
|
|
212
|
+
const { container } = render(
|
|
213
|
+
<TimeseriesChart
|
|
214
|
+
points={[{ atMs: 1000, value: 42 }]}
|
|
215
|
+
windowStartMs={0}
|
|
216
|
+
windowEndMs={2000}
|
|
217
|
+
ariaLabel="Antwortzeit"
|
|
218
|
+
emptyContent={<span>Noch keine Messdaten</span>}
|
|
219
|
+
/>,
|
|
220
|
+
);
|
|
221
|
+
expect(screen.getByText("Noch keine Messdaten")).toBeTruthy();
|
|
222
|
+
expect(container.querySelector("svg")).toBeNull();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("rendert Linie + Fläche + Achsen-Labels", () => {
|
|
226
|
+
const { container } = render(
|
|
227
|
+
<TimeseriesChart
|
|
228
|
+
points={[
|
|
229
|
+
{ atMs: 0, value: 100 },
|
|
230
|
+
{ atMs: 1000, value: 200 },
|
|
231
|
+
{ atMs: 2000, value: null },
|
|
232
|
+
]}
|
|
233
|
+
windowStartMs={0}
|
|
234
|
+
windowEndMs={2000}
|
|
235
|
+
ariaLabel="Antwortzeit"
|
|
236
|
+
axisLabels={{ start: "vor 24h", end: "jetzt" }}
|
|
237
|
+
/>,
|
|
238
|
+
);
|
|
239
|
+
expect(screen.getByRole("img", { name: "Antwortzeit" })).toBeTruthy();
|
|
240
|
+
expect(container.querySelectorAll("path").length).toBe(2);
|
|
241
|
+
expect(screen.getByText("jetzt")).toBeTruthy();
|
|
242
|
+
});
|
|
243
|
+
});
|