@cosmicdrift/kumiko-renderer-web 0.25.0 → 0.27.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.25.0",
3
+ "version": "0.27.0",
4
4
  "description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -0,0 +1,51 @@
1
+ // Avatar Render-Tests (Phase 1, test-luecken-integration, Tier 2).
2
+ //
3
+ // Avatar ist ein pures Presentational-Component (kein Context, kein Radix)
4
+ // → happy-dom-Render reicht. Pinnt die Initials-Extraktion und die
5
+ // deterministische, id-basierte Farbwahl.
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import { render, screen } from "@testing-library/react";
9
+ import { Avatar } from "../avatar";
10
+
11
+ const colorClass = (className: string): string | undefined =>
12
+ className.split(/\s+/).find((c) => c.startsWith("bg-"));
13
+
14
+ describe("Avatar — Initials", () => {
15
+ test("Zwei-Wort-Label → Initialen beider Wörter (DH), role=img, aria-label", () => {
16
+ render(<Avatar id="u1" label="Daniel Hennig" testId="av" />);
17
+ const el = screen.getByTestId("av");
18
+ expect(el.textContent).toBe("DH");
19
+ expect(el.getAttribute("role")).toBe("img");
20
+ expect(el.getAttribute("aria-label")).toBe("Daniel Hennig");
21
+ });
22
+
23
+ test("Single-Word → erste 2 Buchstaben, upper-case (Daniel → DA)", () => {
24
+ render(<Avatar id="u" label="Daniel" testId="av" />);
25
+ expect(screen.getByTestId("av").textContent).toBe("DA");
26
+ });
27
+
28
+ test("Email als Single-Token → erste 2 Buchstaben (alice@… → AL)", () => {
29
+ // Hinweis: der Code-Kommentar behauptet "A", der Code liefert aber "AL"
30
+ // (split(/\s+/) trennt nicht an '@'). Test pinnt das IST-Verhalten.
31
+ render(<Avatar id="u" label="alice@example.com" testId="av" />);
32
+ expect(screen.getByTestId("av").textContent).toBe("AL");
33
+ });
34
+
35
+ test("leeres / reines Whitespace-Label → '?'", () => {
36
+ render(<Avatar id="u" label=" " testId="av" />);
37
+ expect(screen.getByTestId("av").textContent).toBe("?");
38
+ });
39
+ });
40
+
41
+ describe("Avatar — Farbwahl", () => {
42
+ test("deterministisch pro id (gleiche id → gleiche Color-Class, unabhängig vom Label)", () => {
43
+ const { unmount } = render(<Avatar id="stable-id" label="A B" testId="a1" />);
44
+ const c1 = colorClass(screen.getByTestId("a1").className);
45
+ unmount();
46
+ render(<Avatar id="stable-id" label="Z W" testId="a2" />);
47
+ const c2 = colorClass(screen.getByTestId("a2").className);
48
+ expect(c1).toMatch(/^bg-/);
49
+ expect(c1).toBe(c2);
50
+ });
51
+ });
@@ -0,0 +1,73 @@
1
+ // nav-registry Pure-Logik Tests (Phase 1, test-luecken-integration, Tier 1).
2
+ //
3
+ // buildNavRegistrySlice(ForApp) qualifiziert Feature-lokale Nav-IDs zu QNs
4
+ // (feature:nav:id), wendet einen optionalen Workspace-Allow-Filter an und
5
+ // baut topLevel + byParent. Pure, kein DOM.
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import type {
9
+ AppSchema,
10
+ FeatureSchema,
11
+ NavDefinition,
12
+ } from "@cosmicdrift/kumiko-framework/ui-types";
13
+ import { buildNavRegistrySlice, buildNavRegistrySliceForApp } from "../nav-tree";
14
+
15
+ function feature(navs: readonly NavDefinition[], featureName = "tasks"): FeatureSchema {
16
+ return { featureName, entities: {}, screens: [], navs };
17
+ }
18
+
19
+ describe("buildNavRegistrySlice", () => {
20
+ test("qualifiziert Nav-IDs zu feature:nav:id", () => {
21
+ const slice = buildNavRegistrySlice(feature([{ id: "main", label: "Main" }]));
22
+ expect(slice.topLevel.map((n) => n.id)).toEqual(["tasks:nav:main"]);
23
+ });
24
+
25
+ test("parent-child: child unter byParent(qualified-parent), screen wird qualifiziert", () => {
26
+ const slice = buildNavRegistrySlice(
27
+ feature([
28
+ { id: "main", label: "Main" },
29
+ { id: "list", label: "List", parent: "main", screen: "task-list" },
30
+ ]),
31
+ );
32
+ expect(slice.topLevel.map((n) => n.id)).toEqual(["tasks:nav:main"]);
33
+ const children = slice.byParent("tasks:nav:main");
34
+ expect(children.map((n) => n.id)).toEqual(["tasks:nav:list"]);
35
+ expect(children[0]?.screen).toBe("tasks:screen:task-list");
36
+ });
37
+
38
+ test("allowedNavQns filtert nicht-erlaubte Navs raus", () => {
39
+ const slice = buildNavRegistrySlice(
40
+ feature([
41
+ { id: "a", label: "A" },
42
+ { id: "b", label: "B" },
43
+ ]),
44
+ new Set(["tasks:nav:a"]),
45
+ );
46
+ expect(slice.topLevel.map((n) => n.id)).toEqual(["tasks:nav:a"]);
47
+ });
48
+
49
+ test("child mit gedropptem Parent wird top-level (statt zu verschwinden)", () => {
50
+ const slice = buildNavRegistrySlice(
51
+ feature([
52
+ { id: "main", label: "Main" },
53
+ { id: "list", label: "List", parent: "main" },
54
+ ]),
55
+ new Set(["tasks:nav:list"]), // nur child erlaubt, parent gedroppt
56
+ );
57
+ expect(slice.topLevel.map((n) => n.id)).toEqual(["tasks:nav:list"]);
58
+ expect(slice.byParent("tasks:nav:main")).toEqual([]);
59
+ });
60
+ });
61
+
62
+ describe("buildNavRegistrySliceForApp", () => {
63
+ test("qualifiziert Navs pro Feature (multi-feature)", () => {
64
+ const app: AppSchema = {
65
+ features: [
66
+ feature([{ id: "catalog", label: "Catalog" }], "shop"),
67
+ feature([{ id: "users", label: "Users" }], "admin"),
68
+ ],
69
+ };
70
+ const slice = buildNavRegistrySliceForApp(app);
71
+ expect(slice.topLevel.map((n) => n.id)).toEqual(["shop:nav:catalog", "admin:nav:users"]);
72
+ });
73
+ });
@@ -0,0 +1,94 @@
1
+ // workspace-shell Pure-Logik Tests (Phase 1, test-luecken-integration, Tier 1).
2
+ //
3
+ // filterByAccess (Rollen-Filter + Sortierung), resolveDefaultId
4
+ // (preferred > default > first), firstNavScreenId (erste Nav mit Screen).
5
+ // Pure, kein DOM.
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import type {
9
+ AppSchema,
10
+ WorkspaceDefinition,
11
+ WorkspaceSchema,
12
+ } from "@cosmicdrift/kumiko-framework/ui-types";
13
+ import { filterByAccess, firstNavScreenId, resolveDefaultId } from "../workspace-shell";
14
+
15
+ function ws(definition: WorkspaceDefinition): WorkspaceSchema {
16
+ return { definition, navMembers: [] };
17
+ }
18
+
19
+ describe("filterByAccess", () => {
20
+ test("ohne access-rule immer sichtbar", () => {
21
+ const out = filterByAccess([ws({ id: "a", label: "A" }), ws({ id: "b", label: "B" })], []);
22
+ expect(out.map((w) => w.definition.id)).toEqual(["a", "b"]);
23
+ });
24
+
25
+ test("access {roles} filtert nach user-roles", () => {
26
+ const admin = ws({ id: "admin", label: "Admin", access: { roles: ["Admin"] } });
27
+ const open = ws({ id: "all", label: "All" });
28
+ expect(filterByAccess([admin, open], ["Admin"]).map((w) => w.definition.id)).toEqual([
29
+ "admin",
30
+ "all",
31
+ ]);
32
+ expect(filterByAccess([admin, open], ["User"]).map((w) => w.definition.id)).toEqual(["all"]);
33
+ });
34
+
35
+ test("sortiert nach order (lower = earlier)", () => {
36
+ const out = filterByAccess(
37
+ [ws({ id: "b", label: "B", order: 2 }), ws({ id: "a", label: "A", order: 1 })],
38
+ [],
39
+ );
40
+ expect(out.map((w) => w.definition.id)).toEqual(["a", "b"]);
41
+ });
42
+ });
43
+
44
+ describe("resolveDefaultId", () => {
45
+ test("preferred gewinnt wenn sichtbar", () => {
46
+ expect(resolveDefaultId([ws({ id: "a", label: "A" }), ws({ id: "b", label: "B" })], "b")).toBe(
47
+ "b",
48
+ );
49
+ });
50
+
51
+ test("default-flag wenn kein preferred", () => {
52
+ expect(
53
+ resolveDefaultId(
54
+ [ws({ id: "a", label: "A" }), ws({ id: "b", label: "B", default: true })],
55
+ undefined,
56
+ ),
57
+ ).toBe("b");
58
+ });
59
+
60
+ test("erste sichtbare als Fallback", () => {
61
+ expect(
62
+ resolveDefaultId([ws({ id: "a", label: "A" }), ws({ id: "b", label: "B" })], undefined),
63
+ ).toBe("a");
64
+ });
65
+
66
+ test("preferred nicht sichtbar → Fallback (hier: erste)", () => {
67
+ expect(resolveDefaultId([ws({ id: "a", label: "A" })], "nonexistent")).toBe("a");
68
+ });
69
+ });
70
+
71
+ describe("firstNavScreenId", () => {
72
+ const app: AppSchema = {
73
+ features: [
74
+ {
75
+ featureName: "shop",
76
+ entities: {},
77
+ screens: [],
78
+ navs: [
79
+ { id: "header", label: "H" }, // Section-Header ohne screen
80
+ { id: "list", label: "L", screen: "catalog" },
81
+ ],
82
+ },
83
+ ],
84
+ };
85
+
86
+ test("erste Nav (in navMembers-Reihenfolge) mit screen → lastSegment(screen)", () => {
87
+ expect(firstNavScreenId(app, ["shop:nav:header", "shop:nav:list"])).toBe("catalog");
88
+ });
89
+
90
+ test("leere oder undefined navMembers → ''", () => {
91
+ expect(firstNavScreenId(app, [])).toBe("");
92
+ expect(firstNavScreenId(app, undefined)).toBe("");
93
+ });
94
+ });
@@ -0,0 +1,50 @@
1
+ // WorkspaceSwitcher Render-Tests (Phase 1, test-luecken-integration, Tier 2).
2
+ //
3
+ // Dumb presentational component (kein Radix). Pinnt: kein Switcher bei
4
+ // <= 1 Workspace, tablist mit aria-selected am aktiven, onSelect-Callback.
5
+ // Nutzt useTranslation → über test-utils mit LocaleProvider gerendert.
6
+
7
+ import { describe, expect, mock, test } from "bun:test";
8
+ import type { WorkspaceSchema } from "@cosmicdrift/kumiko-renderer";
9
+ import { fireEvent, render, screen } from "../../__tests__/test-utils";
10
+ import { WorkspaceSwitcher } from "../workspace-switcher";
11
+
12
+ function ws(id: string, label = id): WorkspaceSchema {
13
+ return { definition: { id, label }, navMembers: [] };
14
+ }
15
+
16
+ describe("WorkspaceSwitcher — Render", () => {
17
+ test("ein einziger Workspace → rendert nichts (kein nutzloser Switcher)", () => {
18
+ const { container } = render(
19
+ <WorkspaceSwitcher workspaces={[ws("a")]} activeId="a" onSelect={() => {}} />,
20
+ );
21
+ expect(container.querySelector('[role="tablist"]')).toBeNull();
22
+ });
23
+
24
+ test("mehrere Workspaces → tablist, aria-selected am aktiven Tab", () => {
25
+ render(
26
+ <WorkspaceSwitcher
27
+ workspaces={[ws("a", "Alpha"), ws("b", "Beta")]}
28
+ activeId="b"
29
+ onSelect={() => {}}
30
+ testId="sw"
31
+ />,
32
+ );
33
+ expect(screen.getByTestId("sw").getAttribute("role")).toBe("tablist");
34
+ expect(screen.getByTestId("workspace-tab-a").getAttribute("aria-selected")).toBe("false");
35
+ expect(screen.getByTestId("workspace-tab-b").getAttribute("aria-selected")).toBe("true");
36
+ });
37
+
38
+ test("Click ruft onSelect mit der Workspace-id", () => {
39
+ const onSelect = mock((_id: string) => {});
40
+ render(
41
+ <WorkspaceSwitcher
42
+ workspaces={[ws("a", "Alpha"), ws("b", "Beta")]}
43
+ activeId="a"
44
+ onSelect={onSelect}
45
+ />,
46
+ );
47
+ fireEvent.click(screen.getByTestId("workspace-tab-b"));
48
+ expect(onSelect).toHaveBeenCalledWith("b");
49
+ });
50
+ });
@@ -0,0 +1,82 @@
1
+ // DataTable-Pure-Logik Tests (Phase 1, test-luecken-integration, Tier 1).
2
+ //
3
+ // Reine Funktionen aus primitives/index.tsx (exportiert für Test, wie
4
+ // money-input seine Pure-Logik exportiert). Kein DOM.
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+ import { computeVisiblePages, defaultCellRender, isComponentRendererRef } from "../index";
8
+
9
+ describe("computeVisiblePages", () => {
10
+ test("<= 7 Seiten: alle Seiten, keine Ellipsis", () => {
11
+ expect(computeVisiblePages(1, 5)).toEqual([1, 2, 3, 4, 5]);
12
+ expect(computeVisiblePages(3, 7)).toEqual([1, 2, 3, 4, 5, 6, 7]);
13
+ });
14
+
15
+ test("erste + letzte Seite immer als Anker enthalten", () => {
16
+ const pages = computeVisiblePages(10, 20);
17
+ expect(pages[0]).toBe(1);
18
+ expect(pages.at(-1)).toBe(20);
19
+ });
20
+
21
+ test("Mitte (p=10/20): page±2-Window mit Ellipsen beidseitig", () => {
22
+ expect(computeVisiblePages(10, 20)).toEqual([1, "ellipsis", 8, 9, 10, 11, 12, "ellipsis", 20]);
23
+ });
24
+
25
+ test("Rand p=1/20: 5 Zahlen links sichtbar (Fenster verschoben, nicht abgeschnitten)", () => {
26
+ expect(computeVisiblePages(1, 20)).toEqual([1, 2, 3, 4, 5, "ellipsis", 20]);
27
+ });
28
+
29
+ test("Rand p=20/20: 5 Zahlen rechts sichtbar", () => {
30
+ expect(computeVisiblePages(20, 20)).toEqual([1, "ellipsis", 16, 17, 18, 19, 20]);
31
+ });
32
+
33
+ test("page=5/20: Übergang Rand→Mitte (Ellipsis links erscheint)", () => {
34
+ expect(computeVisiblePages(5, 20)).toEqual([1, "ellipsis", 3, 4, 5, 6, 7, "ellipsis", 20]);
35
+ });
36
+ });
37
+
38
+ describe("isComponentRendererRef", () => {
39
+ test("erkennt { react: { __component: 'Name' } }", () => {
40
+ expect(isComponentRendererRef({ react: { __component: "MyCell" } })).toEqual({
41
+ name: "MyCell",
42
+ });
43
+ });
44
+
45
+ test("null / non-object / fehlender react-Branch → undefined", () => {
46
+ expect(isComponentRendererRef(null)).toBeUndefined();
47
+ expect(isComponentRendererRef("x")).toBeUndefined();
48
+ expect(isComponentRendererRef({})).toBeUndefined();
49
+ expect(isComponentRendererRef({ react: null })).toBeUndefined();
50
+ });
51
+
52
+ test("leerer oder fehlender __component → undefined", () => {
53
+ expect(isComponentRendererRef({ react: {} })).toBeUndefined();
54
+ expect(isComponentRendererRef({ react: { __component: "" } })).toBeUndefined();
55
+ });
56
+ });
57
+
58
+ describe("defaultCellRender", () => {
59
+ test("null/undefined/leerer String → leerer String", () => {
60
+ expect(defaultCellRender(null, "text")).toBe("");
61
+ expect(defaultCellRender(undefined, "text")).toBe("");
62
+ expect(defaultCellRender("", "text")).toBe("");
63
+ });
64
+
65
+ test("boolean → ✓ bei true, leer bei false", () => {
66
+ expect(defaultCellRender(true, "boolean")).toBe("✓");
67
+ expect(defaultCellRender(false, "boolean")).toBe("");
68
+ });
69
+
70
+ test("select → humanizeSlug (kebab → Title Case), wenn kein optionLabel", () => {
71
+ expect(defaultCellRender("degraded-performance", "select")).toBe("Degraded performance");
72
+ });
73
+
74
+ test("select → registriertes optionLabel gewinnt vor humanizeSlug", () => {
75
+ expect(defaultCellRender("op-x", "select", { "op-x": "Operativ X" })).toBe("Operativ X");
76
+ });
77
+
78
+ test("text/number → String-Repräsentation", () => {
79
+ expect(defaultCellRender("hallo", "text")).toBe("hallo");
80
+ expect(defaultCellRender(42, "number")).toBe("42");
81
+ });
82
+ });
@@ -0,0 +1,42 @@
1
+ // date-input Pure-Logik Tests (Phase 1, test-luecken-integration, Tier 1).
2
+ //
3
+ // parseIso/toIso aus date-input.tsx (exportiert für Test). Pinst das
4
+ // non-obvious Timezone-Verhalten: parseIso baut ein LOKALES Date (nicht
5
+ // UTC), damit "2026-04-25" im Calendar nicht je nach Zeitzone auf den
6
+ // 24. kippt.
7
+
8
+ import { describe, expect, test } from "bun:test";
9
+ import { parseIso, toIso } from "../date-input";
10
+
11
+ describe("parseIso", () => {
12
+ test("gültiges yyyy-mm-dd → lokales Date (kein UTC-Shift)", () => {
13
+ const d = parseIso("2026-04-25");
14
+ expect(d).toBeInstanceOf(Date);
15
+ expect(d?.getFullYear()).toBe(2026);
16
+ expect(d?.getMonth()).toBe(3); // 0-based: April
17
+ expect(d?.getDate()).toBe(25);
18
+ });
19
+
20
+ test("leerer String → undefined", () => {
21
+ expect(parseIso("")).toBeUndefined();
22
+ });
23
+
24
+ test("falsche Teil-Anzahl oder nicht-numerische Teile → undefined", () => {
25
+ expect(parseIso("2026-04")).toBeUndefined();
26
+ expect(parseIso("2026/04/25")).toBeUndefined();
27
+ expect(parseIso("abc-de-fg")).toBeUndefined();
28
+ });
29
+ });
30
+
31
+ describe("toIso", () => {
32
+ test("Date → yyyy-mm-dd mit Zero-Padding", () => {
33
+ expect(toIso(new Date(2026, 3, 5))).toBe("2026-04-05");
34
+ expect(toIso(new Date(2026, 11, 25))).toBe("2026-12-25");
35
+ });
36
+
37
+ test("Roundtrip parseIso → toIso ist stabil", () => {
38
+ const d = parseIso("2026-04-25");
39
+ expect(d).toBeDefined();
40
+ if (d !== undefined) expect(toIso(d)).toBe("2026-04-25");
41
+ });
42
+ });
@@ -0,0 +1,149 @@
1
+ // money-input Tests (Phase 1, test-luecken-integration).
2
+ //
3
+ // Tier 1 — Pure-Logik (currencyDecimals, parseLocaleNumber): exportierte
4
+ // reine Funktionen, kein DOM nötig.
5
+ // Tier 2 — Render (happy-dom + @testing-library): money-input ist das
6
+ // einzige nicht-Radix-Primitive, daher voll testbar (kein
7
+ // Pointer-Capture-Problem) — Format-Roundtrip, +/- Bump, a11y.
8
+
9
+ import { describe, expect, mock, test } from "bun:test";
10
+ import { fireEvent, render, screen } from "@testing-library/react";
11
+ import { currencyDecimals, MoneyInput, parseLocaleNumber } from "../money-input";
12
+
13
+ describe("currencyDecimals", () => {
14
+ test("0-Decimal-Währungen (JPY/KRW/VND/ISK)", () => {
15
+ for (const c of ["JPY", "KRW", "VND", "ISK"]) expect(currencyDecimals(c)).toBe(0);
16
+ });
17
+
18
+ test("3-Decimal-Währungen (BHD/JOD/KWD/OMR/TND)", () => {
19
+ for (const c of ["BHD", "JOD", "KWD", "OMR", "TND"]) expect(currencyDecimals(c)).toBe(3);
20
+ });
21
+
22
+ test("Default 2 für EUR/USD und unbekannte Codes", () => {
23
+ for (const c of ["EUR", "USD", "CHF", "ZZZ"]) expect(currencyDecimals(c)).toBe(2);
24
+ });
25
+ });
26
+
27
+ describe("parseLocaleNumber", () => {
28
+ test("en-US: Punkt = Decimal, Komma = Gruppierung", () => {
29
+ expect(parseLocaleNumber("1,234.56", "en-US")).toBeCloseTo(1234.56, 5);
30
+ });
31
+
32
+ test("de-DE: Komma = Decimal, Punkt = Gruppierung", () => {
33
+ expect(parseLocaleNumber("1.234,56", "de-DE")).toBeCloseTo(1234.56, 5);
34
+ });
35
+
36
+ test("führendes Minus erlaubt", () => {
37
+ expect(parseLocaleNumber("-123", "en-US")).toBe(-123);
38
+ });
39
+
40
+ test("Minus NUR ganz vorne — '1-23' ist invalid (NaN)", () => {
41
+ expect(parseLocaleNumber("1-23", "en-US")).toBeNaN();
42
+ });
43
+
44
+ test("Buchstaben, leerer String und nackter Separator → NaN", () => {
45
+ expect(parseLocaleNumber("abc", "en-US")).toBeNaN();
46
+ expect(parseLocaleNumber("", "en-US")).toBeNaN();
47
+ expect(parseLocaleNumber(".", "en-US")).toBeNaN();
48
+ });
49
+
50
+ test("umgebender Whitespace wird getrimmt", () => {
51
+ expect(parseLocaleNumber(" 42 ", "en-US")).toBe(42);
52
+ });
53
+ });
54
+
55
+ function inputEl(): HTMLInputElement {
56
+ const el = screen.getByRole("textbox");
57
+ if (!(el instanceof HTMLInputElement)) throw new Error("expected an <input> element");
58
+ return el;
59
+ }
60
+
61
+ describe("MoneyInput — Render (Tier 2)", () => {
62
+ test("zeigt den Canonical-Cent-Wert als formatierte Währung (unfokussiert)", () => {
63
+ render(
64
+ <MoneyInput
65
+ id="amt"
66
+ name="amt"
67
+ value={1000}
68
+ onChange={() => {}}
69
+ currency="EUR"
70
+ locale="de-DE"
71
+ />,
72
+ );
73
+ const value = inputEl().value;
74
+ expect(value).toContain("10");
75
+ expect(value).toContain("€");
76
+ });
77
+
78
+ test("Focus schaltet auf rohen Decimal-String ohne Währungssymbol", () => {
79
+ render(
80
+ <MoneyInput
81
+ id="amt"
82
+ name="amt"
83
+ value={1000}
84
+ onChange={() => {}}
85
+ currency="EUR"
86
+ locale="de-DE"
87
+ />,
88
+ );
89
+ fireEvent.focus(inputEl());
90
+ const value = inputEl().value;
91
+ expect(value).toContain("10,00");
92
+ expect(value).not.toContain("€");
93
+ });
94
+
95
+ test("Blur mit neuem Wert ruft onChange mit Minor-Units (Cents)", () => {
96
+ const onChange = mock((_v: number | undefined) => {});
97
+ render(
98
+ <MoneyInput
99
+ id="amt"
100
+ name="amt"
101
+ value={1000}
102
+ onChange={onChange}
103
+ currency="EUR"
104
+ locale="de-DE"
105
+ />,
106
+ );
107
+ fireEvent.focus(inputEl());
108
+ fireEvent.change(inputEl(), { target: { value: "25,50" } });
109
+ fireEvent.blur(inputEl());
110
+ expect(onChange).toHaveBeenCalledWith(2550);
111
+ });
112
+
113
+ test("+/- Buttons bumpen um eine Major-Unit (= factor Cents)", () => {
114
+ const onChange = mock((_v: number | undefined) => {});
115
+ render(
116
+ <MoneyInput
117
+ id="amt"
118
+ name="amt"
119
+ value={1000}
120
+ onChange={onChange}
121
+ currency="EUR"
122
+ locale="de-DE"
123
+ />,
124
+ );
125
+ const [minus, plus] = screen.getAllByRole("button");
126
+ if (minus === undefined || plus === undefined) throw new Error("expected two step buttons");
127
+ fireEvent.click(plus);
128
+ expect(onChange).toHaveBeenLastCalledWith(1100);
129
+ fireEvent.click(minus);
130
+ expect(onChange).toHaveBeenLastCalledWith(900);
131
+ });
132
+
133
+ test("a11y: aria-required + aria-invalid spiegeln die Props", () => {
134
+ render(
135
+ <MoneyInput
136
+ id="amt"
137
+ name="amt"
138
+ value=""
139
+ onChange={() => {}}
140
+ currency="EUR"
141
+ required
142
+ hasError
143
+ />,
144
+ );
145
+ const input = inputEl();
146
+ expect(input.getAttribute("aria-required")).toBe("true");
147
+ expect(input.getAttribute("aria-invalid")).toBe("true");
148
+ });
149
+ });
@@ -133,7 +133,7 @@ const dayPickerClasses = {
133
133
  disabled: "text-muted-foreground/30 pointer-events-none",
134
134
  };
135
135
 
136
- function parseIso(v: string): Date | undefined {
136
+ export function parseIso(v: string): Date | undefined {
137
137
  if (v === "") return undefined;
138
138
  // Date(yyyy-mm-dd) parses as UTC — wir wollen local damit "2026-04-25"
139
139
  // im Calendar nicht je nach Timezone als 24. oder 25. erscheint.
@@ -152,7 +152,7 @@ function parseIso(v: string): Date | undefined {
152
152
  return new Date(y, m - 1, d);
153
153
  }
154
154
 
155
- function toIso(d: Date): string {
155
+ export function toIso(d: Date): string {
156
156
  const y = d.getFullYear();
157
157
  const m = String(d.getMonth() + 1).padStart(2, "0");
158
158
  const day = String(d.getDate()).padStart(2, "0");
@@ -924,18 +924,34 @@ function PagerButton({
924
924
  // p=10, total=20: 1 … 8 9 [10] 11 12 … 20
925
925
  // p=20, total=20: 1 … 16 17 18 19 [20]
926
926
  // total=5: 1 2 3 4 5 (kein Window nötig)
927
- function computeVisiblePages(page: number, totalPages: number): readonly (number | "ellipsis")[] {
927
+ export function computeVisiblePages(
928
+ page: number,
929
+ totalPages: number,
930
+ ): readonly (number | "ellipsis")[] {
928
931
  if (totalPages <= 7) return Array.from({ length: totalPages }, (_, i) => i + 1);
929
- const out: (number | "ellipsis")[] = [];
930
- // Always show 1
931
- out.push(1);
932
- if (page > 4) out.push("ellipsis");
933
- const start = Math.max(2, page - 2);
934
- const end = Math.min(totalPages - 1, page + 2);
935
- for (let i = start; i <= end; i++) out.push(i);
936
- if (page < totalPages - 3) out.push("ellipsis");
937
- // Always show last
938
- out.push(totalPages);
932
+
933
+ // Fenster von 5 Seiten um die aktuelle Seite. An den Rändern wird das
934
+ // Fenster verschoben (nicht abgeschnitten), damit immer 5 Zahlen + die
935
+ // gegenüberliegende Anker-Seite sichtbar sind:
936
+ // p=1, total=20: 1 2 3 4 5 … 20
937
+ // p=10, total=20: 1 8 9 10 11 12 … 20
938
+ // p=20, total=20: 1 16 17 18 19 20
939
+ const leftSibling = Math.max(page - 2, 1);
940
+ const rightSibling = Math.min(page + 2, totalPages);
941
+ const showLeftEllipsis = leftSibling > 2;
942
+ const showRightEllipsis = rightSibling < totalPages - 1;
943
+
944
+ if (!showLeftEllipsis) {
945
+ return [1, 2, 3, 4, 5, "ellipsis", totalPages];
946
+ }
947
+ if (!showRightEllipsis) {
948
+ const tail: (number | "ellipsis")[] = [1, "ellipsis"];
949
+ for (let i = totalPages - 4; i <= totalPages; i++) tail.push(i);
950
+ return tail;
951
+ }
952
+ const out: (number | "ellipsis")[] = [1, "ellipsis"];
953
+ for (let i = leftSibling; i <= rightSibling; i++) out.push(i);
954
+ out.push("ellipsis", totalPages);
939
955
  return out;
940
956
  }
941
957
 
@@ -1017,7 +1033,7 @@ function nextSortState(current: DataTableSortDir | undefined, field: string): Da
1017
1033
  // PlatformComponent-Renderer im Schema serialisiert ankommen. Schemas
1018
1034
  // reisen über die Wire (Server → Client), echte Component-Refs würden
1019
1035
  // das brechen — der String-Key ist die SSoT.
1020
- function isComponentRendererRef(renderer: unknown): { readonly name: string } | undefined {
1036
+ export function isComponentRendererRef(renderer: unknown): { readonly name: string } | undefined {
1021
1037
  if (renderer === null || typeof renderer !== "object") return undefined;
1022
1038
  const reactBranch = (renderer as { react?: unknown }).react;
1023
1039
  if (reactBranch === null || typeof reactBranch !== "object") return undefined;
@@ -1034,7 +1050,7 @@ function isComponentRendererRef(renderer: unknown): { readonly name: string } |
1034
1050
  // - timestamp/date → locale-formatiert (kein roher ISO-String)
1035
1051
  // - select → human-lesbar (kebab-case → Title Case)
1036
1052
  // - text/number/sonst → toString
1037
- function defaultCellRender(
1053
+ export function defaultCellRender(
1038
1054
  value: unknown,
1039
1055
  type: string,
1040
1056
  optionLabels?: Readonly<Record<string, string>>,