@sundaeswap/sprinkles 0.6.0 → 0.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sundaeswap/sprinkles",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "A TypeScript library for building interactive CLI menus and TUI applications with TypeBox schema validation",
5
5
  "type": "module",
6
6
  "main": "./dist/cjs/index.js",
@@ -150,6 +150,150 @@ describe("FillInStruct", () => {
150
150
  );
151
151
  });
152
152
 
153
+ test("discriminated union propagates default when variant matches", async () => {
154
+ // When default has type "a" and user selects variant A, the default values
155
+ // for non-literal fields should be pre-populated (field starts as "set" status).
156
+ // Submitting without editing should return the default values.
157
+ const schema = Type.Union([
158
+ Type.Object({
159
+ type: Type.Literal("a"),
160
+ value: Type.String(),
161
+ }),
162
+ Type.Object({
163
+ type: Type.Literal("b"),
164
+ count: Type.BigInt(),
165
+ }),
166
+ ]);
167
+
168
+ const defaultValue = { type: "a" as const, value: "existing-value" };
169
+
170
+ // Select the first variant (type "a") - matches the default
171
+ mockSelectCancellable.mockImplementationOnce(async (opts: any) => {
172
+ return opts.choices[0].value;
173
+ });
174
+ // Both fields are "set" (type auto-filled, value pre-populated from default).
175
+ // allRequiredFieldsFilled is true so Submit is enabled. Submit immediately.
176
+ mockSelectCancellable.mockResolvedValueOnce("submit");
177
+
178
+ const result = await sprinkle.FillInStruct(schema, defaultValue as any);
179
+
180
+ // The default value for the "value" field should be included in the result
181
+ expect(result).toEqual({ type: "a", value: "existing-value" });
182
+ });
183
+
184
+ test("discriminated union with nested object propagates nested defaults", async () => {
185
+ // When a discriminated union default has a nested settings object, the nested
186
+ // fields should also be pre-populated from the default.
187
+ const schema = Type.Union([
188
+ Type.Object({
189
+ type: Type.Literal("config"),
190
+ settings: Type.Object({
191
+ timeout: Type.BigInt(),
192
+ retries: Type.BigInt(),
193
+ }),
194
+ }),
195
+ Type.Object({
196
+ type: Type.Literal("other"),
197
+ name: Type.String(),
198
+ }),
199
+ ]);
200
+
201
+ const defaultValue = {
202
+ type: "config" as const,
203
+ settings: { timeout: 30n, retries: 3n },
204
+ };
205
+
206
+ // Select the first variant (type "config") - matches the default
207
+ mockSelectCancellable.mockImplementationOnce(async (opts: any) => {
208
+ return opts.choices[0].value;
209
+ });
210
+ // Outer object: "type" is auto-filled literal, "settings" is pre-populated from default.
211
+ // Both required fields are "set", so Submit is available.
212
+ mockSelectCancellable.mockResolvedValueOnce("submit");
213
+
214
+ const result = await sprinkle.FillInStruct(schema, defaultValue as any);
215
+
216
+ // Both the outer and nested default values should be returned
217
+ expect(result).toEqual({
218
+ type: "config",
219
+ settings: { timeout: 30n, retries: 3n },
220
+ });
221
+ });
222
+
223
+ test("discriminated union does not propagate default when variant does not match", async () => {
224
+ // When default has type "a" but user selects variant B, no default should be passed.
225
+ // The variant B field should start as "unset" and require user input.
226
+ const schema = Type.Union([
227
+ Type.Object({
228
+ type: Type.Literal("a"),
229
+ value: Type.String(),
230
+ }),
231
+ Type.Object({
232
+ type: Type.Literal("b"),
233
+ name: Type.String(),
234
+ }),
235
+ ]);
236
+
237
+ // Default has type "a" but user selects variant "b"
238
+ const defaultValue = { type: "a" as const, value: "should-not-appear" };
239
+
240
+ // Select the second variant (type "b") - does NOT match the default
241
+ mockSelectCancellable.mockImplementationOnce(async (opts: any) => {
242
+ return opts.choices[1].value;
243
+ });
244
+ // Variant B: "type" is auto-filled, "name" starts as unset (no default propagated).
245
+ // Select "name" field and fill it in.
246
+ mockSelectCancellable.mockResolvedValueOnce("field:name");
247
+ mockInputCancellable.mockResolvedValueOnce("new-name");
248
+ mockSelectCancellable.mockResolvedValueOnce("submit");
249
+
250
+ const result = await sprinkle.FillInStruct(schema, defaultValue as any);
251
+
252
+ expect(result).toEqual({ type: "b", name: "new-name" });
253
+ // The input prompt for "name" should have no default (default from variant A was not passed)
254
+ expect(mockInputCancellable.mock.calls[0][0].default).toBeUndefined();
255
+ });
256
+
257
+ test("non-discriminated union propagates default when value structurally matches selected variant", async () => {
258
+ // For a union of simple types, when the default matches the selected variant
259
+ // structurally (via Value.Check), it should be passed through.
260
+ const schema = Type.Union([Type.String(), Type.BigInt()]);
261
+
262
+ const defaultValue = "default-string";
263
+
264
+ // Select the first variant (String)
265
+ mockSelectCancellable.mockImplementationOnce(async (opts: any) => {
266
+ return opts.choices[0].value;
267
+ });
268
+ mockInputCancellable.mockResolvedValueOnce("new-value");
269
+
270
+ await sprinkle.FillInStruct(schema, defaultValue as any);
271
+
272
+ // The input prompt should show the string default
273
+ expect(mockInputCancellable.mock.calls[0][0].default).toBe("default-string");
274
+ });
275
+
276
+ test("non-discriminated union does not propagate default when value does not match selected variant", async () => {
277
+ // When a string default is provided but BigInt variant is selected,
278
+ // the default must not be passed (it would fail structural validation).
279
+ const schema = Type.Union([Type.String(), Type.BigInt()]);
280
+
281
+ // Default is a string but user selects BigInt variant
282
+ const defaultValue = "not-a-bigint";
283
+
284
+ // Select the second variant (BigInt)
285
+ mockSelectCancellable.mockImplementationOnce(async (opts: any) => {
286
+ return opts.choices[1].value;
287
+ });
288
+ mockInputCancellable.mockResolvedValueOnce("42");
289
+
290
+ const result = await sprinkle.FillInStruct(schema, defaultValue as any);
291
+
292
+ expect(result).toBe(42n);
293
+ // The string default must not be passed to the BigInt prompt
294
+ expect(mockInputCancellable.mock.calls[0][0].default).toBeUndefined();
295
+ });
296
+
153
297
  // --- Object types (menu-based) ---
154
298
 
155
299
  test("fills an object with multiple fields", async () => {
@@ -19,6 +19,7 @@ import {
19
19
  } from "./prompts.js";
20
20
  import colors from "yoctocolors-cjs";
21
21
  import { type TSchema, Type, OptionalKind } from "@sinclair/typebox";
22
+ import { Value } from "@sinclair/typebox/value";
22
23
  import * as fs from "fs";
23
24
  import * as path from "path";
24
25
  export * from "@sinclair/typebox";
@@ -590,8 +591,15 @@ export class Sprinkle<S extends TSchema> {
590
591
  {
591
592
  title: "Edit settings",
592
593
  action: async () => {
593
- this.settings = await this.EditStruct(this.type, this.settings);
594
- this.saveSettings();
594
+ try {
595
+ this.settings = await this.EditStruct(this.type, this.settings);
596
+ this.saveSettings();
597
+ } catch (e) {
598
+ if (e instanceof UserCancelledError) {
599
+ return; // User cancelled, return to menu
600
+ }
601
+ throw e;
602
+ }
595
603
  },
596
604
  },
597
605
  {
@@ -1137,7 +1145,40 @@ export class Sprinkle<S extends TSchema> {
1137
1145
  throw new UserCancelledError();
1138
1146
  }
1139
1147
  const selection = selectionResult as TSchema;
1140
- return this._fillInStruct(selection, path, defs) as Promise<TExact<U>>;
1148
+ // Determine if the provided default value matches the selected variant.
1149
+ // For discriminated unions (objects with literal fields like `type`), check
1150
+ // if the literal field values in the selected variant match those in `def`.
1151
+ // For non-discriminated unions, fall back to structural matching with Value.Check.
1152
+ let matchedDef: unknown = undefined;
1153
+ if (def !== undefined) {
1154
+ if (isObject(selection)) {
1155
+ // Check if all literal fields in the selected variant match def
1156
+ const literalFields = Object.entries(selection.properties ?? {}).filter(
1157
+ ([, fieldSchema]) => isLiteral(fieldSchema as TSchema),
1158
+ );
1159
+ if (literalFields.length > 0) {
1160
+ const defRecord = def as Record<string, unknown>;
1161
+ const allLiteralsMatch = literalFields.every(
1162
+ ([fieldName, fieldSchema]) =>
1163
+ defRecord[fieldName] === (fieldSchema as unknown as { const: unknown }).const,
1164
+ );
1165
+ if (allLiteralsMatch) {
1166
+ matchedDef = def;
1167
+ }
1168
+ } else {
1169
+ // No literal discriminators - use structural check
1170
+ if (Value.Check(selection, def)) {
1171
+ matchedDef = def;
1172
+ }
1173
+ }
1174
+ } else {
1175
+ // Non-object variant - use structural check
1176
+ if (Value.Check(selection, def)) {
1177
+ matchedDef = def;
1178
+ }
1179
+ }
1180
+ }
1181
+ return this._fillInStruct(selection, path, defs, matchedDef as TExact<typeof selection>) as Promise<TExact<U>>;
1141
1182
  }
1142
1183
 
1143
1184
  if (isString(type)) {