@cosmicdrift/kumiko-renderer 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/app/__tests__/feature-schema.test.ts +31 -0
- package/src/app/__tests__/nav.test.ts +83 -0
- package/src/app/__tests__/qualified-names.test.ts +37 -0
- package/src/components/__tests__/render-field-app-locale.test.tsx +77 -0
- package/src/components/render-field.tsx +11 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.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
|
-
{
|
|
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":
|