@cosmicdrift/kumiko-renderer 0.25.0 → 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",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -0,0 +1,31 @@
1
+ // feature-schema Pure-Logik Tests (Phase 1, test-luecken-integration, Tier 1).
2
+ //
3
+ // toAppSchema normalisiert FeatureSchema → AppSchema (idempotent),
4
+ // isAppSchema diskriminiert die beiden Formen. Zero source-change.
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+ import type { AppSchema, FeatureSchema } from "@cosmicdrift/kumiko-framework/ui-types";
8
+ import { isAppSchema, toAppSchema } from "../feature-schema";
9
+
10
+ const feature: FeatureSchema = { featureName: "tasks", entities: {}, screens: [] };
11
+
12
+ describe("toAppSchema", () => {
13
+ test("wraps a FeatureSchema into the AppSchema envelope", () => {
14
+ const app = toAppSchema(feature);
15
+ expect(app.features).toHaveLength(1);
16
+ expect(app.features[0]?.featureName).toBe("tasks");
17
+ expect(app.workspaces).toBeUndefined();
18
+ });
19
+
20
+ test("idempotent for AppSchema input (returns the same reference)", () => {
21
+ const app: AppSchema = { features: [feature] };
22
+ expect(toAppSchema(app)).toBe(app);
23
+ });
24
+ });
25
+
26
+ describe("isAppSchema", () => {
27
+ test("true for AppSchema (has 'features'), false for FeatureSchema", () => {
28
+ expect(isAppSchema({ features: [feature] })).toBe(true);
29
+ expect(isAppSchema(feature)).toBe(false);
30
+ });
31
+ });
@@ -0,0 +1,83 @@
1
+ // nav Pure-Logik Tests (Phase 1, test-luecken-integration, Tier 1).
2
+ //
3
+ // parsePath/formatPath: plattform-neutrale URL-Grammatik mit zwei Modi
4
+ // (mit/ohne Workspaces). Pure, kein DOM.
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+ import { formatPath, parsePath } from "../nav";
8
+
9
+ describe("parsePath — ohne Workspaces", () => {
10
+ test("/<screenId>", () => {
11
+ expect(parsePath("/task-list")).toEqual({ screenId: "task-list" });
12
+ });
13
+
14
+ test("/<screenId>/<entityId>", () => {
15
+ expect(parsePath("/task-edit/abc-123")).toEqual({ screenId: "task-edit", entityId: "abc-123" });
16
+ });
17
+
18
+ test("Root und leerer Pfad → undefined", () => {
19
+ expect(parsePath("/")).toBeUndefined();
20
+ expect(parsePath("")).toBeUndefined();
21
+ });
22
+
23
+ test("überzählige Segmente werden ignoriert (kein Nesting)", () => {
24
+ expect(parsePath("/a/b/c/d")).toEqual({ screenId: "a", entityId: "b" });
25
+ });
26
+ });
27
+
28
+ describe("parsePath — mit Workspaces (hasWorkspaces=true)", () => {
29
+ test("/<workspaceId>/<screenId>", () => {
30
+ expect(parsePath("/admin/task-list", true)).toEqual({
31
+ workspaceId: "admin",
32
+ screenId: "task-list",
33
+ });
34
+ });
35
+
36
+ test("/<workspaceId>/<screenId>/<entityId>", () => {
37
+ expect(parsePath("/admin/task-edit/abc", true)).toEqual({
38
+ workspaceId: "admin",
39
+ screenId: "task-edit",
40
+ entityId: "abc",
41
+ });
42
+ });
43
+
44
+ test("Workspace-only /<workspaceId> → leerer screenId (Shell resolved Default)", () => {
45
+ expect(parsePath("/admin", true)).toEqual({ workspaceId: "admin", screenId: "" });
46
+ });
47
+
48
+ test("Root / → undefined", () => {
49
+ expect(parsePath("/", true)).toBeUndefined();
50
+ });
51
+ });
52
+
53
+ describe("formatPath", () => {
54
+ test("flach: /<screenId>", () => {
55
+ expect(formatPath({ screenId: "task-list" })).toBe("/task-list");
56
+ });
57
+
58
+ test("mit entityId", () => {
59
+ expect(formatPath({ screenId: "task-edit", entityId: "abc" })).toBe("/task-edit/abc");
60
+ });
61
+
62
+ test("mit workspaceId-Prefix", () => {
63
+ expect(formatPath({ workspaceId: "admin", screenId: "task-list" })).toBe("/admin/task-list");
64
+ });
65
+
66
+ test("workspace + entity", () => {
67
+ expect(formatPath({ workspaceId: "admin", screenId: "task-edit", entityId: "abc" })).toBe(
68
+ "/admin/task-edit/abc",
69
+ );
70
+ });
71
+ });
72
+
73
+ describe("Roundtrip parsePath ↔ formatPath", () => {
74
+ test("non-workspace", () => {
75
+ const t = { screenId: "task-edit", entityId: "abc" };
76
+ expect(parsePath(formatPath(t))).toEqual(t);
77
+ });
78
+
79
+ test("workspace", () => {
80
+ const t = { workspaceId: "admin", screenId: "task-edit", entityId: "abc" };
81
+ expect(parsePath(formatPath(t), true)).toEqual(t);
82
+ });
83
+ });
@@ -0,0 +1,37 @@
1
+ // Qualified-Name Pure-Logik Tests (Phase 1, test-luecken-integration, Tier 1).
2
+ //
3
+ // lastSegment (qn.ts) ist die Inverse von qualifyScreenId/qualifyNavId
4
+ // (kumiko-screen.tsx) — Schema speichert QN-Form, der Renderer/die URL
5
+ // nutzt Short-Form. Roundtrip pinnt diese Symmetrie.
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import { qualifyNavId, qualifyScreenId } from "../kumiko-screen";
9
+ import { lastSegment } from "../qn";
10
+
11
+ describe("lastSegment", () => {
12
+ test("nimmt den letzten ':'-getrennten Teil", () => {
13
+ expect(lastSegment("tasks:screen:task-list")).toBe("task-list");
14
+ expect(lastSegment("a:b")).toBe("b");
15
+ });
16
+
17
+ test("String ohne ':' bleibt unverändert (Short-Form passt durch)", () => {
18
+ expect(lastSegment("task-list")).toBe("task-list");
19
+ expect(lastSegment("")).toBe("");
20
+ });
21
+
22
+ test("trailing ':' → leerer Suffix", () => {
23
+ expect(lastSegment("a:")).toBe("");
24
+ });
25
+ });
26
+
27
+ describe("qualifyScreenId / qualifyNavId", () => {
28
+ test("baut featureName:screen:id bzw. featureName:nav:id", () => {
29
+ expect(qualifyScreenId("tasks", "task-list")).toBe("tasks:screen:task-list");
30
+ expect(qualifyNavId("tasks", "main")).toBe("tasks:nav:main");
31
+ });
32
+
33
+ test("lastSegment ist die Inverse von qualify*", () => {
34
+ expect(lastSegment(qualifyScreenId("tasks", "task-list"))).toBe("task-list");
35
+ expect(lastSegment(qualifyNavId("tasks", "main"))).toBe("main");
36
+ });
37
+ });
@@ -0,0 +1,77 @@
1
+ // Regression: RenderField muss das App-Locale (useLocale) an money-/date-
2
+ // Inputs durchreichen — auch ohne explizites field.locale. Vorher fielen
3
+ // die Inputs auf navigator.language (Browser-Sprache) statt der per
4
+ // LocaleProvider gewählten App-Sprache zurück → Separator-Mismatch.
5
+ //
6
+ // Capture-Input statt echter Primitive: hält den Test renderer-intern
7
+ // (relativer Import von RenderField → diese Source), unabhängig davon
8
+ // wie @cosmicdrift/* im Workspace aufgelöst wird.
9
+
10
+ import { describe, expect, test } from "bun:test";
11
+ import type { EditFieldViewModel } from "@cosmicdrift/kumiko-headless";
12
+ import { render } from "@testing-library/react";
13
+ import type { ComponentType, ReactNode } from "react";
14
+ import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
15
+ import { type CorePrimitives, type InputProps, PrimitivesProvider } from "../../primitives";
16
+ import { RenderField } from "../render-field";
17
+
18
+ let captured: InputProps | undefined;
19
+ const captureInput: ComponentType<InputProps> = (props) => {
20
+ captured = props;
21
+ return null;
22
+ };
23
+ const noop = (): ReactNode => null;
24
+ const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
25
+
26
+ const testPrimitives: CorePrimitives = {
27
+ Button: noop,
28
+ Banner: noop,
29
+ Field: passChildren,
30
+ Input: captureInput,
31
+ DataTable: noop,
32
+ Form: noop,
33
+ Section: noop,
34
+ Grid: noop,
35
+ GridCell: noop,
36
+ Text: noop,
37
+ Heading: noop,
38
+ Dialog: noop,
39
+ ConfigSourceBadge: noop,
40
+ ConfigCascadeView: noop,
41
+ };
42
+
43
+ function moneyField(): EditFieldViewModel {
44
+ return {
45
+ field: "price",
46
+ label: "Preis",
47
+ type: "money",
48
+ value: 123456,
49
+ visible: true,
50
+ readOnly: false,
51
+ required: false,
52
+ };
53
+ }
54
+
55
+ function renderUnderLocale(locale: string): void {
56
+ captured = undefined;
57
+ render(
58
+ <LocaleProvider resolver={createStaticLocaleResolver({ locale })}>
59
+ <PrimitivesProvider value={testPrimitives}>
60
+ <RenderField field={moneyField()} onChange={() => {}} />
61
+ </PrimitivesProvider>
62
+ </LocaleProvider>,
63
+ );
64
+ }
65
+
66
+ describe("RenderField — App-Locale an money durchreichen", () => {
67
+ test("money ohne field.locale bekommt das App-Locale (de-DE)", () => {
68
+ renderUnderLocale("de-DE");
69
+ expect(captured?.kind).toBe("money");
70
+ if (captured?.kind === "money") expect(captured.locale).toBe("de-DE");
71
+ });
72
+
73
+ test("ein anderes App-Locale wird ebenso durchgereicht (en-US)", () => {
74
+ renderUnderLocale("en-US");
75
+ if (captured?.kind === "money") expect(captured.locale).toBe("en-US");
76
+ });
77
+ });
@@ -2,6 +2,7 @@ import type { EditFieldViewModel, FieldIssue } from "@cosmicdrift/kumiko-headles
2
2
  import { type ReactNode, useCallback, useMemo, useState } from "react";
3
3
  import { REFERENCE_COMBOBOX_LIMIT } from "../hooks/reference-limits";
4
4
  import { useQuery } from "../hooks/use-query";
5
+ import { useLocale } from "../i18n";
5
6
  import { usePrimitives } from "../primitives";
6
7
 
7
8
  // RenderField übersetzt ein EditFieldViewModel → Primitives-Baum.
@@ -36,6 +37,11 @@ export function RenderField({
36
37
  fieldAppendix,
37
38
  }: RenderFieldProps): ReactNode {
38
39
  const { Field, Input } = usePrimitives();
40
+ // App-Locale (i18n) für money/date-Inputs — sonst fielen sie auf
41
+ // navigator.language (Browser-Sprache) zurück statt der gewählten
42
+ // App-Sprache. useLocale() wirft ohne LocaleProvider; ok, weil
43
+ // RenderField nur unter RenderEdit im Kumiko-App-Tree läuft.
44
+ const appLocale = useLocale().locale();
39
45
  if (!field.visible) return null;
40
46
 
41
47
  const id = inputId(field);
@@ -55,7 +61,7 @@ export function RenderField({
55
61
  featureName={featureName ?? ""}
56
62
  />
57
63
  ) : (
58
- renderInput({ field, id, hasError, onChange, Input })
64
+ renderInput({ field, id, hasError, onChange, Input, appLocale })
59
65
  );
60
66
 
61
67
  return (
@@ -189,12 +195,14 @@ function renderInput({
189
195
  hasError,
190
196
  onChange,
191
197
  Input,
198
+ appLocale,
192
199
  }: {
193
200
  readonly field: EditFieldViewModel;
194
201
  readonly id: string;
195
202
  readonly hasError: boolean;
196
203
  readonly onChange: (value: unknown) => void;
197
204
  readonly Input: ReturnType<typeof usePrimitives>["Input"];
205
+ readonly appLocale: string;
198
206
  }): ReactNode {
199
207
  const common = {
200
208
  id,
@@ -223,7 +231,7 @@ function renderInput({
223
231
  value={numberValue(field.value)}
224
232
  onChange={(v) => onChange(v)}
225
233
  {...(moneyDef.currency !== undefined && { currency: moneyDef.currency })}
226
- {...(moneyDef.locale !== undefined && { locale: moneyDef.locale })}
234
+ locale={moneyDef.locale ?? appLocale}
227
235
  />
228
236
  );
229
237
  }
@@ -243,6 +251,7 @@ function renderInput({
243
251
  {...common}
244
252
  value={stringValue(field.value)}
245
253
  onChange={(v) => onChange(v)}
254
+ locale={appLocale}
246
255
  />
247
256
  );
248
257
  case "timestamp":