@cosmicdrift/kumiko-headless 0.328.0 → 0.328.1

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 (108) hide show
  1. package/dist/apex/css.d.ts +3 -0
  2. package/{src/apex/css.ts → dist/apex/css.js} +11 -24
  3. package/dist/apex/index.d.ts +229 -0
  4. package/dist/apex/index.js +360 -0
  5. package/dist/apex/lightbox.d.ts +4 -0
  6. package/{src/apex/lightbox.ts → dist/apex/lightbox.js} +0 -4
  7. package/dist/contracts/asset.d.ts +15 -0
  8. package/dist/contracts/asset.js +17 -0
  9. package/dist/contracts/index.d.ts +3 -0
  10. package/dist/contracts/index.js +1 -0
  11. package/dist/contracts/locale.d.ts +7 -0
  12. package/dist/contracts/locale.js +11 -0
  13. package/dist/contracts/primitives.d.ts +93 -0
  14. package/dist/contracts/primitives.js +21 -0
  15. package/dist/dispatcher/index.d.ts +2 -0
  16. package/dist/dispatcher/index.js +1 -0
  17. package/dist/dispatcher/types.d.ts +86 -0
  18. package/dist/dispatcher/types.js +34 -0
  19. package/dist/form/form-controller.d.ts +2 -0
  20. package/dist/form/form-controller.js +404 -0
  21. package/dist/form/index.d.ts +3 -0
  22. package/dist/form/index.js +2 -0
  23. package/dist/form/types.d.ts +71 -0
  24. package/dist/form/types.js +1 -0
  25. package/dist/form/zod-bridge.d.ts +5 -0
  26. package/{src/form/zod-bridge.ts → dist/form/zod-bridge.js} +52 -58
  27. package/dist/format/escape.d.ts +5 -0
  28. package/{src/format/escape.ts → dist/format/escape.js} +43 -47
  29. package/dist/format/html-template.d.ts +9 -0
  30. package/dist/format/html-template.js +39 -0
  31. package/dist/format/index.d.ts +10 -0
  32. package/dist/format/index.js +192 -0
  33. package/dist/format/money.d.ts +1 -0
  34. package/{src/format/money.ts → dist/format/money.js} +6 -5
  35. package/dist/index.d.ts +14 -0
  36. package/dist/index.js +9 -0
  37. package/dist/locale-routing/index.d.ts +39 -0
  38. package/dist/locale-routing/index.js +143 -0
  39. package/dist/nav/index.d.ts +2 -0
  40. package/dist/nav/index.js +1 -0
  41. package/dist/nav/resolve.d.ts +2 -0
  42. package/dist/nav/resolve.js +58 -0
  43. package/dist/nav/types.d.ts +26 -0
  44. package/dist/nav/types.js +1 -0
  45. package/dist/store/create-store.d.ts +2 -0
  46. package/{src/store/create-store.ts → dist/store/create-store.js} +21 -23
  47. package/dist/store/equality.d.ts +1 -0
  48. package/{src/store/equality.ts → dist/store/equality.js} +19 -17
  49. package/dist/store/index.d.ts +3 -0
  50. package/dist/store/index.js +2 -0
  51. package/dist/store/types.d.ts +7 -0
  52. package/dist/store/types.js +10 -0
  53. package/dist/view-model/edit.d.ts +10 -0
  54. package/dist/view-model/edit.js +392 -0
  55. package/dist/view-model/embedded-list.d.ts +27 -0
  56. package/dist/view-model/embedded-list.js +63 -0
  57. package/dist/view-model/index.d.ts +7 -0
  58. package/dist/view-model/index.js +3 -0
  59. package/dist/view-model/list.d.ts +16 -0
  60. package/dist/view-model/list.js +201 -0
  61. package/dist/view-model/types.d.ts +300 -0
  62. package/dist/view-model/types.js +1 -0
  63. package/package.json +31 -12
  64. package/src/apex/__tests__/lightbox.test.ts +0 -20
  65. package/src/apex/__tests__/page-head.test.ts +0 -63
  66. package/src/apex/__tests__/render.test.ts +0 -401
  67. package/src/apex/index.ts +0 -667
  68. package/src/contracts/__tests__/contracts.test.ts +0 -123
  69. package/src/contracts/__tests__/field-issue-compat.test.ts +0 -19
  70. package/src/contracts/asset.ts +0 -48
  71. package/src/contracts/index.ts +0 -23
  72. package/src/contracts/locale.ts +0 -33
  73. package/src/contracts/primitives.ts +0 -158
  74. package/src/dispatcher/__tests__/contract.test.ts +0 -163
  75. package/src/dispatcher/index.ts +0 -16
  76. package/src/dispatcher/types.ts +0 -217
  77. package/src/form/__tests__/conditional-fields.test.ts +0 -177
  78. package/src/form/__tests__/form-controller.test.ts +0 -231
  79. package/src/form/__tests__/submit.test.ts +0 -575
  80. package/src/form/__tests__/validation.test.ts +0 -274
  81. package/src/form/form-controller.ts +0 -458
  82. package/src/form/index.ts +0 -15
  83. package/src/form/types.ts +0 -297
  84. package/src/format/__tests__/escape.test.ts +0 -79
  85. package/src/format/__tests__/format.test.ts +0 -285
  86. package/src/format/__tests__/html-template.test.ts +0 -47
  87. package/src/format/html-template.ts +0 -49
  88. package/src/format/index.ts +0 -223
  89. package/src/index.ts +0 -118
  90. package/src/locale-routing/__tests__/locale-routing.test.ts +0 -276
  91. package/src/locale-routing/index.ts +0 -218
  92. package/src/nav/__tests__/resolve.test.ts +0 -279
  93. package/src/nav/index.ts +0 -8
  94. package/src/nav/resolve.ts +0 -60
  95. package/src/nav/types.ts +0 -70
  96. package/src/store/__tests__/create-store.test.ts +0 -139
  97. package/src/store/__tests__/equality.test.ts +0 -66
  98. package/src/store/index.ts +0 -3
  99. package/src/store/types.ts +0 -27
  100. package/src/view-model/__tests__/edit.test.ts +0 -1072
  101. package/src/view-model/__tests__/embedded-list.test.ts +0 -96
  102. package/src/view-model/__tests__/field-labels.test.ts +0 -62
  103. package/src/view-model/__tests__/list.test.ts +0 -439
  104. package/src/view-model/edit.ts +0 -645
  105. package/src/view-model/embedded-list.ts +0 -89
  106. package/src/view-model/index.ts +0 -43
  107. package/src/view-model/list.ts +0 -280
  108. package/src/view-model/types.ts +0 -400
package/src/form/types.ts DELETED
@@ -1,297 +0,0 @@
1
- import type { ZodType } from "zod";
2
- import type { Dispatcher, FieldIssue, WriteResult } from "../dispatcher";
3
-
4
- // Form-Controller contract.
5
- //
6
- // One controller per edit/create screen. Wraps a mutable "values" record
7
- // plus derived state (changes vs initial, per-field errors) and exposes a
8
- // subscribe API shaped for React's `useSyncExternalStore`. The framework's
9
- // renderer-react wraps this in a thin `useForm` hook; the mobile renderer
10
- // will do the same with the same controller.
11
- //
12
- // Why not just Zustand / a state library?
13
- // Form state has a small, well-defined shape — values, initial, changes,
14
- // errors, per-field meta. Building it on raw subscribe/emit keeps the
15
- // runtime deps at zero (this package imports nothing but types from
16
- // @cosmicdrift/kumiko-framework and zod), and leaves renderer-specific decisions
17
- // (batching, suspense) to the host framework.
18
- //
19
- // Snapshot identity: `getSnapshot()` returns the SAME reference until a
20
- // mutator (setField / setValues / reset / ...) runs. React compares by
21
- // identity when deciding whether to re-render — recomputing the snapshot
22
- // on every call would re-render on every tick. Mutators rebuild the
23
- // snapshot once, then notify listeners.
24
-
25
- export type FormValues = Record<string, unknown>;
26
-
27
- // Per-field conditional rules (Kumiko's "visible/readonly/required as
28
- // functions" decision, 2026-03-30). A rule is either a static boolean or a
29
- // predicate `(values, ctx) => boolean`. The controller evaluates them on
30
- // every snapshot rebuild and exposes the resolved booleans as
31
- // FieldState — renderers read `snapshot.fields[key].visible` and never
32
- // re-evaluate the predicate themselves.
33
- //
34
- // Why here and not on the framework's FieldDefinition: conditions are
35
- // UI-layer concerns (visibility depends on form state, not entity state)
36
- // and shouldn't couple the server-side schema. The App wires them up per
37
- // screen via the screen-def / form-controller options. The framework's
38
- // own `field.required` is mirrored here when present, so feature authors
39
- // can still declare "always required" once at the entity level.
40
- export type FieldConditionPredicate<TValues extends FormValues, TCtx> = (
41
- values: TValues,
42
- ctx: TCtx,
43
- ) => boolean;
44
-
45
- export type FieldConditionValue<TValues extends FormValues, TCtx> =
46
- | boolean
47
- | FieldConditionPredicate<TValues, TCtx>;
48
-
49
- export type FieldConditions<TValues extends FormValues, TCtx = unknown> = {
50
- readonly visible?: FieldConditionValue<TValues, TCtx>;
51
- readonly readonly?: FieldConditionValue<TValues, TCtx>;
52
- readonly required?: FieldConditionValue<TValues, TCtx>;
53
- };
54
-
55
- // Resolved per-field state, keyed by field name, surfaced on the snapshot.
56
- // Defaults (when no condition is declared): visible=true, readonly=false,
57
- // required=false — renderer treats a field with no rules as a normal
58
- // always-shown-always-editable field.
59
- export type FieldState = {
60
- readonly visible: boolean;
61
- readonly readonly: boolean;
62
- readonly required: boolean;
63
- };
64
-
65
- // Immutable view handed to renderers. Every mutator call produces a fresh
66
- // snapshot; previous snapshot references stay valid for `useSyncExternalStore`'s
67
- // identity compare.
68
- export type FormSnapshot<TValues extends FormValues> = {
69
- // Current values — the user's in-progress edit.
70
- readonly values: TValues;
71
- // Pristine values at mount / last `reset()`. Needed for:
72
- // - diffing `changes` (see below)
73
- // - the "discard changes?" prompt when the user navigates away dirty
74
- // - re-hydrating the form after an optimistic-update rollback
75
- readonly initial: TValues;
76
- // Changes-only: keys whose current value differs from initial, with the
77
- // NEW value. The server uses this directly as the `payload` of an
78
- // entity-level update command (Kumiko writes carry changes, not full
79
- // objects) — see `docs/plans/architecture/event-sourcing-pivot.md`.
80
- readonly changes: Partial<TValues>;
81
- // True iff `changes` is non-empty. Convenience for the submit-button's
82
- // disabled state and the unsaved-changes guard.
83
- readonly isDirty: boolean;
84
- // True iff no field was touched, kept separate from `isDirty` so UI code
85
- // reads the right one without negation (= `!isDirty`). Form consumers
86
- // will reach for both — they're not symmetric in intent (one is "can I
87
- // submit?" and the other is "can I safely close?").
88
- readonly isUnchanged: boolean;
89
- // Per-field errors keyed by dotted path (`title`, `address.city`,
90
- // `tasks.2.title`). Empty when the form is valid, populated after
91
- // `validate()` or a failed submit. The dotted convention matches the
92
- // server's ValidationError.fields[].path — a failed dispatcher call
93
- // pushes its field issues here without any translation.
94
- readonly errors: Readonly<Record<string, readonly FieldIssue[]>>;
95
- // Resolved per-field state from FieldConditions. Renderers read this to
96
- // decide whether to show the field, whether the input is disabled, and
97
- // whether to show the required-marker. Fields without declared
98
- // conditions default to `{ visible: true, readonly: false, required: false }`.
99
- readonly fields: Readonly<Record<string, FieldState>>;
100
- };
101
-
102
- export type FormController<TValues extends FormValues> = {
103
- // --- Subscribe surface (for useSyncExternalStore) ---
104
-
105
- // Returns the current snapshot. SAME reference across calls until a
106
- // mutator runs — callers rely on identity compare.
107
- getSnapshot(): FormSnapshot<TValues>;
108
-
109
- // Registers a listener that fires whenever the snapshot changes. Returns
110
- // an unsubscribe function. Matches the `subscribe(listener) => unsubscribe`
111
- // shape required by `useSyncExternalStore`.
112
- subscribe(listener: () => void): () => void;
113
-
114
- // --- Value mutators ---
115
-
116
- // Sets one field. For scalar fields, `value` is the new value; for
117
- // nested paths (lines/sub-forms) you'd use a sub-controller (Block 2b.4)
118
- // instead of poking this with a dotted path.
119
- setField<K extends keyof TValues>(key: K, value: TValues[K]): void;
120
-
121
- // Bulk-update multiple fields at once. One snapshot rebuild + one
122
- // notify, so a 5-field form-reset from a remote fetch doesn't cascade 5
123
- // re-renders.
124
- setValues(partial: Partial<TValues>): void;
125
-
126
- // Clears field-level errors, keyed by dotted path. Used when the user
127
- // starts typing again in a field that had a server-side failure —
128
- // the UX hint should go away immediately, before the next validate()
129
- // run. Pass no arguments to clear ALL errors.
130
- clearErrors(path?: string): void;
131
-
132
- // Replaces all errors (overwrite, not merge). Used by submit() to
133
- // surface server-side validation failures and by external callers
134
- // that want to project custom error state (e.g. a parent controller
135
- // pushing down cross-field errors onto its children).
136
- setErrors(errors: Readonly<Record<string, readonly FieldIssue[]>>): void;
137
-
138
- // --- Validation ---
139
-
140
- // Runs the controller's zod schema (if configured) against current
141
- // values. Populates errors and returns true iff valid. Noop-returns
142
- // `true` when no schema was wired — a controller without a schema
143
- // relies entirely on server-side validation via the submit() path.
144
- //
145
- // `scope` restricts reported issues to those field names; root-level
146
- // `.refine()` issues (path `(root)`) are dropped on scoped `validate()`
147
- // calls (wizard steps, #1885). `submit()` always re-includes them even
148
- // when `validateScope` is set so RenderEdit `fields={…}` cannot skip
149
- // cross-field rules (#1907).
150
- validate(scope?: readonly string[]): boolean;
151
-
152
- // Reverts values to `initial`, clears errors. Doesn't fire a new
153
- // "initial" baseline — to adopt the current values as the new baseline
154
- // (e.g. after a successful submit), use `rebase()`.
155
- reset(): void;
156
-
157
- // Promotes the current values to the new `initial`, clears errors.
158
- // After a successful submit the user should no longer see "unsaved
159
- // changes" — rebase() is what the submit path calls internally to
160
- // achieve that.
161
- rebase(): void;
162
-
163
- // Swap the external context threaded into field-condition predicates.
164
- // Rare — most conditionals depend on values, not ctx — but needed when
165
- // e.g. the user switches tenant mid-form and visibility rules key off
166
- // tenant-scoped config.
167
- setCtx(ctx: unknown): void;
168
-
169
- // --- Submit ---
170
- //
171
- // Runs validate() first; if it fails, returns `{ validationBlocked: true,
172
- // isSuccess: false }` WITHOUT a network call (the caller knows it's a
173
- // user-level failure — show the errors, let them retry).
174
- //
175
- // If validation passes, dispatches to the configured submit.type with
176
- // values or changes per submit.payloadMode. On server-side
177
- // ValidationError, the error's field issues are pushed onto the form
178
- // via setErrors so the UI reacts identically to local and remote
179
- // validation failures. On success, rebase() — the form becomes "clean"
180
- // and the data returned by the handler flows back via the result.
181
- //
182
- // Throws (not returns a failure) if `submit` config wasn't provided —
183
- // a controller without submit-wiring has no destination, and guessing
184
- // one would hide an integration bug.
185
- submit<TData = unknown>(): Promise<SubmitResult<TData>>;
186
- };
187
-
188
- export type FormControllerOptions<TValues extends FormValues, TCtx = unknown> = {
189
- readonly initial: TValues;
190
- // Optional zod schema used by `validate()`. When present, validate()
191
- // runs schema.safeParse(values) and populates the snapshot's errors
192
- // map. When absent, validate() is a no-op that returns true — the
193
- // controller defers entirely to server-side validation on submit.
194
- //
195
- // Typed as `ZodType` (not `ZodType<TValues>`) because a feature's
196
- // input schema often narrows a subset of the form's surface (e.g.
197
- // "changes-only" update-schemas) and re-exporting that precise type
198
- // all the way through would chain generics across every layer. The
199
- // runtime contract is that schema accepts the form's `values` shape.
200
- readonly schema?: ZodType;
201
- // Per-field conditional rules. Keyed by field name; unlisted fields
202
- // get the default {visible:true, readonly:false, required:false}.
203
- // See FieldConditions for the predicate signature.
204
- readonly fields?: Readonly<Record<string, FieldConditions<TValues, TCtx>>>;
205
- // External context passed to every field-condition predicate. Host app
206
- // picks the shape — typically `{ user, tenant, config, featureToggles }`.
207
- // Captured once; a ctx change without a corresponding setField/validate
208
- // won't re-evaluate predicates. Use setCtx() to trigger re-evaluation
209
- // when the ctx itself changes (rare in practice — most conditionals
210
- // depend on values, not ctx).
211
- readonly ctx?: TCtx;
212
- // Optional submit wiring. When configured, `controller.submit()` runs
213
- // local validate() then dispatches to the configured type with the
214
- // selected payload, maps server-side field errors back onto the form,
215
- // and — on success — rebases so "unsaved changes" goes quiet.
216
- //
217
- // Omitted when the caller drives dispatching manually (custom-screen
218
- // cases, or when a multi-step wizard needs finer control). In that
219
- // case submit() throws rather than guessing a destination.
220
- readonly submit?: SubmitConfig<TValues>;
221
- };
222
-
223
- // How the form's payload is derived when submit() runs. Kumiko's write
224
- // convention says commands carry CHANGES (delta since initial), not full
225
- // objects — but that assumes an update flow. For creates, `changes ===
226
- // values` in practice because initial is empty.
227
- //
228
- // - "values" — send the current `values` object. Right for create
229
- // handlers whose schema expects a full entity payload.
230
- // Untouched fields that are still "" (the seed
231
- // buildInitialValues gives controlled optional inputs)
232
- // are stripped from the payload before it's sent, because
233
- // `.optional()` server schemas accept `undefined` but not
234
- // `""`. Set `stripEmptySeeds: false` on SubmitConfig to
235
- // opt out and send those fields as `""` unchanged.
236
- // - "changes" — send only the `changes` delta. Right for update
237
- // handlers; noop when the form is un-dirty (submit
238
- // short-circuits into a no-network success).
239
- //
240
- // Default is "values" — the common M1 case is a create-screen wiring
241
- // straight through. Update-screens opt in with "changes".
242
- export type SubmitPayloadMode = "values" | "changes";
243
-
244
- export type SubmitConfig<TValues extends FormValues = FormValues> = {
245
- // Optional so callers can default it from an ambient DispatcherProvider
246
- // (see kumiko-renderer's useForm) instead of requiring it at every call
247
- // site — submit() throws at write-time if it's still missing then.
248
- readonly dispatcher?: Dispatcher;
249
- // Qualified write-handler name (e.g. "orders:write:order:create").
250
- readonly type: string;
251
- readonly payloadMode?: SubmitPayloadMode;
252
- /** Only applies to payloadMode "values". Controls whether untouched
253
- * empty-string fields (still equal to their initial "" seed) are
254
- * stripped from the payload before dispatch — see SubmitPayloadMode's
255
- * "values" doc for why the stripping exists. Default `true` (current
256
- * behavior). Set `false` to send those fields as `""` unchanged. */
257
- readonly stripEmptySeeds?: boolean;
258
- // Optional payload transformer — overrides payloadMode. Used for
259
- // nested-writes: the submit path calls buildPayload(snapshot) once at
260
- // submit-time and sends the result. The snapshot is the one captured
261
- // before the network call, so in-flight edits during the await don't
262
- // leak into the payload.
263
- //
264
- // Typical shape for a parent form with hasMany child controllers:
265
- //
266
- // buildPayload: (snap) => ({
267
- // ...snap.values,
268
- // lines: lineControllers.map(c => c.getSnapshot().values),
269
- // })
270
- //
271
- // When both buildPayload and payloadMode are set, buildPayload wins.
272
- // That's intentional: a caller choosing to write a transformer is
273
- // making an explicit statement about payload shape.
274
- readonly buildPayload?: (snapshot: FormSnapshot<TValues>) => unknown;
275
- // When buildPayload nests values under a key, server issue paths carry it
276
- // ("changes.vin"); submit() strips this prefix so errors map onto form fields.
277
- readonly serverFieldPathPrefix?: string;
278
- /** Restricts submit()'s internal validate() call to these field names —
279
- * same contract as validate()'s `scope` param. Set by RenderEdit when the
280
- * caller's `fields` prop narrows which fields are rendered, so an
281
- * unrendered required field can't block a submit the user can't fix.
282
- * Omitted = unscoped (existing behavior, validates everything). */
283
- readonly validateScope?: readonly string[];
284
- };
285
-
286
- // What submit() returns. Mirrors WriteResult so a failed submit can
287
- // carry the structured DispatcherError unchanged — callers log or toast
288
- // based on error.code without the form-controller guessing UX intent.
289
- // `validationBlocked: true` signals a LOCAL (pre-dispatch) validate()
290
- // failure — no network call happened; caller doesn't need to retry the
291
- // network, the user needs to fix fields.
292
- // `isNoOp: true` signals a successful submit that skipped dispatcher.write
293
- // entirely because payloadMode "changes" found nothing changed — callers
294
- // must not treat this like a normal write (e.g. don't discard a draft).
295
- export type SubmitResult<TData = unknown> =
296
- | ({ readonly validationBlocked: false; readonly isNoOp?: boolean } & WriteResult<TData>)
297
- | { readonly validationBlocked: true; readonly isSuccess: false };
@@ -1,79 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { escapeHtml, escapeHtmlAttr, escapeXml } from "../escape";
3
-
4
- describe("escapeHtml", () => {
5
- test('escapes & < > "', () => {
6
- expect(escapeHtml(`& < > "`)).toBe("&amp; &lt; &gt; &quot;");
7
- });
8
-
9
- test("does not escape '", () => {
10
- expect(escapeHtml("it's")).toBe("it's");
11
- });
12
-
13
- test("plain text passes through", () => {
14
- expect(escapeHtml("Hello, World!")).toBe("Hello, World!");
15
- });
16
-
17
- test("empty string", () => {
18
- expect(escapeHtml("")).toBe("");
19
- });
20
-
21
- test("multiple occurrences", () => {
22
- expect(escapeHtml(`<div class="foo">&bar</div>`)).toBe(
23
- "&lt;div class=&quot;foo&quot;&gt;&amp;bar&lt;/div&gt;",
24
- );
25
- });
26
-
27
- test("no double-escape", () => {
28
- const escaped = escapeHtml(`& < > "`);
29
- expect(escapeHtml(escaped)).toBe("&amp;amp; &amp;lt; &amp;gt; &amp;quot;");
30
- });
31
- });
32
-
33
- describe("escapeHtmlAttr", () => {
34
- test('escapes & " < > with priority on & and "', () => {
35
- expect(escapeHtmlAttr(`& " < >`)).toBe("&amp; &quot; &lt; &gt;");
36
- });
37
-
38
- test("escapes ' so single-quoted attributes cannot break out", () => {
39
- expect(escapeHtmlAttr("it's")).toBe("it&#39;s");
40
- });
41
-
42
- test("plain attribute value passes through", () => {
43
- expect(escapeHtmlAttr("https://example.com?a=1&b=2")).toBe("https://example.com?a=1&amp;b=2");
44
- });
45
-
46
- test("empty string", () => {
47
- expect(escapeHtmlAttr("")).toBe("");
48
- });
49
-
50
- test("no double-escape", () => {
51
- const escaped = escapeHtmlAttr(`& " < >`);
52
- expect(escapeHtmlAttr(escaped)).toBe("&amp;amp; &amp;quot; &amp;lt; &amp;gt;");
53
- });
54
- });
55
-
56
- describe("escapeXml", () => {
57
- test("escapes & < > \" '", () => {
58
- expect(escapeXml(`& < > " '`)).toBe("&amp; &lt; &gt; &quot; &apos;");
59
- });
60
-
61
- test("plain text passes through", () => {
62
- expect(escapeXml("Hello, World!")).toBe("Hello, World!");
63
- });
64
-
65
- test("empty string", () => {
66
- expect(escapeXml("")).toBe("");
67
- });
68
-
69
- test("multiple occurrences", () => {
70
- expect(escapeXml(`<element attr="value">it's & stuff</element>`)).toBe(
71
- "&lt;element attr=&quot;value&quot;&gt;it&apos;s &amp; stuff&lt;/element&gt;",
72
- );
73
- });
74
-
75
- test("no double-escape", () => {
76
- const escaped = escapeXml(`& < > " '`);
77
- expect(escapeXml(escaped)).toBe("&amp;amp; &amp;lt; &amp;gt; &amp;quot; &amp;apos;");
78
- });
79
- });
@@ -1,285 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { applyFormatSpec, UNIT_FORMAT_KEYS } from "../index";
3
-
4
- describe("applyFormatSpec — priority", () => {
5
- test("rendert emptyLabel für undefined/null/leer/0 (nicht den globalen ''-Collapse)", () => {
6
- expect(applyFormatSpec({ format: "priority" }, undefined)).toBe("—");
7
- expect(applyFormatSpec({ format: "priority" }, null)).toBe("—");
8
- expect(applyFormatSpec({ format: "priority" }, "")).toBe("—");
9
- expect(applyFormatSpec({ format: "priority" }, 0)).toBe("—");
10
- });
11
-
12
- test("custom emptyLabel + prefix", () => {
13
- expect(applyFormatSpec({ format: "priority", emptyLabel: "none" }, null)).toBe("none");
14
- expect(applyFormatSpec({ format: "priority", prefix: "P" }, 2)).toBe("P2");
15
- });
16
- });
17
-
18
- describe("applyFormatSpec — leere Werte anderer Formate", () => {
19
- test("collapsen zu ''", () => {
20
- expect(applyFormatSpec({ format: "boolean" }, undefined)).toBe("");
21
- expect(applyFormatSpec({ format: "currency", symbol: "€" }, null)).toBe("");
22
- expect(applyFormatSpec({ format: "timestamp" }, "")).toBe("");
23
- });
24
- });
25
-
26
- describe("applyFormatSpec — boolean/currency", () => {
27
- test("boolean mit Default- und Custom-Labels", () => {
28
- expect(applyFormatSpec({ format: "boolean" }, true)).toBe("✓");
29
- expect(applyFormatSpec({ format: "boolean" }, false)).toBe("");
30
- expect(applyFormatSpec({ format: "boolean", trueLabel: "ja", falseLabel: "nein" }, false)).toBe(
31
- "nein",
32
- );
33
- });
34
-
35
- test("currency hängt Symbol an", () => {
36
- expect(applyFormatSpec({ format: "currency", symbol: "€" }, 12)).toBe("12 €");
37
- expect(applyFormatSpec({ format: "currency" }, 12)).toBe("12");
38
- });
39
- });
40
-
41
- describe("applyFormatSpec — number grouping (fw#3234)", () => {
42
- test("groups thousands by default", () => {
43
- expect(applyFormatSpec({ format: "number" }, 2021)).toBe("2,021");
44
- expect(applyFormatSpec({ format: "number", locale: "de-DE" }, 2021)).toBe("2.021");
45
- });
46
-
47
- test("grouping: false renders no thousands separator", () => {
48
- expect(applyFormatSpec({ format: "number", grouping: false }, 2021)).toBe("2021");
49
- expect(applyFormatSpec({ format: "number", locale: "de-DE", grouping: false }, 2021)).toBe(
50
- "2021",
51
- );
52
- });
53
-
54
- test("decimal/bigInt share the same grouping path", () => {
55
- expect(applyFormatSpec({ format: "decimal", locale: "de-DE" }, 2021)).toBe("2.021");
56
- expect(applyFormatSpec({ format: "bigInt", locale: "de-DE", grouping: false }, 2021)).toBe(
57
- "2021",
58
- );
59
- });
60
- });
61
-
62
- describe("applyFormatSpec — timestamp/date (formatDateCell-Pfad)", () => {
63
- // Mittag UTC: das Datum kippt in keiner Zeitzone UTC-11..UTC+11 —
64
- // deterministisch auf CI (UTC) und lokal (CET).
65
- const instant = "2026-01-15T12:00:00Z";
66
-
67
- test("timestamp mit locale+dateStyle+timeStyle rendert lokalisiert", () => {
68
- const out = applyFormatSpec(
69
- { format: "timestamp", locale: "en-US", dateStyle: "long", timeStyle: "short" },
70
- instant,
71
- );
72
- expect(out).toContain("January");
73
- expect(out).toContain("2026");
74
- });
75
-
76
- test("date mit locale de-DE rendert deutschen Monatsnamen", () => {
77
- const out = applyFormatSpec({ format: "date", locale: "de-DE", dateStyle: "long" }, instant);
78
- expect(out).toContain("Januar");
79
- expect(out).toContain("2026");
80
- });
81
-
82
- test("date ohne dateStyle rendert Tag und Monat zweistellig", () => {
83
- expect(applyFormatSpec({ format: "date", locale: "de-DE" }, "2026-03-05")).toBe("05.03.2026");
84
- });
85
-
86
- test("timestamp ohne Optionen nutzt das kompakte Default-Format", () => {
87
- const out = applyFormatSpec({ format: "timestamp", locale: "en-US" }, instant);
88
- expect(out).toContain("2026");
89
- expect(out).not.toBe(instant);
90
- });
91
-
92
- test("unparsebarer Wert fällt auf den Rohstring zurück", () => {
93
- expect(applyFormatSpec({ format: "timestamp" }, "kein-datum")).toBe("kein-datum");
94
- expect(applyFormatSpec({ format: "date" }, "kein-datum")).toBe("kein-datum");
95
- });
96
-
97
- test("offset-lose Timestamps (kein Z/Offset) fallen NICHT auf den Rohstring zurück", () => {
98
- // Temporal.Instant.from is stricter than the old `new Date(raw)` —
99
- // without a UTC designator/offset it throws. Both forms must still
100
- // format instead of passing through raw (see toInstant fallback in
101
- // index.ts).
102
- const withoutOffset = "2026-01-15T12:00:00";
103
- const withoutTime = "2026-01-15 12:00:00";
104
- for (const raw of [withoutOffset, withoutTime]) {
105
- const out = applyFormatSpec({ format: "timestamp", locale: "en-US" }, raw);
106
- expect(out).not.toBe(raw);
107
- expect(out).toContain("2026");
108
- }
109
- });
110
-
111
- test("offset-loser Timestamp im date-Format fällt NICHT auf den Rohstring zurück", () => {
112
- const withoutOffset = "2026-01-15T12:00:00";
113
- const out = applyFormatSpec({ format: "date", locale: "en-US" }, withoutOffset);
114
- expect(out).not.toBe(withoutOffset);
115
- expect(out).toContain("2026");
116
- });
117
- });
118
-
119
- describe("applyFormatSpec — unit (fw#2187)", () => {
120
- test("km/m/kg/percent gehen über Intl.NumberFormat(style:'unit'), locale-korrekt", () => {
121
- expect(applyFormatSpec({ format: "unit", unit: "km" }, 58)).toBe(
122
- new Intl.NumberFormat(undefined, {
123
- style: "unit",
124
- unit: "kilometer",
125
- unitDisplay: "short",
126
- }).format(58),
127
- );
128
- expect(applyFormatSpec({ format: "unit", unit: "km", locale: "de-DE" }, 58)).toBe("58 km");
129
- expect(applyFormatSpec({ format: "unit", unit: "kg", locale: "de-DE" }, 2.5)).toBe("2,5 kg");
130
- // de-DE percent uses a narrow no-break space (U+202F) before "%", not a
131
- // plain space — assert against Intl's own output rather than guessing.
132
- expect(applyFormatSpec({ format: "unit", unit: "percent", locale: "de-DE" }, 12)).toBe(
133
- new Intl.NumberFormat("de-DE", {
134
- style: "unit",
135
- unit: "percent",
136
- unitDisplay: "short",
137
- }).format(12),
138
- );
139
- // Ratio 0.15 must NOT become 15 % — unit:"percent" is percent-points.
140
- expect(applyFormatSpec({ format: "unit", unit: "percent", locale: "de-DE" }, 0.15)).toBe(
141
- new Intl.NumberFormat("de-DE", {
142
- style: "unit",
143
- unit: "percent",
144
- unitDisplay: "short",
145
- }).format(0.15),
146
- );
147
- });
148
-
149
- test("mi (Meilen) geht über Intl.NumberFormat(style:'unit') genau wie km", () => {
150
- expect(applyFormatSpec({ format: "unit", unit: "mi" }, 58)).toBe(
151
- new Intl.NumberFormat(undefined, {
152
- style: "unit",
153
- unit: "mile",
154
- unitDisplay: "short",
155
- }).format(58),
156
- );
157
- expect(applyFormatSpec({ format: "unit", unit: "mi", locale: "en-US" }, 12)).toBe("12 mi");
158
- });
159
-
160
- test("m2 hat kein ECMA-402-sanktioniertes Intl-Unit (RangeError für 'square-meter') — Zahl + literales Suffix", () => {
161
- expect(applyFormatSpec({ format: "unit", unit: "m2", locale: "de-DE" }, 58)).toBe("58 m²");
162
- expect(applyFormatSpec({ format: "unit", unit: "m2", locale: "de-DE" }, 1234.5)).toBe(
163
- "1.234,5 m²",
164
- );
165
- });
166
-
167
- test("unitDisplay steuert long", () => {
168
- expect(
169
- applyFormatSpec({ format: "unit", unit: "km", locale: "en-US", unitDisplay: "long" }, 3),
170
- ).toBe("3 kilometers");
171
- expect(
172
- applyFormatSpec({ format: "unit", unit: "km", locale: "en-US", unitDisplay: "narrow" }, 3),
173
- ).toBe(
174
- new Intl.NumberFormat("en-US", {
175
- style: "unit",
176
- unit: "kilometer",
177
- unitDisplay: "narrow",
178
- }).format(3),
179
- );
180
- });
181
-
182
- test("nicht-numerischer Wert fällt auf String zurück", () => {
183
- expect(applyFormatSpec({ format: "unit", unit: "km" }, "not-a-number")).toBe("not-a-number");
184
- });
185
-
186
- test("fehlende/unbekannte unit fällt auf String zurück, statt zu werfen", () => {
187
- expect(applyFormatSpec({ format: "unit" }, 58)).toBe("58");
188
- expect(applyFormatSpec({ format: "unit", unit: "does-not-exist" }, 58)).toBe("58");
189
- });
190
-
191
- test("Prototype-Chain-Keys (toString, constructor) zählen NICHT als unit — Object.hasOwn statt `in`", () => {
192
- expect(applyFormatSpec({ format: "unit", unit: "toString" }, 58)).toBe("58");
193
- expect(applyFormatSpec({ format: "unit", unit: "constructor" }, 58)).toBe("58");
194
- });
195
-
196
- test("leerer Wert collapst zu '' wie jedes andere Nicht-priority-Format", () => {
197
- expect(applyFormatSpec({ format: "unit", unit: "km" }, null)).toBe("");
198
- expect(applyFormatSpec({ format: "unit", unit: "km" }, "")).toBe("");
199
- });
200
- });
201
-
202
- describe("applyFormatSpec — enumOption (fw#2315)", () => {
203
- const spec = { format: "enumOption", keyPrefix: "contact:entity:contact:field:status:option:" };
204
-
205
- test("übersetzter Key gewinnt gegen den Rohwert", () => {
206
- const translate = (key: string) =>
207
- key === "contact:entity:contact:field:status:option:active" ? "Aktiv" : key;
208
- expect(applyFormatSpec(spec, "active", translate)).toBe("Aktiv");
209
- });
210
-
211
- test("unbekannter Key fällt auf den Rohwert zurück (translate gibt den Key unverändert zurück)", () => {
212
- const translate = (key: string) => key;
213
- expect(applyFormatSpec(spec, "active", translate)).toBe("active");
214
- });
215
-
216
- test("ohne translate-Parameter fällt auf den Rohwert zurück", () => {
217
- expect(applyFormatSpec(spec, "active")).toBe("active");
218
- });
219
-
220
- test("ohne keyPrefix fällt auf den Rohwert zurück, auch mit translate", () => {
221
- const translate = (key: string) => `translated:${key}`;
222
- expect(applyFormatSpec({ format: "enumOption" }, "active", translate)).toBe("active");
223
- });
224
-
225
- test("nicht-string-Wert wird vor der Key-Bildung stringifiziert", () => {
226
- const translate = (key: string) =>
227
- key === "contact:entity:contact:field:status:option:1" ? "Eins" : key;
228
- expect(applyFormatSpec(spec, 1, translate)).toBe("Eins");
229
- });
230
-
231
- test("leerer Wert collapst zu '' wie jedes andere Nicht-priority-Format", () => {
232
- expect(applyFormatSpec(spec, null, (k) => k)).toBe("");
233
- expect(applyFormatSpec(spec, "", (k) => k)).toBe("");
234
- });
235
- });
236
-
237
- describe("applyFormatSpec — boolean labels through translate (fw#2683)", () => {
238
- const TRUE_KEY = "managed-pages:entity:page:field:published:option:true";
239
- const FALSE_KEY = "managed-pages:entity:page:field:published:option:false";
240
- const spec = { format: "boolean", trueLabel: TRUE_KEY, falseLabel: FALSE_KEY };
241
- const translate = (key: string): string => {
242
- if (key === TRUE_KEY) return "Published";
243
- if (key === FALSE_KEY) return "Draft";
244
- return key;
245
- };
246
-
247
- test("an i18n key as trueLabel renders translated", () => {
248
- expect(applyFormatSpec(spec, true, translate)).toBe("Published");
249
- });
250
-
251
- test("an i18n key as falseLabel renders translated", () => {
252
- expect(applyFormatSpec(spec, false, translate)).toBe("Draft");
253
- });
254
-
255
- test("without translate the declared labels stay verbatim", () => {
256
- expect(applyFormatSpec(spec, true)).toBe(TRUE_KEY);
257
- expect(applyFormatSpec(spec, false)).toBe(FALSE_KEY);
258
- });
259
-
260
- test("a plain-text label is not mangled by a pass-through translate", () => {
261
- const plain = { format: "boolean", trueLabel: "Yes", falseLabel: "No" };
262
- expect(applyFormatSpec(plain, true, (k) => k)).toBe("Yes");
263
- expect(applyFormatSpec(plain, false, (k) => k)).toBe("No");
264
- });
265
-
266
- test("undeclared labels keep the ✓ / empty defaults even with translate", () => {
267
- expect(applyFormatSpec({ format: "boolean" }, true, translate)).toBe("✓");
268
- expect(applyFormatSpec({ format: "boolean" }, false, translate)).toBe("");
269
- });
270
- });
271
-
272
- describe("unit format keys stay in lockstep with UnitKey", () => {
273
- // Mirror of packages/types/src/screen.ts UnitKey — keep lists equal without
274
- // adding a kumiko-types dependency to headless.
275
- const TYPE_UNIT_KEYS = ["m2", "km", "m", "kg", "percent", "mi"] as const;
276
- test("every UnitKey formats with a non-bare number", () => {
277
- for (const k of TYPE_UNIT_KEYS) {
278
- expect(UNIT_FORMAT_KEYS.includes(k)).toBe(true);
279
- expect(applyFormatSpec({ format: "unit", unit: k }, 1)).not.toBe("1");
280
- }
281
- });
282
- test("no extra runtime keys beyond UnitKey", () => {
283
- expect([...UNIT_FORMAT_KEYS].sort()).toEqual([...TYPE_UNIT_KEYS].sort());
284
- });
285
- });