@cosmicdrift/kumiko-renderer-web 0.26.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
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
|
+
});
|