@terreno/blocks 57.10.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 (237) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +62 -0
  3. package/biome.jsonc +18 -0
  4. package/bunfig.toml +6 -0
  5. package/dist/asks/errors.d.ts +77 -0
  6. package/dist/asks/errors.js +364 -0
  7. package/dist/asks/files.d.ts +53 -0
  8. package/dist/asks/files.js +171 -0
  9. package/dist/asks/formValues.d.ts +32 -0
  10. package/dist/asks/formValues.js +229 -0
  11. package/dist/asks/headless.d.ts +227 -0
  12. package/dist/asks/headless.js +94 -0
  13. package/dist/asks/jsonSchema.d.ts +6 -0
  14. package/dist/asks/jsonSchema.js +62 -0
  15. package/dist/asks/limits.d.ts +58 -0
  16. package/dist/asks/limits.js +61 -0
  17. package/dist/asks/prompt.d.ts +10 -0
  18. package/dist/asks/prompt.js +144 -0
  19. package/dist/asks/schema.d.ts +826 -0
  20. package/dist/asks/schema.js +864 -0
  21. package/dist/asks/simpleCard.d.ts +85 -0
  22. package/dist/asks/simpleCard.js +279 -0
  23. package/dist/asks/validateInput.d.ts +13 -0
  24. package/dist/asks/validateInput.js +19 -0
  25. package/dist/asks/validateResponse.d.ts +13 -0
  26. package/dist/asks/validateResponse.js +278 -0
  27. package/dist/cli.d.ts +11 -0
  28. package/dist/cli.js +58 -0
  29. package/dist/errors.d.ts +51 -0
  30. package/dist/errors.js +106 -0
  31. package/dist/index.d.ts +31 -0
  32. package/dist/index.js +111 -0
  33. package/dist/jsonSchema.d.ts +5 -0
  34. package/dist/jsonSchema.js +12 -0
  35. package/dist/limits.d.ts +27 -0
  36. package/dist/limits.js +30 -0
  37. package/dist/lint.d.ts +21 -0
  38. package/dist/lint.js +388 -0
  39. package/dist/parse.d.ts +16 -0
  40. package/dist/parse.js +95 -0
  41. package/dist/parsePartial.d.ts +12 -0
  42. package/dist/parsePartial.js +105 -0
  43. package/dist/prompt.d.ts +9 -0
  44. package/dist/prompt.js +95 -0
  45. package/dist/schema.d.ts +199 -0
  46. package/dist/schema.js +277 -0
  47. package/dist/validate.d.ts +17 -0
  48. package/dist/validate.js +331 -0
  49. package/package.json +60 -0
  50. package/schemas/pendingAsksResponse.schema.json +195 -0
  51. package/schemas/simpleCard.schema.json +135 -0
  52. package/schemas/turnRequest.schema.json +108 -0
  53. package/schemas/turnResponse.schema.json +204 -0
  54. package/scripts/writeJsonSchemas.ts +11 -0
  55. package/src/asks/docParity.test.ts +65 -0
  56. package/src/asks/errors.test.ts +250 -0
  57. package/src/asks/errors.ts +449 -0
  58. package/src/asks/files.test.ts +294 -0
  59. package/src/asks/files.ts +216 -0
  60. package/src/asks/fixtures/invalid/choice-blank-text.json +20 -0
  61. package/src/asks/fixtures/invalid/choice-default-not-in-options.json +13 -0
  62. package/src/asks/fixtures/invalid/choice-default-selects-two.json +16 -0
  63. package/src/asks/fixtures/invalid/choice-description-too-long.json +24 -0
  64. package/src/asks/fixtures/invalid/choice-duplicate-ids.json +17 -0
  65. package/src/asks/fixtures/invalid/choice-label-too-long.json +23 -0
  66. package/src/asks/fixtures/invalid/choice-many-bounds-not-whole.json +17 -0
  67. package/src/asks/fixtures/invalid/choice-many-bounds-out-of-range.json +17 -0
  68. package/src/asks/fixtures/invalid/choice-many-default-duplicate.json +17 -0
  69. package/src/asks/fixtures/invalid/choice-many-default-too-many.json +15 -0
  70. package/src/asks/fixtures/invalid/choice-many-max-above-options.json +14 -0
  71. package/src/asks/fixtures/invalid/choice-many-min-above-choices.json +13 -0
  72. package/src/asks/fixtures/invalid/choice-many-min-above-max.json +15 -0
  73. package/src/asks/fixtures/invalid/choice-missing-required.json +14 -0
  74. package/src/asks/fixtures/invalid/choice-mixed-errors.json +19 -0
  75. package/src/asks/fixtures/invalid/choice-not-an-object.json +5 -0
  76. package/src/asks/fixtures/invalid/choice-one-with-many-fields.json +19 -0
  77. package/src/asks/fixtures/invalid/choice-option-id-format.json +35 -0
  78. package/src/asks/fixtures/invalid/choice-options-not-objects.json +12 -0
  79. package/src/asks/fixtures/invalid/choice-other-label-too-long.json +25 -0
  80. package/src/asks/fixtures/invalid/choice-other-label-without-allow-other.json +14 -0
  81. package/src/asks/fixtures/invalid/choice-prompt-empty.json +12 -0
  82. package/src/asks/fixtures/invalid/choice-prompt-too-long.json +23 -0
  83. package/src/asks/fixtures/invalid/choice-select-unknown.json +12 -0
  84. package/src/asks/fixtures/invalid/choice-submit-label-too-long.json +13 -0
  85. package/src/asks/fixtures/invalid/choice-title-too-long.json +24 -0
  86. package/src/asks/fixtures/invalid/choice-too-few-options.json +9 -0
  87. package/src/asks/fixtures/invalid/choice-too-many-options.json +219 -0
  88. package/src/asks/fixtures/invalid/choice-unknown-key-option.json +15 -0
  89. package/src/asks/fixtures/invalid/choice-unknown-key-root.json +13 -0
  90. package/src/asks/fixtures/invalid/choice-wrong-types.json +16 -0
  91. package/src/asks/fixtures/invalid/confirm-emoji-label-too-long.json +13 -0
  92. package/src/asks/fixtures/invalid/confirm-label-too-long.json +14 -0
  93. package/src/asks/fixtures/invalid/confirm-labels-alike-after-trim.json +14 -0
  94. package/src/asks/fixtures/invalid/confirm-labels-alike.json +13 -0
  95. package/src/asks/fixtures/invalid/confirm-missing-prompt-blank-label.json +16 -0
  96. package/src/asks/fixtures/invalid/confirm-unknown-keys.json +23 -0
  97. package/src/asks/fixtures/invalid/confirm-wrong-types.json +18 -0
  98. package/src/asks/fixtures/invalid/files-accept-duplicate.json +13 -0
  99. package/src/asks/fixtures/invalid/files-accept-empty.json +13 -0
  100. package/src/asks/fixtures/invalid/files-accept-unknown.json +13 -0
  101. package/src/asks/fixtures/invalid/files-counts-out-of-range.json +19 -0
  102. package/src/asks/fixtures/invalid/files-min-above-max.json +15 -0
  103. package/src/asks/fixtures/invalid/files-missing-accept-unknown-key.json +17 -0
  104. package/src/asks/fixtures/invalid/form-default-bad-format.json +40 -0
  105. package/src/asks/fixtures/invalid/form-default-invalid-date.json +40 -0
  106. package/src/asks/fixtures/invalid/form-default-not-in-options.json +58 -0
  107. package/src/asks/fixtures/invalid/form-default-out-of-range.json +33 -0
  108. package/src/asks/fixtures/invalid/form-default-text-bounds.json +42 -0
  109. package/src/asks/fixtures/invalid/form-default-wrong-type.json +30 -0
  110. package/src/asks/fixtures/invalid/form-duplicate-field-ids.json +24 -0
  111. package/src/asks/fixtures/invalid/form-field-id-format.json +19 -0
  112. package/src/asks/fixtures/invalid/form-field-text-limits.json +33 -0
  113. package/src/asks/fixtures/invalid/form-keys-of-another-type.json +39 -0
  114. package/src/asks/fixtures/invalid/form-length-above-cap.json +30 -0
  115. package/src/asks/fixtures/invalid/form-min-above-max.json +32 -0
  116. package/src/asks/fixtures/invalid/form-no-fields.json +13 -0
  117. package/src/asks/fixtures/invalid/form-select-options.json +44 -0
  118. package/src/asks/fixtures/invalid/form-too-many-fields.json +59 -0
  119. package/src/asks/fixtures/invalid/form-unknown-field-type.json +27 -0
  120. package/src/asks/fixtures/invalid/markdown-bounds-out-of-range.json +18 -0
  121. package/src/asks/fixtures/invalid/markdown-initial-above-cap.json +13 -0
  122. package/src/asks/fixtures/invalid/markdown-max-above-cap.json +13 -0
  123. package/src/asks/fixtures/invalid/markdown-min-above-cap.json +13 -0
  124. package/src/asks/fixtures/invalid/markdown-min-above-max.json +14 -0
  125. package/src/asks/fixtures/invalid/markdown-placeholder-too-long.json +13 -0
  126. package/src/asks/fixtures/invalid/markdown-wrong-types-unknown-key.json +27 -0
  127. package/src/asks/fixtures/valid/choice-emoji-label-cut.json +32 -0
  128. package/src/asks/fixtures/valid/choice-labels-collide-after-cut.json +21 -0
  129. package/src/asks/fixtures/valid/choice-long-text-cut.json +41 -0
  130. package/src/asks/fixtures/valid/choice-many-default-below-min.json +24 -0
  131. package/src/asks/fixtures/valid/choice-many-every-choice-with-other.json +24 -0
  132. package/src/asks/fixtures/valid/choice-many-no-default.json +21 -0
  133. package/src/asks/fixtures/valid/choice-many-optional-no-buttons.json +20 -0
  134. package/src/asks/fixtures/valid/choice-many-options-default-label-collides.json +23 -0
  135. package/src/asks/fixtures/valid/choice-many-options-no-buttons.json +21 -0
  136. package/src/asks/fixtures/valid/choice-many-options-no-default.json +22 -0
  137. package/src/asks/fixtures/valid/choice-many-options-with-default.json +30 -0
  138. package/src/asks/fixtures/valid/choice-many-required-with-default.json +29 -0
  139. package/src/asks/fixtures/valid/choice-many-single-pick-or-other.json +29 -0
  140. package/src/asks/fixtures/valid/choice-many-toppings-with-default.json +36 -0
  141. package/src/asks/fixtures/valid/choice-max-limits.json +245 -0
  142. package/src/asks/fixtures/valid/choice-plan-with-default.json +42 -0
  143. package/src/asks/fixtures/valid/choice-two-options-no-decline.json +32 -0
  144. package/src/asks/fixtures/valid/choice-two-options.json +32 -0
  145. package/src/asks/fixtures/valid/confirm-allow-decline.json +40 -0
  146. package/src/asks/fixtures/valid/confirm-default-labels.json +36 -0
  147. package/src/asks/fixtures/valid/confirm-destructive-delete.json +41 -0
  148. package/src/asks/fixtures/valid/confirm-emoji-labels.json +38 -0
  149. package/src/asks/fixtures/valid/confirm-long-text-cut.json +40 -0
  150. package/src/asks/fixtures/valid/files-every-type.json +28 -0
  151. package/src/asks/fixtures/valid/files-one-csv-no-decline.json +16 -0
  152. package/src/asks/fixtures/valid/files-receipt-photos.json +24 -0
  153. package/src/asks/fixtures/valid/form-boolean-newsletter.json +30 -0
  154. package/src/asks/fixtures/valid/form-date-due.json +44 -0
  155. package/src/asks/fixtures/valid/form-datetime-meeting.json +44 -0
  156. package/src/asks/fixtures/valid/form-datetime-minutes-offset.json +44 -0
  157. package/src/asks/fixtures/valid/form-eight-fields.json +71 -0
  158. package/src/asks/fixtures/valid/form-email-contact.json +44 -0
  159. package/src/asks/fixtures/valid/form-invoice-details.json +68 -0
  160. package/src/asks/fixtures/valid/form-multiselect-channels.json +64 -0
  161. package/src/asks/fixtures/valid/form-number-budget.json +45 -0
  162. package/src/asks/fixtures/valid/form-phone-callback.json +44 -0
  163. package/src/asks/fixtures/valid/form-select-shipping.json +58 -0
  164. package/src/asks/fixtures/valid/form-text-required-default.json +47 -0
  165. package/src/asks/fixtures/valid/form-textarea-notes.json +23 -0
  166. package/src/asks/fixtures/valid/form-time-reminder.json +44 -0
  167. package/src/asks/fixtures/valid/form-url-website.json +30 -0
  168. package/src/asks/fixtures/valid/markdown-announcement-draft.json +39 -0
  169. package/src/asks/fixtures/valid/markdown-empty-draft.json +34 -0
  170. package/src/asks/fixtures/valid/markdown-initial-blank-below-min.json +24 -0
  171. package/src/asks/fixtures/valid/markdown-initial-over-max.json +24 -0
  172. package/src/asks/fixtures/valid/markdown-long-text-cut.json +37 -0
  173. package/src/asks/fixtures/valid/markdown-no-decline.json +28 -0
  174. package/src/asks/fixtures/valid/markdown-no-initial-with-min.json +25 -0
  175. package/src/asks/formValues.ts +320 -0
  176. package/src/asks/headless.test.ts +146 -0
  177. package/src/asks/headless.ts +130 -0
  178. package/src/asks/jsonSchema.test.ts +171 -0
  179. package/src/asks/jsonSchema.ts +89 -0
  180. package/src/asks/limits.ts +58 -0
  181. package/src/asks/prompt.test.ts +200 -0
  182. package/src/asks/prompt.ts +173 -0
  183. package/src/asks/schema.test.ts +198 -0
  184. package/src/asks/schema.ts +1203 -0
  185. package/src/asks/simpleCard.test.ts +998 -0
  186. package/src/asks/simpleCard.ts +341 -0
  187. package/src/asks/validateInput.test.ts +875 -0
  188. package/src/asks/validateInput.ts +24 -0
  189. package/src/asks/validateResponse.test.ts +740 -0
  190. package/src/asks/validateResponse.ts +341 -0
  191. package/src/blocksDocParity.test.ts +21 -0
  192. package/src/cli.test.ts +134 -0
  193. package/src/cli.ts +65 -0
  194. package/src/display.test.ts +68 -0
  195. package/src/errors.test.ts +29 -0
  196. package/src/errors.ts +125 -0
  197. package/src/fixtures/invalid/column-not-found.yaml +14 -0
  198. package/src/fixtures/invalid/column-type.yaml +14 -0
  199. package/src/fixtures/invalid/dataset-not-found.yaml +7 -0
  200. package/src/fixtures/invalid/dataset-too-large.yaml +20 -0
  201. package/src/fixtures/invalid/depth-exceeded.yaml +10 -0
  202. package/src/fixtures/invalid/duplicate-id.yaml +6 -0
  203. package/src/fixtures/invalid/invalid-enum.yaml +5 -0
  204. package/src/fixtures/invalid/key-order.yaml +4 -0
  205. package/src/fixtures/invalid/missing-required.yaml +3 -0
  206. package/src/fixtures/invalid/not-a-document.txt +1 -0
  207. package/src/fixtures/invalid/row-arity.yaml +11 -0
  208. package/src/fixtures/invalid/select-target.yaml +12 -0
  209. package/src/fixtures/invalid/table-too-wide.yaml +9 -0
  210. package/src/fixtures/invalid/too-many-points.yaml +8 -0
  211. package/src/fixtures/invalid/unknown-key.yaml +5 -0
  212. package/src/fixtures/invalid/unsupported-version.yaml +4 -0
  213. package/src/fixtures/invalid/yaml-anchor.yaml +6 -0
  214. package/src/fixtures/partial/cut-after-divider.yaml +5 -0
  215. package/src/fixtures/valid/actions.yaml +32 -0
  216. package/src/fixtures/valid/chart.yaml +20 -0
  217. package/src/fixtures/valid/layout.yaml +26 -0
  218. package/src/fixtures/valid/prose.yaml +4 -0
  219. package/src/html.test.ts +52 -0
  220. package/src/index.test.ts +114 -0
  221. package/src/index.ts +175 -0
  222. package/src/jsonSchema.test.ts +33 -0
  223. package/src/jsonSchema.ts +11 -0
  224. package/src/limits.ts +27 -0
  225. package/src/lint.test.ts +289 -0
  226. package/src/lint.ts +484 -0
  227. package/src/parse.test.ts +281 -0
  228. package/src/parse.ts +104 -0
  229. package/src/parsePartial.test.ts +53 -0
  230. package/src/parsePartial.ts +116 -0
  231. package/src/prompt.test.ts +24 -0
  232. package/src/prompt.ts +104 -0
  233. package/src/schema.ts +509 -0
  234. package/src/tests/askFixtures.ts +40 -0
  235. package/src/validate.perf.test.ts +43 -0
  236. package/src/validate.ts +369 -0
  237. package/tsconfig.json +18 -0
@@ -0,0 +1,740 @@
1
+ import {describe, expect, it} from "bun:test";
2
+ import type {ChoiceAskInput, ConfirmAskInput, FormAskInput, MarkdownAskInput} from "./schema";
3
+ import {validateAskResponse} from "./validateResponse";
4
+
5
+ const PLAN_INPUT: ChoiceAskInput = {
6
+ default: ["team"],
7
+ options: [
8
+ {description: "$0, one seat", id: "starter", label: "Starter"},
9
+ {description: "$20 per seat", id: "team", label: "Team"},
10
+ {id: "enterprise", label: "Enterprise"},
11
+ ],
12
+ prompt: "Which plan should I set up?",
13
+ select: "one",
14
+ };
15
+
16
+ const REQUIRED_INPUT: ChoiceAskInput = {...PLAN_INPUT, allowDecline: false};
17
+
18
+ const MANY_INPUT: ChoiceAskInput = {
19
+ options: [
20
+ {id: "cheese", label: "Cheese"},
21
+ {id: "mushrooms", label: "Mushrooms"},
22
+ {id: "olives", label: "Olives"},
23
+ {id: "peppers", label: "Peppers"},
24
+ ],
25
+ prompt: "Which toppings?",
26
+ select: "many",
27
+ };
28
+
29
+ const OTHER_INPUT: ChoiceAskInput = {
30
+ ...MANY_INPUT,
31
+ allowOther: true,
32
+ maxSelected: 2,
33
+ minSelected: 1,
34
+ };
35
+
36
+ const check = (response: unknown, input: ChoiceAskInput = PLAN_INPUT) =>
37
+ validateAskResponse({input, kind: "choice", response});
38
+
39
+ const codesAndPaths = (response: unknown, input: ChoiceAskInput = PLAN_INPUT) =>
40
+ check(response, input).map(({code, path}) => ({code, path}));
41
+
42
+ describe("validateAskResponse accepts valid answers", () => {
43
+ it("accepts one offered option", () => {
44
+ expect(check({action: "accept", content: {selected: ["team"]}})).toEqual([]);
45
+ });
46
+
47
+ it("accepts decline when allowDecline is unset", () => {
48
+ expect(check({action: "decline"})).toEqual([]);
49
+ });
50
+
51
+ it("accepts cancel with or without a reason, even when decline is not allowed", () => {
52
+ expect(check({action: "cancel"}, REQUIRED_INPUT)).toEqual([]);
53
+ expect(check({action: "cancel", reason: "one_ask_at_a_time"}, REQUIRED_INPUT)).toEqual([]);
54
+ });
55
+ });
56
+
57
+ describe("validateAskResponse error codes", () => {
58
+ it("OPTION_NOT_OFFERED for an id the ask did not offer", () => {
59
+ expect(check({action: "accept", content: {selected: ["gold"]}})).toEqual([
60
+ {
61
+ code: "OPTION_NOT_OFFERED",
62
+ fix: "Use the id of one of the ask's options.",
63
+ message: '"gold" is not one of the offered options.',
64
+ path: "content.selected[0]",
65
+ },
66
+ ]);
67
+ });
68
+
69
+ it("OPTION_NOT_OFFERED shortens a very long id in the message", () => {
70
+ const [error] = check({action: "accept", content: {selected: ["x".repeat(100)]}});
71
+ expect(error?.message).toBe(`"${"x".repeat(39)}…" is not one of the offered options.`);
72
+ });
73
+
74
+ it("SELECTION_COUNT when select one gets two ids", () => {
75
+ expect(check({action: "accept", content: {selected: ["team", "starter"]}})).toEqual([
76
+ {
77
+ code: "SELECTION_COUNT",
78
+ fix: "Send exactly one option id in content.selected.",
79
+ message: "Choose exactly one option; the answer selects 2.",
80
+ path: "content.selected",
81
+ },
82
+ ]);
83
+ });
84
+
85
+ it("SELECTION_COUNT when select one gets no ids", () => {
86
+ expect(codesAndPaths({action: "accept", content: {selected: []}})).toEqual([
87
+ {code: "SELECTION_COUNT", path: "content.selected"},
88
+ ]);
89
+ });
90
+
91
+ it("reports the count before the ids that were not offered", () => {
92
+ expect(codesAndPaths({action: "accept", content: {selected: ["team", "gold"]}})).toEqual([
93
+ {code: "SELECTION_COUNT", path: "content.selected"},
94
+ {code: "OPTION_NOT_OFFERED", path: "content.selected[1]"},
95
+ ]);
96
+ });
97
+
98
+ it("DECLINE_NOT_ALLOWED when allowDecline is false", () => {
99
+ expect(check({action: "decline"}, REQUIRED_INPUT)).toEqual([
100
+ {
101
+ code: "DECLINE_NOT_ALLOWED",
102
+ fix: 'Answer with action "accept".',
103
+ message: "This ask cannot be skipped.",
104
+ path: "action",
105
+ },
106
+ ]);
107
+ });
108
+
109
+ it("INVALID_ENUM for an unknown action", () => {
110
+ expect(check({action: "maybe"})).toEqual([
111
+ {
112
+ code: "INVALID_ENUM",
113
+ fix: 'Use one of "accept", "decline", "cancel".',
114
+ message: 'action must be one of "accept", "decline", "cancel", not "maybe".',
115
+ path: "action",
116
+ },
117
+ ]);
118
+ });
119
+
120
+ it("MISSING_REQUIRED for a missing action, content, or selected", () => {
121
+ expect(codesAndPaths({})).toEqual([{code: "MISSING_REQUIRED", path: "action"}]);
122
+ expect(codesAndPaths({action: "accept"})).toEqual([
123
+ {code: "MISSING_REQUIRED", path: "content"},
124
+ ]);
125
+ expect(check({action: "accept", content: {}})).toEqual([
126
+ {
127
+ code: "MISSING_REQUIRED",
128
+ fix: 'Add "selected" to content.',
129
+ message: "content.selected is required.",
130
+ path: "content.selected",
131
+ },
132
+ ]);
133
+ });
134
+
135
+ it("INVALID_TYPE for a non-object answer and for non-list or non-string selections", () => {
136
+ expect(codesAndPaths("team")).toEqual([{code: "INVALID_TYPE", path: ""}]);
137
+ expect(check({action: "accept", content: {selected: "team"}})).toEqual([
138
+ {
139
+ code: "INVALID_TYPE",
140
+ fix: "Make content.selected a list.",
141
+ message: "content.selected must be a list, not a string.",
142
+ path: "content.selected",
143
+ },
144
+ ]);
145
+ expect(codesAndPaths({action: "accept", content: {selected: [7]}})).toEqual([
146
+ {code: "INVALID_TYPE", path: "content.selected[0]"},
147
+ ]);
148
+ });
149
+
150
+ it("UNKNOWN_KEY for extra fields on the envelope or the content", () => {
151
+ expect(codesAndPaths({action: "decline", content: {selected: ["team"]}})).toEqual([
152
+ {code: "UNKNOWN_KEY", path: "content"},
153
+ ]);
154
+ expect(codesAndPaths({action: "accept", content: {extra: "Gold", selected: ["team"]}})).toEqual(
155
+ [{code: "UNKNOWN_KEY", path: "content.extra"}]
156
+ );
157
+ });
158
+
159
+ it("OTHER_NOT_ALLOWED when the ask does not allow Other", () => {
160
+ expect(check({action: "accept", content: {other: "Gold", selected: ["team"]}})).toEqual([
161
+ {
162
+ code: "OTHER_NOT_ALLOWED",
163
+ fix: "Remove content.other and pick from the options.",
164
+ message: "This ask does not accept an Other answer.",
165
+ path: "content.other",
166
+ },
167
+ ]);
168
+ expect(
169
+ codesAndPaths({action: "accept", content: {other: "Gold", selected: ["cheese"]}}, MANY_INPUT)
170
+ ).toEqual([{code: "OTHER_NOT_ALLOWED", path: "content.other"}]);
171
+ });
172
+
173
+ it("TOO_LONG and TOO_SHORT for a cancel reason", () => {
174
+ expect(codesAndPaths({action: "cancel", reason: "r".repeat(201)})).toEqual([
175
+ {code: "TOO_LONG", path: "reason"},
176
+ ]);
177
+ expect(codesAndPaths({action: "cancel", reason: " "})).toEqual([
178
+ {code: "TOO_SHORT", path: "reason"},
179
+ ]);
180
+ });
181
+
182
+ it("TOO_MANY when an answer lists more ids than any ask can offer", () => {
183
+ const selected = Array.from({length: 51}, (_, index) => `id-${index}`);
184
+ expect(codesAndPaths({action: "accept", content: {selected}})).toEqual([
185
+ {code: "TOO_MANY", path: "content.selected"},
186
+ ]);
187
+ });
188
+ });
189
+
190
+ describe("validateAskResponse select many", () => {
191
+ it("accepts any count from 1 to the option count by default", () => {
192
+ expect(check({action: "accept", content: {selected: ["cheese"]}}, MANY_INPUT)).toEqual([]);
193
+ expect(
194
+ check(
195
+ {action: "accept", content: {selected: ["cheese", "mushrooms", "olives", "peppers"]}},
196
+ MANY_INPUT
197
+ )
198
+ ).toEqual([]);
199
+ });
200
+
201
+ it("SELECTION_COUNT gives the default bounds when nothing is selected", () => {
202
+ expect(check({action: "accept", content: {selected: []}}, MANY_INPUT)).toEqual([
203
+ {
204
+ code: "SELECTION_COUNT",
205
+ fix: "Send 1 to 4 options in content.selected.",
206
+ message: "Choose 1 to 4 options; the answer selects 0.",
207
+ path: "content.selected",
208
+ },
209
+ ]);
210
+ });
211
+
212
+ it("accepts an empty selection when minSelected is 0", () => {
213
+ expect(
214
+ check({action: "accept", content: {selected: []}}, {...MANY_INPUT, minSelected: 0})
215
+ ).toEqual([]);
216
+ });
217
+
218
+ it("counts Other as one choice", () => {
219
+ expect(check({action: "accept", content: {other: "Basil", selected: []}}, OTHER_INPUT)).toEqual(
220
+ []
221
+ );
222
+ expect(
223
+ check({action: "accept", content: {other: "Basil", selected: ["cheese"]}}, OTHER_INPUT)
224
+ ).toEqual([]);
225
+ expect(
226
+ check(
227
+ {action: "accept", content: {other: "Basil", selected: ["cheese", "olives"]}},
228
+ OTHER_INPUT
229
+ )
230
+ ).toEqual([
231
+ {
232
+ code: "SELECTION_COUNT",
233
+ fix: "Send 1 to 2 options in content.selected. Other counts as one choice.",
234
+ message: "Choose 1 to 2 options; the answer selects 3, counting Other.",
235
+ path: "content.selected",
236
+ },
237
+ ]);
238
+ });
239
+
240
+ it("names exactly N and at most N bounds", () => {
241
+ const [exactly] = check(
242
+ {action: "accept", content: {selected: ["cheese"]}},
243
+ {...MANY_INPUT, maxSelected: 2, minSelected: 2}
244
+ );
245
+ expect(exactly?.message).toBe("Choose exactly 2 options; the answer selects 1.");
246
+ const [atMost] = check(
247
+ {action: "accept", content: {selected: ["cheese", "olives", "peppers"]}},
248
+ {...MANY_INPUT, maxSelected: 2, minSelected: 0}
249
+ );
250
+ expect(atMost?.message).toBe("Choose at most 2 options; the answer selects 3.");
251
+ });
252
+
253
+ it("DUPLICATE_ID for an id selected twice", () => {
254
+ expect(
255
+ check({action: "accept", content: {selected: ["cheese", "cheese"]}}, MANY_INPUT)
256
+ ).toEqual([
257
+ {
258
+ code: "DUPLICATE_ID",
259
+ fix: "List each option id in content.selected once.",
260
+ message: '"cheese" is already selected at content.selected[0].',
261
+ path: "content.selected[1]",
262
+ },
263
+ ]);
264
+ });
265
+
266
+ it("OPTION_NOT_OFFERED for an id the ask did not offer", () => {
267
+ expect(
268
+ codesAndPaths({action: "accept", content: {selected: ["cheese", "ham"]}}, MANY_INPUT)
269
+ ).toEqual([{code: "OPTION_NOT_OFFERED", path: "content.selected[1]"}]);
270
+ });
271
+
272
+ it("TOO_LONG and TOO_SHORT for the Other text", () => {
273
+ expect(
274
+ codesAndPaths(
275
+ {action: "accept", content: {other: "o".repeat(501), selected: []}},
276
+ OTHER_INPUT
277
+ )
278
+ ).toEqual([{code: "TOO_LONG", path: "content.other"}]);
279
+ expect(
280
+ codesAndPaths({action: "accept", content: {other: " ", selected: ["cheese"]}}, OTHER_INPUT)
281
+ ).toEqual([{code: "TOO_SHORT", path: "content.other"}]);
282
+ });
283
+ });
284
+
285
+ describe("validateAskResponse confirm", () => {
286
+ const DELETE_INPUT: ConfirmAskInput = {
287
+ confirmLabel: "Delete 14 todos",
288
+ denyLabel: "Keep them",
289
+ destructive: true,
290
+ prompt: "Delete 14 completed todos?",
291
+ };
292
+
293
+ const checkConfirm = (response: unknown, input: ConfirmAskInput = DELETE_INPUT) =>
294
+ validateAskResponse({input, kind: "confirm", response});
295
+
296
+ it("accepts confirmed true and confirmed false", () => {
297
+ expect(checkConfirm({action: "accept", content: {confirmed: true}})).toEqual([]);
298
+ expect(checkConfirm({action: "accept", content: {confirmed: false}})).toEqual([]);
299
+ });
300
+
301
+ it("refuses decline by default, because deny is the negative answer", () => {
302
+ expect(checkConfirm({action: "decline"})).toEqual([
303
+ {
304
+ code: "DECLINE_NOT_ALLOWED",
305
+ fix: 'Answer with action "accept".',
306
+ message: "This ask cannot be skipped.",
307
+ path: "action",
308
+ },
309
+ ]);
310
+ expect(checkConfirm({action: "decline"}, {...DELETE_INPUT, allowDecline: false})).toHaveLength(
311
+ 1
312
+ );
313
+ });
314
+
315
+ it("accepts decline when allowDecline is true, and cancel always", () => {
316
+ expect(checkConfirm({action: "decline"}, {...DELETE_INPUT, allowDecline: true})).toEqual([]);
317
+ expect(checkConfirm({action: "cancel", reason: "user_sent_message"})).toEqual([]);
318
+ });
319
+
320
+ it("MISSING_REQUIRED when confirmed is missing", () => {
321
+ expect(checkConfirm({action: "accept", content: {}})).toEqual([
322
+ {
323
+ code: "MISSING_REQUIRED",
324
+ fix: 'Add "confirmed" to content.',
325
+ message: "content.confirmed is required.",
326
+ path: "content.confirmed",
327
+ },
328
+ ]);
329
+ });
330
+
331
+ it("INVALID_TYPE when confirmed is not a boolean", () => {
332
+ expect(checkConfirm({action: "accept", content: {confirmed: "yes"}})).toEqual([
333
+ {
334
+ code: "INVALID_TYPE",
335
+ fix: "Make content.confirmed true or false.",
336
+ message: "content.confirmed must be true or false, not a string.",
337
+ path: "content.confirmed",
338
+ },
339
+ ]);
340
+ });
341
+
342
+ it("UNKNOWN_KEY for a choice answer sent to a confirm", () => {
343
+ expect(
344
+ checkConfirm({action: "accept", content: {confirmed: true, selected: ["yes"]}}).map(
345
+ ({code, path}) => ({code, path})
346
+ )
347
+ ).toEqual([{code: "UNKNOWN_KEY", path: "content.selected"}]);
348
+ });
349
+ });
350
+
351
+ describe("validateAskResponse markdown", () => {
352
+ const DRAFT = "# We're live\n\nToday we launched.";
353
+ const input: MarkdownAskInput = {
354
+ initial: DRAFT,
355
+ maxLength: 40,
356
+ minLength: 10,
357
+ prompt: "Edit the announcement, then send it back.",
358
+ };
359
+ const checkMarkdown = (
360
+ response: unknown,
361
+ markdownInput: MarkdownAskInput = input
362
+ ): ReturnType<typeof validateAskResponse> =>
363
+ validateAskResponse({input: markdownInput, kind: "markdown", response});
364
+ const accept = (markdown: string, changed: boolean): unknown => ({
365
+ action: "accept",
366
+ content: {changed, markdown},
367
+ });
368
+
369
+ it("accepts the draft unchanged with changed false, and an edit with changed true", () => {
370
+ expect(checkMarkdown(accept(DRAFT, false))).toEqual([]);
371
+ expect(checkMarkdown(accept("# We're live\n\nWe launched today.", true))).toEqual([]);
372
+ });
373
+
374
+ it("accepts Skip, because allowDecline defaults to true", () => {
375
+ expect(checkMarkdown({action: "decline"})).toEqual([]);
376
+ });
377
+
378
+ it("DECLINE_NOT_ALLOWED when allowDecline is false", () => {
379
+ expect(
380
+ checkMarkdown({action: "decline"}, {...input, allowDecline: false}).map(({code}) => code)
381
+ ).toEqual(["DECLINE_NOT_ALLOWED"]);
382
+ });
383
+
384
+ it("TOO_LONG when the text is longer than maxLength", () => {
385
+ expect(checkMarkdown(accept("x".repeat(41), true))).toEqual([
386
+ {
387
+ code: "TOO_LONG",
388
+ fix: "Shorten content.markdown to 40 characters or fewer.",
389
+ message: "The text is 41 characters, but this ask allows at most 40.",
390
+ path: "content.markdown",
391
+ },
392
+ ]);
393
+ });
394
+
395
+ it("counts maxLength in UTF-16 code units, so most emoji count as 2", () => {
396
+ expect(checkMarkdown(accept("😀".repeat(20), true))).toEqual([]);
397
+ expect(checkMarkdown(accept("😀".repeat(21), true)).map(({code}) => code)).toEqual([
398
+ "TOO_LONG",
399
+ ]);
400
+ });
401
+
402
+ it("TOO_LONG over the 20,000-character cap when the ask sets no maxLength", () => {
403
+ const uncapped: MarkdownAskInput = {prompt: "Write it."};
404
+ expect(checkMarkdown(accept("x".repeat(20_000), true), uncapped)).toEqual([]);
405
+ expect(
406
+ checkMarkdown(accept("x".repeat(20_001), true), uncapped).map(({code, path}) => ({
407
+ code,
408
+ path,
409
+ }))
410
+ ).toEqual([{code: "TOO_LONG", path: "content.markdown"}]);
411
+ expect(
412
+ checkMarkdown(accept("😀".repeat(10_001), true), uncapped).map(({code}) => code)
413
+ ).toEqual(["TOO_LONG"]);
414
+ });
415
+
416
+ it("names the ask's maxLength, not the cap, when the text is also over the cap", () => {
417
+ expect(checkMarkdown(accept("x".repeat(20_001), true))).toEqual([
418
+ {
419
+ code: "TOO_LONG",
420
+ fix: "Shorten content.markdown to 40 characters or fewer.",
421
+ message: "The text is 20001 characters, but this ask allows at most 40.",
422
+ path: "content.markdown",
423
+ },
424
+ ]);
425
+ });
426
+
427
+ it("TOO_SHORT below minLength, not counting spaces at either end", () => {
428
+ expect(checkMarkdown(accept(" short ", true))).toEqual([
429
+ {
430
+ code: "TOO_SHORT",
431
+ fix: "Write at least 10 characters in content.markdown.",
432
+ message: "The text is 5 characters, but this ask needs at least 10.",
433
+ path: "content.markdown",
434
+ },
435
+ ]);
436
+ expect(checkMarkdown(accept(" ten chars! ", true))).toEqual([]);
437
+ });
438
+
439
+ it("accepts an empty answer when the ask sets no minLength", () => {
440
+ expect(checkMarkdown(accept("", false), {prompt: "Write it."})).toEqual([]);
441
+ expect(checkMarkdown(accept("", true), {initial: "Draft", prompt: "Write it."})).toEqual([]);
442
+ });
443
+
444
+ it("CHANGED_MISMATCH when changed does not say whether the text differs from the draft", () => {
445
+ expect(checkMarkdown(accept(DRAFT, true))).toEqual([
446
+ {
447
+ code: "CHANGED_MISMATCH",
448
+ fix: "Set content.changed to false.",
449
+ message: "The text is the same as the draft, but changed is true.",
450
+ path: "content.changed",
451
+ },
452
+ ]);
453
+ expect(checkMarkdown(accept(`${DRAFT}!`, false))).toEqual([
454
+ {
455
+ code: "CHANGED_MISMATCH",
456
+ fix: "Set content.changed to true.",
457
+ message: "The text differs from the draft, but changed is false.",
458
+ path: "content.changed",
459
+ },
460
+ ]);
461
+ });
462
+
463
+ it("compares against an empty draft when the ask has no initial", () => {
464
+ expect(checkMarkdown(accept("", true), {prompt: "Write it."}).map(({code}) => code)).toEqual([
465
+ "CHANGED_MISMATCH",
466
+ ]);
467
+ });
468
+
469
+ it("MISSING_REQUIRED and INVALID_TYPE for a malformed answer", () => {
470
+ expect(
471
+ checkMarkdown({action: "accept", content: {markdown: 7}}).map(({code, path}) => ({
472
+ code,
473
+ path,
474
+ }))
475
+ ).toEqual([
476
+ {code: "MISSING_REQUIRED", path: "content.changed"},
477
+ {code: "INVALID_TYPE", path: "content.markdown"},
478
+ ]);
479
+ });
480
+
481
+ it("UNKNOWN_KEY for a confirm answer sent to a markdown ask", () => {
482
+ expect(
483
+ checkMarkdown({
484
+ action: "accept",
485
+ content: {changed: false, confirmed: true, markdown: DRAFT},
486
+ }).map(({code, path}) => ({code, path}))
487
+ ).toEqual([{code: "UNKNOWN_KEY", path: "content.confirmed"}]);
488
+ });
489
+ });
490
+
491
+ describe("validateAskResponse form", () => {
492
+ const OPTIONS = [
493
+ {id: "us", label: "US"},
494
+ {id: "eu", label: "EU"},
495
+ ];
496
+ const input: FormAskInput = {
497
+ fields: [
498
+ {id: "company", label: "Company name", maxLength: 120, required: true, type: "text"},
499
+ {id: "seats", integer: true, label: "Seats", max: 500, min: 1, type: "number"},
500
+ {id: "start", label: "Start date", type: "date"},
501
+ {id: "region", label: "Region", options: OPTIONS, type: "select"},
502
+ {default: true, id: "notify", label: "Email me the invoice", type: "boolean"},
503
+ ],
504
+ prompt: "A few details for the invoice.",
505
+ };
506
+ const everyType: FormAskInput = {
507
+ fields: [
508
+ {id: "notes", label: "Notes", maxLength: 20, minLength: 5, type: "textarea"},
509
+ {id: "email", label: "Email", type: "email"},
510
+ {id: "site", label: "Site", type: "url"},
511
+ {id: "phone", label: "Phone", type: "phone"},
512
+ {id: "at", label: "At", type: "time"},
513
+ {id: "starts", label: "Starts", type: "datetime"},
514
+ {id: "regions", label: "Regions", options: OPTIONS, required: true, type: "multiselect"},
515
+ ],
516
+ prompt: "Every other type.",
517
+ };
518
+ const checkForm = (values: unknown, formInput: FormAskInput = input) =>
519
+ validateAskResponse({
520
+ input: formInput,
521
+ kind: "form",
522
+ response: {action: "accept", content: {values}},
523
+ });
524
+ const codesAndPaths = (values: unknown, formInput: FormAskInput = input) =>
525
+ checkForm(values, formInput).map(({code, path}) => ({code, path}));
526
+
527
+ it("accepts the answer from the plan's example", () => {
528
+ expect(
529
+ checkForm({company: "Acme", notify: true, region: "us", seats: 12, start: "2026-10-01"})
530
+ ).toEqual([]);
531
+ });
532
+
533
+ it("accepts an answer that leaves optional fields out", () => {
534
+ expect(checkForm({company: "Acme"})).toEqual([]);
535
+ });
536
+
537
+ it("counts a blank value on an optional field as unanswered", () => {
538
+ expect(checkForm({company: "Acme", start: " "})).toEqual([]);
539
+ expect(checkForm({notes: "", regions: ["us"]}, everyType)).toEqual([]);
540
+ });
541
+
542
+ it("accepts a valid value for every other type", () => {
543
+ expect(
544
+ checkForm(
545
+ {
546
+ at: "09:30",
547
+ email: "ada@example.com",
548
+ notes: "Gate 12",
549
+ phone: "+14155552671",
550
+ regions: ["eu", "us"],
551
+ site: "https://example.com/about",
552
+ starts: "2026-10-01T09:30:00-07:00",
553
+ },
554
+ everyType
555
+ )
556
+ ).toEqual([]);
557
+ });
558
+
559
+ it("accepts a datetime with or without seconds", () => {
560
+ for (const starts of [
561
+ "2026-10-01T09:30Z",
562
+ "2026-10-01T09:30+02:00",
563
+ "2026-10-01T09:30:15Z",
564
+ "2026-10-01T09:30:15.250-07:00",
565
+ ]) {
566
+ expect(checkForm({regions: ["us"], starts}, everyType)).toEqual([]);
567
+ }
568
+ expect(codesAndPaths({regions: ["us"], starts: "2026-10-01T09:30"}, everyType)).toEqual([
569
+ {code: "INVALID_DATE", path: "content.values.starts"},
570
+ ]);
571
+ });
572
+
573
+ it("REQUIRED_FIELD when a required field is missing, blank, or an empty list", () => {
574
+ expect(checkForm({})).toEqual([
575
+ {
576
+ code: "REQUIRED_FIELD",
577
+ fix: 'Fill in "Company name" (content.values.company).',
578
+ message: '"Company name" is required.',
579
+ path: "content.values.company",
580
+ },
581
+ ]);
582
+ expect(codesAndPaths({company: " "})).toEqual([
583
+ {code: "REQUIRED_FIELD", path: "content.values.company"},
584
+ ]);
585
+ expect(codesAndPaths({regions: []}, everyType)).toEqual([
586
+ {code: "REQUIRED_FIELD", path: "content.values.regions"},
587
+ ]);
588
+ });
589
+
590
+ it("FIELD_TYPE_MISMATCH for the wrong JSON type, a fraction in an integer field, or null", () => {
591
+ expect(checkForm({company: "Acme", seats: "12"})).toEqual([
592
+ {
593
+ code: "FIELD_TYPE_MISMATCH",
594
+ fix: "Make content.values.seats a number.",
595
+ message: 'content.values.seats must be a number for the number field "seats".',
596
+ path: "content.values.seats",
597
+ },
598
+ ]);
599
+ expect(checkForm({company: "Acme", seats: 1.5})).toEqual([
600
+ {
601
+ code: "FIELD_TYPE_MISMATCH",
602
+ fix: "Make content.values.seats a whole number.",
603
+ message: "content.values.seats is 1.5, but the field takes whole numbers only.",
604
+ path: "content.values.seats",
605
+ },
606
+ ]);
607
+ expect(codesAndPaths({company: 7, notify: "yes", region: ["us"], start: null})).toEqual([
608
+ {code: "FIELD_TYPE_MISMATCH", path: "content.values.company"},
609
+ {code: "FIELD_TYPE_MISMATCH", path: "content.values.notify"},
610
+ {code: "FIELD_TYPE_MISMATCH", path: "content.values.region"},
611
+ {code: "FIELD_TYPE_MISMATCH", path: "content.values.start"},
612
+ ]);
613
+ });
614
+
615
+ it("FIELD_TYPE_MISMATCH for an invalid email, URL, or phone number", () => {
616
+ expect(
617
+ codesAndPaths(
618
+ {email: "ada@", phone: "call me", regions: ["us"], site: "ftp://example.com"},
619
+ everyType
620
+ )
621
+ ).toEqual([
622
+ {code: "FIELD_TYPE_MISMATCH", path: "content.values.email"},
623
+ {code: "FIELD_TYPE_MISMATCH", path: "content.values.phone"},
624
+ {code: "FIELD_TYPE_MISMATCH", path: "content.values.site"},
625
+ ]);
626
+ expect(codesAndPaths({phone: "+1 234", regions: ["us"]}, everyType)).toEqual([
627
+ {code: "FIELD_TYPE_MISMATCH", path: "content.values.phone"},
628
+ ]);
629
+ expect(checkForm({phone: "(415) 555-2671", regions: ["us"]}, everyType)).toEqual([]);
630
+ });
631
+
632
+ it("OUT_OF_RANGE below min or above max", () => {
633
+ expect(checkForm({company: "Acme", seats: 0})).toEqual([
634
+ {
635
+ code: "OUT_OF_RANGE",
636
+ fix: "Make content.values.seats at least 1.",
637
+ message: "content.values.seats is 0, below the field's min of 1.",
638
+ path: "content.values.seats",
639
+ },
640
+ ]);
641
+ expect(codesAndPaths({company: "Acme", seats: 501})).toEqual([
642
+ {code: "OUT_OF_RANGE", path: "content.values.seats"},
643
+ ]);
644
+ expect(checkForm({company: "Acme", seats: 500})).toEqual([]);
645
+ });
646
+
647
+ it("INVALID_DATE for a date that is not real or not in the field's format", () => {
648
+ expect(checkForm({company: "Acme", start: "2026-02-30"})).toEqual([
649
+ {
650
+ code: "INVALID_DATE",
651
+ fix: 'Write content.values.start as YYYY-MM-DD, such as "2026-10-01".',
652
+ message: 'content.values.start "2026-02-30" is not a real date in YYYY-MM-DD.',
653
+ path: "content.values.start",
654
+ },
655
+ ]);
656
+ expect(
657
+ codesAndPaths({at: "9:30 PM", regions: ["us"], starts: "2026-10-01T09:30:00"}, everyType)
658
+ ).toEqual([
659
+ {code: "INVALID_DATE", path: "content.values.at"},
660
+ {code: "INVALID_DATE", path: "content.values.starts"},
661
+ ]);
662
+ expect(codesAndPaths({company: "Acme", start: "2026-10-01T00:00:00Z"})).toEqual([
663
+ {code: "INVALID_DATE", path: "content.values.start"},
664
+ ]);
665
+ });
666
+
667
+ it("TOO_LONG and TOO_SHORT against the field's length bounds", () => {
668
+ expect(codesAndPaths({company: "x".repeat(121)})).toEqual([
669
+ {code: "TOO_LONG", path: "content.values.company"},
670
+ ]);
671
+ expect(checkForm({notes: " abc ", regions: ["us"]}, everyType)).toEqual([
672
+ {
673
+ code: "TOO_SHORT",
674
+ fix: "Write at least 5 characters in content.values.notes.",
675
+ message: "content.values.notes is 3 characters, but the field needs at least 5.",
676
+ path: "content.values.notes",
677
+ },
678
+ ]);
679
+ });
680
+
681
+ it("caps a text field without maxLength at 2,000 characters and a textarea at 10,000", () => {
682
+ const uncapped: FormAskInput = {
683
+ fields: [
684
+ {id: "title", label: "Title", type: "text"},
685
+ {id: "body", label: "Body", type: "textarea"},
686
+ ],
687
+ prompt: "Write it.",
688
+ };
689
+ expect(checkForm({body: "x".repeat(10_000), title: "x".repeat(2000)}, uncapped)).toEqual([]);
690
+ expect(codesAndPaths({body: "x".repeat(10_001), title: "x".repeat(2001)}, uncapped)).toEqual([
691
+ {code: "TOO_LONG", path: "content.values.body"},
692
+ {code: "TOO_LONG", path: "content.values.title"},
693
+ ]);
694
+ });
695
+
696
+ it("OPTION_NOT_OFFERED and DUPLICATE_ID for select and multiselect values", () => {
697
+ expect(codesAndPaths({company: "Acme", region: "apac"})).toEqual([
698
+ {code: "OPTION_NOT_OFFERED", path: "content.values.region"},
699
+ ]);
700
+ expect(codesAndPaths({regions: ["us", "apac", "us"]}, everyType)).toEqual([
701
+ {code: "OPTION_NOT_OFFERED", path: "content.values.regions[1]"},
702
+ {code: "DUPLICATE_ID", path: "content.values.regions[2]"},
703
+ ]);
704
+ });
705
+
706
+ it("UNKNOWN_KEY for a value that belongs to no field", () => {
707
+ expect(checkForm({company: "Acme", password: "hunter2"})).toEqual([
708
+ {
709
+ code: "UNKNOWN_KEY",
710
+ fix: 'Remove "password" from content.values.',
711
+ message: '"password" is not the id of any field in this form.',
712
+ path: "content.values.password",
713
+ },
714
+ ]);
715
+ });
716
+
717
+ it("MISSING_REQUIRED and UNKNOWN_KEY for a malformed content object", () => {
718
+ expect(
719
+ validateAskResponse({
720
+ input,
721
+ kind: "form",
722
+ response: {action: "accept", content: {company: "Acme"}},
723
+ }).map(({code, path}) => ({code, path}))
724
+ ).toEqual([
725
+ {code: "UNKNOWN_KEY", path: "content.company"},
726
+ {code: "MISSING_REQUIRED", path: "content.values"},
727
+ ]);
728
+ });
729
+
730
+ it("accepts Skip unless allowDecline is false", () => {
731
+ expect(validateAskResponse({input, kind: "form", response: {action: "decline"}})).toEqual([]);
732
+ expect(
733
+ validateAskResponse({
734
+ input: {...input, allowDecline: false},
735
+ kind: "form",
736
+ response: {action: "decline"},
737
+ }).map(({code}) => code)
738
+ ).toEqual(["DECLINE_NOT_ALLOWED"]);
739
+ });
740
+ });