@terreno/blocks 57.12.0 → 57.13.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.
- package/dist/errors.d.ts +2 -0
- package/dist/errors.js +2 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.js +13 -1
- package/dist/interactive.d.ts +26 -0
- package/dist/interactive.js +62 -0
- package/dist/limits.d.ts +26 -0
- package/dist/limits.js +26 -0
- package/dist/lint.d.ts +18 -1
- package/dist/lint.js +186 -26
- package/dist/plainText.d.ts +17 -0
- package/dist/plainText.js +56 -0
- package/dist/prompt.d.ts +10 -1
- package/dist/prompt.js +73 -3
- package/dist/schema.d.ts +113 -2
- package/dist/schema.js +121 -5
- package/dist/validate.js +26 -2
- package/package.json +1 -1
- package/src/__snapshots__/prompt.test.ts.snap +530 -0
- package/src/errors.ts +4 -0
- package/src/fixtures/golden/sunday-roast.yaml +174 -0
- package/src/fixtures/invalid/card-eyebrow-empty.yaml +7 -0
- package/src/fixtures/invalid/card-eyebrow-too-long.yaml +7 -0
- package/src/fixtures/invalid/checklist-duplicate-item.yaml +9 -0
- package/src/fixtures/invalid/checklist-item-id-too-long.yaml +7 -0
- package/src/fixtures/invalid/checklist-reserved-id.yaml +16 -0
- package/src/fixtures/invalid/copy-missing-text-and-target.yaml +10 -0
- package/src/fixtures/invalid/copy-target.yaml +14 -0
- package/src/fixtures/invalid/gallery-caption-too-long.yaml +9 -0
- package/src/fixtures/invalid/gallery-image-host.yaml +10 -0
- package/src/fixtures/invalid/gallery-too-few.yaml +6 -0
- package/src/fixtures/invalid/list-image-host.yaml +12 -0
- package/src/fixtures/invalid/list-missing-title.yaml +6 -0
- package/src/fixtures/invalid/list-too-many.yaml +17 -0
- package/src/fixtures/invalid/stepper-decimals.yaml +14 -0
- package/src/fixtures/invalid/stepper-out-of-range.yaml +10 -0
- package/src/fixtures/invalid/stepper-reserved-id.yaml +19 -0
- package/src/fixtures/valid/card-eyebrow.yaml +8 -0
- package/src/fixtures/valid/checklist.yaml +29 -0
- package/src/fixtures/valid/copy.yaml +85 -0
- package/src/fixtures/valid/gallery.yaml +22 -0
- package/src/fixtures/valid/list.yaml +24 -0
- package/src/fixtures/valid/stepper.yaml +35 -0
- package/src/golden.test.ts +234 -0
- package/src/index.test.ts +9 -0
- package/src/index.ts +24 -0
- package/src/interactive.test.ts +141 -0
- package/src/interactive.ts +75 -0
- package/src/limits.ts +26 -0
- package/src/lint.test.ts +672 -0
- package/src/lint.ts +264 -33
- package/src/parse.test.ts +40 -0
- package/src/plainText.test.ts +158 -0
- package/src/plainText.ts +81 -0
- package/src/prompt.test.ts +325 -2
- package/src/prompt.ts +87 -2
- package/src/schema.ts +259 -6
- package/src/validate.ts +26 -2
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import {describe, expect, it} from "bun:test";
|
|
2
|
+
import {readFileSync} from "node:fs";
|
|
3
|
+
import {join} from "node:path";
|
|
4
|
+
|
|
5
|
+
import {parseBlocks} from "./parse";
|
|
6
|
+
import {blockPlainText} from "./plainText";
|
|
7
|
+
import type {Block, BlocksDocument} from "./schema";
|
|
8
|
+
import {validateBlocks} from "./validate";
|
|
9
|
+
|
|
10
|
+
const goldenPath = join(import.meta.dir, "fixtures", "golden", "sunday-roast.yaml");
|
|
11
|
+
|
|
12
|
+
// The roast reply needs these options: https images and both host callbacks.
|
|
13
|
+
const goldenOptions = {
|
|
14
|
+
checklistActions: ["toggleChecklist"],
|
|
15
|
+
hostActions: ["scaleStepper", "toggleChecklist"],
|
|
16
|
+
imageHosts: ["images.example.com"],
|
|
17
|
+
stepperActions: ["scaleStepper"],
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const loadGolden = (): BlocksDocument => {
|
|
21
|
+
const parsed = parseBlocks(readFileSync(goldenPath, "utf8"));
|
|
22
|
+
if (!parsed.ok) {
|
|
23
|
+
throw new Error(`sunday-roast.yaml did not parse: ${JSON.stringify(parsed.errors)}`);
|
|
24
|
+
}
|
|
25
|
+
const validated = validateBlocks(parsed.value, goldenOptions);
|
|
26
|
+
if (!validated.ok) {
|
|
27
|
+
throw new Error(`sunday-roast.yaml did not validate: ${JSON.stringify(validated.errors)}`);
|
|
28
|
+
}
|
|
29
|
+
return validated.doc;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const allBlocks = (blocks: readonly Block[]): Block[] => {
|
|
33
|
+
return blocks.flatMap((block) =>
|
|
34
|
+
block.type === "card" || block.type === "columns" ? [block, ...block.children] : [block]
|
|
35
|
+
);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const blocksOfType = <T extends Block["type"]>(
|
|
39
|
+
doc: BlocksDocument,
|
|
40
|
+
type: T
|
|
41
|
+
): Extract<Block, {type: T}>[] => {
|
|
42
|
+
return allBlocks(doc.blocks).filter(
|
|
43
|
+
(block): block is Extract<Block, {type: T}> => block.type === type
|
|
44
|
+
);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
describe("sunday roast golden document", () => {
|
|
48
|
+
it("validates with the roast host options and no warnings", () => {
|
|
49
|
+
const parsed = parseBlocks(readFileSync(goldenPath, "utf8"));
|
|
50
|
+
expect(parsed.ok).toBe(true);
|
|
51
|
+
if (!parsed.ok) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const validated = validateBlocks(parsed.value, goldenOptions);
|
|
55
|
+
expect(validated.ok).toBe(true);
|
|
56
|
+
expect(validated.warnings).toEqual([]);
|
|
57
|
+
expect(allBlocks(loadGolden().blocks).length).toBeLessThanOrEqual(50);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("fails without imageHosts, so it stays out of the option-less valid/ loop", () => {
|
|
61
|
+
const parsed = parseBlocks(readFileSync(goldenPath, "utf8"));
|
|
62
|
+
expect(parsed.ok).toBe(true);
|
|
63
|
+
if (!parsed.ok) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const validated = validateBlocks(parsed.value);
|
|
67
|
+
expect(validated.ok).toBe(false);
|
|
68
|
+
if (validated.ok) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
expect(new Set(validated.errors.map((error) => error.code))).toEqual(
|
|
72
|
+
new Set(["IMAGE_HOST_NOT_ALLOWED"])
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("rejects the stepper and checklist callbacks when the host lacks those actions", () => {
|
|
77
|
+
const parsed = parseBlocks(readFileSync(goldenPath, "utf8"));
|
|
78
|
+
expect(parsed.ok).toBe(true);
|
|
79
|
+
if (!parsed.ok) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const validated = validateBlocks(parsed.value, {
|
|
83
|
+
checklistActions: [],
|
|
84
|
+
hostActions: [],
|
|
85
|
+
imageHosts: ["images.example.com"],
|
|
86
|
+
stepperActions: [],
|
|
87
|
+
});
|
|
88
|
+
expect(validated.ok).toBe(false);
|
|
89
|
+
if (validated.ok) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
expect(validated.errors.map((error) => error.code)).toEqual([
|
|
93
|
+
"UNKNOWN_HOST_ACTION",
|
|
94
|
+
"UNKNOWN_HOST_ACTION",
|
|
95
|
+
]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("reproduces the GPT-6 reply: title, gallery, summary card, and photo menu", () => {
|
|
99
|
+
const doc = loadGolden();
|
|
100
|
+
expect(doc.blocks[0]).toEqual({size: "lg", text: "Your Sunday lamb roast 🍷", type: "heading"});
|
|
101
|
+
|
|
102
|
+
const [gallery] = blocksOfType(doc, "gallery");
|
|
103
|
+
expect(gallery?.images.map((image) => image.alt)).toEqual([
|
|
104
|
+
"Roast lamb",
|
|
105
|
+
"Crispy roast potatoes",
|
|
106
|
+
"Sunday roast inspiration",
|
|
107
|
+
]);
|
|
108
|
+
|
|
109
|
+
const summary = blocksOfType(doc, "card").find((card) => card.eyebrow !== undefined);
|
|
110
|
+
expect(summary?.eyebrow).toBe("Your dinner plan");
|
|
111
|
+
expect(summary?.title).toBe("Sunday roast with friends");
|
|
112
|
+
expect(summary?.children[0]?.type).toBe("text");
|
|
113
|
+
|
|
114
|
+
const [menu] = blocksOfType(doc, "list");
|
|
115
|
+
expect(menu?.items).toHaveLength(5);
|
|
116
|
+
for (const item of menu?.items ?? []) {
|
|
117
|
+
expect(item.image?.src).toStartWith("https://images.example.com/");
|
|
118
|
+
expect(item.text).toBeDefined();
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("puts the stepper and its copy button in one card", () => {
|
|
123
|
+
const doc = loadGolden();
|
|
124
|
+
const card = blocksOfType(doc, "card").find((block) =>
|
|
125
|
+
block.children.some((child) => child.type === "stepper")
|
|
126
|
+
);
|
|
127
|
+
expect(card).toBeDefined();
|
|
128
|
+
const stepper = card?.children.find((child) => child.type === "stepper");
|
|
129
|
+
if (stepper?.type !== "stepper") {
|
|
130
|
+
throw new Error("expected a stepper in the shopping card");
|
|
131
|
+
}
|
|
132
|
+
expect(stepper.itemsTitle).toBe("Your shopping quantities");
|
|
133
|
+
expect(stepper.items).toHaveLength(6);
|
|
134
|
+
expect(stepper.callback).toEqual({name: "scaleStepper"});
|
|
135
|
+
expect([stepper.value, stepper.min, stepper.max, stepper.unit]).toEqual([5, 1, 20, "People"]);
|
|
136
|
+
|
|
137
|
+
const copyActions = card?.children
|
|
138
|
+
.filter((child) => child.type === "actions")
|
|
139
|
+
.flatMap((child) => child.elements)
|
|
140
|
+
.filter((element) => element.type === "button" && element.action.kind === "copy");
|
|
141
|
+
expect(copyActions).toHaveLength(1);
|
|
142
|
+
const [copyButton] = copyActions ?? [];
|
|
143
|
+
expect(copyButton?.type === "button" ? copyButton.text : undefined).toBe("Copy shopping list");
|
|
144
|
+
expect(copyButton?.type === "button" ? copyButton.action : undefined).toEqual({
|
|
145
|
+
kind: "copy",
|
|
146
|
+
target: stepper.id,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
expect(blockPlainText(stepper)).toBe(
|
|
150
|
+
[
|
|
151
|
+
"Number of people: 5 People",
|
|
152
|
+
"Bone-in leg of lamb: 2.0 kg",
|
|
153
|
+
"Potatoes: 1500 g",
|
|
154
|
+
"Carrots: 8",
|
|
155
|
+
"Parsnips: 5",
|
|
156
|
+
"Tenderstem broccoli: 625 g",
|
|
157
|
+
"Apples for crumble: 4",
|
|
158
|
+
].join("\n")
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("has the 8-item cooking checklist with the first item ticked, then the USDA note", () => {
|
|
163
|
+
const doc = loadGolden();
|
|
164
|
+
const [checklist] = blocksOfType(doc, "checklist");
|
|
165
|
+
expect(checklist?.title).toBe("Cooking checklist");
|
|
166
|
+
expect(checklist?.callback).toEqual({name: "toggleChecklist"});
|
|
167
|
+
expect(checklist?.items).toHaveLength(8);
|
|
168
|
+
expect(checklist?.items.map((item) => item.checked === true)).toEqual([
|
|
169
|
+
true,
|
|
170
|
+
false,
|
|
171
|
+
false,
|
|
172
|
+
false,
|
|
173
|
+
false,
|
|
174
|
+
false,
|
|
175
|
+
false,
|
|
176
|
+
false,
|
|
177
|
+
]);
|
|
178
|
+
expect(checklist?.items.map((item) => item.meta)).toEqual([
|
|
179
|
+
"Saturday",
|
|
180
|
+
"12:30 PM",
|
|
181
|
+
"1:30 PM",
|
|
182
|
+
"2:30 PM",
|
|
183
|
+
"3:30 PM",
|
|
184
|
+
"4:15 PM",
|
|
185
|
+
"4:45 PM",
|
|
186
|
+
"5:00 PM",
|
|
187
|
+
]);
|
|
188
|
+
|
|
189
|
+
const index = doc.blocks.findIndex((block) => block.type === "checklist");
|
|
190
|
+
const note = doc.blocks[index + 1];
|
|
191
|
+
expect(note?.type).toBe("context");
|
|
192
|
+
expect(note?.type === "context" ? note.text : "").toContain("USDA");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("includes the GPT-5.6 'How much lamb?' table bound to an inline dataset", () => {
|
|
196
|
+
const doc = loadGolden();
|
|
197
|
+
const [table] = blocksOfType(doc, "table");
|
|
198
|
+
expect(table?.title).toBe("How much lamb?");
|
|
199
|
+
const dataset = table?.data === undefined ? undefined : doc.datasets?.[table.data];
|
|
200
|
+
expect(dataset).toEqual({
|
|
201
|
+
columns: [
|
|
202
|
+
{name: "guests", type: "number"},
|
|
203
|
+
{name: "bone_in_lamb", type: "string"},
|
|
204
|
+
{name: "potatoes", type: "string"},
|
|
205
|
+
],
|
|
206
|
+
rows: [
|
|
207
|
+
[4, "3–4 lb", "2 lb"],
|
|
208
|
+
[6, "4–5 lb", "3 lb"],
|
|
209
|
+
[8, "5–6 lb", "4 lb"],
|
|
210
|
+
[10, "6–7½ lb", "5 lb"],
|
|
211
|
+
],
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("ends with the hosting tips as text and three follow-up reply buttons", () => {
|
|
216
|
+
const doc = loadGolden();
|
|
217
|
+
const tips = blocksOfType(doc, "text").find((block) => block.id === "hosting_tips");
|
|
218
|
+
expect(tips?.markdown).toContain("**Buy the lamb last:**");
|
|
219
|
+
|
|
220
|
+
const last = doc.blocks[doc.blocks.length - 1];
|
|
221
|
+
if (last?.type !== "actions") {
|
|
222
|
+
throw new Error("expected the reply to end with follow-up actions");
|
|
223
|
+
}
|
|
224
|
+
expect(
|
|
225
|
+
last.elements.map((element) =>
|
|
226
|
+
element.type === "button" && element.action.kind === "reply" ? element.action.text : ""
|
|
227
|
+
)
|
|
228
|
+
).toEqual([
|
|
229
|
+
"Help me adjust the lamb roast plan for 5 guests",
|
|
230
|
+
"Add vegetarian side dish options for the lamb roast menu",
|
|
231
|
+
"Advise on making the lamb roast plan gluten-free",
|
|
232
|
+
]);
|
|
233
|
+
});
|
|
234
|
+
});
|
package/src/index.test.ts
CHANGED
|
@@ -16,16 +16,25 @@ describe("@terreno/blocks public exports", () => {
|
|
|
16
16
|
"CALLOUT_STATUSES",
|
|
17
17
|
"CHART_HEIGHTS",
|
|
18
18
|
"CHART_KINDS",
|
|
19
|
+
"COPY_TARGET_TYPES",
|
|
19
20
|
"DATASET_COLUMN_TYPES",
|
|
20
21
|
"DATASET_GRAINS",
|
|
21
22
|
"HEADING_SIZES",
|
|
22
23
|
"HTML_HEIGHTS",
|
|
23
24
|
"METRIC_TRENDS",
|
|
25
|
+
"STEPPER_ROUNDING",
|
|
26
|
+
"applyChecklistState",
|
|
27
|
+
"blockPlainText",
|
|
24
28
|
"blocksJsonSchema",
|
|
25
29
|
"blocksPromptSection",
|
|
26
30
|
"blocksSchema",
|
|
31
|
+
"checklistElementId",
|
|
32
|
+
"isStepperValueAllowed",
|
|
27
33
|
"parseBlocks",
|
|
28
34
|
"parseBlocksPartial",
|
|
35
|
+
"scaleStepperBlock",
|
|
36
|
+
"stepperElementIds",
|
|
37
|
+
"unknownChecklistItemIds",
|
|
29
38
|
"validateBlocks",
|
|
30
39
|
"wrapAsTextDocument",
|
|
31
40
|
|
package/src/index.ts
CHANGED
|
@@ -118,13 +118,23 @@ export {validateAskInput} from "./asks/validateInput";
|
|
|
118
118
|
export {validateAskResponse} from "./asks/validateResponse";
|
|
119
119
|
export type {BlockError, BlockErrorCode, BlockWarningCode} from "./errors";
|
|
120
120
|
export {BLOCK_ERROR_CODES, BLOCK_WARNING_CODES} from "./errors";
|
|
121
|
+
export type {ChecklistTick} from "./interactive";
|
|
122
|
+
export {
|
|
123
|
+
applyChecklistState,
|
|
124
|
+
isStepperValueAllowed,
|
|
125
|
+
scaleStepperBlock,
|
|
126
|
+
unknownChecklistItemIds,
|
|
127
|
+
} from "./interactive";
|
|
121
128
|
export {blocksJsonSchema} from "./jsonSchema";
|
|
122
129
|
export {BLOCK_LIMITS} from "./limits";
|
|
123
130
|
export type {KnownDataset, LintBlocksOptions} from "./lint";
|
|
131
|
+
export {checklistElementId, stepperElementIds} from "./lint";
|
|
124
132
|
export type {ParseBlocksResult} from "./parse";
|
|
125
133
|
export {parseBlocks} from "./parse";
|
|
126
134
|
export type {ParseBlocksPartialResult} from "./parsePartial";
|
|
127
135
|
export {parseBlocksPartial} from "./parsePartial";
|
|
136
|
+
export type {BlockPlainTextOptions} from "./plainText";
|
|
137
|
+
export {blockPlainText} from "./plainText";
|
|
128
138
|
export {blocksPromptSection} from "./prompt";
|
|
129
139
|
export type {
|
|
130
140
|
ActionsBlock,
|
|
@@ -137,23 +147,35 @@ export type {
|
|
|
137
147
|
CalloutBlock,
|
|
138
148
|
CardBlock,
|
|
139
149
|
ChartBlock,
|
|
150
|
+
ChecklistBlock,
|
|
151
|
+
ChecklistCallback,
|
|
152
|
+
ChecklistItem,
|
|
140
153
|
ColumnsBlock,
|
|
141
154
|
ContextBlock,
|
|
155
|
+
CopyAction,
|
|
142
156
|
Dataset,
|
|
143
157
|
DatasetColumn,
|
|
144
158
|
DetailsBlock,
|
|
145
159
|
DividerBlock,
|
|
160
|
+
GalleryBlock,
|
|
161
|
+
GalleryImage,
|
|
146
162
|
HeadingBlock,
|
|
147
163
|
HtmlBlock,
|
|
148
164
|
ImageBlock,
|
|
149
165
|
InlineDataset,
|
|
150
166
|
LeafBlock,
|
|
167
|
+
ListBlock,
|
|
168
|
+
ListItem,
|
|
169
|
+
ListItemImage,
|
|
151
170
|
MetricBlock,
|
|
152
171
|
OpenAction,
|
|
153
172
|
RefDataset,
|
|
154
173
|
ReplyAction,
|
|
155
174
|
SegmentedElement,
|
|
156
175
|
SelectAction,
|
|
176
|
+
StepperBlock,
|
|
177
|
+
StepperCallback,
|
|
178
|
+
StepperItem,
|
|
157
179
|
TableBlock,
|
|
158
180
|
TextBlock,
|
|
159
181
|
} from "./schema";
|
|
@@ -164,11 +186,13 @@ export {
|
|
|
164
186
|
CALLOUT_STATUSES,
|
|
165
187
|
CHART_HEIGHTS,
|
|
166
188
|
CHART_KINDS,
|
|
189
|
+
COPY_TARGET_TYPES,
|
|
167
190
|
DATASET_COLUMN_TYPES,
|
|
168
191
|
DATASET_GRAINS,
|
|
169
192
|
HEADING_SIZES,
|
|
170
193
|
HTML_HEIGHTS,
|
|
171
194
|
METRIC_TRENDS,
|
|
195
|
+
STEPPER_ROUNDING,
|
|
172
196
|
wrapAsTextDocument,
|
|
173
197
|
} from "./schema";
|
|
174
198
|
export type {ValidateBlocksResult} from "./validate";
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import {describe, expect, it} from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
applyChecklistState,
|
|
5
|
+
isStepperValueAllowed,
|
|
6
|
+
scaleStepperBlock,
|
|
7
|
+
unknownChecklistItemIds,
|
|
8
|
+
} from "./interactive";
|
|
9
|
+
import type {Block, BlocksDocument, ChecklistBlock, StepperBlock} from "./schema";
|
|
10
|
+
import {validateBlocks} from "./validate";
|
|
11
|
+
|
|
12
|
+
// Built key by key because validateBlocks wants `v` before `blocks`.
|
|
13
|
+
const documentOf = (block: Block): BlocksDocument => {
|
|
14
|
+
const doc = {v: 1} as BlocksDocument;
|
|
15
|
+
doc.blocks = [block];
|
|
16
|
+
return doc;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const STEPPER: StepperBlock = {
|
|
20
|
+
callback: {name: "scaleStepper", payload: {recipe: "roast"}},
|
|
21
|
+
id: "guests",
|
|
22
|
+
items: [
|
|
23
|
+
{amount: 2, decimals: 1, label: "Lamb", unit: "kg"},
|
|
24
|
+
{amount: 7, label: "Parsnips", round: "up"},
|
|
25
|
+
{amount: 625, label: "Broccoli", unit: "g"},
|
|
26
|
+
],
|
|
27
|
+
label: "Number of people",
|
|
28
|
+
max: 20,
|
|
29
|
+
min: 1,
|
|
30
|
+
note: "Generous portions.",
|
|
31
|
+
type: "stepper",
|
|
32
|
+
unit: "People",
|
|
33
|
+
value: 5,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const CHECKLIST: ChecklistBlock = {
|
|
37
|
+
callback: {name: "toggleChecklist"},
|
|
38
|
+
id: "cooking",
|
|
39
|
+
items: [
|
|
40
|
+
{checked: true, id: "oven", meta: "1:00 pm", text: "Preheat the oven"},
|
|
41
|
+
{id: "lamb_in", text: "Put the lamb in"},
|
|
42
|
+
{id: "rest", text: "Rest the lamb"},
|
|
43
|
+
],
|
|
44
|
+
title: "Cooking checklist",
|
|
45
|
+
type: "checklist",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
describe("scaleStepperBlock", () => {
|
|
49
|
+
it("scales every item linearly from the original by its decimals and rounding", () => {
|
|
50
|
+
const scaled = scaleStepperBlock(STEPPER, 6);
|
|
51
|
+
expect(scaled.value).toBe(6);
|
|
52
|
+
expect(scaled.items?.map((item) => item.amount)).toEqual([2.4, 9, 750]);
|
|
53
|
+
expect(scaled.items?.[0]).toEqual({amount: 2.4, decimals: 1, label: "Lamb", unit: "kg"});
|
|
54
|
+
expect(scaled.note).toBe("Generous portions.");
|
|
55
|
+
expect(validateBlocks(documentOf(scaled)).ok).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("returns the agent's amounts when scaled back to the original value", () => {
|
|
59
|
+
expect(scaleStepperBlock(STEPPER, 5)).toEqual(STEPPER);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("does not mutate the original", () => {
|
|
63
|
+
const before = structuredClone(STEPPER);
|
|
64
|
+
scaleStepperBlock(STEPPER, 10);
|
|
65
|
+
expect(STEPPER).toEqual(before);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("keeps the amounts as written when the original value is 0", () => {
|
|
69
|
+
const zero: StepperBlock = {...STEPPER, min: 0, value: 0};
|
|
70
|
+
expect(scaleStepperBlock(zero, 3).items?.map((item) => item.amount)).toEqual([2, 7, 625]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("only changes the value when there are no items", () => {
|
|
74
|
+
const {items: _items, ...bare} = STEPPER;
|
|
75
|
+
expect(scaleStepperBlock(bare, 9)).toEqual({...bare, value: 9});
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("isStepperValueAllowed", () => {
|
|
80
|
+
it("accepts a finite value within the range on the step grid from the original", () => {
|
|
81
|
+
expect(isStepperValueAllowed(STEPPER, 1)).toBe(true);
|
|
82
|
+
expect(isStepperValueAllowed(STEPPER, 20)).toBe(true);
|
|
83
|
+
expect(isStepperValueAllowed({...STEPPER, step: 0.5}, 5.5)).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("rejects values outside the range, off the grid, or not finite", () => {
|
|
87
|
+
expect(isStepperValueAllowed(STEPPER, 0)).toBe(false);
|
|
88
|
+
expect(isStepperValueAllowed(STEPPER, 21)).toBe(false);
|
|
89
|
+
expect(isStepperValueAllowed({...STEPPER, step: 2}, 6)).toBe(false);
|
|
90
|
+
expect(isStepperValueAllowed(STEPPER, Number.NaN)).toBe(false);
|
|
91
|
+
expect(isStepperValueAllowed(STEPPER, Number.POSITIVE_INFINITY)).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("applyChecklistState", () => {
|
|
96
|
+
it("takes every item's checked from state, with the ticked item taking checked", () => {
|
|
97
|
+
const ticked = applyChecklistState(CHECKLIST, {
|
|
98
|
+
checked: true,
|
|
99
|
+
itemId: "lamb_in",
|
|
100
|
+
state: {lamb_in: false, oven: true},
|
|
101
|
+
});
|
|
102
|
+
expect(ticked.items.map((item) => item.checked)).toEqual([true, true, false]);
|
|
103
|
+
expect(ticked.items[0]).toEqual({
|
|
104
|
+
checked: true,
|
|
105
|
+
id: "oven",
|
|
106
|
+
meta: "1:00 pm",
|
|
107
|
+
text: "Preheat the oven",
|
|
108
|
+
});
|
|
109
|
+
expect(ticked.title).toBe("Cooking checklist");
|
|
110
|
+
expect(validateBlocks(documentOf(ticked)).ok).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("unchecks an item missing from state, even one the agent wrote as checked", () => {
|
|
114
|
+
const ticked = applyChecklistState(CHECKLIST, {checked: true, itemId: "rest", state: {}});
|
|
115
|
+
expect(ticked.items.map((item) => item.checked)).toEqual([false, false, true]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("does not mutate the original", () => {
|
|
119
|
+
const before = structuredClone(CHECKLIST);
|
|
120
|
+
applyChecklistState(CHECKLIST, {checked: false, itemId: "oven", state: {}});
|
|
121
|
+
expect(CHECKLIST).toEqual(before);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("unknownChecklistItemIds", () => {
|
|
126
|
+
it("lists each id in the tick or state that the checklist does not have, once", () => {
|
|
127
|
+
expect(
|
|
128
|
+
unknownChecklistItemIds(CHECKLIST, {
|
|
129
|
+
checked: true,
|
|
130
|
+
itemId: "gravy",
|
|
131
|
+
state: {gravy: true, oven: true, salad: false},
|
|
132
|
+
})
|
|
133
|
+
).toEqual(["gravy", "salad"]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("is empty for a tick over known items", () => {
|
|
137
|
+
expect(
|
|
138
|
+
unknownChecklistItemIds(CHECKLIST, {checked: true, itemId: "oven", state: {rest: true}})
|
|
139
|
+
).toEqual([]);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type {ChecklistBlock, StepperBlock, StepperItem} from "./schema";
|
|
2
|
+
|
|
3
|
+
/** Tolerance for float noise when checking the step grid and rounding up. */
|
|
4
|
+
const EPSILON = 1e-9;
|
|
5
|
+
|
|
6
|
+
/** A checklist tick as the renderer sends it: the ticked item and every item's state. */
|
|
7
|
+
export interface ChecklistTick {
|
|
8
|
+
checked: boolean;
|
|
9
|
+
itemId: string;
|
|
10
|
+
state: Record<string, boolean>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const roundAmount = (amount: number, item: StepperItem): number => {
|
|
14
|
+
const factor = 10 ** (item.decimals ?? 0);
|
|
15
|
+
const scaled = amount * factor;
|
|
16
|
+
const rounded = item.round === "up" ? Math.ceil(scaled - EPSILON) : Math.round(scaled);
|
|
17
|
+
return Number((rounded / factor).toFixed(item.decimals ?? 0));
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* True when `value` is finite, within `min`..`max`, and a whole number of `step`s from the
|
|
22
|
+
* stepper's own `value`.
|
|
23
|
+
*/
|
|
24
|
+
export const isStepperValueAllowed = (stepper: StepperBlock, value: number): boolean => {
|
|
25
|
+
if (!Number.isFinite(value) || value < stepper.min || value > stepper.max) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
const steps = (value - stepper.value) / (stepper.step ?? 1);
|
|
29
|
+
return Math.abs(steps - Math.round(steps)) < EPSILON;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Returns `original` at `value` with every item scaled linearly from `original`, rounded by its
|
|
34
|
+
* `decimals` and `round`. Always scale from the block the agent wrote, not the last result, so
|
|
35
|
+
* rounding never drifts tap after tap. An original `value` of 0 keeps the amounts as written.
|
|
36
|
+
* Check the value with `isStepperValueAllowed` first.
|
|
37
|
+
*/
|
|
38
|
+
export const scaleStepperBlock = (original: StepperBlock, value: number): StepperBlock => {
|
|
39
|
+
if (original.items === undefined) {
|
|
40
|
+
return {...original, value};
|
|
41
|
+
}
|
|
42
|
+
const ratio = original.value === 0 ? 1 : value / original.value;
|
|
43
|
+
return {
|
|
44
|
+
...original,
|
|
45
|
+
items: original.items.map((item) => ({
|
|
46
|
+
...item,
|
|
47
|
+
amount: roundAmount(item.amount * ratio, item),
|
|
48
|
+
})),
|
|
49
|
+
value,
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** The ids in a tick (its `itemId` and `state` keys) that `checklist` has no item for, once each. */
|
|
54
|
+
export const unknownChecklistItemIds = (
|
|
55
|
+
checklist: ChecklistBlock,
|
|
56
|
+
tick: ChecklistTick
|
|
57
|
+
): string[] => {
|
|
58
|
+
const itemIds = new Set(checklist.items.map((item) => item.id));
|
|
59
|
+
return [...new Set([tick.itemId, ...Object.keys(tick.state)])].filter((id) => !itemIds.has(id));
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Returns `checklist` with every item's `checked` taken from `tick.state`. An item missing from
|
|
64
|
+
* `state` is unchecked, and the ticked item always takes `tick.checked`.
|
|
65
|
+
*/
|
|
66
|
+
export const applyChecklistState = (
|
|
67
|
+
checklist: ChecklistBlock,
|
|
68
|
+
tick: ChecklistTick
|
|
69
|
+
): ChecklistBlock => ({
|
|
70
|
+
...checklist,
|
|
71
|
+
items: checklist.items.map((item) => ({
|
|
72
|
+
...item,
|
|
73
|
+
checked: item.id === tick.itemId ? tick.checked : tick.state[item.id] === true,
|
|
74
|
+
})),
|
|
75
|
+
});
|
package/src/limits.ts
CHANGED
|
@@ -6,16 +6,35 @@ export const BLOCK_LIMITS = {
|
|
|
6
6
|
badgeTextMaxLength: 80,
|
|
7
7
|
barCategoryWarning: 60,
|
|
8
8
|
blockTextMaxLength: 4_000,
|
|
9
|
+
cardEyebrowMaxLength: 60,
|
|
9
10
|
cardTitleMaxLength: 120,
|
|
11
|
+
checklistIdMaxLength: 31,
|
|
12
|
+
checklistItemDetailMaxLength: 280,
|
|
13
|
+
checklistItemIdMaxLength: 32,
|
|
14
|
+
checklistItemMetaMaxLength: 40,
|
|
15
|
+
checklistItemsMax: 30,
|
|
16
|
+
checklistItemsMin: 1,
|
|
17
|
+
checklistItemTextMaxLength: 120,
|
|
18
|
+
checklistTitleMaxLength: 120,
|
|
10
19
|
columnsMax: 4,
|
|
11
20
|
columnsMin: 2,
|
|
12
21
|
contextTextMaxLength: 280,
|
|
22
|
+
copyTextMaxLength: 4_000,
|
|
13
23
|
datasetColumnMax: 12,
|
|
14
24
|
datasetRowMax: 500,
|
|
15
25
|
documentTextMaxLength: 20_000,
|
|
16
26
|
donutSliceWarning: 8,
|
|
27
|
+
galleryAltMaxLength: 200,
|
|
28
|
+
galleryCaptionMaxLength: 120,
|
|
29
|
+
galleryImagesMax: 6,
|
|
30
|
+
galleryImagesMin: 2,
|
|
17
31
|
headingTextMaxLength: 200,
|
|
18
32
|
htmlMaxBytes: 100_000,
|
|
33
|
+
listItemMetaMaxLength: 40,
|
|
34
|
+
listItemsMax: 12,
|
|
35
|
+
listItemsMin: 1,
|
|
36
|
+
listItemTextMaxLength: 500,
|
|
37
|
+
listItemTitleMaxLength: 120,
|
|
19
38
|
maxBlocks: 50,
|
|
20
39
|
maxDatasets: 8,
|
|
21
40
|
maxDepth: 2,
|
|
@@ -24,4 +43,11 @@ export const BLOCK_LIMITS = {
|
|
|
24
43
|
metricLabelMaxLength: 80,
|
|
25
44
|
metricValueMaxLength: 80,
|
|
26
45
|
refLimitMax: 1_000,
|
|
46
|
+
stepperDecimalsMax: 3,
|
|
47
|
+
stepperIdMaxLength: 54,
|
|
48
|
+
stepperItemsMax: 12,
|
|
49
|
+
stepperItemUnitMaxLength: 20,
|
|
50
|
+
stepperLabelMaxLength: 80,
|
|
51
|
+
stepperNoteMaxLength: 280,
|
|
52
|
+
stepperUnitMaxLength: 40,
|
|
27
53
|
} as const;
|