@cosmicdrift/kumiko-renderer-web 1.0.0 → 2.0.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 +4 -4
- package/src/__tests__/action-menu.test.tsx +75 -0
- package/src/__tests__/breadcrumb.test.tsx +50 -0
- package/src/__tests__/create-app.test.tsx +171 -1
- package/src/__tests__/dashboard-screen.test.tsx +393 -0
- package/src/__tests__/default-app-shell.test.tsx +47 -1
- package/src/__tests__/form-action-bar.test.tsx +9 -0
- package/src/__tests__/kumiko-screen.test.tsx +155 -1
- package/src/__tests__/lightbox.test.tsx +46 -0
- package/src/__tests__/nav-tree.test.tsx +122 -0
- package/src/__tests__/primitives.test.tsx +32 -1
- package/src/__tests__/profile-menu.test.tsx +29 -0
- package/src/__tests__/projection-detail.test.tsx +101 -0
- package/src/__tests__/projection-list-actions.test.tsx +131 -0
- package/src/__tests__/render-list.test.tsx +11 -3
- package/src/__tests__/test-utils.tsx +1 -0
- package/src/__tests__/toast.test.tsx +9 -9
- package/src/__tests__/use-mutation.test.tsx +86 -0
- package/src/__tests__/workspace-shell.test.tsx +102 -47
- package/src/__tests__/xss-safety.test.tsx +62 -0
- package/src/app/__tests__/first-open-screen-qn.test.ts +77 -0
- package/src/app/browser-locale.ts +1 -1
- package/src/app/create-app.tsx +96 -29
- package/src/app/dashboard-body.tsx +454 -0
- package/src/index.ts +75 -1
- package/src/layout/__tests__/nav-registry.test.ts +105 -0
- package/src/layout/__tests__/shell-breadcrumb.test.ts +82 -0
- package/src/layout/default-app-shell.tsx +9 -71
- package/src/layout/nav-tree.tsx +167 -37
- package/src/layout/shell-breadcrumb.ts +46 -0
- package/src/layout/shell-header.tsx +114 -0
- package/src/layout/workspace-shell.tsx +45 -18
- package/src/primitives/__tests__/data-table-logic.test.ts +27 -0
- package/src/primitives/__tests__/input-testid.test.tsx +45 -0
- package/src/primitives/__tests__/money-input.test.tsx +13 -1
- package/src/primitives/dialog.tsx +47 -76
- package/src/primitives/index.tsx +215 -37
- package/src/primitives/layout.tsx +39 -0
- package/src/primitives/lightbox.tsx +39 -0
- package/src/primitives/modal-shell.tsx +62 -0
- package/src/primitives/money-input.tsx +20 -10
- package/src/primitives/toast.tsx +14 -9
- package/src/sse/live-events.ts +6 -1
- package/src/styles.css +25 -0
- package/src/tokens.ts +4 -0
- package/src/ui/__tests__/avatar.test.tsx +66 -0
- package/src/ui/__tests__/sheet.test.tsx +102 -0
- package/src/version/__tests__/update-checker.test.tsx +115 -0
- package/src/widgets/__tests__/ai-text-field.test.tsx +267 -0
- package/src/widgets/__tests__/drawer.test.tsx +35 -0
- package/src/widgets/__tests__/form-kit.test.tsx +368 -0
- package/src/widgets/__tests__/infinity-list.test.tsx +132 -0
- package/src/widgets/__tests__/query-table.test.tsx +118 -0
- package/src/widgets/__tests__/result-panel.test.tsx +117 -0
- package/src/widgets/__tests__/widgets.test.tsx +243 -0
- package/src/widgets/ai-text-field.tsx +370 -0
- package/src/widgets/charts.tsx +228 -0
- package/src/widgets/collapsible-section.tsx +42 -0
- package/src/widgets/detail-list.tsx +43 -0
- package/src/widgets/drawer.tsx +49 -0
- package/src/widgets/feed-list.tsx +35 -0
- package/src/widgets/form-fields.tsx +326 -0
- package/src/widgets/index.ts +59 -0
- package/src/widgets/infinity-list.tsx +112 -0
- package/src/widgets/mode-switch.tsx +41 -0
- package/src/widgets/progress-bar.tsx +27 -0
- package/src/widgets/progress-list.tsx +38 -0
- package/src/widgets/query-table.tsx +103 -0
- package/src/widgets/result-panel.tsx +251 -0
- package/src/widgets/section-card.tsx +30 -0
- package/src/widgets/stat.tsx +181 -0
- package/src/widgets/states.tsx +89 -0
- package/src/widgets/status-badge.tsx +51 -0
- package/src/widgets/use-draft.ts +40 -0
- package/src/ui/card.tsx +0 -93
- package/src/ui/collapsible.tsx +0 -34
- package/src/ui/dropdown-menu.tsx +0 -258
- package/src/ui/select.tsx +0 -191
- /package/src/__tests__/{download.test.ts → download.test.tsx} +0 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { defaultPrimitives } from "../primitives";
|
|
4
|
+
import { render, screen } from "./test-utils";
|
|
5
|
+
|
|
6
|
+
const { Lightbox } = defaultPrimitives;
|
|
7
|
+
|
|
8
|
+
describe("Lightbox", () => {
|
|
9
|
+
test("open=true renders image with src and alt", () => {
|
|
10
|
+
render(
|
|
11
|
+
<Lightbox
|
|
12
|
+
open
|
|
13
|
+
onOpenChange={() => undefined}
|
|
14
|
+
src="/demo.png"
|
|
15
|
+
alt="Product screenshot"
|
|
16
|
+
testId="lb"
|
|
17
|
+
/>,
|
|
18
|
+
);
|
|
19
|
+
const img = screen.getByRole("img", { name: "Product screenshot" });
|
|
20
|
+
expect(img.getAttribute("src")).toBe("/demo.png");
|
|
21
|
+
expect(screen.getByTestId("lb")).toBeTruthy();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("open=false renders nothing", () => {
|
|
25
|
+
render(
|
|
26
|
+
<Lightbox
|
|
27
|
+
open={false}
|
|
28
|
+
onOpenChange={() => undefined}
|
|
29
|
+
src="/demo.png"
|
|
30
|
+
alt="Hidden"
|
|
31
|
+
testId="lb-hidden"
|
|
32
|
+
/>,
|
|
33
|
+
);
|
|
34
|
+
expect(screen.queryByTestId("lb-hidden")).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("close button calls onOpenChange(false)", async () => {
|
|
38
|
+
const user = userEvent.setup();
|
|
39
|
+
const onOpenChange = mock();
|
|
40
|
+
render(
|
|
41
|
+
<Lightbox open onOpenChange={onOpenChange} src="/demo.png" alt="Preview" testId="lb-close" />,
|
|
42
|
+
);
|
|
43
|
+
await user.click(screen.getByLabelText("Close"));
|
|
44
|
+
expect(onOpenChange).toHaveBeenCalledWith(false);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -224,6 +224,66 @@ describe("NavTree", () => {
|
|
|
224
224
|
expect(container.querySelectorAll("svg").length).toBe(2);
|
|
225
225
|
});
|
|
226
226
|
|
|
227
|
+
test("tag + key (custom-fields / api-tokens) lösen auf ein Icon auf", () => {
|
|
228
|
+
const schema = {
|
|
229
|
+
featureName: "showcase",
|
|
230
|
+
entities: {},
|
|
231
|
+
screens: [
|
|
232
|
+
{ id: "fields", type: "entityList", entity: "x", columns: [] },
|
|
233
|
+
{ id: "tokens", type: "entityList", entity: "x", columns: [] },
|
|
234
|
+
],
|
|
235
|
+
navs: [
|
|
236
|
+
{ id: "fields", label: "Felder", screen: "fields", order: 10, icon: "tag" },
|
|
237
|
+
{ id: "tokens", label: "Tokens", screen: "tokens", order: 20, icon: "key" },
|
|
238
|
+
],
|
|
239
|
+
} as FeatureSchema;
|
|
240
|
+
const { container } = render(<NavTree schema={schema} />);
|
|
241
|
+
expect(container.querySelectorAll("svg").length).toBe(2);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("server, mail, download, rocket lösen auf ein Icon auf (Config-/SMTP-Nav)", () => {
|
|
245
|
+
// Config-Settings-Hub leitet Child-Nav-Icons aus dem mask.icon der
|
|
246
|
+
// ConfigKey ab (smtp-host="server", from="mail", subscription="rocket");
|
|
247
|
+
// fehlten sie in NAV_ICONS, rendert das Nav blank statt Icon.
|
|
248
|
+
const schema = {
|
|
249
|
+
featureName: "app",
|
|
250
|
+
entities: {},
|
|
251
|
+
screens: [
|
|
252
|
+
{ id: "a", type: "entityList", entity: "x", columns: [] },
|
|
253
|
+
{ id: "b", type: "entityList", entity: "x", columns: [] },
|
|
254
|
+
{ id: "c", type: "entityList", entity: "x", columns: [] },
|
|
255
|
+
{ id: "d", type: "entityList", entity: "x", columns: [] },
|
|
256
|
+
],
|
|
257
|
+
navs: [
|
|
258
|
+
{ id: "a", label: "SMTP", screen: "a", order: 10, icon: "server" },
|
|
259
|
+
{ id: "b", label: "From", screen: "b", order: 20, icon: "mail" },
|
|
260
|
+
{ id: "c", label: "Export", screen: "c", order: 30, icon: "download" },
|
|
261
|
+
{ id: "d", label: "Billing", screen: "d", order: 40, icon: "rocket" },
|
|
262
|
+
],
|
|
263
|
+
} as FeatureSchema;
|
|
264
|
+
const { container } = render(<NavTree schema={schema} />);
|
|
265
|
+
expect(container.querySelectorAll("svg").length).toBe(4);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("palette, link und share rendern Lucide-Icons (Share-/Branding-Nav)", () => {
|
|
269
|
+
const schema = {
|
|
270
|
+
featureName: "app",
|
|
271
|
+
entities: {},
|
|
272
|
+
screens: [
|
|
273
|
+
{ id: "branding", type: "entityList", entity: "x", columns: [] },
|
|
274
|
+
{ id: "links", type: "entityList", entity: "x", columns: [] },
|
|
275
|
+
{ id: "defaults", type: "entityList", entity: "x", columns: [] },
|
|
276
|
+
],
|
|
277
|
+
navs: [
|
|
278
|
+
{ id: "branding", label: "Branding", screen: "branding", order: 10, icon: "palette" },
|
|
279
|
+
{ id: "links", label: "Links", screen: "links", order: 20, icon: "link" },
|
|
280
|
+
{ id: "defaults", label: "Defaults", screen: "defaults", order: 30, icon: "share" },
|
|
281
|
+
],
|
|
282
|
+
} as FeatureSchema;
|
|
283
|
+
const { container } = render(<NavTree schema={schema} />);
|
|
284
|
+
expect(container.querySelectorAll("svg").length).toBe(3);
|
|
285
|
+
});
|
|
286
|
+
|
|
227
287
|
test("unbekannter icon-Key fällt sauber auf den Dot zurück (kein svg)", () => {
|
|
228
288
|
const schema = {
|
|
229
289
|
featureName: "showcase",
|
|
@@ -506,3 +566,65 @@ describe("NavTree dynamic provider nodes", () => {
|
|
|
506
566
|
expect(active()).toBe(0); // Unmount baut alles ab → kein Leak
|
|
507
567
|
});
|
|
508
568
|
});
|
|
569
|
+
|
|
570
|
+
describe("NavTree Suchfeld", () => {
|
|
571
|
+
// Das Suchfeld ist das einzige <input> im Baum; über die Rolle statt den
|
|
572
|
+
// i18n-Placeholder gefunden (i18n-Bundle resolvt im Worktree gegen den
|
|
573
|
+
// Haupt-Checkout, wo der neue Key noch fehlt — in CI greift er).
|
|
574
|
+
|
|
575
|
+
test("Filter blendet Nicht-Treffer aus, hält Treffer + deren Ancestors", () => {
|
|
576
|
+
render(<NavTree schema={makeSchema()} />);
|
|
577
|
+
const input = screen.getByRole("textbox") as HTMLInputElement;
|
|
578
|
+
|
|
579
|
+
fireEvent.change(input, { target: { value: "active" } });
|
|
580
|
+
|
|
581
|
+
// Treffer selbst sichtbar (case-insensitiv).
|
|
582
|
+
expect(screen.getByText("Active")).toBeTruthy();
|
|
583
|
+
// Nicht-Treffer-Geschwister weg.
|
|
584
|
+
expect(screen.queryByText("Backlog")).toBeNull();
|
|
585
|
+
// Ancestors bleiben, damit der Treffer erreichbar bleibt.
|
|
586
|
+
expect(screen.getByText("Items")).toBeTruthy();
|
|
587
|
+
expect(screen.getByText("Data")).toBeTruthy();
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
test("Filter ohne Treffer leert den Baum; Leeren stellt alles wieder her", () => {
|
|
591
|
+
render(<NavTree schema={makeSchema()} />);
|
|
592
|
+
const input = screen.getByRole("textbox") as HTMLInputElement;
|
|
593
|
+
|
|
594
|
+
fireEvent.change(input, { target: { value: "zzz-nichts" } });
|
|
595
|
+
expect(screen.queryByText("Items")).toBeNull();
|
|
596
|
+
expect(screen.queryByText("Data")).toBeNull();
|
|
597
|
+
|
|
598
|
+
fireEvent.change(input, { target: { value: "" } });
|
|
599
|
+
expect(screen.getByText("Backlog")).toBeTruthy();
|
|
600
|
+
expect(screen.getByText("Data")).toBeTruthy();
|
|
601
|
+
});
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
describe("NavTree folder-open Icon", () => {
|
|
605
|
+
test("Folder-Knoten zeigt folder-open wenn expanded, folder wenn collapsed", async () => {
|
|
606
|
+
const provider: TreeChildrenSubscribe = () => (emit) => {
|
|
607
|
+
emit([{ label: "page", icon: "folder", children: [pageLeaf("hero")] }]);
|
|
608
|
+
return () => {};
|
|
609
|
+
};
|
|
610
|
+
let container: HTMLElement | undefined;
|
|
611
|
+
await act(async () => {
|
|
612
|
+
container = renderDynamic({
|
|
613
|
+
schema: dynamicSchema(),
|
|
614
|
+
providers: new Map([["cms:nav:content", provider]]),
|
|
615
|
+
}).container;
|
|
616
|
+
});
|
|
617
|
+
// Default-expanded → offener Ordner, kein geschlossener.
|
|
618
|
+
expect(container?.querySelector(".lucide-folder-open")).toBeTruthy();
|
|
619
|
+
expect(container?.querySelector(".lucide-folder")).toBeNull();
|
|
620
|
+
|
|
621
|
+
// Der einzige aria-gelabelte Chevron gehört dem "page"-Sub-Ordner
|
|
622
|
+
// (der Provider-Container toggelt inline, ohne aria-Label).
|
|
623
|
+
const chevron = screen.getByRole("button", { name: /Expand|Collapse/ });
|
|
624
|
+
await act(async () => {
|
|
625
|
+
fireEvent.click(chevron);
|
|
626
|
+
});
|
|
627
|
+
expect(container?.querySelector(".lucide-folder")).toBeTruthy();
|
|
628
|
+
expect(container?.querySelector(".lucide-folder-open")).toBeNull();
|
|
629
|
+
});
|
|
630
|
+
});
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { describe, expect, mock, test } from "bun:test";
|
|
11
11
|
import userEvent from "@testing-library/user-event";
|
|
12
12
|
import { defaultPrimitives } from "../primitives";
|
|
13
|
+
import { PageSection, Stack } from "../primitives/layout";
|
|
13
14
|
import { fireEvent, render, screen } from "./test-utils";
|
|
14
15
|
|
|
15
16
|
const { Button, Banner, Field, Input, DataTable, Form, Text, Heading, Dialog, Card } =
|
|
@@ -979,7 +980,7 @@ describe("Card", () => {
|
|
|
979
980
|
<span>body</span>
|
|
980
981
|
</Card>,
|
|
981
982
|
);
|
|
982
|
-
expect(screen.getByTestId("c").innerHTML).toContain("p-
|
|
983
|
+
expect(screen.getByTestId("c").innerHTML).toContain("p-[var(--card-padding)]");
|
|
983
984
|
});
|
|
984
985
|
|
|
985
986
|
test("padded=false renders body without padding classes", () => {
|
|
@@ -1053,3 +1054,33 @@ describe("Card", () => {
|
|
|
1053
1054
|
expect(screen.getByTestId("c").innerHTML).not.toContain("grow");
|
|
1054
1055
|
});
|
|
1055
1056
|
});
|
|
1057
|
+
|
|
1058
|
+
describe("Stack", () => {
|
|
1059
|
+
test("gap-Variante bildet auf die Tailwind-gap-Klasse ab", () => {
|
|
1060
|
+
render(
|
|
1061
|
+
<Stack testId="s" gap="lg">
|
|
1062
|
+
<span>a</span>
|
|
1063
|
+
</Stack>,
|
|
1064
|
+
);
|
|
1065
|
+
const el = screen.getByTestId("s");
|
|
1066
|
+
expect(el.className).toContain("flex flex-col");
|
|
1067
|
+
expect(el.className).toContain("gap-6");
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
test("default gap = md", () => {
|
|
1071
|
+
render(<Stack testId="s">x</Stack>);
|
|
1072
|
+
expect(screen.getByTestId("s").className).toContain("gap-4");
|
|
1073
|
+
});
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
describe("PageSection", () => {
|
|
1077
|
+
test("wrappt children mit einheitlichem Padding", () => {
|
|
1078
|
+
render(
|
|
1079
|
+
<PageSection testId="p">
|
|
1080
|
+
<span data-testid="child">x</span>
|
|
1081
|
+
</PageSection>,
|
|
1082
|
+
);
|
|
1083
|
+
expect(screen.getByTestId("p").className).toContain("p-6");
|
|
1084
|
+
expect(screen.getByTestId("child")).toBeDefined();
|
|
1085
|
+
});
|
|
1086
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import { ProfileMenu } from "../layout/profile-menu";
|
|
5
|
+
|
|
6
|
+
describe("ProfileMenu", () => {
|
|
7
|
+
test("wraps Avatar trigger and opens ActionMenu items", async () => {
|
|
8
|
+
const user = userEvent.setup();
|
|
9
|
+
const onSelect = mock(() => {});
|
|
10
|
+
render(
|
|
11
|
+
<ProfileMenu
|
|
12
|
+
user={{ id: "u1", label: "Ada Lovelace" }}
|
|
13
|
+
items={[
|
|
14
|
+
{ kind: "item", id: "profile", label: "View profile", onSelect },
|
|
15
|
+
{ kind: "separator" },
|
|
16
|
+
{ kind: "item", id: "out", label: "Sign out", variant: "danger", onSelect: () => {} },
|
|
17
|
+
]}
|
|
18
|
+
/>,
|
|
19
|
+
);
|
|
20
|
+
const trigger = screen.getByTestId("profile-menu-trigger");
|
|
21
|
+
expect(trigger.getAttribute("aria-label")).toBe("Open Ada Lovelace menu");
|
|
22
|
+
await user.click(trigger);
|
|
23
|
+
await waitFor(() => {
|
|
24
|
+
expect(screen.getByTestId("action-menu-item-profile")).toBeTruthy();
|
|
25
|
+
});
|
|
26
|
+
await user.click(screen.getByTestId("action-menu-item-profile"));
|
|
27
|
+
expect(onSelect).toHaveBeenCalled();
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Unit-Tests für den projectionDetail-Screen-Type (read-only single-row
|
|
2
|
+
// inspector, kumiko-framework#255). Deckt den Pfad ab, der Integration/E2E
|
|
3
|
+
// nicht prüft (e2e-generator skippt projectionDetail explizit — kein CRUD):
|
|
4
|
+
// - Row wird über idParam gefetcht, Felder zeigen die Query-Response-Werte
|
|
5
|
+
// - jedes Feld ist readOnly, kein Submit-Button (hasEditableSection=false)
|
|
6
|
+
// - fehlende entityId → Error-Banner statt Crash
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
import type { ProjectionDetailScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
10
|
+
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
11
|
+
import type { FeatureSchema } from "@cosmicdrift/kumiko-renderer";
|
|
12
|
+
import { DispatcherProvider, KumikoScreen } from "@cosmicdrift/kumiko-renderer";
|
|
13
|
+
import { act, createMockDispatcher, render, screen, waitFor } from "./test-utils";
|
|
14
|
+
|
|
15
|
+
const detailScreen: ProjectionDetailScreenDefinition = {
|
|
16
|
+
id: "session-detail",
|
|
17
|
+
type: "projectionDetail",
|
|
18
|
+
query: "sessions:query:user-session:detail",
|
|
19
|
+
idParam: "id",
|
|
20
|
+
layout: {
|
|
21
|
+
sections: [{ title: "Session", fields: ["userId", "createdAt"] }],
|
|
22
|
+
},
|
|
23
|
+
fieldLabels: {
|
|
24
|
+
userId: "sessions.detail.field.userId",
|
|
25
|
+
createdAt: "sessions.detail.field.createdAt",
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const schema: FeatureSchema = {
|
|
30
|
+
featureName: "sessions",
|
|
31
|
+
entities: {},
|
|
32
|
+
screens: [detailScreen],
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
describe("KumikoScreen / projectionDetail", () => {
|
|
36
|
+
test("fetches the row via idParam and renders its fields read-only, no submit button", async () => {
|
|
37
|
+
const querySpy = (async (_qn: string, payload: unknown) => {
|
|
38
|
+
expect(payload).toEqual({ id: "sess-1" });
|
|
39
|
+
return {
|
|
40
|
+
isSuccess: true,
|
|
41
|
+
data: { userId: "user-42", createdAt: "2026-07-01T00:00:00Z" },
|
|
42
|
+
};
|
|
43
|
+
}) as unknown as Dispatcher["query"];
|
|
44
|
+
const dispatcher: Dispatcher = createMockDispatcher({ query: querySpy });
|
|
45
|
+
|
|
46
|
+
render(
|
|
47
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
48
|
+
<KumikoScreen schema={schema} qn="sessions:screen:session-detail" entityId="sess-1" />
|
|
49
|
+
</DispatcherProvider>,
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
await waitFor(() => screen.getByTestId("render-edit-form"));
|
|
53
|
+
const userIdInput = screen.getByTestId("field-userId").querySelector("input");
|
|
54
|
+
expect(userIdInput?.value).toBe("user-42");
|
|
55
|
+
expect(userIdInput?.disabled).toBe(true);
|
|
56
|
+
|
|
57
|
+
// hasEditableSection() reads readOnly on every field — projectionDetail
|
|
58
|
+
// forces it hard in the shim, so RenderEdit must never draw a Save button.
|
|
59
|
+
expect(screen.queryByTestId("render-edit-submit")).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("missing entityId shows an error banner instead of crashing", async () => {
|
|
63
|
+
let resolveQuery: (value: unknown) => void = () => {};
|
|
64
|
+
const dispatcher: Dispatcher = createMockDispatcher({
|
|
65
|
+
query: (() =>
|
|
66
|
+
new Promise((resolve) => {
|
|
67
|
+
resolveQuery = resolve;
|
|
68
|
+
})) as unknown as Dispatcher["query"],
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
render(
|
|
72
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
73
|
+
<KumikoScreen schema={schema} qn="sessions:screen:session-detail" />
|
|
74
|
+
</DispatcherProvider>,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
expect(screen.getByTestId("kumiko-screen-projection-detail-missing-id")).toBeTruthy();
|
|
78
|
+
|
|
79
|
+
// Screen skips the record entirely without entityId, but useQuery's
|
|
80
|
+
// effect still fired (unconditional hook call) — settle it so its async
|
|
81
|
+
// setState doesn't land after the test unmounts.
|
|
82
|
+
await act(async () => {
|
|
83
|
+
resolveQuery({ isSuccess: true, data: {} });
|
|
84
|
+
await Promise.resolve();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("record not found shows an error banner", async () => {
|
|
89
|
+
const dispatcher: Dispatcher = createMockDispatcher({
|
|
90
|
+
query: (async () => ({ isSuccess: true, data: null })) as unknown as Dispatcher["query"],
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
render(
|
|
94
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
95
|
+
<KumikoScreen schema={schema} qn="sessions:screen:session-detail" entityId="sess-missing" />
|
|
96
|
+
</DispatcherProvider>,
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
await waitFor(() => screen.getByTestId("kumiko-screen-record-missing"));
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import type { ProjectionListScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
3
|
+
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
4
|
+
import type { FeatureSchema } from "@cosmicdrift/kumiko-renderer";
|
|
5
|
+
import { DispatcherProvider, KumikoScreen } from "@cosmicdrift/kumiko-renderer";
|
|
6
|
+
import { createMockDispatcher, fireEvent, render, screen, waitFor } from "./test-utils";
|
|
7
|
+
|
|
8
|
+
// writeHandler-Row/Toolbar-Actions auf projectionList — der entityList-
|
|
9
|
+
// Dispatch-Pfad gilt jetzt auch hier (vorher v1: nur navigate).
|
|
10
|
+
|
|
11
|
+
const projectionScreen: ProjectionListScreenDefinition = {
|
|
12
|
+
id: "maintenance-list",
|
|
13
|
+
type: "projectionList",
|
|
14
|
+
query: "status:query:maintenance:upcoming",
|
|
15
|
+
columns: [{ field: "name", label: "status:col:name" }],
|
|
16
|
+
rowActions: [
|
|
17
|
+
{
|
|
18
|
+
kind: "writeHandler",
|
|
19
|
+
id: "start",
|
|
20
|
+
label: "status:action:start",
|
|
21
|
+
handler: "status:write:maintenance:start",
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
toolbarActions: [
|
|
25
|
+
{
|
|
26
|
+
kind: "writeHandler",
|
|
27
|
+
id: "sync",
|
|
28
|
+
label: "status:action:sync",
|
|
29
|
+
handler: "status:write:maintenance:sync",
|
|
30
|
+
payload: { source: "manual" },
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const schema: FeatureSchema = {
|
|
36
|
+
featureName: "status",
|
|
37
|
+
entities: {},
|
|
38
|
+
screens: [projectionScreen],
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function makeDispatcher(write: Dispatcher["write"]): Dispatcher {
|
|
42
|
+
return {
|
|
43
|
+
...createMockDispatcher({
|
|
44
|
+
query: (async () => ({
|
|
45
|
+
isSuccess: true,
|
|
46
|
+
data: { rows: [{ id: "m1", name: "DB-Upgrade" }], nextCursor: null },
|
|
47
|
+
})) as unknown as Dispatcher["query"],
|
|
48
|
+
}),
|
|
49
|
+
write,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe("projectionList writeHandler-Actions", () => {
|
|
54
|
+
test("Row-Action dispatcht den Handler mit Default-Payload {id}", async () => {
|
|
55
|
+
const write = mock(async (_type: string, _payload: unknown) => ({
|
|
56
|
+
isSuccess: true,
|
|
57
|
+
data: {},
|
|
58
|
+
}));
|
|
59
|
+
render(
|
|
60
|
+
<DispatcherProvider dispatcher={makeDispatcher(write as unknown as Dispatcher["write"])}>
|
|
61
|
+
<KumikoScreen schema={schema} qn="status:screen:maintenance-list" />
|
|
62
|
+
</DispatcherProvider>,
|
|
63
|
+
);
|
|
64
|
+
await waitFor(() => expect(screen.getByText("DB-Upgrade")).toBeTruthy());
|
|
65
|
+
|
|
66
|
+
fireEvent.click(screen.getByRole("button", { name: "status:action:start" }));
|
|
67
|
+
await waitFor(() => expect(write).toHaveBeenCalledTimes(1));
|
|
68
|
+
expect(write.mock.calls[0]?.[0]).toBe("status:write:maintenance:start");
|
|
69
|
+
expect(write.mock.calls[0]?.[1]).toEqual({ id: "m1" });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("Toolbar-Action dispatcht den Handler mit deklariertem Payload", async () => {
|
|
73
|
+
const write = mock(async (_type: string, _payload: unknown) => ({
|
|
74
|
+
isSuccess: true,
|
|
75
|
+
data: {},
|
|
76
|
+
}));
|
|
77
|
+
render(
|
|
78
|
+
<DispatcherProvider dispatcher={makeDispatcher(write as unknown as Dispatcher["write"])}>
|
|
79
|
+
<KumikoScreen schema={schema} qn="status:screen:maintenance-list" />
|
|
80
|
+
</DispatcherProvider>,
|
|
81
|
+
);
|
|
82
|
+
await waitFor(() => expect(screen.getByText("DB-Upgrade")).toBeTruthy());
|
|
83
|
+
|
|
84
|
+
fireEvent.click(screen.getByRole("button", { name: "status:action:sync" }));
|
|
85
|
+
await waitFor(() => expect(write).toHaveBeenCalledTimes(1));
|
|
86
|
+
expect(write.mock.calls[0]?.[0]).toBe("status:write:maintenance:sync");
|
|
87
|
+
expect(write.mock.calls[0]?.[1]).toEqual({ source: "manual" });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Prod-Bug 2026-06-07 (siehe useRowActionTrigger): ein verschluckter
|
|
91
|
+
// Write-Fehler sah für den User wie "nichts passiert" aus. Row- UND
|
|
92
|
+
// Toolbar-Action auf projectionList müssen denselben Surfacing-Pfad wie
|
|
93
|
+
// entityList nehmen (Toast statt stiller no-op).
|
|
94
|
+
test("Row-Action-Fehler wird als Toast surfaced, nicht verschluckt", async () => {
|
|
95
|
+
const write = mock(async (_type: string, _payload: unknown) => ({
|
|
96
|
+
isSuccess: false,
|
|
97
|
+
error: { code: "internal_error", httpStatus: 500, message: "maintenance start failed" },
|
|
98
|
+
}));
|
|
99
|
+
const { ToastProvider } = await import("../primitives/toast");
|
|
100
|
+
render(
|
|
101
|
+
<ToastProvider>
|
|
102
|
+
<DispatcherProvider dispatcher={makeDispatcher(write as unknown as Dispatcher["write"])}>
|
|
103
|
+
<KumikoScreen schema={schema} qn="status:screen:maintenance-list" />
|
|
104
|
+
</DispatcherProvider>
|
|
105
|
+
</ToastProvider>,
|
|
106
|
+
);
|
|
107
|
+
await waitFor(() => expect(screen.getByText("DB-Upgrade")).toBeTruthy());
|
|
108
|
+
|
|
109
|
+
fireEvent.click(screen.getByRole("button", { name: "status:action:start" }));
|
|
110
|
+
expect(await screen.findByText("maintenance start failed")).toBeTruthy();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("Toolbar-Action-Fehler wird als Toast surfaced, nicht verschluckt", async () => {
|
|
114
|
+
const write = mock(async (_type: string, _payload: unknown) => ({
|
|
115
|
+
isSuccess: false,
|
|
116
|
+
error: { code: "internal_error", httpStatus: 500, message: "maintenance sync failed" },
|
|
117
|
+
}));
|
|
118
|
+
const { ToastProvider } = await import("../primitives/toast");
|
|
119
|
+
render(
|
|
120
|
+
<ToastProvider>
|
|
121
|
+
<DispatcherProvider dispatcher={makeDispatcher(write as unknown as Dispatcher["write"])}>
|
|
122
|
+
<KumikoScreen schema={schema} qn="status:screen:maintenance-list" />
|
|
123
|
+
</DispatcherProvider>
|
|
124
|
+
</ToastProvider>,
|
|
125
|
+
);
|
|
126
|
+
await waitFor(() => expect(screen.getByText("DB-Upgrade")).toBeTruthy());
|
|
127
|
+
|
|
128
|
+
fireEvent.click(screen.getByRole("button", { name: "status:action:sync" }));
|
|
129
|
+
expect(await screen.findByText("maintenance sync failed")).toBeTruthy();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, mock, spyOn, test } from "bun:test";
|
|
1
|
+
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
|
|
2
2
|
import type {
|
|
3
3
|
EntityDefinition,
|
|
4
4
|
EntityListScreenDefinition,
|
|
@@ -188,13 +188,21 @@ describe("RenderList — slots.header", () => {
|
|
|
188
188
|
});
|
|
189
189
|
|
|
190
190
|
test("slots.header gesetzt, aber Component nicht registriert → kein Crash, kein Header", () => {
|
|
191
|
-
|
|
191
|
+
spyOn(console, "warn").mockImplementation(() => {});
|
|
192
192
|
render(
|
|
193
193
|
<ExtensionSectionsProvider value={{}}>
|
|
194
194
|
<RenderList screen={screenWithHeader} entity={taskEntity} rows={[]} featureName="tasks" />
|
|
195
195
|
</ExtensionSectionsProvider>,
|
|
196
196
|
);
|
|
197
197
|
expect(screen.queryByTestId("list-header-slot")).toBeNull();
|
|
198
|
-
warn.mockRestore();
|
|
199
198
|
});
|
|
200
199
|
});
|
|
200
|
+
|
|
201
|
+
// 573/1: a mid-test assertion throw would skip the trailing warn.mockRestore()
|
|
202
|
+
// above, leaking the spy into every later test — this backstop restores
|
|
203
|
+
// unconditionally regardless of how the test exited.
|
|
204
|
+
afterEach(() => {
|
|
205
|
+
// biome-ignore lint/suspicious/noConsole: reading the spy handle to restore it, not logging
|
|
206
|
+
const warn = console.warn as unknown as { mockRestore?: () => void };
|
|
207
|
+
warn.mockRestore?.();
|
|
208
|
+
});
|
|
@@ -157,6 +157,7 @@ export function createMockDispatcher(options: MockDispatcherOptions = {}): Dispa
|
|
|
157
157
|
options.batch ??
|
|
158
158
|
((async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"]),
|
|
159
159
|
statusStore,
|
|
160
|
+
async *stream() {},
|
|
160
161
|
pendingWrites: () => [],
|
|
161
162
|
pendingFiles: () => [],
|
|
162
163
|
};
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
//
|
|
2
2
|
// ToastProvider + useToast pinnt: toast() rendert Title+Description in
|
|
3
|
-
// einem Radix-Toast; mehrere toasts stapeln; Variant=
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// einem Radix-Toast; mehrere toasts stapeln; Variant=bad setzt die
|
|
4
|
+
// StatusTone-Klasse; useToast außerhalb des Providers ist no-op (kein
|
|
5
|
+
// crash); IDs sind kollisionsfrei auch bei zwei Calls im selben Tick
|
|
6
|
+
// (Counter-Race-Bug).
|
|
7
7
|
|
|
8
8
|
import { describe, expect, test } from "bun:test";
|
|
9
9
|
import { act, fireEvent, screen } from "@testing-library/react";
|
|
@@ -49,7 +49,7 @@ describe("ToastProvider + useToast", () => {
|
|
|
49
49
|
options={[
|
|
50
50
|
{
|
|
51
51
|
title: "Konflikt",
|
|
52
|
-
variant: "
|
|
52
|
+
variant: "bad",
|
|
53
53
|
docsUrl: "https://docs.kumiko.rocks/errors/stale_state",
|
|
54
54
|
},
|
|
55
55
|
]}
|
|
@@ -88,18 +88,18 @@ describe("ToastProvider + useToast", () => {
|
|
|
88
88
|
expect(screen.queryByRole("link")).toBeNull();
|
|
89
89
|
});
|
|
90
90
|
|
|
91
|
-
test("variant=
|
|
91
|
+
test("variant=bad: setzt die status-bad StatusTone-Klasse auf den Root", () => {
|
|
92
92
|
render(
|
|
93
93
|
<ToastProvider>
|
|
94
|
-
<ToastTrigger options={[{ title: "Failed", variant: "
|
|
94
|
+
<ToastTrigger options={[{ title: "Failed", variant: "bad" }]} />
|
|
95
95
|
</ToastProvider>,
|
|
96
96
|
);
|
|
97
97
|
// Class-Mapping ist der Public-Vertrag mit Tailwind-Tokens. Wir
|
|
98
98
|
// suchen den nearest Ancestor des Title-Knotens dessen Klassen-
|
|
99
|
-
// String "
|
|
99
|
+
// String "status-bad" enthält — Radix-Toast.Root rendert in einem
|
|
100
100
|
// <li>, aber die genaue Role wechselt je nach priority/type.
|
|
101
101
|
let node: HTMLElement | null = screen.getByText("Failed");
|
|
102
|
-
while (node !== null && !node.className.includes("
|
|
102
|
+
while (node !== null && !node.className.includes("status-bad")) {
|
|
103
103
|
node = node.parentElement;
|
|
104
104
|
}
|
|
105
105
|
expect(node).not.toBeNull();
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
3
|
+
import { DispatcherProvider, useMutation } from "@cosmicdrift/kumiko-renderer";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
import { act, createMockDispatcher, renderHook, waitFor } from "./test-utils";
|
|
6
|
+
|
|
7
|
+
function wrap(dispatcher: Dispatcher) {
|
|
8
|
+
return ({ children }: { children: ReactNode }) => (
|
|
9
|
+
<DispatcherProvider dispatcher={dispatcher}>{children}</DispatcherProvider>
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("useMutation", () => {
|
|
14
|
+
// #902/2: two overlapping calls on one instance (list-row actions sharing
|
|
15
|
+
// it) must not let the earlier-STARTED, later-RESOLVING call clobber the
|
|
16
|
+
// state set by the later-started, earlier-resolving one.
|
|
17
|
+
test("out-of-order resolution: state reflects the most recently started call, not the most recently resolved one", async () => {
|
|
18
|
+
let resolveFirst: (() => void) | undefined;
|
|
19
|
+
const write = mock(async (_type: string, payload: unknown) => {
|
|
20
|
+
if ((payload as { id: string }).id === "row-1") {
|
|
21
|
+
await new Promise<void>((resolve) => {
|
|
22
|
+
resolveFirst = resolve;
|
|
23
|
+
});
|
|
24
|
+
return { isSuccess: true, data: { id: "row-1" } };
|
|
25
|
+
}
|
|
26
|
+
return { isSuccess: true, data: { id: "row-2" } };
|
|
27
|
+
});
|
|
28
|
+
const dispatcher = createMockDispatcher({ write: write as unknown as Dispatcher["write"] });
|
|
29
|
+
const { result } = renderHook(() => useMutation("row:update"), {
|
|
30
|
+
wrapper: wrap(dispatcher),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
let firstCallDone: Promise<unknown>;
|
|
34
|
+
let secondCallDone: Promise<unknown>;
|
|
35
|
+
act(() => {
|
|
36
|
+
firstCallDone = result.current.mutate({ id: "row-1" });
|
|
37
|
+
});
|
|
38
|
+
await act(async () => {
|
|
39
|
+
secondCallDone = result.current.mutate({ id: "row-2" });
|
|
40
|
+
// let the second (no-delay) call resolve before the first
|
|
41
|
+
await Promise.resolve();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
await waitFor(() => expect(result.current.data).toEqual({ id: "row-2" }));
|
|
45
|
+
expect(result.current.pending).toBe(false); // row-2 (the latest call) already settled
|
|
46
|
+
|
|
47
|
+
await act(async () => {
|
|
48
|
+
resolveFirst?.();
|
|
49
|
+
await firstCallDone;
|
|
50
|
+
await secondCallDone;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// The first call resolving late must not overwrite row-2's state.
|
|
54
|
+
expect(result.current.data).toEqual({ id: "row-2" });
|
|
55
|
+
expect(result.current.pending).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("reset() invalidates a still-pending call's late state update", async () => {
|
|
59
|
+
let resolveWrite: (() => void) | undefined;
|
|
60
|
+
const write = mock(async () => {
|
|
61
|
+
await new Promise<void>((resolve) => {
|
|
62
|
+
resolveWrite = resolve;
|
|
63
|
+
});
|
|
64
|
+
return { isSuccess: true, data: { id: "late" } };
|
|
65
|
+
});
|
|
66
|
+
const dispatcher = createMockDispatcher({ write: write as unknown as Dispatcher["write"] });
|
|
67
|
+
const { result } = renderHook(() => useMutation("row:update"), {
|
|
68
|
+
wrapper: wrap(dispatcher),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
let mutateDone: Promise<unknown>;
|
|
72
|
+
act(() => {
|
|
73
|
+
mutateDone = result.current.mutate({ id: "x" });
|
|
74
|
+
});
|
|
75
|
+
act(() => {
|
|
76
|
+
result.current.reset();
|
|
77
|
+
});
|
|
78
|
+
await act(async () => {
|
|
79
|
+
resolveWrite?.();
|
|
80
|
+
await mutateDone;
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(result.current.data).toBeNull();
|
|
84
|
+
expect(result.current.pending).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
});
|