@cosmicdrift/kumiko-framework 0.187.0 → 0.189.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.
@@ -7,8 +7,11 @@ import { validateBoot as validateBootRaw } from "../boot-validator";
7
7
  import { createSystemConfig, createTenantConfig } from "../config-helpers";
8
8
  import {
9
9
  createDerivedField,
10
+ createEmbeddedField,
10
11
  createEmbeddedListField,
11
12
  createEntity,
13
+ createFilesField,
14
+ createJsonbField,
12
15
  createMultiSelectField,
13
16
  createTextField,
14
17
  defineFeature,
@@ -1742,6 +1745,8 @@ describe("boot-validator", () => {
1742
1745
  readonly redirect?: string;
1743
1746
  readonly cancelTarget?: string | false;
1744
1747
  readonly extraScreens?: readonly string[];
1748
+ readonly mode?: "single" | "wizard";
1749
+ readonly draft?: boolean;
1745
1750
  };
1746
1751
 
1747
1752
  // Hilfs-Schema-Setup: stamps eine Test-Entity + write-handler
@@ -1770,7 +1775,11 @@ describe("boot-validator", () => {
1770
1775
  type: "actionForm",
1771
1776
  handler,
1772
1777
  fields: fields as never,
1773
- layout: { sections: sections as never },
1778
+ layout: {
1779
+ sections: sections as never,
1780
+ ...(override.mode !== undefined && { mode: override.mode }),
1781
+ ...(override.draft !== undefined && { draft: override.draft }),
1782
+ },
1774
1783
  ...(override.redirect !== undefined && { redirect: override.redirect }),
1775
1784
  ...(override.cancelTarget !== undefined && { cancelTarget: override.cancelTarget }),
1776
1785
  });
@@ -1858,6 +1867,39 @@ describe("boot-validator", () => {
1858
1867
  expect(() => validateBoot([makeFeature({ cancelTarget: false })])).not.toThrow();
1859
1868
  });
1860
1869
 
1870
+ // --- redirect/cancelTarget: cross-feature QN (#1946) ---
1871
+ test("redirect → voll-qualifizierte Cross-Feature-QN → kein Throw", () => {
1872
+ const consumer = defineFeature("statements", (r) => {
1873
+ r.screen({ id: "statement-upload-list", type: "custom", renderer: { react: "stub" } });
1874
+ });
1875
+ expect(() =>
1876
+ validateBoot([
1877
+ makeFeature({ redirect: "statements:screen:statement-upload-list" }),
1878
+ consumer,
1879
+ ]),
1880
+ ).not.toThrow();
1881
+ });
1882
+
1883
+ test("redirect → unbekannte Cross-Feature-QN → Throw", () => {
1884
+ expect(() =>
1885
+ validateBoot([makeFeature({ redirect: "statements:screen:ghost-screen" })]),
1886
+ ).toThrow(
1887
+ /redirect "statements:screen:ghost-screen" does not resolve to a registered screen/,
1888
+ );
1889
+ });
1890
+
1891
+ test("cancelTarget → voll-qualifizierte Cross-Feature-QN → kein Throw", () => {
1892
+ const consumer = defineFeature("statements", (r) => {
1893
+ r.screen({ id: "statement-upload-list", type: "custom", renderer: { react: "stub" } });
1894
+ });
1895
+ expect(() =>
1896
+ validateBoot([
1897
+ makeFeature({ cancelTarget: "statements:screen:statement-upload-list" }),
1898
+ consumer,
1899
+ ]),
1900
+ ).not.toThrow();
1901
+ });
1902
+
1861
1903
  test("extension section ohne component → Throw (Parität zu entityEdit)", () => {
1862
1904
  // synthesizeActionFormScreen reicht die layout 1:1 an RenderEdit weiter —
1863
1905
  // eine Extension-Section ohne react/native-Marker rendert sonst stumm leer.
@@ -1871,6 +1913,66 @@ describe("boot-validator", () => {
1871
1913
  const section = { kind: "extension", title: "Custom", component: { react: "Panel" } };
1872
1914
  expect(() => validateBoot([makeFeature({ sections: [section] as never })])).not.toThrow();
1873
1915
  });
1916
+
1917
+ test("mode: wizard mit nur 1 Section → Throw", () => {
1918
+ expect(() =>
1919
+ validateBoot([
1920
+ makeFeature({
1921
+ mode: "wizard",
1922
+ sections: [{ title: "Step 1", fields: ["note"] }],
1923
+ }),
1924
+ ]),
1925
+ ).toThrow(/mode: "wizard" but only 1 section\(s\)/);
1926
+ });
1927
+
1928
+ test("mode: wizard mit Section ohne Titel → Throw", () => {
1929
+ const sections = [
1930
+ { title: "Step 1", fields: ["note"] },
1931
+ { title: "", fields: ["priority"] },
1932
+ ];
1933
+ expect(() => validateBoot([makeFeature({ mode: "wizard", sections })])).toThrow(
1934
+ /sections\[1\] has no title/,
1935
+ );
1936
+ });
1937
+
1938
+ test("mode: wizard mit >= 2 betitelten Sections → kein Throw", () => {
1939
+ const sections = [
1940
+ { title: "Step 1", fields: ["note"] },
1941
+ { title: "Step 2", fields: ["priority"] },
1942
+ ];
1943
+ expect(() => validateBoot([makeFeature({ mode: "wizard", sections })])).not.toThrow();
1944
+ });
1945
+
1946
+ test("draft: true ohne gemountetes form-draft-Feature → Throw", () => {
1947
+ const sections = [
1948
+ { title: "Step 1", fields: ["note"] },
1949
+ { title: "Step 2", fields: ["priority"] },
1950
+ ];
1951
+ expect(() => validateBoot([makeFeature({ mode: "wizard", sections, draft: true })])).toThrow(
1952
+ /"form-draft" is not mounted/,
1953
+ );
1954
+ });
1955
+
1956
+ test("draft: true mit gemountetem form-draft-Feature → kein Throw", () => {
1957
+ const sections = [
1958
+ { title: "Step 1", fields: ["note"] },
1959
+ { title: "Step 2", fields: ["priority"] },
1960
+ ];
1961
+ expect(() =>
1962
+ validateBoot([
1963
+ makeFeature({ mode: "wizard", sections, draft: true }),
1964
+ defineFeature("form-draft", () => {}),
1965
+ ]),
1966
+ ).not.toThrow();
1967
+ });
1968
+
1969
+ test("draft: true ohne mode: 'wizard' → Throw", () => {
1970
+ expect(() => validateBoot([makeFeature({ draft: true })])).toThrow(/mode is not "wizard"/);
1971
+ });
1972
+
1973
+ test("mode weggelassen (Default 'single') bleibt bestehendes Layout gültig → kein Throw", () => {
1974
+ expect(() => validateBoot([makeFeature()])).not.toThrow();
1975
+ });
1874
1976
  });
1875
1977
 
1876
1978
  // --- configEdit-Screen ---
@@ -2046,6 +2148,308 @@ describe("boot-validator", () => {
2046
2148
  });
2047
2149
  });
2048
2150
 
2151
+ // --- entityEdit wizard mode (framework#1884) ---
2152
+ // layout.mode: "wizard" renders one section per step instead of all
2153
+ // sections at once — needs >= 2 sections, each with a title (the step
2154
+ // title), or it's a boot-fail instead of a broken step UI.
2155
+ describe("entityEdit wizard mode", () => {
2156
+ function makeFeature(
2157
+ sections: readonly { readonly title?: string; readonly fields: readonly string[] }[],
2158
+ mode?: "single" | "wizard",
2159
+ ) {
2160
+ return defineFeature("shop", (r) => {
2161
+ r.entity(
2162
+ "product",
2163
+ createEntity({ fields: { name: createTextField(), sku: createTextField() } }),
2164
+ );
2165
+ r.screen({
2166
+ id: "product-edit",
2167
+ type: "entityEdit",
2168
+ entity: "product",
2169
+ layout: {
2170
+ sections: sections as never,
2171
+ ...(mode !== undefined && { mode }),
2172
+ },
2173
+ });
2174
+ });
2175
+ }
2176
+
2177
+ test("mode: wizard mit nur 1 Section → Throw", () => {
2178
+ expect(() =>
2179
+ validateBoot([makeFeature([{ title: "Step 1", fields: ["name"] }], "wizard")]),
2180
+ ).toThrow(/mode: "wizard" but only 1 section\(s\)/);
2181
+ });
2182
+
2183
+ test("mode: wizard mit Section ohne Titel → Throw", () => {
2184
+ expect(() =>
2185
+ validateBoot([
2186
+ makeFeature([{ title: "Step 1", fields: ["name"] }, { fields: ["sku"] }], "wizard"),
2187
+ ]),
2188
+ ).toThrow(/sections\[1\] has no title/);
2189
+ });
2190
+
2191
+ test("mode: wizard mit >= 2 betitelten Sections → kein Throw", () => {
2192
+ expect(() =>
2193
+ validateBoot([
2194
+ makeFeature(
2195
+ [
2196
+ { title: "Step 1", fields: ["name"] },
2197
+ { title: "Step 2", fields: ["sku"] },
2198
+ ],
2199
+ "wizard",
2200
+ ),
2201
+ ]),
2202
+ ).not.toThrow();
2203
+ });
2204
+
2205
+ test("mode weggelassen (Default 'single') bleibt bestehendes Layout gültig → kein Throw", () => {
2206
+ expect(() =>
2207
+ validateBoot([makeFeature([{ title: "Details", fields: ["name", "sku"] }])]),
2208
+ ).not.toThrow();
2209
+ });
2210
+ });
2211
+
2212
+ // --- entityEdit redirect (framework#1942) ---
2213
+ // Post-save navigation target, same validation as actionForm's redirect:
2214
+ // short screen-ID, same feature, must resolve to a registered screen.
2215
+ describe("entityEdit redirect", () => {
2216
+ function makeFeature(redirect?: string, extraScreens: readonly string[] = []) {
2217
+ return defineFeature("shop", (r) => {
2218
+ r.entity(
2219
+ "product",
2220
+ createEntity({ fields: { name: createTextField(), sku: createTextField() } }),
2221
+ );
2222
+ r.screen({
2223
+ id: "product-edit",
2224
+ type: "entityEdit",
2225
+ entity: "product",
2226
+ layout: { sections: [{ fields: ["name", "sku"] }] },
2227
+ ...(redirect !== undefined && { redirect }),
2228
+ });
2229
+ for (const extra of extraScreens) {
2230
+ r.screen({
2231
+ id: extra,
2232
+ type: "custom",
2233
+ renderer: { react: "stub" },
2234
+ });
2235
+ }
2236
+ });
2237
+ }
2238
+
2239
+ test("redirect → existing screen-id im selben feature → kein Throw", () => {
2240
+ expect(() => validateBoot([makeFeature("product-list", ["product-list"])])).not.toThrow();
2241
+ });
2242
+
2243
+ test("redirect → unknown screen-id → Throw", () => {
2244
+ expect(() => validateBoot([makeFeature("ghost-screen")])).toThrow(
2245
+ /redirect "ghost-screen" does not resolve to a registered screen/,
2246
+ );
2247
+ });
2248
+
2249
+ test("kein redirect gesetzt → kein Throw (Default: Liste)", () => {
2250
+ expect(() => validateBoot([makeFeature()])).not.toThrow();
2251
+ });
2252
+
2253
+ // --- redirect: cross-feature QN (#1946) ---
2254
+ test("redirect → voll-qualifizierte Cross-Feature-QN → kein Throw", () => {
2255
+ const consumer = defineFeature("statements", (r) => {
2256
+ r.screen({ id: "statement-upload-list", type: "custom", renderer: { react: "stub" } });
2257
+ });
2258
+ expect(() =>
2259
+ validateBoot([makeFeature("statements:screen:statement-upload-list"), consumer]),
2260
+ ).not.toThrow();
2261
+ });
2262
+
2263
+ test("redirect → unbekannte Cross-Feature-QN → Throw", () => {
2264
+ expect(() => validateBoot([makeFeature("statements:screen:ghost-screen")])).toThrow(
2265
+ /redirect "statements:screen:ghost-screen" does not resolve to a registered screen/,
2266
+ );
2267
+ });
2268
+ });
2269
+
2270
+ // --- no-widget field types + required (#1925) ---
2271
+ // jsonb/embedded/files/images render read-only on the auto-wired
2272
+ // entityEdit path (render-field.tsx) — a statically-`required: true`
2273
+ // field of one of these types would be unresolvable by the user, so it's
2274
+ // caught loudly at boot instead of silently accepting an unfillable form.
2275
+ // Only the static case (literal `true`, entity-level or screen-spec) is
2276
+ // checked; a dynamic FieldCondition can't be evaluated without runtime
2277
+ // form values and is a documented, accepted gap.
2278
+ describe("no-widget field types + required (#1925)", () => {
2279
+ test("embedded field, entity-level required: true → Throw", () => {
2280
+ const features = [
2281
+ defineFeature("shop", (r) => {
2282
+ r.entity(
2283
+ "order",
2284
+ createEntity({
2285
+ fields: {
2286
+ lines: createEmbeddedField({ note: { type: "text" } }, { required: true }),
2287
+ },
2288
+ }),
2289
+ );
2290
+ r.screen({
2291
+ id: "order-edit",
2292
+ type: "entityEdit",
2293
+ entity: "order",
2294
+ layout: { sections: [{ fields: ["lines"] }] },
2295
+ });
2296
+ }),
2297
+ ];
2298
+ expect(() => validateBoot(features)).toThrow(
2299
+ /field "lines" is type "embedded", which renders read-only/,
2300
+ );
2301
+ });
2302
+
2303
+ test("embedded field, entity-level required: true, screen-spec required: false override → kein Throw", () => {
2304
+ const features = [
2305
+ defineFeature("shop", (r) => {
2306
+ r.entity(
2307
+ "order",
2308
+ createEntity({
2309
+ fields: {
2310
+ lines: createEmbeddedField({ note: { type: "text" } }, { required: true }),
2311
+ },
2312
+ }),
2313
+ );
2314
+ r.screen({
2315
+ id: "order-edit",
2316
+ type: "entityEdit",
2317
+ entity: "order",
2318
+ layout: { sections: [{ fields: [{ field: "lines", required: false }] }] },
2319
+ });
2320
+ }),
2321
+ ];
2322
+ expect(() => validateBoot(features)).not.toThrow();
2323
+ });
2324
+
2325
+ test("embedded field, entity-level required: true, screen-spec readOnly: true → kein Throw", () => {
2326
+ const features = [
2327
+ defineFeature("shop", (r) => {
2328
+ r.entity(
2329
+ "order",
2330
+ createEntity({
2331
+ fields: {
2332
+ lines: createEmbeddedField({ note: { type: "text" } }, { required: true }),
2333
+ },
2334
+ }),
2335
+ );
2336
+ r.screen({
2337
+ id: "order-edit",
2338
+ type: "entityEdit",
2339
+ entity: "order",
2340
+ layout: { sections: [{ fields: [{ field: "lines", readOnly: true }] }] },
2341
+ });
2342
+ }),
2343
+ ];
2344
+ expect(() => validateBoot(features)).not.toThrow();
2345
+ });
2346
+
2347
+ test("embedded field, entity-level required: true, dynamic screen-spec required condition → kein Throw (not statically resolvable)", () => {
2348
+ const features = [
2349
+ defineFeature("shop", (r) => {
2350
+ r.entity(
2351
+ "order",
2352
+ createEntity({
2353
+ fields: {
2354
+ kind: createTextField(),
2355
+ lines: createEmbeddedField({ note: { type: "text" } }, { required: true }),
2356
+ },
2357
+ }),
2358
+ );
2359
+ r.screen({
2360
+ id: "order-edit",
2361
+ type: "entityEdit",
2362
+ entity: "order",
2363
+ layout: {
2364
+ sections: [
2365
+ { fields: ["kind", { field: "lines", required: { field: "kind", eq: "b2b" } }] },
2366
+ ],
2367
+ },
2368
+ });
2369
+ }),
2370
+ ];
2371
+ expect(() => validateBoot(features)).not.toThrow();
2372
+ });
2373
+
2374
+ test("jsonb field, screen-spec required: true override → Throw (JsonbFieldDef has no entity-level required)", () => {
2375
+ const features = [
2376
+ defineFeature("shop", (r) => {
2377
+ r.entity("order", createEntity({ fields: { data: createJsonbField() } }));
2378
+ r.screen({
2379
+ id: "order-edit",
2380
+ type: "entityEdit",
2381
+ entity: "order",
2382
+ layout: { sections: [{ fields: [{ field: "data", required: true }] }] },
2383
+ });
2384
+ }),
2385
+ ];
2386
+ expect(() => validateBoot(features)).toThrow(
2387
+ /field "data" is type "jsonb", which renders read-only/,
2388
+ );
2389
+ });
2390
+
2391
+ test("files field, screen-spec required: true override → Throw (deliberately deferred, no multi-upload widget)", () => {
2392
+ const features = [
2393
+ defineFeature("shop", (r) => {
2394
+ r.entity("order", createEntity({ fields: { attachments: createFilesField() } }));
2395
+ r.screen({
2396
+ id: "order-edit",
2397
+ type: "entityEdit",
2398
+ entity: "order",
2399
+ layout: { sections: [{ fields: [{ field: "attachments", required: true }] }] },
2400
+ });
2401
+ }),
2402
+ ];
2403
+ expect(() => validateBoot(features)).toThrow(
2404
+ /field "attachments" is type "files", which renders read-only/,
2405
+ );
2406
+ });
2407
+
2408
+ test("multiSelect field, entity-level required: true → kein Throw (has a combobox widget since #1925)", () => {
2409
+ const features = [
2410
+ defineFeature("shop", (r) => {
2411
+ r.entity(
2412
+ "product",
2413
+ createEntity({
2414
+ fields: {
2415
+ tags: createMultiSelectField({ options: ["a", "b"] as const, required: true }),
2416
+ },
2417
+ }),
2418
+ );
2419
+ r.screen({
2420
+ id: "product-edit",
2421
+ type: "entityEdit",
2422
+ entity: "product",
2423
+ layout: { sections: [{ fields: ["tags"] }] },
2424
+ });
2425
+ }),
2426
+ ];
2427
+ expect(() => validateBoot(features)).not.toThrow();
2428
+ });
2429
+
2430
+ test("embedded LIST field (multiple: true), entity-level required: true → kein Throw (has an EmbeddedListField grid widget since #1838)", () => {
2431
+ const features = [
2432
+ defineFeature("shop", (r) => {
2433
+ r.entity(
2434
+ "invoice",
2435
+ createEntity({
2436
+ fields: {
2437
+ lines: createEmbeddedListField({ note: { type: "text" } }, { required: true }),
2438
+ },
2439
+ }),
2440
+ );
2441
+ r.screen({
2442
+ id: "invoice-edit",
2443
+ type: "entityEdit",
2444
+ entity: "invoice",
2445
+ layout: { sections: [{ fields: ["lines"] }] },
2446
+ });
2447
+ }),
2448
+ ];
2449
+ expect(() => validateBoot(features)).not.toThrow();
2450
+ });
2451
+ });
2452
+
2049
2453
  // --- Tier 2.7e-3: ReferenceFieldDef ---
2050
2454
  describe("reference field (Tier 2.7e-3)", () => {
2051
2455
  test("reference auf bestehende Entity → kein Throw", () => {
@@ -160,7 +160,7 @@ describe("buildAppSchema", () => {
160
160
  });
161
161
  const app = buildAppSchema(createRegistry([f]));
162
162
  const fields = (
163
- app.features[0]?.entities["thing"] as unknown as {
163
+ app.features[0]!.entities["thing"] as unknown as {
164
164
  fields: Record<string, Record<string, unknown>>;
165
165
  }
166
166
  ).fields;
@@ -186,7 +186,7 @@ describe("buildAppSchema", () => {
186
186
  });
187
187
  const app = buildAppSchema(createRegistry([f]));
188
188
  const fields = (
189
- app.features[0]?.entities["thing"] as unknown as {
189
+ app.features[0]!.entities["thing"] as unknown as {
190
190
  fields: Record<string, Record<string, unknown>>;
191
191
  }
192
192
  ).fields;
@@ -207,7 +207,7 @@ describe("buildAppSchema", () => {
207
207
  });
208
208
  const app = buildAppSchema(createRegistry([f]));
209
209
  const fields = (
210
- app.features[0]?.entities["thing"] as unknown as {
210
+ app.features[0]!.entities["thing"] as unknown as {
211
211
  fields: Record<string, Record<string, unknown>>;
212
212
  }
213
213
  ).fields;
@@ -228,7 +228,7 @@ describe("buildAppSchema", () => {
228
228
  });
229
229
  const app = buildAppSchema(createRegistry([f]));
230
230
  const fields = (
231
- app.features[0]?.entities["thing"] as unknown as {
231
+ app.features[0]!.entities["thing"] as unknown as {
232
232
  fields: Record<string, Record<string, unknown>>;
233
233
  }
234
234
  ).fields;
@@ -634,14 +634,13 @@ describe("hasAccess", () => {
634
634
  { userRoles: [], requiredRoles: ["Admin"], expected: false },
635
635
  // Empty required-roles list denies everyone under default-deny.
636
636
  { userRoles: ["Admin"], requiredRoles: [], expected: false },
637
- ])("user $userRoles vs required $requiredRoles → $expected", ({
638
- userRoles,
639
- requiredRoles,
640
- expected,
641
- }) => {
642
- const user = createTestUser({ roles: userRoles });
643
- expect(hasAccess(user, { roles: requiredRoles })).toBe(expected);
644
- });
637
+ ])(
638
+ "user $userRoles vs required $requiredRoles → $expected",
639
+ ({ userRoles, requiredRoles, expected }) => {
640
+ const user = createTestUser({ roles: userRoles });
641
+ expect(hasAccess(user, { roles: requiredRoles })).toBe(expected);
642
+ },
643
+ );
645
644
 
646
645
  test("missing access rule denies access (default-deny)", () => {
647
646
  const user = createTestUser({ roles: ["Employee"] });
@@ -120,7 +120,7 @@ describe("softDeleteCleanupJob handler", () => {
120
120
  test("cutoff defaults to DEFAULT_GRACE_DAYS when no config resolver", async () => {
121
121
  const calls: DeleteCall[] = [];
122
122
  await softDeleteCleanupJob({}, makeCtx({ calls }));
123
- const cutoff = (calls[0]?.where["deletedAt"] as { lt: Temporal.Instant }).lt;
123
+ const cutoff = (calls[0]!.where["deletedAt"] as { lt: Temporal.Instant }).lt;
124
124
  const expected = Temporal.Now.instant().subtract({ hours: DEFAULT_GRACE_DAYS * 24 });
125
125
  expect(Math.abs(cutoff.epochMilliseconds - expected.epochMilliseconds)).toBeLessThan(10_000);
126
126
  });
@@ -128,7 +128,7 @@ describe("softDeleteCleanupJob handler", () => {
128
128
  test("honours a per-tenant grace-days value from the config resolver", async () => {
129
129
  const calls: DeleteCall[] = [];
130
130
  await softDeleteCleanupJob({}, makeCtx({ calls, graceDays: 7 }));
131
- const cutoff = (calls[0]?.where["deletedAt"] as { lt: Temporal.Instant }).lt;
131
+ const cutoff = (calls[0]!.where["deletedAt"] as { lt: Temporal.Instant }).lt;
132
132
  const expected = Temporal.Now.instant().subtract({ hours: 7 * 24 });
133
133
  expect(Math.abs(cutoff.epochMilliseconds - expected.epochMilliseconds)).toBeLessThan(10_000);
134
134
  });
@@ -155,7 +155,7 @@ describe("softDeleteCleanupSystemJob handler", () => {
155
155
  test("cutoff is DEFAULT_GRACE_DAYS — no per-tenant config to read", async () => {
156
156
  const calls: DeleteCall[] = [];
157
157
  await softDeleteCleanupSystemJob({}, makeCtx({ calls }));
158
- const cutoff = (calls[0]?.where["deletedAt"] as { lt: Temporal.Instant }).lt;
158
+ const cutoff = (calls[0]!.where["deletedAt"] as { lt: Temporal.Instant }).lt;
159
159
  const expected = Temporal.Now.instant().subtract({ hours: DEFAULT_GRACE_DAYS * 24 });
160
160
  expect(Math.abs(cutoff.epochMilliseconds - expected.epochMilliseconds)).toBeLessThan(10_000);
161
161
  });