@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.24.1",
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");
@@ -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>>,
@@ -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
- });