@cosmicdrift/kumiko-headless 0.1.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.
@@ -0,0 +1,315 @@
1
+ import { describe, expect, test, vi } from "vitest";
2
+ import { z } from "zod";
3
+ import type { Dispatcher, WriteResult } from "../../dispatcher";
4
+ import { createStore } from "../../store";
5
+ import { createFormController } from "../form-controller";
6
+
7
+ // Fake dispatcher scoped to this test file — same shape as contract.test.ts
8
+ // but with an explicit spy on write() so assertions can inspect argv.
9
+ function makeDispatcher(response?: WriteResult): Dispatcher & {
10
+ readonly writeSpy: ReturnType<typeof vi.fn>;
11
+ } {
12
+ const writeSpy = vi.fn(
13
+ async () => response ?? ({ isSuccess: true, data: { id: "srv-1" } } as WriteResult),
14
+ );
15
+ return {
16
+ writeSpy,
17
+ write: writeSpy as unknown as Dispatcher["write"],
18
+ async query<TData>() {
19
+ return { isSuccess: true, data: null } as unknown as { isSuccess: true; data: TData };
20
+ },
21
+ async batch() {
22
+ return { isSuccess: true, results: [] };
23
+ },
24
+ statusStore: createStore("online"),
25
+ pendingWrites: () => [],
26
+ pendingFiles: () => [],
27
+ };
28
+ }
29
+
30
+ describe("createFormController — submit()", () => {
31
+ test("throws when called without a submit-config — no guessing the destination", async () => {
32
+ const form = createFormController({ initial: { title: "hello" } });
33
+
34
+ await expect(form.submit()).rejects.toThrow(/submit\(\) called without a `submit` config/);
35
+ });
36
+
37
+ test("happy path: dispatches values, returns success, rebases form", async () => {
38
+ const disp = makeDispatcher();
39
+ const form = createFormController({
40
+ initial: { title: "hello" },
41
+ submit: { dispatcher: disp, type: "app:write:task:create" },
42
+ });
43
+ form.setField("title", "world");
44
+
45
+ const result = await form.submit();
46
+
47
+ expect(disp.writeSpy).toHaveBeenCalledWith("app:write:task:create", { title: "world" });
48
+ expect(result.isSuccess).toBe(true);
49
+ if (result.isSuccess && !("validationBlocked" in result && result.validationBlocked)) {
50
+ expect(result.data).toEqual({ id: "srv-1" });
51
+ }
52
+ // Post-submit the form should be at its new clean baseline.
53
+ const snap = form.getSnapshot();
54
+ expect(snap.isDirty).toBe(false);
55
+ expect(snap.initial.title).toBe("world");
56
+ expect(snap.changes).toEqual({});
57
+ });
58
+
59
+ test("local validation failure short-circuits — no network call", async () => {
60
+ const disp = makeDispatcher();
61
+ const form = createFormController({
62
+ initial: { title: "" },
63
+ schema: z.object({ title: z.string().min(3) }),
64
+ submit: { dispatcher: disp, type: "app:write:task:create" },
65
+ });
66
+
67
+ const result = await form.submit();
68
+
69
+ expect(disp.writeSpy).not.toHaveBeenCalled();
70
+ expect(result.validationBlocked).toBe(true);
71
+ expect(result.isSuccess).toBe(false);
72
+ expect(form.getSnapshot().errors["title"]).toBeDefined();
73
+ });
74
+
75
+ test("server validation failure: field errors land on the form", async () => {
76
+ const disp = makeDispatcher({
77
+ isSuccess: false,
78
+ error: {
79
+ code: "validation_error",
80
+ httpStatus: 400,
81
+ i18nKey: "errors.validation.failed",
82
+ message: "Validation failed",
83
+ details: {
84
+ fields: [{ path: "title", code: "too_small", i18nKey: "errors.validation.too_small" }],
85
+ },
86
+ },
87
+ });
88
+ const form = createFormController({
89
+ initial: { title: "hello" }, // passes local validate()
90
+ submit: { dispatcher: disp, type: "app:write:task:create" },
91
+ });
92
+
93
+ const result = await form.submit();
94
+
95
+ expect(result.isSuccess).toBe(false);
96
+ expect(result.validationBlocked).toBe(false);
97
+ // Errors pushed onto the form — renderer shows them identically to
98
+ // local-validate failures.
99
+ expect(form.getSnapshot().errors["title"]).toBeDefined();
100
+ expect(form.getSnapshot().errors["title"]?.[0]?.code).toBe("too_small");
101
+ });
102
+
103
+ test("non-validation server error: form state is left alone, error passes through", async () => {
104
+ const disp = makeDispatcher({
105
+ isSuccess: false,
106
+ error: {
107
+ code: "rate_limited",
108
+ httpStatus: 429,
109
+ i18nKey: "errors.rateLimited",
110
+ message: "Too many requests",
111
+ },
112
+ });
113
+ const form = createFormController({
114
+ initial: { title: "hello" },
115
+ submit: { dispatcher: disp, type: "app:write:task:create" },
116
+ });
117
+
118
+ const result = await form.submit();
119
+
120
+ expect(result.isSuccess).toBe(false);
121
+ expect(form.getSnapshot().errors).toEqual({}); // untouched
122
+ if (!result.isSuccess && !("validationBlocked" in result && result.validationBlocked)) {
123
+ expect(result.error.code).toBe("rate_limited");
124
+ }
125
+ });
126
+
127
+ test("payloadMode: 'changes' — sends only the delta, skips dispatch when clean", async () => {
128
+ const disp = makeDispatcher();
129
+ const form = createFormController({
130
+ initial: { title: "hello", count: 3 },
131
+ submit: {
132
+ dispatcher: disp,
133
+ type: "app:write:task:update",
134
+ payloadMode: "changes",
135
+ },
136
+ });
137
+
138
+ // Clean form → no network call, success with current values.
139
+ const cleanResult = await form.submit();
140
+ expect(disp.writeSpy).not.toHaveBeenCalled();
141
+ expect(cleanResult.isSuccess).toBe(true);
142
+
143
+ // Touch one field, submit — only that field goes out.
144
+ form.setField("title", "world");
145
+ await form.submit();
146
+ expect(disp.writeSpy).toHaveBeenCalledWith("app:write:task:update", { title: "world" });
147
+ });
148
+
149
+ test("stale-submit race: edits during the in-flight write stay dirty after success", async () => {
150
+ // User submits "hello", the network takes 50ms. During those 50ms the
151
+ // user types "world" into the same field. The server sees "hello"
152
+ // (correct — we snapshotted before the await). On success, baseline
153
+ // becomes "hello" (what was submitted), NOT "world" (what's currently
154
+ // displayed). The user still sees their in-flight edit AND it still
155
+ // counts as dirty — so they can submit it again.
156
+ let resolve: ((result: WriteResult) => void) | undefined;
157
+ const slowResponse = new Promise<WriteResult>((r) => {
158
+ resolve = r;
159
+ });
160
+ const disp: Dispatcher = {
161
+ write: (async () => slowResponse) as unknown as Dispatcher["write"],
162
+ async query<TData>() {
163
+ return { isSuccess: true, data: null } as unknown as { isSuccess: true; data: TData };
164
+ },
165
+ async batch() {
166
+ return { isSuccess: true as const, results: [] };
167
+ },
168
+ statusStore: createStore("online"),
169
+ pendingWrites: () => [],
170
+ pendingFiles: () => [],
171
+ };
172
+
173
+ const form = createFormController({
174
+ initial: { title: "" },
175
+ submit: { dispatcher: disp, type: "app:write:task:create" },
176
+ });
177
+ form.setField("title", "hello");
178
+
179
+ const submitPromise = form.submit();
180
+
181
+ // User types during the in-flight call.
182
+ form.setField("title", "world");
183
+
184
+ resolve?.({ isSuccess: true, data: { id: "s1" } });
185
+ await submitPromise;
186
+
187
+ const snap = form.getSnapshot();
188
+ expect(snap.values.title).toBe("world"); // user's in-flight edit preserved
189
+ expect(snap.initial.title).toBe("hello"); // baseline = what was submitted
190
+ expect(snap.isDirty).toBe(true); // "world" is a new unsaved change
191
+ expect(snap.changes).toEqual({ title: "world" });
192
+ });
193
+
194
+ test("concurrent submit(): two parallel calls produce ONE write, both see the same result", async () => {
195
+ // Double-click scenario: the form fires submit() twice in quick
196
+ // succession, before the first write returned. The guard serializes
197
+ // them — only one network call, both promises resolve to the same
198
+ // result.
199
+ const disp = makeDispatcher({ isSuccess: true, data: { id: "only-one" } });
200
+ const form = createFormController({
201
+ initial: { title: "hello" },
202
+ submit: { dispatcher: disp, type: "app:write:task:create" },
203
+ });
204
+
205
+ const [r1, r2] = await Promise.all([form.submit(), form.submit()]);
206
+
207
+ expect(disp.writeSpy).toHaveBeenCalledTimes(1);
208
+ // Both callers get the same success envelope.
209
+ expect(r1.isSuccess).toBe(true);
210
+ expect(r2.isSuccess).toBe(true);
211
+ });
212
+
213
+ test("buildPayload: composes parent + child controllers into nested payload", async () => {
214
+ // Typical sub-controller pattern for hasMany-lines: parent form +
215
+ // N separate line-controllers. buildPayload assembles the nested
216
+ // payload the server's nested-write expects.
217
+ const disp = makeDispatcher();
218
+
219
+ const parent = createFormController({
220
+ initial: { title: "Order #1" },
221
+ });
222
+ const line1 = createFormController({
223
+ initial: { product: "Widget", qty: 2 },
224
+ });
225
+ const line2 = createFormController({
226
+ initial: { product: "Gadget", qty: 5 },
227
+ });
228
+
229
+ // Parent drives submit; buildPayload pulls lines from the
230
+ // externally-held controllers. The server's nested-write expander
231
+ // does the rest.
232
+ const formWithSubmit = createFormController({
233
+ initial: parent.getSnapshot().values,
234
+ submit: {
235
+ dispatcher: disp,
236
+ type: "orders:write:order:create",
237
+ buildPayload: (snap) => ({
238
+ ...snap.values,
239
+ lines: [line1.getSnapshot().values, line2.getSnapshot().values],
240
+ }),
241
+ },
242
+ });
243
+
244
+ await formWithSubmit.submit();
245
+
246
+ expect(disp.writeSpy).toHaveBeenCalledWith("orders:write:order:create", {
247
+ title: "Order #1",
248
+ lines: [
249
+ { product: "Widget", qty: 2 },
250
+ { product: "Gadget", qty: 5 },
251
+ ],
252
+ });
253
+ });
254
+
255
+ test("buildPayload is called once at submit-start, not re-called during in-flight", async () => {
256
+ // Stale-safety check for buildPayload: the user may keep editing
257
+ // during the await. The payload is captured BEFORE the network
258
+ // call, so late edits don't leak into what the server receives.
259
+ let resolve: ((result: WriteResult) => void) | undefined;
260
+ const slow = new Promise<WriteResult>((r) => {
261
+ resolve = r;
262
+ });
263
+ const disp: Dispatcher = {
264
+ write: vi.fn(async () => slow) as unknown as Dispatcher["write"],
265
+ async query<TData>() {
266
+ return { isSuccess: true, data: null } as unknown as { isSuccess: true; data: TData };
267
+ },
268
+ async batch() {
269
+ return { isSuccess: true as const, results: [] };
270
+ },
271
+ statusStore: createStore("online"),
272
+ pendingWrites: () => [],
273
+ pendingFiles: () => [],
274
+ };
275
+
276
+ let buildCalls = 0;
277
+ const form = createFormController({
278
+ initial: { title: "a" },
279
+ submit: {
280
+ dispatcher: disp,
281
+ type: "app:write:x:create",
282
+ buildPayload: (snap) => {
283
+ buildCalls++;
284
+ return { title: snap.values.title };
285
+ },
286
+ },
287
+ });
288
+
289
+ const submitPromise = form.submit();
290
+ // User types during the await — this MUST NOT re-trigger buildPayload.
291
+ form.setField("title", "mutated");
292
+ resolve?.({ isSuccess: true, data: { id: "s1" } });
293
+ await submitPromise;
294
+
295
+ expect(buildCalls).toBe(1);
296
+ });
297
+
298
+ test("submit with schema + payloadMode='values' validates BEFORE dispatch", async () => {
299
+ // Order matters: if validate() runs after dispatch, a bad payload
300
+ // hits the network and the caller can't tell user-error from
301
+ // server-error. The form's contract is "validate first, network
302
+ // only if clean".
303
+ const disp = makeDispatcher();
304
+ const form = createFormController({
305
+ initial: { title: "" },
306
+ schema: z.object({ title: z.string().min(1) }),
307
+ submit: { dispatcher: disp, type: "app:write:task:create" },
308
+ });
309
+
310
+ const result = await form.submit();
311
+
312
+ expect(result.validationBlocked).toBe(true);
313
+ expect(disp.writeSpy).not.toHaveBeenCalled();
314
+ });
315
+ });
@@ -0,0 +1,124 @@
1
+ import { describe, expect, test, vi } from "vitest";
2
+ import { z } from "zod";
3
+ import { createFormController } from "../form-controller";
4
+ import { groupIssuesByPath, zodErrorToFieldIssues } from "../zod-bridge";
5
+
6
+ describe("zodErrorToFieldIssues", () => {
7
+ test("flattens zod issues to FieldIssue with dotted paths", () => {
8
+ const schema = z.object({
9
+ title: z.string().min(1),
10
+ address: z.object({ city: z.string().min(1) }),
11
+ tags: z.array(z.string().min(1)),
12
+ });
13
+ const result = schema.safeParse({ title: "", address: { city: "" }, tags: ["ok", ""] });
14
+
15
+ expect(result.success).toBe(false);
16
+ if (result.success) return;
17
+ const issues = zodErrorToFieldIssues(result.error);
18
+
19
+ const paths = issues.map((i) => i.path).sort();
20
+ expect(paths).toContain("title");
21
+ expect(paths).toContain("address.city");
22
+ expect(paths).toContain("tags.1");
23
+ });
24
+
25
+ test("top-level issues get path='(root)' — matches server zod-bridge", () => {
26
+ const schema = z.object({ foo: z.string() });
27
+ // Pass a non-object → zod raises an issue with path=[].
28
+ const result = schema.safeParse("not-an-object");
29
+
30
+ expect(result.success).toBe(false);
31
+ if (result.success) return;
32
+ const issues = zodErrorToFieldIssues(result.error);
33
+
34
+ expect(issues[0]?.path).toBe("(root)");
35
+ });
36
+
37
+ test("surfaces zod params (minimum/maximum/expected) under issue.params", () => {
38
+ const schema = z.object({ count: z.number().min(10).max(100) });
39
+ const result = schema.safeParse({ count: 3 });
40
+
41
+ expect(result.success).toBe(false);
42
+ if (result.success) return;
43
+ const issues = zodErrorToFieldIssues(result.error);
44
+
45
+ expect(issues[0]?.params).toBeDefined();
46
+ expect(issues[0]?.params?.["minimum"]).toBe(10);
47
+ });
48
+ });
49
+
50
+ describe("groupIssuesByPath", () => {
51
+ test("groups multiple issues on the same path into one bucket", () => {
52
+ const grouped = groupIssuesByPath([
53
+ { path: "title", code: "too_small", i18nKey: "x" },
54
+ { path: "title", code: "invalid_format", i18nKey: "x" },
55
+ { path: "age", code: "invalid_type", i18nKey: "x" },
56
+ ]);
57
+
58
+ expect(grouped["title"]).toHaveLength(2);
59
+ expect(grouped["age"]).toHaveLength(1);
60
+ });
61
+ });
62
+
63
+ describe("createFormController — validate()", () => {
64
+ test("without a schema: validate() is a no-op that returns true", () => {
65
+ const form = createFormController({ initial: { title: "" } });
66
+ const listener = vi.fn();
67
+ form.subscribe(listener);
68
+
69
+ const ok = form.validate();
70
+
71
+ expect(ok).toBe(true);
72
+ expect(form.getSnapshot().errors).toEqual({});
73
+ expect(listener).not.toHaveBeenCalled(); // no-op
74
+ });
75
+
76
+ test("with a schema: validate() runs it and populates errors on failure", () => {
77
+ const schema = z.object({ title: z.string().min(3), age: z.number().int() });
78
+ const form = createFormController({
79
+ initial: { title: "a", age: 1.5 },
80
+ schema,
81
+ });
82
+
83
+ const ok = form.validate();
84
+ const snap = form.getSnapshot();
85
+
86
+ expect(ok).toBe(false);
87
+ expect(snap.errors["title"]).toBeDefined();
88
+ expect(snap.errors["age"]).toBeDefined();
89
+ });
90
+
91
+ test("validate() returns true when values match the schema", () => {
92
+ const schema = z.object({ title: z.string().min(1) });
93
+ const form = createFormController({ initial: { title: "hello" }, schema });
94
+
95
+ expect(form.validate()).toBe(true);
96
+ expect(form.getSnapshot().errors).toEqual({});
97
+ });
98
+
99
+ test("validate() clears previous errors on subsequent success", () => {
100
+ // Common flow: user submits, sees errors, fixes fields, hits validate
101
+ // again — old errors must disappear.
102
+ const schema = z.object({ title: z.string().min(3) });
103
+ const form = createFormController({ initial: { title: "a" }, schema });
104
+
105
+ form.validate();
106
+ expect(form.getSnapshot().errors["title"]).toBeDefined();
107
+
108
+ form.setField("title", "hello");
109
+ form.validate();
110
+
111
+ expect(form.getSnapshot().errors).toEqual({});
112
+ });
113
+
114
+ test("validate() with nested values: errors keyed by dotted path", () => {
115
+ const schema = z.object({
116
+ address: z.object({ city: z.string().min(1) }),
117
+ });
118
+ const form = createFormController({ initial: { address: { city: "" } }, schema });
119
+
120
+ form.validate();
121
+
122
+ expect(form.getSnapshot().errors["address.city"]).toBeDefined();
123
+ });
124
+ });