@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 +1 -1
- package/src/layout/__tests__/avatar.test.tsx +51 -0
- package/src/layout/__tests__/nav-registry.test.ts +73 -0
- package/src/layout/__tests__/workspace-access.test.ts +94 -0
- package/src/layout/__tests__/workspace-switcher.test.tsx +50 -0
- package/src/primitives/__tests__/data-table-logic.test.ts +82 -0
- package/src/primitives/__tests__/date-input.test.ts +42 -0
- package/src/primitives/__tests__/money-input.test.tsx +149 -0
- package/src/primitives/date-input.tsx +2 -2
- package/src/primitives/index.tsx +29 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "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");
|
package/src/primitives/index.tsx
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
930
|
-
//
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
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>>,
|