@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,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
+ });