@cosmicdrift/kumiko-renderer-web 0.193.0 → 0.194.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.
Files changed (46) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/create-app.test.tsx +1 -1
  3. package/src/__tests__/entity-edit-redirect.test.tsx +111 -0
  4. package/src/__tests__/field-hide-label.test.tsx +166 -1
  5. package/src/__tests__/kumiko-screen.test.tsx +59 -0
  6. package/src/__tests__/nav-tree.test.tsx +43 -11
  7. package/src/__tests__/primitives.test.tsx +33 -2
  8. package/src/__tests__/render-edit.test.tsx +344 -9
  9. package/src/__tests__/test-utils.tsx +20 -0
  10. package/src/__tests__/wizard-form-validation.test.tsx +129 -1
  11. package/src/app/__tests__/merge-content-editors.test.ts +2 -2
  12. package/src/app/__tests__/plain-content-editor.test.tsx +4 -0
  13. package/src/app/__tests__/rich-content-editor.test.tsx +5 -2
  14. package/src/app/__tests__/tiptap-editor.test.tsx +50 -0
  15. package/src/app/client-plugin.tsx +3 -3
  16. package/src/app/create-app.tsx +40 -49
  17. package/src/app/tiptap-editor.tsx +10 -0
  18. package/src/layout/__tests__/workspace-switcher.test.tsx +40 -0
  19. package/src/layout/sidebar-panel.tsx +1 -4
  20. package/src/layout/workspace-switcher.tsx +20 -13
  21. package/src/lib/__tests__/resize-image.test.ts +152 -0
  22. package/src/lib/clamp.ts +3 -0
  23. package/src/lib/resize-image.ts +61 -0
  24. package/src/primitives/__tests__/data-table-logic.test.ts +15 -3
  25. package/src/primitives/__tests__/date-field.test.tsx +37 -0
  26. package/src/primitives/__tests__/date-parse.test.ts +14 -2
  27. package/src/primitives/__tests__/embedded-list-input.test.tsx +36 -2
  28. package/src/primitives/__tests__/file-upload.test.tsx +24 -0
  29. package/src/primitives/date-field.tsx +8 -0
  30. package/src/primitives/date-parse.ts +6 -1
  31. package/src/primitives/dropdown-menu.tsx +23 -0
  32. package/src/primitives/embedded-list-input.tsx +14 -5
  33. package/src/primitives/file-upload.tsx +6 -1
  34. package/src/primitives/index.tsx +114 -120
  35. package/src/primitives/money-input.tsx +4 -7
  36. package/src/ui/sheet.tsx +3 -1
  37. package/src/widgets/__tests__/drawer.test.tsx +293 -0
  38. package/src/widgets/__tests__/infinity-list.test.tsx +38 -0
  39. package/src/widgets/__tests__/upload-zone.test.tsx +125 -1
  40. package/src/widgets/__tests__/widgets.test.tsx +25 -0
  41. package/src/widgets/ai-text-field.tsx +3 -1
  42. package/src/widgets/drawer.tsx +54 -29
  43. package/src/widgets/infinity-list.tsx +18 -5
  44. package/src/widgets/progress-bar.tsx +3 -1
  45. package/src/widgets/step-bar.tsx +1 -1
  46. package/src/widgets/upload-zone.tsx +42 -3
@@ -1,4 +1,4 @@
1
- import { describe, expect, mock, test } from "bun:test";
1
+ import { describe, expect, mock, spyOn, test } from "bun:test";
2
2
  import type {
3
3
  EntityDefinition,
4
4
  EntityEditScreenDefinition,
@@ -724,6 +724,39 @@ describe("RenderEdit — controlled mode (#1887)", () => {
724
724
  expect(controls?.getValues().count).toBe("Acme".length);
725
725
  });
726
726
 
727
+ test("onControlsReady fires before the mount-time onChange call (fw#1899)", () => {
728
+ // A prefilled `initial` derives a dependent field on mount via
729
+ // controls.patch() from inside onChange — this only works if controls
730
+ // are already handed out by the time onChange fires for the first time.
731
+ let mountTimeControlsWereDefined = false;
732
+ let onChangeCalls = 0;
733
+ let controls: RenderEditControls<TestValues> | undefined;
734
+ render(
735
+ <DispatcherProvider dispatcher={makeDispatcher()}>
736
+ <RenderEdit<TestValues>
737
+ screen={makeScreen()}
738
+ entity={orderEntity}
739
+ featureName="orders"
740
+ initial={{ title: "Acme", count: 0, isUrgent: false }}
741
+ writeCommand="order:create"
742
+ onChange={(state) => {
743
+ onChangeCalls += 1;
744
+ if (onChangeCalls === 1) {
745
+ mountTimeControlsWereDefined = controls !== undefined;
746
+ controls?.patch({ count: state.values.title.length });
747
+ }
748
+ }}
749
+ onControlsReady={(c) => {
750
+ controls = c;
751
+ }}
752
+ />
753
+ </DispatcherProvider>,
754
+ );
755
+
756
+ expect(mountTimeControlsWereDefined).toBe(true);
757
+ expect(controls?.getValues().count).toBe("Acme".length);
758
+ });
759
+
727
760
  test("controls.patch sets values from outside without losing edits already made in other fields", () => {
728
761
  let controls: RenderEditControls<TestValues> | undefined;
729
762
  render(
@@ -946,9 +979,9 @@ describe("RenderEdit — FieldConditions react to extension patch() (#1916)", ()
946
979
  );
947
980
  }
948
981
 
949
- function renderVehicleForm(): void {
982
+ function renderVehicleForm(writeFn?: Dispatcher["write"], schema?: z.ZodType): void {
950
983
  render(
951
- <DispatcherProvider dispatcher={makeDispatcher()}>
984
+ <DispatcherProvider dispatcher={makeDispatcher(writeFn)}>
952
985
  <ExtensionSectionsProvider value={{ VinDecodeSection }}>
953
986
  <RenderEdit<VehicleValues>
954
987
  screen={makeVehicleScreen()}
@@ -956,6 +989,7 @@ describe("RenderEdit — FieldConditions react to extension patch() (#1916)", ()
956
989
  featureName="vehicles"
957
990
  initial={{ title: "Listing", vin: "" }}
958
991
  writeCommand="vehicle:create"
992
+ {...(schema !== undefined && { schema })}
959
993
  />
960
994
  </ExtensionSectionsProvider>
961
995
  </DispatcherProvider>,
@@ -963,15 +997,13 @@ describe("RenderEdit — FieldConditions react to extension patch() (#1916)", ()
963
997
  }
964
998
 
965
999
  function isRequired(testId: string): boolean {
966
- const label = screen.getByTestId(testId).querySelector("label");
967
- return (label?.textContent ?? "").includes("*");
1000
+ return screen.getByTestId(testId).querySelector("[data-required]") !== null;
968
1001
  }
969
1002
 
970
1003
  function isDisabled(testId: string): boolean {
971
- return (
972
- (screen.getByTestId(testId).querySelector("input") as HTMLInputElement | null)?.disabled ??
973
- false
974
- );
1004
+ const input = screen.getByTestId(testId).querySelector("input");
1005
+ if (!input) throw new Error(`${testId} has no input`);
1006
+ return (input as HTMLInputElement).disabled;
975
1007
  }
976
1008
 
977
1009
  test("provider delivers a value: the gated field becomes visible and required, the source field locks", () => {
@@ -1021,6 +1053,44 @@ describe("RenderEdit — FieldConditions react to extension patch() (#1916)", ()
1021
1053
  expect(screen.queryByTestId("field-trim")).toBeNull();
1022
1054
  expect(isDisabled("field-vin")).toBe(false);
1023
1055
  });
1056
+
1057
+ // #1916's required condition must reach the validation record, not just
1058
+ // the rendered asterisk — a regression where the extension patch() lands
1059
+ // in the render model but not the values the schema validates against
1060
+ // would stay green without this: the field would look required but submit
1061
+ // would go through anyway. Mirrors buildFormSchema's conditional-required
1062
+ // shape (form-schema.ts) with an inline schema, same as the other
1063
+ // RenderEdit tests in this file — RenderEdit itself takes `schema` as a
1064
+ // prop and does not derive one from the layout on its own.
1065
+ test("clearing trim after a decode-hit blocks submit — the required condition is enforced, not just displayed", async () => {
1066
+ const write = mock(async () => ({ isSuccess: true, data: { id: "1" } }) as never);
1067
+ const schema = z
1068
+ .object({ title: z.string().min(1) })
1069
+ .passthrough()
1070
+ .superRefine((values, ctx) => {
1071
+ const v = values as { decodeStatus?: string; trim?: string };
1072
+ if (v.decodeStatus === "hit" && (v.trim === undefined || v.trim === "")) {
1073
+ ctx.addIssue({ code: "custom", path: ["trim"], message: '"trim" is required.' });
1074
+ }
1075
+ });
1076
+ renderVehicleForm(write as unknown as Dispatcher["write"], schema);
1077
+
1078
+ act(() => {
1079
+ fireEvent.click(screen.getByTestId("decode-hit"));
1080
+ });
1081
+ const trimInput = screen.getByTestId("field-trim").querySelector("input") as HTMLInputElement;
1082
+ expect(isRequired("field-trim")).toBe(true);
1083
+
1084
+ fireEvent.change(trimInput, { target: { value: "" } });
1085
+
1086
+ await act(async () => {
1087
+ fireEvent.submit(screen.getByTestId("render-edit-form"));
1088
+ await Promise.resolve();
1089
+ });
1090
+
1091
+ expect(write).not.toHaveBeenCalled();
1092
+ expect(screen.getByTestId("field-trim-errors")).toBeTruthy();
1093
+ });
1024
1094
  });
1025
1095
 
1026
1096
  describe("RenderEdit wizard mode", () => {
@@ -1326,6 +1396,10 @@ describe("RenderEdit wizard mode", () => {
1326
1396
  });
1327
1397
 
1328
1398
  describe("RenderEdit wizard draft", () => {
1399
+ // Mirrors render-edit.tsx's own PATCH_DRAFT_SAVE_DEBOUNCE_MS (not
1400
+ // exported) — tests below wait out a full window to catch stragglers.
1401
+ const PATCH_DRAFT_SAVE_DEBOUNCE_MS = 500;
1402
+
1329
1403
  function makeDraftWizardScreen(draft: boolean): EntityEditScreenDefinition {
1330
1404
  return {
1331
1405
  id: "orders:screen:order-wizard-draft",
@@ -1506,6 +1580,59 @@ describe("RenderEdit wizard draft", () => {
1506
1580
  expect(calls.some((c) => c.startsWith("form-draft:"))).toBe(false);
1507
1581
  });
1508
1582
 
1583
+ // The restore effect's `if (controller.getSnapshot().isDirty) return;`
1584
+ // guard (render-edit.tsx) — a user who starts typing before the
1585
+ // form-draft:query:get round-trip resolves must not have their input
1586
+ // clobbered by the stored draft landing afterwards.
1587
+ test("typing while the restore query is in flight — the typed value wins over the stored draft", async () => {
1588
+ let resolveRestore: (result: {
1589
+ readonly isSuccess: true;
1590
+ readonly data: { readonly draft: DraftBlob | null };
1591
+ }) => void = () => {};
1592
+ const restorePromise = new Promise<{
1593
+ readonly isSuccess: true;
1594
+ readonly data: { readonly draft: DraftBlob | null };
1595
+ }>((resolve) => {
1596
+ resolveRestore = resolve;
1597
+ });
1598
+ const dispatcher = createMockDispatcher({
1599
+ query: (async (type: string) => {
1600
+ if (type === "form-draft:query:get") return restorePromise;
1601
+ return { isSuccess: true, data: {} };
1602
+ }) as Dispatcher["query"],
1603
+ write: (async () => ({ isSuccess: true, data: { id: "1" } })) as Dispatcher["write"],
1604
+ });
1605
+
1606
+ render(
1607
+ <DispatcherProvider dispatcher={dispatcher}>
1608
+ <RenderEdit<TestValues>
1609
+ screen={makeDraftWizardScreen(true)}
1610
+ entity={orderEntity}
1611
+ featureName="orders"
1612
+ initial={{ title: "", count: 0 }}
1613
+ entityId="order-1"
1614
+ writeCommand="order:update"
1615
+ />
1616
+ </DispatcherProvider>,
1617
+ );
1618
+
1619
+ const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
1620
+ fireEvent.change(titleInput, { target: { value: "Typed while loading" } });
1621
+
1622
+ resolveRestore({
1623
+ isSuccess: true,
1624
+ data: { draft: { values: { title: "From storage", count: 9 }, stepIndex: 1 } },
1625
+ });
1626
+ // The step advancing to 2 only happens once setValues/setRawStep from
1627
+ // the restore effect actually ran — waiting for it proves the restore
1628
+ // fully landed before asserting the title was left untouched.
1629
+ await waitFor(() =>
1630
+ expect(screen.getByTestId("render-edit-wizard-step-label").textContent).toContain("2"),
1631
+ );
1632
+
1633
+ expect(titleInput.value).toBe("Typed while loading");
1634
+ });
1635
+
1509
1636
  // Issue #1914: patch() (controlled mode / extension sections) previously
1510
1637
  // never triggered a draft save — only handleWizardNext/Back did. A patch()
1511
1638
  // on the last step (no further Next click coming) or on a step abandoned
@@ -1609,6 +1736,38 @@ describe("RenderEdit wizard draft", () => {
1609
1736
  );
1610
1737
  });
1611
1738
 
1739
+ test("a draftKey over the server's 256-char limit is not sent — warns and skips instead of failing silently server-side (fw#1903)", async () => {
1740
+ const { dispatcher, calls } = makeDraftDispatcher();
1741
+ let controls: RenderEditControls<TestValues> | undefined;
1742
+ const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
1743
+
1744
+ try {
1745
+ render(
1746
+ <DispatcherProvider dispatcher={dispatcher}>
1747
+ <RenderEdit<TestValues>
1748
+ screen={{ ...makeDraftWizardScreen(true), id: `orders:screen:${"x".repeat(300)}` }}
1749
+ entity={orderEntity}
1750
+ featureName="orders"
1751
+ initial={{ title: "", count: 0 }}
1752
+ writeCommand="order:create"
1753
+ onControlsReady={(c) => {
1754
+ controls = c;
1755
+ }}
1756
+ />
1757
+ </DispatcherProvider>,
1758
+ );
1759
+
1760
+ act(() => {
1761
+ controls?.patch({ count: 1 });
1762
+ });
1763
+
1764
+ await waitFor(() => expect(warnSpy).toHaveBeenCalled(), { timeout: 3000 });
1765
+ expect(calls.filter((c) => c === "form-draft:write:save").length).toBe(0);
1766
+ } finally {
1767
+ warnSpy.mockRestore();
1768
+ }
1769
+ });
1770
+
1612
1771
  test("a burst of patch() calls collapses into a single debounced draft save", async () => {
1613
1772
  const { dispatcher, calls } = makeDraftDispatcher();
1614
1773
  let controls: RenderEditControls<TestValues> | undefined;
@@ -1641,6 +1800,63 @@ describe("RenderEdit wizard draft", () => {
1641
1800
 
1642
1801
  await waitFor(() => expect(savesSoFar()).toBe(before + 1), { timeout: 3000 });
1643
1802
  expect(savesSoFar()).toBe(before + 1);
1803
+
1804
+ // The debounce collapses bursts into one save, but a save landing in a
1805
+ // LATER window (the burst's tail re-arming the timer, or an unrelated
1806
+ // second burst) would slip past an assertion taken right after the
1807
+ // first save. Wait out a full extra debounce window to prove there is
1808
+ // no straggler.
1809
+ await new Promise((resolve) => setTimeout(resolve, PATCH_DRAFT_SAVE_DEBOUNCE_MS + 200));
1810
+ expect(savesSoFar()).toBe(before + 1);
1811
+ });
1812
+
1813
+ // #1914's debounce must not resurrect a draft after it was intentionally
1814
+ // ended: discardDraft() clears the pending timer (render-edit.tsx:735-738)
1815
+ // specifically so a patch() immediately followed by submit can't have its
1816
+ // debounced save land AFTER the discard and leave an orphaned row behind.
1817
+ test("a pending debounced patch-save is killed by submit's discard, not resurrected later", async () => {
1818
+ const { dispatcher, store, calls } = makeDraftDispatcher();
1819
+ let controls: RenderEditControls<TestValues> | undefined;
1820
+
1821
+ render(
1822
+ <DispatcherProvider dispatcher={dispatcher}>
1823
+ <RenderEdit<TestValues>
1824
+ screen={makeDraftWizardScreen(true)}
1825
+ entity={orderEntity}
1826
+ featureName="orders"
1827
+ initial={{ title: "Acme", count: 0 }}
1828
+ writeCommand="order:create"
1829
+ onControlsReady={(c) => {
1830
+ controls = c;
1831
+ }}
1832
+ />
1833
+ </DispatcherProvider>,
1834
+ );
1835
+
1836
+ fireEvent.click(screen.getByTestId("render-edit-wizard-next"));
1837
+ expect(screen.getByTestId("field-count")).toBeTruthy();
1838
+
1839
+ // patch() arms a 500ms debounced draft save, then submit fires well
1840
+ // inside that window — before the timer could ever run on its own.
1841
+ act(() => {
1842
+ controls?.patch({ count: 9 });
1843
+ });
1844
+ await act(async () => {
1845
+ fireEvent.click(screen.getByTestId("render-edit-submit"));
1846
+ await Promise.resolve();
1847
+ });
1848
+
1849
+ expect(calls).toContain("form-draft:write:discard");
1850
+ const savesAtSubmit = calls.filter((c) => c === "form-draft:write:save").length;
1851
+ expect(store.current).toBeNull();
1852
+
1853
+ // Wait past the debounce window the patch() call armed. If discard
1854
+ // hadn't cleared the timer, a straggling save would land here and
1855
+ // resurrect the just-discarded draft.
1856
+ await new Promise((resolve) => setTimeout(resolve, PATCH_DRAFT_SAVE_DEBOUNCE_MS + 200));
1857
+
1858
+ expect(calls.filter((c) => c === "form-draft:write:save").length).toBe(savesAtSubmit);
1859
+ expect(store.current).toBeNull();
1644
1860
  });
1645
1861
 
1646
1862
  // A rejected discard write (network error) must be best-effort: the entity
@@ -1864,6 +2080,71 @@ describe("RenderEdit create-mode draftId (issue #1913)", () => {
1864
2080
  expect(titles).toEqual(["Session A", "Session B"]);
1865
2081
  });
1866
2082
 
2083
+ // Same-tab variant of the test above: two RenderEdit instances sharing
2084
+ // ONE DraftStorage AND one dispatcher — the layout a single browser tab
2085
+ // with two open create forms actually has (a fresh DraftStorage per
2086
+ // session only happens across tabs). The mint keys off each component's
2087
+ // own `draftId` React state, not a re-read of the shared storage slot, so
2088
+ // this must isolate too.
2089
+ test("two parallel create sessions sharing one DraftStorage still get different draftKeys (#1908)", async () => {
2090
+ const { dispatcher, drafts } = makeMultiDraftDispatcher();
2091
+ const screenDef = makeDraftWizardScreen();
2092
+ const sharedDraftStorage = createFakeDraftStorage();
2093
+
2094
+ const sessionA = render(
2095
+ <DispatcherProvider dispatcher={dispatcher}>
2096
+ <DraftStorageProvider value={sharedDraftStorage}>
2097
+ <RenderEdit<TestValues>
2098
+ screen={screenDef}
2099
+ entity={orderEntity}
2100
+ featureName="orders"
2101
+ initial={{ title: "", count: 0 }}
2102
+ writeCommand="order:create"
2103
+ />
2104
+ </DraftStorageProvider>
2105
+ </DispatcherProvider>,
2106
+ );
2107
+ const sessionB = render(
2108
+ <DispatcherProvider dispatcher={dispatcher}>
2109
+ <DraftStorageProvider value={sharedDraftStorage}>
2110
+ <RenderEdit<TestValues>
2111
+ screen={screenDef}
2112
+ entity={orderEntity}
2113
+ featureName="orders"
2114
+ initial={{ title: "", count: 0 }}
2115
+ writeCommand="order:create"
2116
+ />
2117
+ </DraftStorageProvider>
2118
+ </DispatcherProvider>,
2119
+ );
2120
+
2121
+ const titleA = within(sessionA.container)
2122
+ .getByTestId("field-title")
2123
+ .querySelector("input") as HTMLInputElement;
2124
+ fireEvent.change(titleA, { target: { value: "Session A" } });
2125
+ await act(async () => {
2126
+ fireEvent.submit(within(sessionA.container).getByTestId("render-edit-form"));
2127
+ await Promise.resolve();
2128
+ });
2129
+
2130
+ const titleB = within(sessionB.container)
2131
+ .getByTestId("field-title")
2132
+ .querySelector("input") as HTMLInputElement;
2133
+ fireEvent.change(titleB, { target: { value: "Session B" } });
2134
+ await act(async () => {
2135
+ fireEvent.submit(within(sessionB.container).getByTestId("render-edit-form"));
2136
+ await Promise.resolve();
2137
+ });
2138
+
2139
+ const prefix = `${screenDef.id}:new:`;
2140
+ const createDraftKeys = [...drafts.keys()].filter((k) => k.startsWith(prefix));
2141
+ expect(createDraftKeys).toHaveLength(2);
2142
+ const [keyA, keyB] = createDraftKeys as [string, string];
2143
+ expect(keyA).not.toBe(keyB);
2144
+ const titles = createDraftKeys.map((k) => drafts.get(k)?.values["title"]).sort();
2145
+ expect(titles).toEqual(["Session A", "Session B"]);
2146
+ });
2147
+
1867
2148
  test("cleared storage with exactly one open draft still shows the picker — no silent cross-tab adopt", async () => {
1868
2149
  const { dispatcher, drafts } = makeMultiDraftDispatcher();
1869
2150
  const screenDef = makeDraftWizardScreen();
@@ -2159,6 +2440,60 @@ describe("RenderEdit create-mode draftId (issue #1913)", () => {
2159
2440
 
2160
2441
  expect([...drafts.keys()]).toEqual([`${screenDef.id}:order-77`]);
2161
2442
  });
2443
+
2444
+ // draftKey is `${screen.id}:${entityId}` — opening a different entity on
2445
+ // the same screen must not resume the previous entity's saved draft.
2446
+ test("switching entityId does not load the previous entity's draft", async () => {
2447
+ const { dispatcher, drafts } = makeMultiDraftDispatcher();
2448
+ const screenDef = makeDraftWizardScreen();
2449
+ drafts.set(`${screenDef.id}:order-A`, {
2450
+ values: { title: "Draft for A", count: 1 },
2451
+ stepIndex: 0,
2452
+ savedAt: "2026-01-01T00:00:00Z",
2453
+ });
2454
+
2455
+ const first = render(
2456
+ <DispatcherProvider dispatcher={dispatcher}>
2457
+ <DraftStorageProvider value={createFakeDraftStorage()}>
2458
+ <RenderEdit<TestValues>
2459
+ screen={screenDef}
2460
+ entity={orderEntity}
2461
+ featureName="orders"
2462
+ initial={{ title: "", count: 0 }}
2463
+ entityId="order-A"
2464
+ writeCommand="order:update"
2465
+ />
2466
+ </DraftStorageProvider>
2467
+ </DispatcherProvider>,
2468
+ );
2469
+
2470
+ await waitFor(() =>
2471
+ expect(
2472
+ (screen.getByTestId("field-title").querySelector("input") as HTMLInputElement).value,
2473
+ ).toBe("Draft for A"),
2474
+ );
2475
+ first.unmount();
2476
+
2477
+ render(
2478
+ <DispatcherProvider dispatcher={dispatcher}>
2479
+ <DraftStorageProvider value={createFakeDraftStorage()}>
2480
+ <RenderEdit<TestValues>
2481
+ screen={screenDef}
2482
+ entity={orderEntity}
2483
+ featureName="orders"
2484
+ initial={{ title: "", count: 0 }}
2485
+ entityId="order-B"
2486
+ writeCommand="order:update"
2487
+ />
2488
+ </DraftStorageProvider>
2489
+ </DispatcherProvider>,
2490
+ );
2491
+
2492
+ const titleInputB = screen
2493
+ .getByTestId("field-title")
2494
+ .querySelector("input") as HTMLInputElement;
2495
+ expect(titleInputB.value).toBe("");
2496
+ });
2162
2497
  });
2163
2498
 
2164
2499
  describe("RenderEdit locked state (#1896)", () => {
@@ -112,6 +112,26 @@ export function renderWithSidebar(
112
112
  return _render(ui, { wrapper: SidebarProviders, ...options });
113
113
  }
114
114
 
115
+ // Nested PrimitivesProvider overrides the outer DefaultProviders one for
116
+ // this subtree — for tests exercising a fallback when a primitive isn't
117
+ // registered (e.g. StepBar undefined on an older PrimitivesRegistry).
118
+ export function renderWithPrimitivesOverride(
119
+ ui: ReactElement,
120
+ primitivesOverride: Partial<typeof defaultPrimitives>,
121
+ options?: Omit<RenderOptions, "wrapper">,
122
+ ): RenderResult {
123
+ function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
124
+ return (
125
+ <DefaultProviders>
126
+ <PrimitivesProvider value={{ ...defaultPrimitives, ...primitivesOverride }}>
127
+ {children}
128
+ </PrimitivesProvider>
129
+ </DefaultProviders>
130
+ );
131
+ }
132
+ return _render(ui, { wrapper: Wrapper, ...options });
133
+ }
134
+
115
135
  // ---------------------------------------------------------------------------
116
136
  // Mock-Dispatcher-Helper
117
137
  // ---------------------------------------------------------------------------
@@ -8,10 +8,11 @@ import type {
8
8
  EntityDefinition,
9
9
  EntityEditScreenDefinition,
10
10
  } from "@cosmicdrift/kumiko-framework/ui-types";
11
+ import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
11
12
  import type { FeatureSchema } from "@cosmicdrift/kumiko-renderer";
12
13
  import { DispatcherProvider, KumikoScreen } from "@cosmicdrift/kumiko-renderer";
13
14
  import userEvent from "@testing-library/user-event";
14
- import { createMockDispatcher, render, screen } from "./test-utils";
15
+ import { createMockDispatcher, render, renderWithPrimitivesOverride, screen } from "./test-utils";
15
16
 
16
17
  const profileEntity = {
17
18
  fields: {
@@ -70,6 +71,84 @@ describe("entityEdit wizard — presence validation on Next (fw#1910)", () => {
70
71
  expect(screen.getByTestId("render-edit-wizard-step-label").textContent).toContain("2");
71
72
  expect(screen.queryByTestId("field-fullName-errors")).toBeNull();
72
73
  });
74
+
75
+ // handleWizardNext validates only the CURRENT step's fields
76
+ // (render-edit.tsx:679) — an unscoped controller.validate() regression
77
+ // would block Next on step 1 for a required field that only exists on
78
+ // step 2, permanently stuck on step 1. Submit must still enforce it.
79
+ test("a required field on a later step never blocks Next on an earlier step, but blocks submit", async () => {
80
+ const requiredEmailEntity = {
81
+ fields: {
82
+ fullName: { type: "text", required: true },
83
+ email: { type: "text", required: true },
84
+ },
85
+ } as unknown as EntityDefinition;
86
+ const requiredEmailSchema: FeatureSchema = {
87
+ featureName: "demo",
88
+ entities: { profile: requiredEmailEntity },
89
+ screens: [wizardScreen],
90
+ };
91
+ const { container } = render(
92
+ <DispatcherProvider dispatcher={createMockDispatcher()}>
93
+ <KumikoScreen schema={requiredEmailSchema} qn="demo:screen:profile-edit" />
94
+ </DispatcherProvider>,
95
+ );
96
+
97
+ const fullNameInput = container.querySelector("#kumiko-edit-fullName");
98
+ await userEvent.type(fullNameInput as Element, "Ada Lovelace");
99
+ await userEvent.click(screen.getByTestId("render-edit-wizard-next"));
100
+
101
+ expect(screen.getByTestId("render-edit-wizard-step-label").textContent).toContain("2");
102
+
103
+ await userEvent.click(screen.getByTestId("render-edit-submit"));
104
+
105
+ expect(screen.getByTestId("render-edit-wizard-step-label").textContent).toContain("2");
106
+ expect(screen.getByTestId("field-email-errors")).toBeTruthy();
107
+ });
108
+
109
+ // `fieldset[disabled]` also excludes its descendants' controls from a real
110
+ // `new FormData(form)` / native form submission — if render-edit's submit
111
+ // path ever read values that way instead of from controller/React state,
112
+ // disabling step 1's fieldset after Next would silently drop fullName on
113
+ // submit. Assert on the value the dispatcher actually receives, not just
114
+ // that step 2 shows no error.
115
+ test("submitting from a later step still sends an earlier step's value to the dispatcher", async () => {
116
+ const calls: Array<[string, unknown]> = [];
117
+ const write = (async (type: string, payload: unknown) => {
118
+ calls.push([type, payload]);
119
+ return { isSuccess: true, data: {} };
120
+ }) as unknown as Dispatcher["write"];
121
+ const { container } = render(
122
+ <DispatcherProvider dispatcher={createMockDispatcher({ write })}>
123
+ <KumikoScreen schema={schema} qn="demo:screen:profile-edit" />
124
+ </DispatcherProvider>,
125
+ );
126
+
127
+ const fullNameInput = container.querySelector("#kumiko-edit-fullName");
128
+ await userEvent.type(fullNameInput as Element, "Ada Lovelace");
129
+ await userEvent.click(screen.getByTestId("render-edit-wizard-next"));
130
+ await userEvent.click(screen.getByTestId("render-edit-submit"));
131
+
132
+ expect(calls).toHaveLength(1);
133
+ const [, payload] = calls[0] as [string, Record<string, unknown>];
134
+ expect(payload["fullName"]).toBe("Ada Lovelace");
135
+ });
136
+
137
+ // fw high-severity finding: `hidden` alone does not bar a field from the
138
+ // browser's native constraint validation, so a required field on an
139
+ // off-screen step blocked the whole form's `type="submit"` Next button —
140
+ // jsdom doesn't implement constraint validation, so this only reproduces
141
+ // as a DOM-shape check (fieldset[disabled] ancestor), not the actual
142
+ // browser-blocks-navigation symptom.
143
+ test("an off-screen step's fields sit inside a disabled fieldset, the current step's don't", async () => {
144
+ renderWizard();
145
+
146
+ const hiddenStepField = screen.getByTestId("field-email").closest("fieldset");
147
+ expect(hiddenStepField?.disabled).toBe(true);
148
+
149
+ const currentStepField = screen.getByTestId("field-fullName").closest("fieldset");
150
+ expect(currentStepField?.disabled).toBe(false);
151
+ });
73
152
  });
74
153
 
75
154
  // fw#1966: the wizard chrome used to only show a "Step X of Y" counter —
@@ -133,4 +212,53 @@ describe("entityEdit wizard — step bar (fw#1966)", () => {
133
212
  const steps = container.querySelectorAll('[data-testid^="render-edit-wizard-steps-step-"]');
134
213
  expect(steps.length).toBe(3);
135
214
  });
215
+
216
+ // fw#1967: a PrimitivesRegistry without StepBar (older/custom registry)
217
+ // must stay on the plain "Step X of Y" label RenderEdit always had —
218
+ // this is the changeset's stated backward-compat guarantee.
219
+ test("falls back to the plain step label when the registry doesn't supply StepBar", () => {
220
+ renderWithPrimitivesOverride(
221
+ <DispatcherProvider dispatcher={createMockDispatcher()}>
222
+ <KumikoScreen schema={schema} qn="demo:screen:profile-edit" />
223
+ </DispatcherProvider>,
224
+ { StepBar: undefined },
225
+ );
226
+
227
+ expect(screen.getByTestId("render-edit-wizard-step-label").textContent).toBe(
228
+ "Step 1 of 2 · Step 1",
229
+ );
230
+ expect(screen.queryByTestId("render-edit-wizard-steps-step-0")).toBeNull();
231
+ });
232
+
233
+ // fw#1967: a titleless section used to fall back to the stringified step
234
+ // number, duplicating the StepBar's own numbered badge ("3 3"). The
235
+ // fallback is now an empty label — only the badge shows.
236
+ test("a titleless section shows only the step number, not a duplicated digit", () => {
237
+ const untitledStepScreen: EntityEditScreenDefinition = {
238
+ id: "profile-edit-untitled",
239
+ type: "entityEdit",
240
+ entity: "profile",
241
+ layout: {
242
+ mode: "wizard",
243
+ sections: [
244
+ { title: "Basics", fields: ["fullName"] },
245
+ { title: "Contact", fields: ["email"] },
246
+ { fields: [] },
247
+ ],
248
+ },
249
+ };
250
+ const untitledStepSchema: FeatureSchema = {
251
+ featureName: "demo",
252
+ entities: { profile: profileEntity },
253
+ screens: [untitledStepScreen],
254
+ };
255
+
256
+ render(
257
+ <DispatcherProvider dispatcher={createMockDispatcher()}>
258
+ <KumikoScreen schema={untitledStepSchema} qn="demo:screen:profile-edit-untitled" />
259
+ </DispatcherProvider>,
260
+ );
261
+
262
+ expect(screen.getByTestId("render-edit-wizard-steps-step-2").textContent).toBe("3");
263
+ });
136
264
  });
@@ -19,7 +19,7 @@ describe("mergeContentEditors", () => {
19
19
  expect(merged["rich"]).toBe(richEditor);
20
20
  });
21
21
 
22
- test("Key-Kollision → warnt + last-wins gewinnt", () => {
22
+ test("key collision → warns + last-wins applies", () => {
23
23
  const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
24
24
  const first = editor();
25
25
  const second = editor();
@@ -37,7 +37,7 @@ describe("mergeContentEditors", () => {
37
37
  warnSpy.mockRestore();
38
38
  });
39
39
 
40
- test("clientFeatures ohne contentEditors werden übersprungen", () => {
40
+ test("clientFeatures without contentEditors are skipped", () => {
41
41
  const features: ClientFeatureDefinition[] = [{ name: "a" }];
42
42
  expect(mergeContentEditors(features)).toEqual({});
43
43
  });
@@ -32,6 +32,10 @@ describe("PlainContentEditor", () => {
32
32
  textarea.setSelectionRange(6, 6);
33
33
  fireEvent.click(screen.getByText("{{name}}"));
34
34
  expect(textarea.value).toBe("Hello {{name}} world");
35
+ // Caret-restore effect: focus + selection must land right after the
36
+ // inserted placeholder, not wherever the DOM happened to leave it.
37
+ expect(textarea.selectionStart).toBe(14);
38
+ expect(textarea.selectionEnd).toBe(14);
35
39
  });
36
40
 
37
41
  test("chip click replaces a selection instead of inserting alongside it", () => {
@@ -15,7 +15,10 @@ describe("RichContentEditor", () => {
15
15
  );
16
16
  // The textarea id proves #1794's "never an empty panel" fallback is the
17
17
  // Suspense boundary — a null fallback would leave nothing to find here.
18
- expect(document.getElementById(CONTENT_EDITOR_ELEMENT_ID)).not.toBeNull();
19
- expect(await screen.findByText("hello")).toBeTruthy();
18
+ // TiptapEditor puts the same id on its contenteditable, so asserting
19
+ // presence alone wouldn't distinguish the two; assert the tag instead.
20
+ expect(document.getElementById(CONTENT_EDITOR_ELEMENT_ID)?.tagName).toBe("TEXTAREA");
21
+ const editable = await screen.findByText("hello");
22
+ expect(editable.closest('[contenteditable="true"]')).not.toBeNull();
20
23
  });
21
24
  });