@cosmicdrift/kumiko-renderer-web 0.24.1 → 0.26.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/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/src/__tests__/tokens.test.ts +0 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.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,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>>,
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { applyTokensToCssVars } from "../tokens";
|
|
3
|
-
|
|
4
|
-
describe("applyTokensToCssVars", () => {
|
|
5
|
-
test("is a documented no-op kept for backward compatibility", () => {
|
|
6
|
-
expect(() => applyTokensToCssVars({} as never)).not.toThrow();
|
|
7
|
-
});
|
|
8
|
-
});
|