@cosmicdrift/kumiko-headless 0.327.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.
- package/dist/apex/css.d.ts +3 -0
- package/{src/apex/css.ts → dist/apex/css.js} +11 -24
- package/dist/apex/index.d.ts +229 -0
- package/dist/apex/index.js +360 -0
- package/dist/apex/lightbox.d.ts +4 -0
- package/{src/apex/lightbox.ts → dist/apex/lightbox.js} +0 -4
- package/dist/contracts/asset.d.ts +15 -0
- package/dist/contracts/asset.js +17 -0
- package/dist/contracts/index.d.ts +3 -0
- package/dist/contracts/index.js +1 -0
- package/dist/contracts/locale.d.ts +7 -0
- package/dist/contracts/locale.js +11 -0
- package/dist/contracts/primitives.d.ts +93 -0
- package/dist/contracts/primitives.js +21 -0
- package/dist/dispatcher/index.d.ts +2 -0
- package/dist/dispatcher/index.js +1 -0
- package/dist/dispatcher/types.d.ts +86 -0
- package/dist/dispatcher/types.js +34 -0
- package/dist/form/form-controller.d.ts +2 -0
- package/dist/form/form-controller.js +404 -0
- package/dist/form/index.d.ts +3 -0
- package/dist/form/index.js +2 -0
- package/dist/form/types.d.ts +71 -0
- package/dist/form/types.js +1 -0
- package/dist/form/zod-bridge.d.ts +5 -0
- package/{src/form/zod-bridge.ts → dist/form/zod-bridge.js} +52 -58
- package/dist/format/escape.d.ts +5 -0
- package/{src/format/escape.ts → dist/format/escape.js} +43 -47
- package/dist/format/html-template.d.ts +9 -0
- package/dist/format/html-template.js +39 -0
- package/dist/format/index.d.ts +10 -0
- package/dist/format/index.js +192 -0
- package/dist/format/money.d.ts +1 -0
- package/{src/format/money.ts → dist/format/money.js} +6 -5
- package/dist/index.d.ts +14 -0
- package/dist/index.js +9 -0
- package/dist/locale-routing/index.d.ts +39 -0
- package/dist/locale-routing/index.js +143 -0
- package/dist/nav/index.d.ts +2 -0
- package/dist/nav/index.js +1 -0
- package/dist/nav/resolve.d.ts +2 -0
- package/dist/nav/resolve.js +58 -0
- package/dist/nav/types.d.ts +26 -0
- package/dist/nav/types.js +1 -0
- package/dist/store/create-store.d.ts +2 -0
- package/{src/store/create-store.ts → dist/store/create-store.js} +21 -23
- package/dist/store/equality.d.ts +1 -0
- package/{src/store/equality.ts → dist/store/equality.js} +19 -17
- package/dist/store/index.d.ts +3 -0
- package/dist/store/index.js +2 -0
- package/dist/store/types.d.ts +7 -0
- package/dist/store/types.js +10 -0
- package/dist/view-model/edit.d.ts +10 -0
- package/dist/view-model/edit.js +392 -0
- package/dist/view-model/embedded-list.d.ts +27 -0
- package/dist/view-model/embedded-list.js +63 -0
- package/dist/view-model/index.d.ts +7 -0
- package/dist/view-model/index.js +3 -0
- package/dist/view-model/list.d.ts +16 -0
- package/dist/view-model/list.js +201 -0
- package/dist/view-model/types.d.ts +300 -0
- package/dist/view-model/types.js +1 -0
- package/package.json +31 -12
- package/src/changes.json +7 -0
- package/src/apex/__tests__/lightbox.test.ts +0 -20
- package/src/apex/__tests__/page-head.test.ts +0 -63
- package/src/apex/__tests__/render.test.ts +0 -401
- package/src/apex/index.ts +0 -667
- package/src/contracts/__tests__/contracts.test.ts +0 -123
- package/src/contracts/__tests__/field-issue-compat.test.ts +0 -19
- package/src/contracts/asset.ts +0 -48
- package/src/contracts/index.ts +0 -23
- package/src/contracts/locale.ts +0 -33
- package/src/contracts/primitives.ts +0 -158
- package/src/dispatcher/__tests__/contract.test.ts +0 -163
- package/src/dispatcher/index.ts +0 -16
- package/src/dispatcher/types.ts +0 -217
- package/src/form/__tests__/conditional-fields.test.ts +0 -177
- package/src/form/__tests__/form-controller.test.ts +0 -231
- package/src/form/__tests__/submit.test.ts +0 -575
- package/src/form/__tests__/validation.test.ts +0 -274
- package/src/form/form-controller.ts +0 -458
- package/src/form/index.ts +0 -15
- package/src/form/types.ts +0 -297
- package/src/format/__tests__/escape.test.ts +0 -79
- package/src/format/__tests__/format.test.ts +0 -281
- package/src/format/__tests__/html-template.test.ts +0 -47
- package/src/format/html-template.ts +0 -49
- package/src/format/index.ts +0 -219
- package/src/index.ts +0 -118
- package/src/locale-routing/__tests__/locale-routing.test.ts +0 -276
- package/src/locale-routing/index.ts +0 -218
- package/src/nav/__tests__/resolve.test.ts +0 -279
- package/src/nav/index.ts +0 -8
- package/src/nav/resolve.ts +0 -60
- package/src/nav/types.ts +0 -70
- package/src/store/__tests__/create-store.test.ts +0 -139
- package/src/store/__tests__/equality.test.ts +0 -66
- package/src/store/index.ts +0 -3
- package/src/store/types.ts +0 -27
- package/src/view-model/__tests__/edit.test.ts +0 -1072
- package/src/view-model/__tests__/embedded-list.test.ts +0 -96
- package/src/view-model/__tests__/field-labels.test.ts +0 -62
- package/src/view-model/__tests__/list.test.ts +0 -428
- package/src/view-model/edit.ts +0 -641
- package/src/view-model/embedded-list.ts +0 -89
- package/src/view-model/index.ts +0 -43
- package/src/view-model/list.ts +0 -277
- package/src/view-model/types.ts +0 -392
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("& < > "");
|
|
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
|
-
"<div class="foo">&bar</div>",
|
|
24
|
-
);
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
test("no double-escape", () => {
|
|
28
|
-
const escaped = escapeHtml(`& < > "`);
|
|
29
|
-
expect(escapeHtml(escaped)).toBe("&amp; &lt; &gt; &quot;");
|
|
30
|
-
});
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
describe("escapeHtmlAttr", () => {
|
|
34
|
-
test('escapes & " < > with priority on & and "', () => {
|
|
35
|
-
expect(escapeHtmlAttr(`& " < >`)).toBe("& " < >");
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
test("escapes ' so single-quoted attributes cannot break out", () => {
|
|
39
|
-
expect(escapeHtmlAttr("it's")).toBe("it'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&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; &quot; &lt; &gt;");
|
|
53
|
-
});
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
describe("escapeXml", () => {
|
|
57
|
-
test("escapes & < > \" '", () => {
|
|
58
|
-
expect(escapeXml(`& < > " '`)).toBe("& < > " '");
|
|
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
|
-
"<element attr="value">it's & stuff</element>",
|
|
72
|
-
);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
test("no double-escape", () => {
|
|
76
|
-
const escaped = escapeXml(`& < > " '`);
|
|
77
|
-
expect(escapeXml(escaped)).toBe("&amp; &lt; &gt; &quot; &apos;");
|
|
78
|
-
});
|
|
79
|
-
});
|
|
@@ -1,281 +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("timestamp ohne Optionen nutzt das kompakte Default-Format", () => {
|
|
83
|
-
const out = applyFormatSpec({ format: "timestamp", locale: "en-US" }, instant);
|
|
84
|
-
expect(out).toContain("2026");
|
|
85
|
-
expect(out).not.toBe(instant);
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
test("unparsebarer Wert fällt auf den Rohstring zurück", () => {
|
|
89
|
-
expect(applyFormatSpec({ format: "timestamp" }, "kein-datum")).toBe("kein-datum");
|
|
90
|
-
expect(applyFormatSpec({ format: "date" }, "kein-datum")).toBe("kein-datum");
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
test("offset-lose Timestamps (kein Z/Offset) fallen NICHT auf den Rohstring zurück", () => {
|
|
94
|
-
// Temporal.Instant.from is stricter than the old `new Date(raw)` —
|
|
95
|
-
// without a UTC designator/offset it throws. Both forms must still
|
|
96
|
-
// format instead of passing through raw (see toInstant fallback in
|
|
97
|
-
// index.ts).
|
|
98
|
-
const withoutOffset = "2026-01-15T12:00:00";
|
|
99
|
-
const withoutTime = "2026-01-15 12:00:00";
|
|
100
|
-
for (const raw of [withoutOffset, withoutTime]) {
|
|
101
|
-
const out = applyFormatSpec({ format: "timestamp", locale: "en-US" }, raw);
|
|
102
|
-
expect(out).not.toBe(raw);
|
|
103
|
-
expect(out).toContain("2026");
|
|
104
|
-
}
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
test("offset-loser Timestamp im date-Format fällt NICHT auf den Rohstring zurück", () => {
|
|
108
|
-
const withoutOffset = "2026-01-15T12:00:00";
|
|
109
|
-
const out = applyFormatSpec({ format: "date", locale: "en-US" }, withoutOffset);
|
|
110
|
-
expect(out).not.toBe(withoutOffset);
|
|
111
|
-
expect(out).toContain("2026");
|
|
112
|
-
});
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
describe("applyFormatSpec — unit (fw#2187)", () => {
|
|
116
|
-
test("km/m/kg/percent gehen über Intl.NumberFormat(style:'unit'), locale-korrekt", () => {
|
|
117
|
-
expect(applyFormatSpec({ format: "unit", unit: "km" }, 58)).toBe(
|
|
118
|
-
new Intl.NumberFormat(undefined, {
|
|
119
|
-
style: "unit",
|
|
120
|
-
unit: "kilometer",
|
|
121
|
-
unitDisplay: "short",
|
|
122
|
-
}).format(58),
|
|
123
|
-
);
|
|
124
|
-
expect(applyFormatSpec({ format: "unit", unit: "km", locale: "de-DE" }, 58)).toBe("58 km");
|
|
125
|
-
expect(applyFormatSpec({ format: "unit", unit: "kg", locale: "de-DE" }, 2.5)).toBe("2,5 kg");
|
|
126
|
-
// de-DE percent uses a narrow no-break space (U+202F) before "%", not a
|
|
127
|
-
// plain space — assert against Intl's own output rather than guessing.
|
|
128
|
-
expect(applyFormatSpec({ format: "unit", unit: "percent", locale: "de-DE" }, 12)).toBe(
|
|
129
|
-
new Intl.NumberFormat("de-DE", {
|
|
130
|
-
style: "unit",
|
|
131
|
-
unit: "percent",
|
|
132
|
-
unitDisplay: "short",
|
|
133
|
-
}).format(12),
|
|
134
|
-
);
|
|
135
|
-
// Ratio 0.15 must NOT become 15 % — unit:"percent" is percent-points.
|
|
136
|
-
expect(applyFormatSpec({ format: "unit", unit: "percent", locale: "de-DE" }, 0.15)).toBe(
|
|
137
|
-
new Intl.NumberFormat("de-DE", {
|
|
138
|
-
style: "unit",
|
|
139
|
-
unit: "percent",
|
|
140
|
-
unitDisplay: "short",
|
|
141
|
-
}).format(0.15),
|
|
142
|
-
);
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
test("mi (Meilen) geht über Intl.NumberFormat(style:'unit') genau wie km", () => {
|
|
146
|
-
expect(applyFormatSpec({ format: "unit", unit: "mi" }, 58)).toBe(
|
|
147
|
-
new Intl.NumberFormat(undefined, {
|
|
148
|
-
style: "unit",
|
|
149
|
-
unit: "mile",
|
|
150
|
-
unitDisplay: "short",
|
|
151
|
-
}).format(58),
|
|
152
|
-
);
|
|
153
|
-
expect(applyFormatSpec({ format: "unit", unit: "mi", locale: "en-US" }, 12)).toBe("12 mi");
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
test("m2 hat kein ECMA-402-sanktioniertes Intl-Unit (RangeError für 'square-meter') — Zahl + literales Suffix", () => {
|
|
157
|
-
expect(applyFormatSpec({ format: "unit", unit: "m2", locale: "de-DE" }, 58)).toBe("58 m²");
|
|
158
|
-
expect(applyFormatSpec({ format: "unit", unit: "m2", locale: "de-DE" }, 1234.5)).toBe(
|
|
159
|
-
"1.234,5 m²",
|
|
160
|
-
);
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
test("unitDisplay steuert long", () => {
|
|
164
|
-
expect(
|
|
165
|
-
applyFormatSpec({ format: "unit", unit: "km", locale: "en-US", unitDisplay: "long" }, 3),
|
|
166
|
-
).toBe("3 kilometers");
|
|
167
|
-
expect(
|
|
168
|
-
applyFormatSpec({ format: "unit", unit: "km", locale: "en-US", unitDisplay: "narrow" }, 3),
|
|
169
|
-
).toBe(
|
|
170
|
-
new Intl.NumberFormat("en-US", {
|
|
171
|
-
style: "unit",
|
|
172
|
-
unit: "kilometer",
|
|
173
|
-
unitDisplay: "narrow",
|
|
174
|
-
}).format(3),
|
|
175
|
-
);
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
test("nicht-numerischer Wert fällt auf String zurück", () => {
|
|
179
|
-
expect(applyFormatSpec({ format: "unit", unit: "km" }, "not-a-number")).toBe("not-a-number");
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
test("fehlende/unbekannte unit fällt auf String zurück, statt zu werfen", () => {
|
|
183
|
-
expect(applyFormatSpec({ format: "unit" }, 58)).toBe("58");
|
|
184
|
-
expect(applyFormatSpec({ format: "unit", unit: "does-not-exist" }, 58)).toBe("58");
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
test("Prototype-Chain-Keys (toString, constructor) zählen NICHT als unit — Object.hasOwn statt `in`", () => {
|
|
188
|
-
expect(applyFormatSpec({ format: "unit", unit: "toString" }, 58)).toBe("58");
|
|
189
|
-
expect(applyFormatSpec({ format: "unit", unit: "constructor" }, 58)).toBe("58");
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
test("leerer Wert collapst zu '' wie jedes andere Nicht-priority-Format", () => {
|
|
193
|
-
expect(applyFormatSpec({ format: "unit", unit: "km" }, null)).toBe("");
|
|
194
|
-
expect(applyFormatSpec({ format: "unit", unit: "km" }, "")).toBe("");
|
|
195
|
-
});
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
describe("applyFormatSpec — enumOption (fw#2315)", () => {
|
|
199
|
-
const spec = { format: "enumOption", keyPrefix: "contact:entity:contact:field:status:option:" };
|
|
200
|
-
|
|
201
|
-
test("übersetzter Key gewinnt gegen den Rohwert", () => {
|
|
202
|
-
const translate = (key: string) =>
|
|
203
|
-
key === "contact:entity:contact:field:status:option:active" ? "Aktiv" : key;
|
|
204
|
-
expect(applyFormatSpec(spec, "active", translate)).toBe("Aktiv");
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
test("unbekannter Key fällt auf den Rohwert zurück (translate gibt den Key unverändert zurück)", () => {
|
|
208
|
-
const translate = (key: string) => key;
|
|
209
|
-
expect(applyFormatSpec(spec, "active", translate)).toBe("active");
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
test("ohne translate-Parameter fällt auf den Rohwert zurück", () => {
|
|
213
|
-
expect(applyFormatSpec(spec, "active")).toBe("active");
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
test("ohne keyPrefix fällt auf den Rohwert zurück, auch mit translate", () => {
|
|
217
|
-
const translate = (key: string) => `translated:${key}`;
|
|
218
|
-
expect(applyFormatSpec({ format: "enumOption" }, "active", translate)).toBe("active");
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
test("nicht-string-Wert wird vor der Key-Bildung stringifiziert", () => {
|
|
222
|
-
const translate = (key: string) =>
|
|
223
|
-
key === "contact:entity:contact:field:status:option:1" ? "Eins" : key;
|
|
224
|
-
expect(applyFormatSpec(spec, 1, translate)).toBe("Eins");
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
test("leerer Wert collapst zu '' wie jedes andere Nicht-priority-Format", () => {
|
|
228
|
-
expect(applyFormatSpec(spec, null, (k) => k)).toBe("");
|
|
229
|
-
expect(applyFormatSpec(spec, "", (k) => k)).toBe("");
|
|
230
|
-
});
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
describe("applyFormatSpec — boolean labels through translate (fw#2683)", () => {
|
|
234
|
-
const TRUE_KEY = "managed-pages:entity:page:field:published:option:true";
|
|
235
|
-
const FALSE_KEY = "managed-pages:entity:page:field:published:option:false";
|
|
236
|
-
const spec = { format: "boolean", trueLabel: TRUE_KEY, falseLabel: FALSE_KEY };
|
|
237
|
-
const translate = (key: string): string => {
|
|
238
|
-
if (key === TRUE_KEY) return "Published";
|
|
239
|
-
if (key === FALSE_KEY) return "Draft";
|
|
240
|
-
return key;
|
|
241
|
-
};
|
|
242
|
-
|
|
243
|
-
test("an i18n key as trueLabel renders translated", () => {
|
|
244
|
-
expect(applyFormatSpec(spec, true, translate)).toBe("Published");
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
test("an i18n key as falseLabel renders translated", () => {
|
|
248
|
-
expect(applyFormatSpec(spec, false, translate)).toBe("Draft");
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
test("without translate the declared labels stay verbatim", () => {
|
|
252
|
-
expect(applyFormatSpec(spec, true)).toBe(TRUE_KEY);
|
|
253
|
-
expect(applyFormatSpec(spec, false)).toBe(FALSE_KEY);
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
test("a plain-text label is not mangled by a pass-through translate", () => {
|
|
257
|
-
const plain = { format: "boolean", trueLabel: "Yes", falseLabel: "No" };
|
|
258
|
-
expect(applyFormatSpec(plain, true, (k) => k)).toBe("Yes");
|
|
259
|
-
expect(applyFormatSpec(plain, false, (k) => k)).toBe("No");
|
|
260
|
-
});
|
|
261
|
-
|
|
262
|
-
test("undeclared labels keep the ✓ / empty defaults even with translate", () => {
|
|
263
|
-
expect(applyFormatSpec({ format: "boolean" }, true, translate)).toBe("✓");
|
|
264
|
-
expect(applyFormatSpec({ format: "boolean" }, false, translate)).toBe("");
|
|
265
|
-
});
|
|
266
|
-
});
|
|
267
|
-
|
|
268
|
-
describe("unit format keys stay in lockstep with UnitKey", () => {
|
|
269
|
-
// Mirror of packages/types/src/screen.ts UnitKey — keep lists equal without
|
|
270
|
-
// adding a kumiko-types dependency to headless.
|
|
271
|
-
const TYPE_UNIT_KEYS = ["m2", "km", "m", "kg", "percent", "mi"] as const;
|
|
272
|
-
test("every UnitKey formats with a non-bare number", () => {
|
|
273
|
-
for (const k of TYPE_UNIT_KEYS) {
|
|
274
|
-
expect(UNIT_FORMAT_KEYS.includes(k)).toBe(true);
|
|
275
|
-
expect(applyFormatSpec({ format: "unit", unit: k }, 1)).not.toBe("1");
|
|
276
|
-
}
|
|
277
|
-
});
|
|
278
|
-
test("no extra runtime keys beyond UnitKey", () => {
|
|
279
|
-
expect([...UNIT_FORMAT_KEYS].sort()).toEqual([...TYPE_UNIT_KEYS].sort());
|
|
280
|
-
});
|
|
281
|
-
});
|