@cosmicdrift/kumiko-renderer-web 0.233.0 → 0.234.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.
@@ -129,6 +129,69 @@ describe("KumikoScreen / projectionDetail — record header + metrics band", ()
129
129
  expect(screen.queryByTestId("kumiko-screen-projection-detail-title")).toBeNull();
130
130
  expect(screen.queryByTestId("kumiko-screen-projection-detail-metrics")).toBeNull();
131
131
  });
132
+
133
+ test("colors the status badge via the StatusTone heuristic for a recognized value", async () => {
134
+ const headerScreen: ProjectionDetailScreenDefinition = {
135
+ ...baseScreen,
136
+ header: { title: "tenantName", status: "state" },
137
+ };
138
+ const dispatcher = dispatcherReturning({ ...rowData, state: "overdue" });
139
+
140
+ render(
141
+ <DispatcherProvider dispatcher={dispatcher}>
142
+ <KumikoScreen
143
+ schema={schemaFor(headerScreen)}
144
+ qn="rentals:screen:rent-detail"
145
+ entityId="rent-1"
146
+ />
147
+ </DispatcherProvider>,
148
+ );
149
+
150
+ await waitFor(() => screen.getByTestId("render-edit-form"));
151
+ expect(screen.getByTestId("kumiko-screen-projection-detail-status").className).toContain(
152
+ "text-status-bad",
153
+ );
154
+ });
155
+
156
+ test("an unmapped status value falls back to the muted tone", async () => {
157
+ const headerScreen: ProjectionDetailScreenDefinition = {
158
+ ...baseScreen,
159
+ header: { title: "tenantName", status: "state" },
160
+ };
161
+ const dispatcher = dispatcherReturning({ ...rowData, state: "archived" });
162
+
163
+ render(
164
+ <DispatcherProvider dispatcher={dispatcher}>
165
+ <KumikoScreen
166
+ schema={schemaFor(headerScreen)}
167
+ qn="rentals:screen:rent-detail"
168
+ entityId="rent-1"
169
+ />
170
+ </DispatcherProvider>,
171
+ );
172
+
173
+ await waitFor(() => screen.getByTestId("render-edit-form"));
174
+ expect(screen.getByTestId("kumiko-screen-projection-detail-status").className).toContain(
175
+ "text-muted-foreground",
176
+ );
177
+ });
178
+
179
+ test("read-only detail screens render no footer action bar", async () => {
180
+ const dispatcher = dispatcherReturning(rowData);
181
+
182
+ render(
183
+ <DispatcherProvider dispatcher={dispatcher}>
184
+ <KumikoScreen
185
+ schema={schemaFor(baseScreen)}
186
+ qn="rentals:screen:rent-detail"
187
+ entityId="rent-1"
188
+ />
189
+ </DispatcherProvider>,
190
+ );
191
+
192
+ await waitFor(() => screen.getByTestId("render-edit-form"));
193
+ expect(screen.queryByTestId("render-edit-form-actions")).toBeNull();
194
+ });
132
195
  });
133
196
 
134
197
  describe("KumikoScreen / projectionDetail — metric tiles render through the Metric primitive", () => {
@@ -399,6 +462,30 @@ describe("KumikoScreen / projectionDetail — layout.mode: 'tabs'", () => {
399
462
 
400
463
  expect(setSearchParamsCalls).toContainEqual({ tab: "payments" });
401
464
  });
465
+
466
+ test("relatedList tab content renders without its own Section card wrapper", async () => {
467
+ const dispatcher = dispatcherReturning(rowData);
468
+ const { navApi } = navWithTab("payments");
469
+
470
+ render(
471
+ <NavProvider value={navApi}>
472
+ <DispatcherProvider dispatcher={dispatcher}>
473
+ <KumikoScreen
474
+ schema={schemaFor(tabsScreen)}
475
+ qn="rentals:screen:rent-detail"
476
+ entityId="rent-1"
477
+ />
478
+ </DispatcherProvider>
479
+ </NavProvider>,
480
+ );
481
+
482
+ await waitFor(() =>
483
+ expect(dispatcher.calls.some((c) => c.type === "rentals:query:rent:payments")).toBe(true),
484
+ );
485
+ // hideTitle (tabs mode) drops the Section wrapper — DataTable already
486
+ // draws its own card frame, so a second nested Card would double it up.
487
+ expect(screen.queryByTestId("related-list-Payments")).toBeNull();
488
+ });
402
489
  });
403
490
 
404
491
  // Only guard against a solon-shaped screen (relatedList-heavy) silently regressing.
@@ -448,6 +535,10 @@ describe("KumikoScreen / projectionDetail — unchanged for a solon-shaped scree
448
535
  // Contrast for the tabs-mode "form title suppressed" test above — outside
449
536
  // tabs mode (hideSectionTitles unset) the form's own title still renders.
450
537
  expect(screen.getByTestId("render-edit-form-title")).toBeTruthy();
538
+ // Contrast for the tabs-mode "no Section wrapper" test above — a stacked
539
+ // (non-tabs) relatedList section has a visible title, so it keeps its
540
+ // own Section card.
541
+ expect(screen.getByTestId("related-list-Payments")).toBeTruthy();
451
542
  });
452
543
  });
453
544
 
@@ -119,8 +119,8 @@ describe("RenderEdit", () => {
119
119
  // notes is hidden (isUrgent=false): title, count, isUrgent → 3 cells.
120
120
  expect(grid?.children.length).toBe(3);
121
121
 
122
- const urgentCheckbox = screen.getByTestId("field-isUrgent").querySelector('[role="checkbox"]');
123
- fireEvent.click(urgentCheckbox as HTMLElement);
122
+ const urgentSwitch = screen.getByTestId("field-isUrgent").querySelector('[role="switch"]');
123
+ fireEvent.click(urgentSwitch as HTMLElement);
124
124
 
125
125
  // notes becomes visible → 4 cells, no leftover empty cell from before.
126
126
  expect(grid?.children.length).toBe(4);
@@ -240,10 +240,9 @@ describe("RenderEdit", () => {
240
240
  );
241
241
 
242
242
  expect(screen.queryByTestId("field-notes")).toBeNull();
243
- // boolean-Feld = vendored Radix-Checkbox → button[role=checkbox], kein
244
- // native input[type=checkbox] mehr.
245
- const urgentCheckbox = screen.getByTestId("field-isUrgent").querySelector('[role="checkbox"]');
246
- fireEvent.click(urgentCheckbox as HTMLElement);
243
+ // boolean field in a standard form = vendored Radix Switch → button[role=switch].
244
+ const urgentSwitch = screen.getByTestId("field-isUrgent").querySelector('[role="switch"]');
245
+ fireEvent.click(urgentSwitch as HTMLElement);
247
246
  expect(screen.queryByTestId("field-notes")).toBeTruthy();
248
247
  });
249
248
 
@@ -284,7 +283,7 @@ describe("RenderEdit", () => {
284
283
  expect(seenResults[0]?.isSuccess).toBe(true);
285
284
  });
286
285
 
287
- test("layout.width defaults the form shell to max-w-full when unset", () => {
286
+ test("layout.width defaults the form shell to max-w-4xl when unset", () => {
288
287
  render(
289
288
  <DispatcherProvider dispatcher={makeDispatcher()}>
290
289
  <RenderEdit<TestValues>
@@ -298,7 +297,7 @@ describe("RenderEdit", () => {
298
297
  );
299
298
 
300
299
  const shell = screen.getByTestId("render-edit-form").firstElementChild;
301
- expect(shell?.className).toContain("max-w-full");
300
+ expect(shell?.className).toContain("max-w-4xl");
302
301
  expect(shell?.className).not.toContain("max-w-3xl");
303
302
  });
304
303
 
@@ -2858,8 +2857,8 @@ describe("RenderEdit locked state (#1896)", () => {
2858
2857
  expect((titleInput as HTMLInputElement).disabled).toBe(true);
2859
2858
  const countInput = screen.getByTestId("field-count").querySelector("input");
2860
2859
  expect((countInput as HTMLInputElement).disabled).toBe(true);
2861
- const urgentCheckbox = screen.getByTestId("field-isUrgent").querySelector('[role="checkbox"]');
2862
- expect((urgentCheckbox as HTMLButtonElement).disabled).toBe(true);
2860
+ const urgentSwitch = screen.getByTestId("field-isUrgent").querySelector('[role="switch"]');
2861
+ expect((urgentSwitch as HTMLButtonElement).disabled).toBe(true);
2863
2862
  expect((screen.getByTestId("render-edit-submit") as HTMLButtonElement).disabled).toBe(true);
2864
2863
  });
2865
2864
 
@@ -440,13 +440,12 @@ describe("WorkspaceShell", () => {
440
440
  expect(screen.queryByText("Tours")).toBeNull();
441
441
  });
442
442
 
443
- test("fill=true applies h-svh on root and min-h-0 on inset/main", () => {
443
+ test("fill defaults to true: applies h-svh on root and min-h-0 on inset/main", () => {
444
444
  renderShell(
445
445
  <WorkspaceShell
446
446
  brand={<div>Brand</div>}
447
447
  schema={schema}
448
448
  user={{ id: "u1", roles: ["admin"] }}
449
- fill
450
449
  >
451
450
  <div>content</div>
452
451
  </WorkspaceShell>,
@@ -462,13 +461,15 @@ describe("WorkspaceShell", () => {
462
461
  expect(innerMain?.classList.contains("overflow-auto")).toBe(true);
463
462
  });
464
463
 
465
- // #1692: default (no fill) must not force viewport-lock classes
466
- test("without fill does not apply h-svh or min-h-0", () => {
464
+ // fill={false} is the escape hatch back to page-scroll: must not apply
465
+ // viewport-lock classes.
466
+ test("fill={false} does not apply h-svh or min-h-0", () => {
467
467
  renderShell(
468
468
  <WorkspaceShell
469
469
  brand={<div>Brand</div>}
470
470
  schema={schema}
471
471
  user={{ id: "u1", roles: ["admin"] }}
472
+ fill={false}
472
473
  >
473
474
  <div>content</div>
474
475
  </WorkspaceShell>,
package/src/icons.tsx CHANGED
@@ -1,5 +1,20 @@
1
+ // Icon registry: symbolic `NavIconKey` → lucide-react component. `NavIconKey`
2
+ // is the closed vocabulary a feature author can write (packages/types/src/
3
+ // nav-icon.ts); `satisfies` below makes this map a compile-time drift
4
+ // guard — a key added to one without the other fails the build.
5
+ //
6
+ // Runtime data (provider-supplied nav/action icons) is only typed as plain
7
+ // `string` at the resolved-tree layer, so an unknown key can still show up
8
+ // at runtime on occasion — `Icon` falls back to rendering the raw key as
9
+ // text rather than crashing, mirroring the old `ActionGlyph` behavior in
10
+ // nav-tree.tsx.
11
+ //
12
+ // Buttons resolve icons through the same map — importing it from the nav
13
+ // layout module would couple them to the sidebar.
1
14
  import type { NavIconKey } from "@cosmicdrift/kumiko-framework/ui-types";
2
15
  import {
16
+ AlertTriangle,
17
+ Archive,
3
18
  ArrowLeft,
4
19
  ArrowRight,
5
20
  BarChart3,
@@ -9,17 +24,27 @@ import {
9
24
  Calculator,
10
25
  CalendarDays,
11
26
  Check,
27
+ CheckCircle2,
28
+ ChevronDown,
29
+ ChevronRight,
12
30
  ClipboardList,
31
+ Clock,
13
32
  Coins,
14
33
  Copy,
15
34
  CreditCard,
16
35
  Download,
36
+ ExternalLink,
37
+ Eye,
38
+ EyeOff,
17
39
  FileText,
40
+ Filter,
41
+ Flag,
18
42
  Folder,
19
43
  FolderOpen,
20
44
  Gauge,
21
45
  Hash,
22
46
  Home,
47
+ Info,
23
48
  KeyRound,
24
49
  Languages,
25
50
  Layers,
@@ -28,14 +53,23 @@ import {
28
53
  LineChart,
29
54
  Link,
30
55
  List,
56
+ Loader2,
31
57
  Lock,
32
58
  Mail,
59
+ MapPin,
60
+ MoreHorizontal,
61
+ MoreVertical,
33
62
  Package,
34
63
  Palette,
64
+ Pencil,
65
+ Phone,
35
66
  PiggyBank,
36
67
  Plus,
68
+ Printer,
37
69
  Receipt,
70
+ RefreshCw,
38
71
  Rocket,
72
+ Save,
39
73
  Search,
40
74
  Send,
41
75
  Server,
@@ -44,19 +78,23 @@ import {
44
78
  Shield,
45
79
  ShieldCheck,
46
80
  Sparkles,
81
+ Star,
47
82
  Table,
48
83
  Tag,
49
84
  Trash2,
50
85
  TrendingUp,
86
+ Undo2,
51
87
  Upload,
52
88
  User,
53
89
  Users,
54
90
  Wallet,
55
91
  Wand2,
56
92
  X,
93
+ XCircle,
57
94
  } from "lucide-react";
95
+ import type { ReactNode } from "react";
96
+ import { cn } from "./lib/cn";
58
97
 
59
- // Buttons resolve icons through the same map, importing it from the nav layout module would couple them to the sidebar.
60
98
  export const NAV_ICONS = {
61
99
  dashboard: LayoutDashboard,
62
100
  "layout-grid": LayoutGrid,
@@ -113,4 +151,49 @@ export const NAV_ICONS = {
113
151
  "arrow-left": ArrowLeft,
114
152
  "arrow-right": ArrowRight,
115
153
  copy: Copy,
154
+ pencil: Pencil,
155
+ eye: Eye,
156
+ "eye-off": EyeOff,
157
+ filter: Filter,
158
+ refresh: RefreshCw,
159
+ "more-horizontal": MoreHorizontal,
160
+ "more-vertical": MoreVertical,
161
+ "external-link": ExternalLink,
162
+ "chevron-down": ChevronDown,
163
+ "chevron-right": ChevronRight,
164
+ save: Save,
165
+ undo: Undo2,
166
+ archive: Archive,
167
+ star: Star,
168
+ flag: Flag,
169
+ clock: Clock,
170
+ "map-pin": MapPin,
171
+ phone: Phone,
172
+ printer: Printer,
173
+ "alert-triangle": AlertTriangle,
174
+ info: Info,
175
+ "check-circle": CheckCircle2,
176
+ "x-circle": XCircle,
177
+ loader: Loader2,
116
178
  } as const satisfies Readonly<Record<NavIconKey, typeof Folder>>;
179
+
180
+ // Widened alias for runtime lookups against the plain `string` icon keys
181
+ // resolved-tree nodes carry — not the closed NavIconKey union NAV_ICONS
182
+ // itself is typed against.
183
+ const ICON_LOOKUP: Readonly<Record<string, typeof Folder | undefined>> = NAV_ICONS;
184
+
185
+ export function Icon({
186
+ name,
187
+ className,
188
+ }: {
189
+ readonly name: NavIconKey;
190
+ readonly className?: string;
191
+ }): ReactNode {
192
+ const LucideIcon = Object.hasOwn(NAV_ICONS, name) ? ICON_LOOKUP[name] : undefined;
193
+ if (LucideIcon !== undefined) return <LucideIcon aria-hidden="true" className={className} />;
194
+ return (
195
+ <span aria-hidden="true" className={cn("text-xs", className)}>
196
+ {name}
197
+ </span>
198
+ );
199
+ }
@@ -13,12 +13,12 @@ export type AppLayoutProps = {
13
13
  readonly sidebar?: ReactNode;
14
14
  readonly children: ReactNode;
15
15
  readonly testId?: string;
16
- /** Viewport-fit Shell. true → Wurzel = `h-screen` (fixe Viewport-Höhe),
17
- * Sidebar/Topbar bleiben stehen, der Main-Bereich scrollt INNEN
18
- * (`min-h-0` + `overflow-auto`). false (Default) → klassischer
19
- * `min-h-screen`-Flow, die ganze Seite scrollt. Dashboard-artige Apps
20
- * wollen `true`; eine öffentliche, lange Content-Seite eher `false`.
21
- * Clippt nie — der Content scrollt in `main` statt im Body. */
16
+ /** Viewport-fit Shell. Default `true` → Wurzel = `h-screen` (fixe
17
+ * Viewport-Höhe), Sidebar/Topbar bleiben stehen, der Main-Bereich
18
+ * scrollt INNEN (`min-h-0` + `overflow-auto`). `false` → klassischer
19
+ * `min-h-screen`-Flow, die ganze Seite scrollt — Escape-Hatch für
20
+ * eine öffentliche, lange Content-Seite die echten Body-Scroll
21
+ * braucht. Clippt nie — der Content scrollt in `main` statt im Body. */
22
22
  readonly fill?: boolean;
23
23
  /** Optionaler Klassen-Append an die Wurzel (eigener Hintergrund etc.).
24
24
  * Erweitert die Defaults, ersetzt sie nicht (cn-merge). */
@@ -32,7 +32,7 @@ export function AppLayout({
32
32
  sidebar,
33
33
  children,
34
34
  testId,
35
- fill,
35
+ fill = true,
36
36
  className,
37
37
  mainClassName,
38
38
  }: AppLayoutProps): ReactNode {
@@ -68,7 +68,8 @@ export type DefaultAppShellProps = {
68
68
  * Plan-Banner. */
69
69
  readonly sidebarFooter?: ReactNode;
70
70
  /** Viewport-fit Shell. true → fixe Viewport-Höhe (`h-svh`), der Content
71
- * scrollt INNEN statt der ganzen Seite. Default false (Seiten-Scroll). */
71
+ * scrollt INNEN statt der ganzen Seite. Default true `fill={false}`
72
+ * schaltet zurück auf Seiten-Scroll. */
72
73
  readonly fill?: boolean;
73
74
  /** Screen-Content der im SidebarInset gerendert wird. */
74
75
  readonly children: ReactNode;
@@ -82,7 +83,7 @@ export function DefaultAppShell({
82
83
  headerActions,
83
84
  navBadges,
84
85
  sidebarFooter,
85
- fill,
86
+ fill = true,
86
87
  children,
87
88
  }: DefaultAppShellProps): ReactNode {
88
89
  const fillCls = fillClasses(fill);
@@ -80,7 +80,8 @@ export type WorkspaceShellProps = {
80
80
  /** Runtime badge slot per nav leaf (bare nav id), same as DefaultAppShell. */
81
81
  readonly navBadges?: ReadonlyMap<string, ReactNode>;
82
82
  /** Viewport-fit shell. true → fixed viewport height (`h-svh`), content
83
- * scrolls INSIDE instead of the whole page. Default false (page scroll). */
83
+ * scrolls INSIDE instead of the whole page. Default true set
84
+ * `fill={false}` to opt back into page scroll. */
84
85
  readonly fill?: boolean;
85
86
  /** Screen content. */
86
87
  readonly children: ReactNode;
@@ -94,7 +95,7 @@ export function WorkspaceShell({
94
95
  initialWorkspaceId,
95
96
  sidebarFooter,
96
97
  navBadges,
97
- fill,
98
+ fill = true,
98
99
  children,
99
100
  }: WorkspaceShellProps): ReactNode {
100
101
  const app = useMemo(() => toAppSchema(schema), [schema]);
@@ -25,3 +25,40 @@ describe("DefaultButton className/ref (fw#1831)", () => {
25
25
  expect(ref.current).toBe(screen.getByTestId("btn"));
26
26
  });
27
27
  });
28
+
29
+ describe("DefaultButton icon (fw-ui-defaults)", () => {
30
+ test("icon prop renders the resolved icon left of the text", () => {
31
+ render(
32
+ <Button icon="trash" testId="btn">
33
+ Delete
34
+ </Button>,
35
+ );
36
+ const btn = screen.getByTestId("btn");
37
+ expect(btn.querySelector("svg")).not.toBeNull();
38
+ expect(btn.textContent).toBe("Delete");
39
+ });
40
+
41
+ test("size='icon' + a resolved icon still renders icon, children and aria-label", () => {
42
+ render(
43
+ <Button icon="trash" size="icon" ariaLabel="Delete" testId="btn">
44
+ Delete
45
+ </Button>,
46
+ );
47
+ const btn = screen.getByTestId("btn");
48
+ expect(btn.textContent).toBe("Delete");
49
+ expect(btn.querySelector("svg")).not.toBeNull();
50
+ expect(btn.getAttribute("aria-label")).toBe("Delete");
51
+ });
52
+
53
+ test("unknown icon key: no crash, falls back to rendering children only", () => {
54
+ render(
55
+ // @ts-expect-error — exercising the runtime fallback for a schema-supplied key outside the closed IconKey union
56
+ <Button icon="not-a-real-icon" testId="btn">
57
+ Delete
58
+ </Button>,
59
+ );
60
+ const btn = screen.getByTestId("btn");
61
+ expect(btn.querySelector("svg")).toBeNull();
62
+ expect(btn.textContent).toBe("Delete");
63
+ });
64
+ });
@@ -0,0 +1,84 @@
1
+ // Segment control for `kind: "select"` with a small closed option set
2
+ // (edit-existing screenshot feedback): a Status field with 3 short values
3
+ // no longer stretches into a full-width dropdown. Threshold: ≤4 options,
4
+ // each label ≤14 chars — otherwise the unchanged ComboboxInput dropdown.
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+ import { fireEvent } from "@testing-library/react";
8
+ import { render, screen } from "../../__tests__/test-utils";
9
+ import { defaultPrimitives } from "../index";
10
+
11
+ const { Field, Input } = defaultPrimitives;
12
+
13
+ function renderSelect(
14
+ options: readonly string[],
15
+ overrides: { readonly value?: string; readonly disabled?: boolean } = {},
16
+ ): string[] {
17
+ const changes: string[] = [];
18
+ render(
19
+ <Field id="status" label="Status" testId="field-status">
20
+ <Input
21
+ kind="select"
22
+ id="status"
23
+ name="status"
24
+ value={overrides.value ?? options[0] ?? ""}
25
+ onChange={(v) => changes.push(v)}
26
+ options={options}
27
+ {...(overrides.disabled !== undefined && { disabled: overrides.disabled })}
28
+ />
29
+ </Field>,
30
+ );
31
+ return changes;
32
+ }
33
+
34
+ describe("DefaultInput select → segmented control (edit-existing feedback)", () => {
35
+ test("≤4 short options render the segmented control, not the dropdown", () => {
36
+ renderSelect(["Draft", "Review", "Published"]);
37
+ expect(screen.queryByTestId("combobox-status")).toBeNull();
38
+ expect(screen.getByRole("radiogroup")).toBeTruthy();
39
+ expect(screen.getAllByRole("radio")).toHaveLength(3);
40
+ });
41
+
42
+ test("4 options render the segmented control", () => {
43
+ renderSelect(["Draft", "Review", "Published", "Archived"]);
44
+ expect(screen.queryByTestId("combobox-status")).toBeNull();
45
+ expect(screen.getByRole("radiogroup")).toBeTruthy();
46
+ expect(screen.getAllByRole("radio")).toHaveLength(4);
47
+ });
48
+
49
+ test("5 options keep the dropdown", () => {
50
+ renderSelect(["Draft", "Review", "Published", "Archived", "Deleted"]);
51
+ expect(screen.queryByRole("radiogroup")).toBeNull();
52
+ expect(screen.getByTestId("combobox-status")).toBeTruthy();
53
+ });
54
+
55
+ test("3 options with one long label (>14 chars) keep the dropdown", () => {
56
+ renderSelect(["Draft", "Review", "Published Status"]);
57
+ expect(screen.queryByRole("radiogroup")).toBeNull();
58
+ expect(screen.getByTestId("combobox-status")).toBeTruthy();
59
+ });
60
+
61
+ test("clicking a segment reports the same value the dropdown's onChange would give", () => {
62
+ const changes = renderSelect(["Draft", "Review", "Published"], { value: "Draft" });
63
+ fireEvent.click(screen.getByRole("radio", { name: "Review" }));
64
+ expect(changes).toEqual(["Review"]);
65
+ });
66
+
67
+ test("group has an accessible name and the selected segment is aria-checked", () => {
68
+ renderSelect(["Draft", "Review", "Published"], { value: "Review" });
69
+ expect(screen.getByRole("radiogroup", { name: "Status" })).toBeTruthy();
70
+ expect(screen.getByRole("radio", { name: "Review" }).getAttribute("aria-checked")).toBe("true");
71
+ expect(screen.getByRole("radio", { name: "Draft" }).getAttribute("aria-checked")).toBe("false");
72
+ });
73
+
74
+ test("disabled prevents selection", () => {
75
+ const changes = renderSelect(["Draft", "Review", "Published"], {
76
+ value: "Draft",
77
+ disabled: true,
78
+ });
79
+ const review = screen.getByRole("radio", { name: "Review" });
80
+ expect((review as HTMLButtonElement).disabled).toBe(true);
81
+ fireEvent.click(review);
82
+ expect(changes).toEqual([]);
83
+ });
84
+ });