@cosmicdrift/kumiko-renderer 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.
Files changed (36) hide show
  1. package/package.json +5 -4
  2. package/src/__tests__/error-i18n-defaults.test.ts +13 -0
  3. package/src/__tests__/format-when.test.ts +12 -0
  4. package/src/__tests__/i18n.test.tsx +59 -0
  5. package/src/__tests__/qn.test.ts +44 -1
  6. package/src/__tests__/sort-by-accessor.test.ts +48 -0
  7. package/src/app/__tests__/config-edit-shim.test.ts +40 -0
  8. package/src/app/__tests__/screen-access-allows.test.ts +24 -0
  9. package/src/app/dashboard-body.tsx +32 -0
  10. package/src/app/extension-sections.tsx +11 -4
  11. package/src/app/kumiko-screen.tsx +408 -20
  12. package/src/app/projection-detail-shim.ts +72 -0
  13. package/src/app/projection-list-shim.ts +62 -0
  14. package/src/app/qn.ts +13 -0
  15. package/src/components/__tests__/render-field-app-locale.test.tsx +2 -0
  16. package/src/components/render-edit-logic.ts +8 -5
  17. package/src/components/render-edit.tsx +25 -2
  18. package/src/components/render-field.tsx +2 -1
  19. package/src/components/render-list.tsx +24 -2
  20. package/src/context/user-roles-context.tsx +27 -0
  21. package/src/format-when.ts +11 -0
  22. package/src/hooks/__tests__/use-ai-text.test.tsx +177 -0
  23. package/src/hooks/__tests__/use-disclosure.test.tsx +25 -0
  24. package/src/hooks/__tests__/use-mutation.test.tsx +77 -0
  25. package/src/hooks/__tests__/use-stream-handler.test.tsx +107 -0
  26. package/src/hooks/use-ai-text.ts +172 -0
  27. package/src/hooks/use-disclosure.ts +20 -0
  28. package/src/hooks/use-mutation.ts +61 -0
  29. package/src/hooks/use-query.ts +5 -2
  30. package/src/hooks/use-reference-lookup.ts +2 -1
  31. package/src/hooks/use-stream-handler.ts +134 -0
  32. package/src/i18n-defaults.ts +56 -0
  33. package/src/i18n.tsx +72 -31
  34. package/src/index.ts +31 -0
  35. package/src/primitives.tsx +70 -4
  36. package/src/sort-by-accessor.ts +20 -0
@@ -0,0 +1,72 @@
1
+ // Shim für ProjectionDetail-Renderer (analog zu projection-list-shim.ts /
2
+ // config-edit-shim.ts).
3
+ //
4
+ // RenderEdit verlangt ein `entity: EntityDefinition` + ein
5
+ // `screen: EntityEditScreenDefinition`-Pair, nutzt davon aber nur
6
+ // `entity.fields` (Feld-Typ für den Input-Renderer) und `screen.layout`
7
+ // (welche Felder in welchen Sections). Ein projectionDetail-Screen ist
8
+ // query-getrieben und hat KEINE Entity — die zwei Helper hier shapen den
9
+ // Input ad-hoc um, damit RenderEdit reused werden kann.
10
+ //
11
+ // Der strukturelle Read-Only-Beweis liegt in synthesizeProjectionDetailScreen:
12
+ // JEDES Feld wird hart auf readOnly:true gesetzt (nicht nur was der Author im
13
+ // Layout gesetzt hat) — hasEditableSection() liest genau dieses Flag und
14
+ // blendet den Save-Button aus, wenn kein Feld editierbar ist. Der Author kann
15
+ // diese Garantie nicht versehentlich umgehen.
16
+ //
17
+ // Selbe Schulden-Reservation wie bei den anderen Shims: greift RenderEdit
18
+ // künftig auf weitere EntityDefinition-Felder (transitions, idType) zu, oder
19
+ // cross-referenziert ein Boot-Validator die schema.entities-Map, brechen die
20
+ // Type-Lies hier silent — dann ist Zeit für eine echte RenderProjectionDetail-
21
+ // Komponente.
22
+
23
+ import type {
24
+ EditLayout,
25
+ EntityDefinition,
26
+ EntityEditScreenDefinition,
27
+ ProjectionDetailScreenDefinition,
28
+ } from "@cosmicdrift/kumiko-framework/ui-types";
29
+ import {
30
+ isExtensionEditSection,
31
+ normalizeEditField,
32
+ PROJECTION_DETAIL_ENTITY as PROJECTION_DETAIL_PSEUDO_ENTITY,
33
+ } from "@cosmicdrift/kumiko-framework/ui-types";
34
+
35
+ /** Minimale EntityDefinition aus den Layout-Feldern: jedes Feld ein Text-
36
+ * Feld — computeEditViewModel liest nur `fields[<f>].type`, Text reicht für
37
+ * eine reine Anzeige (kein Select/Number-spezifisches Rendering nötig). */
38
+ export function synthesizeProjectionDetailEntity(layout: EditLayout): EntityDefinition {
39
+ const fields: Record<string, { type: "text" }> = {};
40
+ for (const section of layout.sections) {
41
+ if (isExtensionEditSection(section)) continue; // rejected at boot, defensive here
42
+ for (const spec of section.fields) {
43
+ fields[normalizeEditField(spec).field] = { type: "text" };
44
+ }
45
+ }
46
+ return { fields } as unknown as EntityDefinition;
47
+ }
48
+
49
+ /** Wandelt ein ProjectionDetailScreenDefinition in die EntityEditScreen-Shape
50
+ * die RenderEdit erwartet — mit jedem Feld hart auf readOnly:true erzwungen. */
51
+ export function synthesizeProjectionDetailScreen(
52
+ screen: ProjectionDetailScreenDefinition,
53
+ ): EntityEditScreenDefinition {
54
+ const sections = screen.layout.sections.map((section) => {
55
+ if (isExtensionEditSection(section)) return section; // rejected at boot, defensive here
56
+ return {
57
+ ...section,
58
+ fields: section.fields.map((spec) => ({ ...normalizeEditField(spec), readOnly: true })),
59
+ };
60
+ });
61
+ return {
62
+ id: screen.id,
63
+ type: "entityEdit",
64
+ entity: PROJECTION_DETAIL_PSEUDO_ENTITY,
65
+ layout: { sections },
66
+ allowCreate: false,
67
+ allowDelete: false,
68
+ ...(screen.fieldLabels !== undefined && { fieldLabels: screen.fieldLabels }),
69
+ ...(screen.slots !== undefined && { slots: screen.slots }),
70
+ ...(screen.access !== undefined && { access: screen.access }),
71
+ };
72
+ }
@@ -0,0 +1,62 @@
1
+ // Shim für ProjectionList-Renderer (analog action-form-shim.ts / config-edit-shim.ts).
2
+ //
3
+ // RenderList + computeListViewModel verlangen ein `entity: EntityDefinition` +
4
+ // ein `screen: EntityListScreenDefinition`-Pair, nutzen davon aber nur
5
+ // `entity.fields` (Spalten-Typ/Label) und die List-Felder des Screens
6
+ // (columns/rowActions/…). Ein projectionList-Screen ist query-getrieben und hat
7
+ // KEINE Entity — die zwei Helper hier shapen den Input ad-hoc um, damit die
8
+ // bestehende List-Maschinerie reused werden kann. Die Query selbst wird NICHT
9
+ // hierüber aufgelöst (der Body nimmt `screen.query` direkt).
10
+ //
11
+ // Selbe Schulden-Reservation wie bei den anderen Shims: greift die List-
12
+ // Maschinerie künftig auf weitere EntityDefinition-Felder (transitions, idType,
13
+ // derivedFields) oder cross-referenziert ein Boot-Validator die schema.entities-
14
+ // Map, brechen die Type-Lies hier silent — dann ist Zeit für eine echte
15
+ // query-native ListView-Komponente.
16
+
17
+ import type {
18
+ EntityDefinition,
19
+ EntityListScreenDefinition,
20
+ ListColumnSpec,
21
+ ProjectionListScreenDefinition,
22
+ } from "@cosmicdrift/kumiko-framework/ui-types";
23
+ import { normalizeListColumn } from "@cosmicdrift/kumiko-framework/ui-types";
24
+
25
+ const PROJECTION_PSEUDO_ENTITY = "__projection__";
26
+
27
+ /** Minimale EntityDefinition aus den Column-Feldern: jedes Feld ein Text-Feld,
28
+ * nicht sortierbar (eine Projection-Query hat keinen garantierten Server-Sort).
29
+ * computeListViewModel liest nur `fields[<col>].type` → Text reicht; die
30
+ * Präsentation kommt aus dem Column-Renderer + explizitem Label. */
31
+ export function synthesizeProjectionEntity(columns: readonly ListColumnSpec[]): EntityDefinition {
32
+ const fields: Record<string, { type: "text"; sortable: false }> = {};
33
+ for (const col of columns) {
34
+ fields[normalizeListColumn(col).field] = { type: "text", sortable: false };
35
+ }
36
+ return { fields } as unknown as EntityDefinition;
37
+ }
38
+
39
+ /** Wandelt ein ProjectionListScreenDefinition in die EntityListScreen-Shape die
40
+ * RenderList erwartet. `type` = "entityList" + Pseudo-Entity halten den Type-
41
+ * Constraint; RenderList branched nicht auf `type` und liest `entity` nur für
42
+ * Fehlermeldungen. Die List-Felder werden 1:1 durchgereicht. */
43
+ export function synthesizeProjectionScreen(
44
+ screen: ProjectionListScreenDefinition,
45
+ ): EntityListScreenDefinition {
46
+ return {
47
+ id: screen.id,
48
+ type: "entityList",
49
+ entity: PROJECTION_PSEUDO_ENTITY,
50
+ columns: screen.columns,
51
+ ...(screen.rowRenderer !== undefined && { rowRenderer: screen.rowRenderer }),
52
+ ...(screen.cardRenderer !== undefined && { cardRenderer: screen.cardRenderer }),
53
+ ...(screen.rowActions !== undefined && { rowActions: screen.rowActions }),
54
+ ...(screen.toolbarActions !== undefined && { toolbarActions: screen.toolbarActions }),
55
+ ...(screen.pagination !== undefined && { pagination: screen.pagination }),
56
+ ...(screen.pageSize !== undefined && { pageSize: screen.pageSize }),
57
+ ...(screen.defaultSort !== undefined && { defaultSort: screen.defaultSort }),
58
+ ...(screen.searchable !== undefined && { searchable: screen.searchable }),
59
+ ...(screen.slots !== undefined && { slots: screen.slots }),
60
+ ...(screen.access !== undefined && { access: screen.access }),
61
+ };
62
+ }
package/src/app/qn.ts CHANGED
@@ -21,3 +21,16 @@ export function lastSegment(qn: string): string {
21
21
  const idx = qn.lastIndexOf(":");
22
22
  return idx < 0 ? qn : qn.slice(idx + 1);
23
23
  }
24
+
25
+ /**
26
+ * Client-safe copy of framework `toKebab` (engine/qualified-name.ts).
27
+ * Must stay in sync — do NOT import from `@cosmicdrift/kumiko-framework/engine`
28
+ * here: that barrel pulls server deps into the browser bundle.
29
+ */
30
+ export function toKebab(input: string): string {
31
+ return input
32
+ .replace(/\./g, "-")
33
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2")
34
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
35
+ .toLowerCase();
36
+ }
@@ -37,8 +37,10 @@ const testPrimitives: CorePrimitives = {
37
37
  Text: noop,
38
38
  Heading: noop,
39
39
  Dialog: noop,
40
+ Lightbox: noop,
40
41
  ConfigSourceBadge: noop,
41
42
  ConfigCascadeView: noop,
43
+ Link: noop,
42
44
  };
43
45
 
44
46
  function moneyField(): EditFieldViewModel {
@@ -1,11 +1,14 @@
1
1
  import type { EditSectionViewModel, SubmitResult } from "@cosmicdrift/kumiko-headless";
2
2
 
3
- // A read-only inspector form every field readOnly, no editable section — has
4
- // nothing to submit, so the renderer drops the Save button rather than show a
5
- // dead disabled one. An extension section carries its own dirty/save, so it
6
- // counts as editable.
3
+ // Hides the Save button when no field is editable and no extension section
4
+ // exists (extension carries its own dirty/save; a purely read-only form has nothing to submit).
5
+ // Explicit-positive per kind (653/1), not `s.kind !== "fields"` a future
6
+ // third EditSectionViewModel member would otherwise default to "editable"
7
+ // without anyone deciding that on purpose.
7
8
  export function hasEditableSection(sections: readonly EditSectionViewModel[]): boolean {
8
- return sections.some((s) => s.kind !== "fields" || s.fields.some((f) => !f.readOnly));
9
+ return sections.some(
10
+ (s) => s.kind === "extension" || (s.kind === "fields" && s.fields.some((f) => !f.readOnly)),
11
+ );
9
12
  }
10
13
 
11
14
  // Single source of truth for the extension-section entity-id. The section mount
@@ -76,6 +76,13 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
76
76
  readonly onDelete?: () => Promise<void> | void;
77
77
  readonly onCancel?: () => void;
78
78
  readonly onReload?: () => void;
79
+ /** Copy-Link-Action (Issue #912) — nur in update-mode gesetzt (create-mode
80
+ * hat noch keine entity-id, also keinen Permalink). Der Callback ist
81
+ * bereits vollständig gebunden (URL-Bau + Clipboard passiert außerhalb,
82
+ * in `@cosmicdrift/kumiko-renderer-web`'s RoutedScreen — dieses
83
+ * platform-neutrale Package darf kein `navigator`/`window` anfassen,
84
+ * siehe guard-renderer-boundaries). undefined = kein Button. */
85
+ readonly onCopyLink?: () => Promise<void> | void;
79
86
  /** i18n-key für den Submit-Button. Default: "kumiko.actions.save".
80
87
  * Action-Forms (Tier 2.7d) übergeben hier ihren screen.submitLabel,
81
88
  * damit "Speichern" durch domain-spezifischere Strings ersetzt
@@ -185,6 +192,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
185
192
  onDelete,
186
193
  onCancel,
187
194
  onReload,
195
+ onCopyLink,
188
196
  submitLabel,
189
197
  labelAppendix,
190
198
  fieldAppendix,
@@ -201,6 +209,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
201
209
  const translate = translateProp ?? t;
202
210
 
203
211
  const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
212
+ const [linkCopied, setLinkCopied] = useState(false);
204
213
  const [isSubmitting, setIsSubmitting] = useState(false);
205
214
  const [formError, setFormError] = useState<DispatcherError | null>(null);
206
215
  // Composed-Save: Extension-Sections melden hier ihren dirty-State (damit der
@@ -247,7 +256,8 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
247
256
  [screen, entity, snapshot.values, translate, featureName],
248
257
  );
249
258
 
250
- const hasEditableField = hasEditableSection(vm.sections);
259
+ // true for an extension section with no fields of its own too (it carries its own dirty/save).
260
+ const isFormEditable = hasEditableSection(vm.sections);
251
261
 
252
262
  // Persistiert alle composed Extension-Sections mit der aufgelösten entityId.
253
263
  // false = eine Section schlug fehl (ihr i18n-Key landet im Banner). Ohne
@@ -335,6 +345,19 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
335
345
  // gegen Fehlklicks. Save bleibt rechts (primary affordance).
336
346
  const formActions = (
337
347
  <>
348
+ {onCopyLink !== undefined && (
349
+ <Button
350
+ type="button"
351
+ variant="secondary"
352
+ testId="render-edit-copy-link"
353
+ onClick={async () => {
354
+ await onCopyLink();
355
+ setLinkCopied(true);
356
+ }}
357
+ >
358
+ {translate(linkCopied ? "kumiko.actions.copyLinkCopied" : "kumiko.actions.copyLink")}
359
+ </Button>
360
+ )}
338
361
  {onDelete !== undefined && (
339
362
  <Button
340
363
  type="button"
@@ -355,7 +378,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
355
378
  {translate("kumiko.actions.cancel")}
356
379
  </Button>
357
380
  )}
358
- {hasEditableField && (
381
+ {isFormEditable && (
359
382
  <Button
360
383
  type="submit"
361
384
  disabled={(snapshot.isUnchanged && !extensionDirty) || isSubmitting}
@@ -1,5 +1,6 @@
1
1
  import type { EditFieldViewModel, FieldIssue } from "@cosmicdrift/kumiko-headless";
2
2
  import { type ReactNode, useCallback, useMemo, useState } from "react";
3
+ import { toKebab } from "../app/qn";
3
4
  import { REFERENCE_COMBOBOX_LIMIT } from "../hooks/reference-limits";
4
5
  import { useQuery } from "../hooks/use-query";
5
6
  import { useLocale } from "../i18n";
@@ -118,7 +119,7 @@ function ReferenceInput({
118
119
  // Tier 2.7e Cross-Feature: refFeature kann ≠ featureName sein
119
120
  // (z.B. items.assignee → users:query:user:list). Default ist
120
121
  // same-feature, kommt aus dem ViewModel (parseRefTarget).
121
- const queryQn = `${refFeature}:query:${refEntity}:list`;
122
+ const queryQn = `${toKebab(refFeature)}:query:${toKebab(refEntity)}:list`;
122
123
  // Tier 2.7e Remote-Search: User tippt im Combobox → Server filtert
123
124
  // via existing list-payload `search`-Param (Tier 2.6c). Combobox
124
125
  // debounced den keystroke selbst (300ms) und ruft onSearchChange.
@@ -148,7 +148,7 @@ export function RenderList(props: RenderListProps): ReactNode {
148
148
  // wären Column-Header raw i18n-Keys.
149
149
  const t = useTranslation();
150
150
  const translate: Translate = translateProp ?? t;
151
- const { DataTable, Button, Dialog, Input, Text } = usePrimitives();
151
+ const { DataTable, Button, Dialog, Input, Text, Banner } = usePrimitives();
152
152
 
153
153
  // Local Search-Buffer + Debounce. Externe Änderungen (Browser-Back,
154
154
  // Cross-Component-Reset) spiegeln wir per Sync-Effect zurück; Tipps
@@ -280,7 +280,13 @@ export function RenderList(props: RenderListProps): ReactNode {
280
280
  {hasHeaderSlot && <ListHeaderSlotMount screen={screen} />}
281
281
  {hasToolbarActions &&
282
282
  toolbarActions.map((a) => (
283
- <ToolbarActionView key={a.id} action={a} Button={Button} Dialog={Dialog} />
283
+ <ToolbarActionView
284
+ key={a.id}
285
+ action={a}
286
+ Button={Button}
287
+ Dialog={Dialog}
288
+ Banner={Banner}
289
+ />
284
290
  ))}
285
291
  {onCreate !== undefined && (
286
292
  <Button variant="primary" onClick={onCreate} testId="render-list-create">
@@ -423,18 +429,29 @@ function ToolbarActionView({
423
429
  action,
424
430
  Button,
425
431
  Dialog,
432
+ Banner,
426
433
  }: {
427
434
  readonly action: ToolbarActionButton;
428
435
  readonly Button: ReturnType<typeof usePrimitives>["Button"];
429
436
  readonly Dialog: ReturnType<typeof usePrimitives>["Dialog"];
437
+ readonly Banner: ReturnType<typeof usePrimitives>["Banner"];
430
438
  }): ReactNode {
431
439
  const [busy, setBusy] = useState(false);
432
440
  const [confirmOpen, setConfirmOpen] = useState(false);
441
+ // Toolbar-Actions haben (anders als rowActions, die über die DataTable-
442
+ // Primitive laufen) keine Toast-Primitive zur Verfügung — ein inline
443
+ // Error-Banner ist der surfacing-Pfad hier. Gleicher Grund wie bei
444
+ // rowActions (Prod-Bug 2026-06-07): ein verschlucktes Failure-Result
445
+ // sieht für den User wie "nichts passiert" aus.
446
+ const [errorText, setErrorText] = useState<string | null>(null);
433
447
 
434
448
  const trigger = async (): Promise<void> => {
435
449
  setBusy(true);
450
+ setErrorText(null);
436
451
  try {
437
452
  await action.onTrigger();
453
+ } catch (e) {
454
+ setErrorText(e instanceof Error ? e.message : String(e));
438
455
  } finally {
439
456
  setBusy(false);
440
457
  }
@@ -469,6 +486,11 @@ function ToolbarActionView({
469
486
  onConfirm={trigger}
470
487
  testId={`render-list-toolbar-action-${action.id}-dialog`}
471
488
  />
489
+ {errorText !== null && (
490
+ <Banner variant="error" testId={`render-list-toolbar-action-${action.id}-error`}>
491
+ {errorText}
492
+ </Banner>
493
+ )}
472
494
  </>
473
495
  );
474
496
  }
@@ -0,0 +1,27 @@
1
+ import { createContext, type ReactNode, useContext } from "react";
2
+
3
+ // Threads the current user's roles through the tree so KumikoScreen can
4
+ // gate role-restricted screens at render time (see #1203 — nav filtering
5
+ // alone doesn't stop a direct-URL/screenQn hit on a role-gated screen).
6
+ // Apps wire this from their `shell` render-prop, the same place they
7
+ // already pass `user` to WorkspaceShell for nav filtering.
8
+ //
9
+ // undefined (no provider mounted, or `roles` not passed) means "roles
10
+ // unknown" — deliberately distinct from `[]` ("authenticated, no
11
+ // roles"). Both deny role-gated screens; only `undefined` also lets an
12
+ // app without any role-gated screens skip wiring this entirely.
13
+
14
+ const UserRolesContext = createContext<readonly string[] | undefined>(undefined);
15
+
16
+ export type UserRolesProviderProps = {
17
+ readonly roles: readonly string[] | undefined;
18
+ readonly children: ReactNode;
19
+ };
20
+
21
+ export function UserRolesProvider({ roles, children }: UserRolesProviderProps): ReactNode {
22
+ return <UserRolesContext value={roles}>{children}</UserRolesContext>;
23
+ }
24
+
25
+ export function useUserRoles(): readonly string[] | undefined {
26
+ return useContext(UserRolesContext);
27
+ }
@@ -0,0 +1,11 @@
1
+ import { toInstant } from "@cosmicdrift/kumiko-headless";
2
+
3
+ // Shared timestamp formatter for operator screens (audit log, job runs) —
4
+ // falls back to the raw ISO string on an unparseable value instead of "Invalid Date".
5
+ export function formatWhen(value: string): string {
6
+ try {
7
+ return toInstant(value).toLocaleString();
8
+ } catch {
9
+ return value;
10
+ }
11
+ }
@@ -0,0 +1,177 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
3
+ import { act, renderHook, waitFor } from "@testing-library/react";
4
+ import type { ReactNode } from "react";
5
+ import { DispatcherProvider } from "../../context/dispatcher-context";
6
+ import { useAiTextAction, useCompletion } from "../use-ai-text";
7
+
8
+ function makeDispatcher(query: Dispatcher["query"]): Dispatcher {
9
+ return {
10
+ query,
11
+ write: (async () => ({ isSuccess: true, data: {} })) as unknown as Dispatcher["write"],
12
+ batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
13
+ statusStore: {
14
+ getState: () => "online",
15
+ subscribe: () => () => {},
16
+ } as unknown as Dispatcher["statusStore"],
17
+ async *stream() {},
18
+ pendingWrites: () => [],
19
+ pendingFiles: () => [],
20
+ };
21
+ }
22
+
23
+ function wrapperFor(dispatcher: Dispatcher) {
24
+ return ({ children }: { readonly children: ReactNode }) => (
25
+ <DispatcherProvider dispatcher={dispatcher}>{children}</DispatcherProvider>
26
+ );
27
+ }
28
+
29
+ describe("useAiTextAction", () => {
30
+ test("success → state='success', result carries the text", async () => {
31
+ const dispatcher = makeDispatcher((async (_type: string, payload: unknown) => ({
32
+ isSuccess: true,
33
+ data: {
34
+ type: "text",
35
+ text: `echo:${(payload as { text: string }).text}`,
36
+ usage: { inputTokens: 1, outputTokens: 1 },
37
+ },
38
+ })) as unknown as Dispatcher["query"]);
39
+
40
+ const { result } = renderHook(() => useAiTextAction(), { wrapper: wrapperFor(dispatcher) });
41
+
42
+ expect(result.current.state).toBe("idle");
43
+ await act(async () => {
44
+ await result.current.run({ mode: "correct", text: "hi" });
45
+ });
46
+
47
+ expect(result.current.state).toBe("success");
48
+ expect(result.current.result).toEqual({
49
+ type: "text",
50
+ text: "echo:hi",
51
+ usage: { inputTokens: 1, outputTokens: 1 },
52
+ });
53
+ expect(result.current.error).toBeNull();
54
+ });
55
+
56
+ test("cap_exceeded error → state='cap-exceeded'", async () => {
57
+ const dispatcher = makeDispatcher((async () => ({
58
+ isSuccess: false,
59
+ error: { code: "cap_exceeded", message: "capped", i18nKey: "errors.cap" },
60
+ })) as unknown as Dispatcher["query"]);
61
+
62
+ const { result } = renderHook(() => useAiTextAction(), { wrapper: wrapperFor(dispatcher) });
63
+
64
+ await act(async () => {
65
+ await result.current.run({ mode: "correct", text: "hi" });
66
+ });
67
+
68
+ expect(result.current.state).toBe("cap-exceeded");
69
+ expect(result.current.error?.code).toBe("cap_exceeded");
70
+ });
71
+
72
+ test("feature_disabled error → state='unavailable' (graceful degradation)", async () => {
73
+ const dispatcher = makeDispatcher((async () => ({
74
+ isSuccess: false,
75
+ error: { code: "feature_disabled", message: "off", i18nKey: "errors.disabled" },
76
+ })) as unknown as Dispatcher["query"]);
77
+
78
+ const { result } = renderHook(() => useAiTextAction(), { wrapper: wrapperFor(dispatcher) });
79
+
80
+ await act(async () => {
81
+ await result.current.run({ mode: "complete", text: "hi" });
82
+ });
83
+
84
+ expect(result.current.state).toBe("unavailable");
85
+ });
86
+
87
+ test("reset clears state/result/error back to idle", async () => {
88
+ const dispatcher = makeDispatcher((async () => ({
89
+ isSuccess: false,
90
+ error: { code: "conflict", message: "boom", i18nKey: "errors.conflict" },
91
+ })) as unknown as Dispatcher["query"]);
92
+
93
+ const { result } = renderHook(() => useAiTextAction(), { wrapper: wrapperFor(dispatcher) });
94
+ await act(async () => {
95
+ await result.current.run({ mode: "correct", text: "hi" });
96
+ });
97
+ expect(result.current.state).toBe("error");
98
+
99
+ act(() => result.current.reset());
100
+ expect(result.current.state).toBe("idle");
101
+ expect(result.current.error).toBeNull();
102
+ expect(result.current.result).toBeNull();
103
+ });
104
+ });
105
+
106
+ describe("useCompletion", () => {
107
+ test("debounces requestCompletion — only the last call within the window fires", async () => {
108
+ let calls = 0;
109
+ const dispatcher = makeDispatcher((async (_type: string, payload: unknown) => {
110
+ calls++;
111
+ return {
112
+ isSuccess: true,
113
+ data: {
114
+ type: "text",
115
+ text: `suggestion for "${(payload as { text: string }).text}"`,
116
+ usage: { inputTokens: 1, outputTokens: 1 },
117
+ },
118
+ };
119
+ }) as unknown as Dispatcher["query"]);
120
+
121
+ const { result } = renderHook(() => useCompletion(20), { wrapper: wrapperFor(dispatcher) });
122
+
123
+ act(() => {
124
+ result.current.requestCompletion("a");
125
+ result.current.requestCompletion("ab");
126
+ result.current.requestCompletion("abc");
127
+ });
128
+
129
+ await waitFor(() => expect(result.current.suggestion).not.toBeNull(), { timeout: 1000 });
130
+
131
+ expect(calls).toBe(1);
132
+ expect(result.current.suggestion).toBe('suggestion for "abc"');
133
+ });
134
+
135
+ test("empty text resets immediately without a request", async () => {
136
+ let calls = 0;
137
+ const dispatcher = makeDispatcher((async () => {
138
+ calls++;
139
+ return {
140
+ isSuccess: true,
141
+ data: { type: "text", text: "x", usage: { inputTokens: 1, outputTokens: 1 } },
142
+ };
143
+ }) as unknown as Dispatcher["query"]);
144
+
145
+ const { result } = renderHook(() => useCompletion(10), { wrapper: wrapperFor(dispatcher) });
146
+
147
+ act(() => {
148
+ result.current.requestCompletion("");
149
+ });
150
+ await new Promise((r) => setTimeout(r, 30));
151
+
152
+ expect(calls).toBe(0);
153
+ expect(result.current.suggestion).toBeNull();
154
+ });
155
+
156
+ test("clear cancels a pending debounce and resets the suggestion", async () => {
157
+ let calls = 0;
158
+ const dispatcher = makeDispatcher((async () => {
159
+ calls++;
160
+ return {
161
+ isSuccess: true,
162
+ data: { type: "text", text: "x", usage: { inputTokens: 1, outputTokens: 1 } },
163
+ };
164
+ }) as unknown as Dispatcher["query"]);
165
+
166
+ const { result } = renderHook(() => useCompletion(30), { wrapper: wrapperFor(dispatcher) });
167
+
168
+ act(() => {
169
+ result.current.requestCompletion("hello");
170
+ });
171
+ act(() => result.current.clear());
172
+ await new Promise((r) => setTimeout(r, 60));
173
+
174
+ expect(calls).toBe(0);
175
+ expect(result.current.suggestion).toBeNull();
176
+ });
177
+ });
@@ -0,0 +1,25 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { act, renderHook } from "@testing-library/react";
3
+ import { useDisclosure } from "../use-disclosure";
4
+
5
+ describe("useDisclosure", () => {
6
+ test("open/close/toggle steuern den Zustand", () => {
7
+ const { result } = renderHook(() => useDisclosure());
8
+ expect(result.current.open).toBe(false);
9
+ act(() => result.current.onOpen());
10
+ expect(result.current.open).toBe(true);
11
+ act(() => result.current.onClose());
12
+ expect(result.current.open).toBe(false);
13
+ act(() => result.current.onToggle());
14
+ expect(result.current.open).toBe(true);
15
+ });
16
+
17
+ test("Callbacks sind referenz-stabil über Re-Renders", () => {
18
+ const { result, rerender } = renderHook(() => useDisclosure(true));
19
+ const first = result.current;
20
+ rerender();
21
+ expect(result.current.onOpen).toBe(first.onOpen);
22
+ expect(result.current.onClose).toBe(first.onClose);
23
+ expect(result.current.onToggle).toBe(first.onToggle);
24
+ });
25
+ });
@@ -0,0 +1,77 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
3
+ import { act, renderHook, waitFor } from "@testing-library/react";
4
+ import type { ReactNode } from "react";
5
+ import { DispatcherProvider } from "../../context/dispatcher-context";
6
+ import { useMutation } from "../use-mutation";
7
+
8
+ function makeDispatcher(write: Dispatcher["write"]): Dispatcher {
9
+ return {
10
+ write,
11
+ query: (async () => ({ isSuccess: true, data: {} })) as unknown as Dispatcher["query"],
12
+ batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
13
+ statusStore: {
14
+ getState: () => "online",
15
+ subscribe: () => () => {},
16
+ } as unknown as Dispatcher["statusStore"],
17
+ async *stream() {},
18
+ pendingWrites: () => [],
19
+ pendingFiles: () => [],
20
+ };
21
+ }
22
+
23
+ function wrapperFor(dispatcher: Dispatcher) {
24
+ return ({ children }: { readonly children: ReactNode }) => (
25
+ <DispatcherProvider dispatcher={dispatcher}>{children}</DispatcherProvider>
26
+ );
27
+ }
28
+
29
+ describe("useMutation", () => {
30
+ test("Success setzt data, pending toggelt, Result wird durchgereicht", async () => {
31
+ let resolve: (() => void) | undefined;
32
+ const gate = new Promise<void>((r) => {
33
+ resolve = r;
34
+ });
35
+ const dispatcher = makeDispatcher((async (_type: string, payload: unknown) => {
36
+ await gate;
37
+ return { isSuccess: true, data: { echoed: payload } };
38
+ }) as unknown as Dispatcher["write"]);
39
+
40
+ const { result } = renderHook(() => useMutation<{ echoed: unknown }>("f:write:x:create"), {
41
+ wrapper: wrapperFor(dispatcher),
42
+ });
43
+
44
+ expect(result.current.pending).toBe(false);
45
+ let outcome: Awaited<ReturnType<typeof result.current.mutate>> | undefined;
46
+ act(() => {
47
+ void result.current.mutate({ name: "a" }).then((r) => {
48
+ outcome = r;
49
+ });
50
+ });
51
+ await waitFor(() => expect(result.current.pending).toBe(true));
52
+ act(() => resolve?.());
53
+ await waitFor(() => expect(result.current.pending).toBe(false));
54
+ expect(result.current.data).toEqual({ echoed: { name: "a" } });
55
+ expect(result.current.error).toBeNull();
56
+ expect(outcome?.isSuccess).toBe(true);
57
+ });
58
+
59
+ test("Failure setzt error, reset räumt auf", async () => {
60
+ const dispatcher = makeDispatcher((async () => ({
61
+ isSuccess: false,
62
+ error: { code: "conflict", message: "boom", i18nKey: "errors.conflict" },
63
+ })) as unknown as Dispatcher["write"]);
64
+
65
+ const { result } = renderHook(() => useMutation("f:write:x:create"), {
66
+ wrapper: wrapperFor(dispatcher),
67
+ });
68
+
69
+ await act(async () => {
70
+ await result.current.mutate({});
71
+ });
72
+ expect(result.current.error?.code).toBe("conflict");
73
+ act(() => result.current.reset());
74
+ expect(result.current.error).toBeNull();
75
+ expect(result.current.data).toBeNull();
76
+ });
77
+ });