@rebasepro/plugin-ai 0.17.3 → 0.18.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/LICENSE +0 -1
- package/README.md +4 -0
- package/dist/api.d.ts +8 -0
- package/dist/index.es.js.map +1 -1
- package/package.json +35 -21
- package/src/api.ts +0 -320
- package/src/components/AutofillReviewDialog.tsx +0 -209
- package/src/components/DataEnhancementControllerProvider.tsx +0 -320
- package/src/components/FormEnhanceAction.tsx +0 -307
- package/src/editor/useEditorAIController.tsx +0 -24
- package/src/index.ts +0 -9
- package/src/tests/AutofillReviewDialog.test.tsx +0 -340
- package/src/tests/api.test.ts +0 -382
- package/src/tests/properties.test.ts +0 -420
- package/src/tests/request_agreement.test.ts +0 -240
- package/src/tests/review.test.tsx +0 -596
- package/src/tests/useDataEnhancementPlugin.test.tsx +0 -72
- package/src/tests/useEditorAIController.test.ts +0 -98
- package/src/tests/values.test.ts +0 -87
- package/src/types/data_enhancement_controller.tsx +0 -142
- package/src/useDataEnhancementPlugin.tsx +0 -68
- package/src/utils/properties.ts +0 -168
- package/src/utils/values.ts +0 -72
- package/src/vite-env.d.ts +0 -1
|
@@ -1,420 +0,0 @@
|
|
|
1
|
-
import { getSimplifiedProperties } from "../utils/properties";
|
|
2
|
-
import { Properties } from "@rebasepro/types";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* The schema serializer.
|
|
6
|
-
*
|
|
7
|
-
* This is the file that decides *what the model is asked to fill*. The service
|
|
8
|
-
* builds its JSON schema from whatever comes out of here, so a key this
|
|
9
|
-
* function drops is a field autofill will never write, silently — no error, no
|
|
10
|
-
* warning, just a form that stays half empty on exactly the collections that
|
|
11
|
-
* are most tedious to fill by hand.
|
|
12
|
-
*
|
|
13
|
-
* It also has to agree with `flatMapEntityValues` about paths. Properties are
|
|
14
|
-
* keyed on dotted paths (`seo.title`) and values are flattened onto the same
|
|
15
|
-
* ones; if the two disagree, the model is told a field exists and shown no
|
|
16
|
-
* value for it, and cheerfully overwrites what the operator already wrote.
|
|
17
|
-
*
|
|
18
|
-
* `getFieldId` is mocked to something deterministic — the real one resolves
|
|
19
|
-
* against the admin field registry, which is not what is under test here.
|
|
20
|
-
*/
|
|
21
|
-
jest.mock("@rebasepro/cms", () => ({
|
|
22
|
-
getFieldId: (property: { type?: string; admin?: { markdown?: boolean; multiline?: boolean } }) => {
|
|
23
|
-
if (!property?.type) return undefined;
|
|
24
|
-
if (property.type === "string" && property.admin?.markdown) return "markdown";
|
|
25
|
-
if (property.type === "string" && property.admin?.multiline) return "multiline";
|
|
26
|
-
if (property.type === "string") return "text_field";
|
|
27
|
-
if (property.type === "number") return "number_field";
|
|
28
|
-
if (property.type === "boolean") return "switch";
|
|
29
|
-
if (property.type === "date") return "date_time";
|
|
30
|
-
if (property.type === "reference") return "reference";
|
|
31
|
-
return undefined;
|
|
32
|
-
}
|
|
33
|
-
}));
|
|
34
|
-
|
|
35
|
-
describe("getSimplifiedProperties — flat properties", () => {
|
|
36
|
-
|
|
37
|
-
it("keys each scalar on its own name", () => {
|
|
38
|
-
const properties = {
|
|
39
|
-
title: { name: "Title",
|
|
40
|
-
type: "string" },
|
|
41
|
-
stock: { name: "Stock",
|
|
42
|
-
type: "number" }
|
|
43
|
-
} as unknown as Properties;
|
|
44
|
-
|
|
45
|
-
const out = getSimplifiedProperties(properties, {});
|
|
46
|
-
|
|
47
|
-
expect(Object.keys(out).sort()).toEqual(["stock", "title"]);
|
|
48
|
-
expect(out.title).toMatchObject({ name: "Title",
|
|
49
|
-
type: "string",
|
|
50
|
-
fieldConfigId: "text_field" });
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it("carries the description through, because it is what the model is steered by", () => {
|
|
54
|
-
const properties = {
|
|
55
|
-
summary: { name: "Summary",
|
|
56
|
-
type: "string",
|
|
57
|
-
description: "One sentence, no marketing" }
|
|
58
|
-
} as unknown as Properties;
|
|
59
|
-
|
|
60
|
-
expect(getSimplifiedProperties(properties, {}).summary.description).toBe("One sentence, no marketing");
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it("marks disabled and read-only fields, so the service can refuse to fill them", () => {
|
|
64
|
-
const properties = {
|
|
65
|
-
slug: { name: "Slug",
|
|
66
|
-
type: "string",
|
|
67
|
-
admin: { disabled: true } },
|
|
68
|
-
computed: { name: "Computed",
|
|
69
|
-
type: "string",
|
|
70
|
-
admin: { readOnly: true } },
|
|
71
|
-
normal: { name: "Normal",
|
|
72
|
-
type: "string" }
|
|
73
|
-
} as unknown as Properties;
|
|
74
|
-
|
|
75
|
-
const out = getSimplifiedProperties(properties, {});
|
|
76
|
-
expect(out.slug.disabled).toBe(true);
|
|
77
|
-
expect(out.computed.disabled).toBe(true);
|
|
78
|
-
expect(out.normal.disabled).toBe(false);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it("flattens enum values to the ids the model must choose between", () => {
|
|
82
|
-
const asArray = {
|
|
83
|
-
status: { name: "Status",
|
|
84
|
-
type: "string",
|
|
85
|
-
enum: [{ id: "draft",
|
|
86
|
-
label: "Draft" }, { id: "live",
|
|
87
|
-
label: "Live" }] }
|
|
88
|
-
} as unknown as Properties;
|
|
89
|
-
expect(getSimplifiedProperties(asArray, {}).status.enum).toEqual(["draft", "live"]);
|
|
90
|
-
|
|
91
|
-
const asObject = {
|
|
92
|
-
status: { name: "Status",
|
|
93
|
-
type: "string",
|
|
94
|
-
enum: { draft: "Draft",
|
|
95
|
-
live: "Live" } }
|
|
96
|
-
} as unknown as Properties;
|
|
97
|
-
expect(getSimplifiedProperties(asObject, {}).status.enum).toEqual(["draft", "live"]);
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
it("distinguishes markdown and multiline, which change how a suggestion is joined", () => {
|
|
101
|
-
const properties = {
|
|
102
|
-
body: { name: "Body",
|
|
103
|
-
type: "string",
|
|
104
|
-
admin: { markdown: true } },
|
|
105
|
-
notes: { name: "Notes",
|
|
106
|
-
type: "string",
|
|
107
|
-
admin: { multiline: true } }
|
|
108
|
-
} as unknown as Properties;
|
|
109
|
-
|
|
110
|
-
const out = getSimplifiedProperties(properties, {});
|
|
111
|
-
expect(out.body.fieldConfigId).toBe("markdown");
|
|
112
|
-
expect(out.notes.fieldConfigId).toBe("multiline");
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
it("skips a property builder, which is a function with no schema to read", () => {
|
|
116
|
-
const properties = {
|
|
117
|
-
title: { name: "Title",
|
|
118
|
-
type: "string" },
|
|
119
|
-
derived: () => ({ name: "Derived",
|
|
120
|
-
type: "string" })
|
|
121
|
-
} as unknown as Properties;
|
|
122
|
-
|
|
123
|
-
expect(Object.keys(getSimplifiedProperties(properties, {}))).toEqual(["title"]);
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
it("drops a property whose type has no field, rather than throwing", () => {
|
|
127
|
-
const properties = {
|
|
128
|
-
title: { name: "Title",
|
|
129
|
-
type: "string" },
|
|
130
|
-
weird: { name: "Weird",
|
|
131
|
-
type: "geopoint" }
|
|
132
|
-
} as unknown as Properties;
|
|
133
|
-
|
|
134
|
-
expect(Object.keys(getSimplifiedProperties(properties, {}))).toEqual(["title"]);
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
it("returns nothing for an absent property map", () => {
|
|
138
|
-
expect(getSimplifiedProperties(undefined as unknown as Properties, {})).toEqual({});
|
|
139
|
-
});
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
describe("getSimplifiedProperties — nested maps", () => {
|
|
143
|
-
|
|
144
|
-
it("keys children on the dotted path the values are flattened onto", () => {
|
|
145
|
-
// The contract with `flatMapEntityValues`. If these disagree the model
|
|
146
|
-
// is told about `seo.title` and shown a value for `seo`, so it treats
|
|
147
|
-
// every existing value as absent.
|
|
148
|
-
const properties = {
|
|
149
|
-
seo: {
|
|
150
|
-
name: "SEO",
|
|
151
|
-
type: "map",
|
|
152
|
-
properties: {
|
|
153
|
-
title: { name: "SEO title",
|
|
154
|
-
type: "string" },
|
|
155
|
-
description: { name: "SEO description",
|
|
156
|
-
type: "string" }
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
} as unknown as Properties;
|
|
160
|
-
|
|
161
|
-
const out = getSimplifiedProperties(properties, {});
|
|
162
|
-
|
|
163
|
-
expect(Object.keys(out).sort()).toEqual(["seo", "seo.description", "seo.title"]);
|
|
164
|
-
expect(out["seo.title"]).toMatchObject({ name: "SEO title",
|
|
165
|
-
fieldConfigId: "text_field" });
|
|
166
|
-
// The container itself is described too, so the model knows the grouping.
|
|
167
|
-
expect(out.seo).toMatchObject({ type: "map",
|
|
168
|
-
fieldConfigId: "group" });
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
it("nests two levels deep", () => {
|
|
172
|
-
const properties = {
|
|
173
|
-
meta: {
|
|
174
|
-
name: "Meta",
|
|
175
|
-
type: "map",
|
|
176
|
-
properties: {
|
|
177
|
-
seo: {
|
|
178
|
-
name: "SEO",
|
|
179
|
-
type: "map",
|
|
180
|
-
properties: { title: { name: "Title",
|
|
181
|
-
type: "string" } }
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
} as unknown as Properties;
|
|
186
|
-
|
|
187
|
-
const out = getSimplifiedProperties(properties, {});
|
|
188
|
-
expect(out["meta.seo.title"]).toMatchObject({ name: "Title" });
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
it("omits a map that declares no properties", () => {
|
|
192
|
-
const properties = {
|
|
193
|
-
title: { name: "Title",
|
|
194
|
-
type: "string" },
|
|
195
|
-
blob: { name: "Blob",
|
|
196
|
-
type: "map" }
|
|
197
|
-
} as unknown as Properties;
|
|
198
|
-
|
|
199
|
-
expect(Object.keys(getSimplifiedProperties(properties, {}))).toEqual(["title"]);
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
it("omits a map whose children are all unfillable", () => {
|
|
203
|
-
const properties = {
|
|
204
|
-
refs: {
|
|
205
|
-
name: "Refs",
|
|
206
|
-
type: "map",
|
|
207
|
-
properties: { point: { name: "Point",
|
|
208
|
-
type: "geopoint" } }
|
|
209
|
-
}
|
|
210
|
-
} as unknown as Properties;
|
|
211
|
-
|
|
212
|
-
expect(getSimplifiedProperties(properties, {})).toEqual({});
|
|
213
|
-
});
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
describe("getSimplifiedProperties — arrays", () => {
|
|
217
|
-
|
|
218
|
-
it("describes an array of scalars by its element type", () => {
|
|
219
|
-
// What lets the service offer `{type: "array", items: {type: "string"}}`
|
|
220
|
-
// for a tags field rather than skipping it.
|
|
221
|
-
const properties = {
|
|
222
|
-
tags: { name: "Tags",
|
|
223
|
-
type: "array",
|
|
224
|
-
of: { name: "Tag",
|
|
225
|
-
type: "string" } }
|
|
226
|
-
} as unknown as Properties;
|
|
227
|
-
|
|
228
|
-
const out = getSimplifiedProperties(properties, {});
|
|
229
|
-
expect(out.tags).toMatchObject({ type: "array",
|
|
230
|
-
fieldConfigId: "repeat" });
|
|
231
|
-
expect(out.tags.of).toMatchObject({ type: "string",
|
|
232
|
-
fieldConfigId: "text_field" });
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
it("carries the element's enum, so an array of choices stays constrained", () => {
|
|
236
|
-
const properties = {
|
|
237
|
-
categories: {
|
|
238
|
-
name: "Categories",
|
|
239
|
-
type: "array",
|
|
240
|
-
of: { name: "Category",
|
|
241
|
-
type: "string",
|
|
242
|
-
enum: [{ id: "a",
|
|
243
|
-
label: "A" }, { id: "b",
|
|
244
|
-
label: "B" }] }
|
|
245
|
-
}
|
|
246
|
-
} as unknown as Properties;
|
|
247
|
-
|
|
248
|
-
expect(getSimplifiedProperties(properties, {}).categories.of?.enum).toEqual(["a", "b"]);
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
it("marks a disabled array so the service leaves it alone", () => {
|
|
252
|
-
const properties = {
|
|
253
|
-
tags: { name: "Tags",
|
|
254
|
-
type: "array",
|
|
255
|
-
admin: { readOnly: true },
|
|
256
|
-
of: { name: "Tag",
|
|
257
|
-
type: "string" } }
|
|
258
|
-
} as unknown as Properties;
|
|
259
|
-
|
|
260
|
-
expect(getSimplifiedProperties(properties, {}).tags.disabled).toBe(true);
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
it("omits an array with neither `of` nor `oneOf`", () => {
|
|
264
|
-
const properties = {
|
|
265
|
-
title: { name: "Title",
|
|
266
|
-
type: "string" },
|
|
267
|
-
mystery: { name: "Mystery",
|
|
268
|
-
type: "array" }
|
|
269
|
-
} as unknown as Properties;
|
|
270
|
-
|
|
271
|
-
expect(Object.keys(getSimplifiedProperties(properties, {}))).toEqual(["title"]);
|
|
272
|
-
});
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
describe("getSimplifiedProperties — oneOf blocks", () => {
|
|
276
|
-
|
|
277
|
-
const blockProperties = {
|
|
278
|
-
content: {
|
|
279
|
-
name: "Content",
|
|
280
|
-
type: "array",
|
|
281
|
-
oneOf: {
|
|
282
|
-
typeField: "type",
|
|
283
|
-
valueField: "value",
|
|
284
|
-
properties: {
|
|
285
|
-
text: { name: "Text",
|
|
286
|
-
type: "string",
|
|
287
|
-
admin: { markdown: true } },
|
|
288
|
-
count: { name: "Count",
|
|
289
|
-
type: "number" }
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
} as unknown as Properties;
|
|
294
|
-
|
|
295
|
-
it("describes the available block types when the field is empty", () => {
|
|
296
|
-
const out = getSimplifiedProperties(blockProperties, {});
|
|
297
|
-
expect(out.content).toMatchObject({ type: "array",
|
|
298
|
-
fieldConfigId: "block" });
|
|
299
|
-
expect(Object.keys(out.content.oneOf!.properties).sort()).toEqual(["count", "text"]);
|
|
300
|
-
expect(out.content.oneOf!.typeField).toBe("type");
|
|
301
|
-
expect(out.content.oneOf!.valueField).toBe("value");
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
it("expands the blocks that already exist, keyed by index", () => {
|
|
305
|
-
const values = {
|
|
306
|
-
content: [
|
|
307
|
-
{ type: "text",
|
|
308
|
-
value: "Hello" },
|
|
309
|
-
{ type: "count",
|
|
310
|
-
value: 3 }
|
|
311
|
-
]
|
|
312
|
-
};
|
|
313
|
-
|
|
314
|
-
const out = getSimplifiedProperties(blockProperties, values);
|
|
315
|
-
|
|
316
|
-
expect(out["content.0.value"]).toMatchObject({ name: "Text",
|
|
317
|
-
fieldConfigId: "markdown" });
|
|
318
|
-
expect(out["content.1.value"]).toMatchObject({ name: "Count",
|
|
319
|
-
fieldConfigId: "number_field" });
|
|
320
|
-
});
|
|
321
|
-
|
|
322
|
-
it("skips a block whose declared type is not in the schema", () => {
|
|
323
|
-
// Data can outlive a schema change. An unknown block must not take the
|
|
324
|
-
// whole collection's serialization down with it.
|
|
325
|
-
const values = { content: [{ type: "removed_block",
|
|
326
|
-
value: "x" }] };
|
|
327
|
-
const out = getSimplifiedProperties(blockProperties, values);
|
|
328
|
-
expect(out.content).toBeDefined();
|
|
329
|
-
expect(Object.keys(out).filter((k) => k.startsWith("content."))).toEqual([]);
|
|
330
|
-
});
|
|
331
|
-
|
|
332
|
-
it("skips a null entry in the block list", () => {
|
|
333
|
-
const values = { content: [null, { type: "text",
|
|
334
|
-
value: "Hi" }] };
|
|
335
|
-
const out = getSimplifiedProperties(blockProperties, values);
|
|
336
|
-
expect(out["content.1.value"]).toBeDefined();
|
|
337
|
-
});
|
|
338
|
-
|
|
339
|
-
it("defaults the type and value field names when the schema omits them", () => {
|
|
340
|
-
const properties = {
|
|
341
|
-
content: {
|
|
342
|
-
name: "Content",
|
|
343
|
-
type: "array",
|
|
344
|
-
oneOf: { properties: { text: { name: "Text",
|
|
345
|
-
type: "string" } } }
|
|
346
|
-
}
|
|
347
|
-
} as unknown as Properties;
|
|
348
|
-
|
|
349
|
-
const out = getSimplifiedProperties(properties, { content: [{ type: "text",
|
|
350
|
-
value: "Hi" }] });
|
|
351
|
-
expect(out["content.0.value"]).toMatchObject({ name: "Text" });
|
|
352
|
-
});
|
|
353
|
-
});
|
|
354
|
-
|
|
355
|
-
describe("getSimplifiedProperties — the paths it produces", () => {
|
|
356
|
-
|
|
357
|
-
it("agrees with the flattened value paths for every shape at once", () => {
|
|
358
|
-
// The single assertion that catches a drift between this function and
|
|
359
|
-
// `flatMapEntityValues`: every leaf key the model is told about must be
|
|
360
|
-
// one the values can actually be keyed on.
|
|
361
|
-
const properties = {
|
|
362
|
-
title: { name: "Title",
|
|
363
|
-
type: "string" },
|
|
364
|
-
seo: {
|
|
365
|
-
name: "SEO",
|
|
366
|
-
type: "map",
|
|
367
|
-
properties: { title: { name: "SEO title",
|
|
368
|
-
type: "string" } }
|
|
369
|
-
},
|
|
370
|
-
tags: { name: "Tags",
|
|
371
|
-
type: "array",
|
|
372
|
-
of: { name: "Tag",
|
|
373
|
-
type: "string" } }
|
|
374
|
-
} as unknown as Properties;
|
|
375
|
-
|
|
376
|
-
const out = getSimplifiedProperties(properties, { title: "T",
|
|
377
|
-
seo: { title: "S" } });
|
|
378
|
-
|
|
379
|
-
expect(Object.keys(out).sort()).toEqual(["seo", "seo.title", "tags", "title"]);
|
|
380
|
-
for (const key of Object.keys(out)) {
|
|
381
|
-
expect(key).toMatch(/^[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)*$/);
|
|
382
|
-
}
|
|
383
|
-
});
|
|
384
|
-
});
|
|
385
|
-
|
|
386
|
-
describe("getSimplifiedProperties — a known wart", () => {
|
|
387
|
-
|
|
388
|
-
it("emits the block's type discriminator as a bare string, not a property", () => {
|
|
389
|
-
// Pinned rather than fixed, because it is pre-existing and harmless in
|
|
390
|
-
// effect — but it *is* malformed: the return type says
|
|
391
|
-
// `Record<string, InputProperty>` and this entry is the raw string
|
|
392
|
-
// `"text"`. It reaches the service, which skips it via an explicit
|
|
393
|
-
// `typeof property !== "object"` guard in `planFill` (see the matching
|
|
394
|
-
// test in saas/backend/functions/ai.test.ts).
|
|
395
|
-
//
|
|
396
|
-
// If that guard is ever removed, `property.disabled` and `property.type`
|
|
397
|
-
// read `undefined` off a string and the key is dropped anyway — so the
|
|
398
|
-
// worst case is noise on the wire rather than a bad schema. Fixing it
|
|
399
|
-
// means deciding whether the model benefits from knowing a block's type,
|
|
400
|
-
// which is a behaviour change, not a cleanup.
|
|
401
|
-
const properties = {
|
|
402
|
-
content: {
|
|
403
|
-
name: "Content",
|
|
404
|
-
type: "array",
|
|
405
|
-
oneOf: { properties: { text: { name: "Text",
|
|
406
|
-
type: "string" } } }
|
|
407
|
-
}
|
|
408
|
-
} as unknown as Properties;
|
|
409
|
-
|
|
410
|
-
const out = getSimplifiedProperties(properties, { content: [{ type: "text",
|
|
411
|
-
value: "Hi" }] });
|
|
412
|
-
|
|
413
|
-
expect(Object.keys(out)).toContain("content.0.type");
|
|
414
|
-
expect(typeof out["content.0.type"]).toBe("string");
|
|
415
|
-
expect(out["content.0.type"] as unknown).toBe("text");
|
|
416
|
-
// The entry that carries real schema is the value one, and it is correct.
|
|
417
|
-
expect(out["content.0.value"]).toMatchObject({ name: "Text",
|
|
418
|
-
type: "string" });
|
|
419
|
-
});
|
|
420
|
-
});
|
|
@@ -1,240 +0,0 @@
|
|
|
1
|
-
import { Properties } from "@rebasepro/types";
|
|
2
|
-
import { getValueInPath } from "@rebasepro/utils";
|
|
3
|
-
|
|
4
|
-
import { getSimplifiedProperties } from "../utils/properties";
|
|
5
|
-
import { flatMapEntityValues, omitDisabledValues } from "../utils/values";
|
|
6
|
-
import { InputProperty } from "../types/data_enhancement_controller";
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* The two halves of an autofill request have to be keyed the same way.
|
|
10
|
-
*
|
|
11
|
-
* "Autofill only fills blanks" is not enforced anywhere in this package. It is
|
|
12
|
-
* enforced on the service, by omitting already-filled fields from the JSON
|
|
13
|
-
* schema the model answers into — and the service decides a field is filled by
|
|
14
|
-
* looking up `values[key]` for each `key` of `properties`. So the guard is only
|
|
15
|
-
* as true as the agreement between the two maps this file checks.
|
|
16
|
-
*
|
|
17
|
-
* That is why these tests build *both* maps from one record and then replay the
|
|
18
|
-
* service's own decision over them, rather than testing either map alone. Every
|
|
19
|
-
* assertion here passed against a client that flattened `tags: ["a","b"]` into
|
|
20
|
-
* `tags.0`/`tags.1` and dropped `Date` values entirely — as long as it was the
|
|
21
|
-
* flattener, or the property map, that was looked at in isolation.
|
|
22
|
-
*
|
|
23
|
-
* `getFieldId` is mocked deterministically; the real one resolves against the
|
|
24
|
-
* admin field registry, which is not what is under test.
|
|
25
|
-
*/
|
|
26
|
-
jest.mock("@rebasepro/cms", () => ({
|
|
27
|
-
getFieldId: (property: { type?: string }) => {
|
|
28
|
-
if (!property?.type) return undefined;
|
|
29
|
-
if (property.type === "string") return "text_field";
|
|
30
|
-
if (property.type === "number") return "number_field";
|
|
31
|
-
if (property.type === "boolean") return "switch";
|
|
32
|
-
if (property.type === "date") return "date_time";
|
|
33
|
-
return undefined;
|
|
34
|
-
}
|
|
35
|
-
}));
|
|
36
|
-
|
|
37
|
-
// ---------------------------------------------------------------------------
|
|
38
|
-
// The service's side of the contract, reproduced.
|
|
39
|
-
//
|
|
40
|
-
// Copied from `saas/backend/functions/ai.ts` (`alreadyFilled`, `scalarSchema`,
|
|
41
|
-
// `propertySchema`, `planFill`). It lives in another repository and cannot be
|
|
42
|
-
// imported, so it is mirrored — and mirrored *including* the JSON round trip,
|
|
43
|
-
// because the difference between a `Date` object and the ISO string a `Date`
|
|
44
|
-
// serialises to is exactly the difference between "this field is empty" and
|
|
45
|
-
// "this field is filled" over there.
|
|
46
|
-
// ---------------------------------------------------------------------------
|
|
47
|
-
|
|
48
|
-
function alreadyFilled(value: unknown): boolean {
|
|
49
|
-
if (value === null || value === undefined) return false;
|
|
50
|
-
if (typeof value === "string") return value.trim().length > 0;
|
|
51
|
-
if (Array.isArray(value)) return value.length > 0;
|
|
52
|
-
if (typeof value === "object") return Object.keys(value as object).length > 0;
|
|
53
|
-
return true;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function scalarSchema(property: InputProperty): object | undefined {
|
|
57
|
-
if (property.enum && property.enum.length > 0) return { type: "string",
|
|
58
|
-
enum: property.enum };
|
|
59
|
-
switch (property.type) {
|
|
60
|
-
case "string":
|
|
61
|
-
return { type: "string" };
|
|
62
|
-
case "number":
|
|
63
|
-
return { type: "number" };
|
|
64
|
-
case "boolean":
|
|
65
|
-
return { type: "boolean" };
|
|
66
|
-
case "date":
|
|
67
|
-
return { type: "string",
|
|
68
|
-
format: "date-time" };
|
|
69
|
-
default:
|
|
70
|
-
return undefined;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function propertySchema(property: InputProperty): object | undefined {
|
|
75
|
-
if (property.disabled) return undefined;
|
|
76
|
-
if (property.type === "array") {
|
|
77
|
-
const items = property.of ? scalarSchema(property.of) : undefined;
|
|
78
|
-
return items ? { type: "array",
|
|
79
|
-
items } : undefined;
|
|
80
|
-
}
|
|
81
|
-
return scalarSchema(property);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/** The keys the service would offer the model, given one request body. */
|
|
85
|
-
function plannedFillKeys(request: { properties: Record<string, InputProperty>, values: Record<string, unknown> }): string[] {
|
|
86
|
-
// As the service receives it: JSON, not the client's live objects.
|
|
87
|
-
const values = JSON.parse(JSON.stringify(request.values)) as Record<string, unknown>;
|
|
88
|
-
return Object.entries(request.properties)
|
|
89
|
-
.filter(([key, property]) => {
|
|
90
|
-
if (!property || typeof property !== "object") return false;
|
|
91
|
-
if (alreadyFilled(values[key])) return false;
|
|
92
|
-
return Boolean(propertySchema(property));
|
|
93
|
-
})
|
|
94
|
-
.map(([key]) => key);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/** What the client puts on the wire, built the way the provider builds it. */
|
|
98
|
-
function requestFor(properties: Properties, record: Record<string, unknown>) {
|
|
99
|
-
const simplified = getSimplifiedProperties(properties, record);
|
|
100
|
-
return {
|
|
101
|
-
properties: simplified,
|
|
102
|
-
values: omitDisabledValues(flatMapEntityValues(record), simplified)
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
const POST_PROPERTIES: Properties = {
|
|
107
|
-
title: { name: "Title",
|
|
108
|
-
type: "string" },
|
|
109
|
-
summary: { name: "Summary",
|
|
110
|
-
type: "string" },
|
|
111
|
-
tags: {
|
|
112
|
-
name: "Tags",
|
|
113
|
-
type: "array",
|
|
114
|
-
of: { name: "Tag",
|
|
115
|
-
type: "string" }
|
|
116
|
-
},
|
|
117
|
-
published_at: { name: "Published at",
|
|
118
|
-
type: "date" },
|
|
119
|
-
seo: {
|
|
120
|
-
name: "SEO",
|
|
121
|
-
type: "map",
|
|
122
|
-
properties: {
|
|
123
|
-
title: { name: "SEO title",
|
|
124
|
-
type: "string" },
|
|
125
|
-
description: { name: "SEO description",
|
|
126
|
-
type: "string" }
|
|
127
|
-
}
|
|
128
|
-
},
|
|
129
|
-
internal_notes: {
|
|
130
|
-
name: "Internal notes",
|
|
131
|
-
type: "string",
|
|
132
|
-
admin: { readOnly: true }
|
|
133
|
-
}
|
|
134
|
-
} as Properties;
|
|
135
|
-
|
|
136
|
-
/** A published post: five tags, a publication date, one genuinely empty field. */
|
|
137
|
-
const PUBLISHED_POST = {
|
|
138
|
-
title: "Hello",
|
|
139
|
-
tags: ["news", "launch"],
|
|
140
|
-
published_at: new Date("2026-01-01T00:00:00.000Z"),
|
|
141
|
-
seo: { title: "Hello — SEO" },
|
|
142
|
-
internal_notes: "written by a backend hook"
|
|
143
|
-
};
|
|
144
|
-
|
|
145
|
-
describe("the request the client sends", () => {
|
|
146
|
-
|
|
147
|
-
it("keys an array and a date exactly as the property map names them", () => {
|
|
148
|
-
const { properties, values } = requestFor(POST_PROPERTIES, PUBLISHED_POST);
|
|
149
|
-
|
|
150
|
-
expect(Object.keys(properties)).toEqual(expect.arrayContaining(["tags", "published_at"]));
|
|
151
|
-
expect(values.tags).toEqual(["news", "launch"]);
|
|
152
|
-
expect(values.published_at).toEqual(new Date("2026-01-01T00:00:00.000Z"));
|
|
153
|
-
// The whole key set, because the old shapes — `tags.0`, `tags.1`, and no
|
|
154
|
-
// `published_at` at all — are absences a per-key assertion would miss.
|
|
155
|
-
expect(Object.keys(values).sort()).toEqual(["published_at", "seo.title", "tags", "title"]);
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
it("still descends into a map, which is the one container with its own fields", () => {
|
|
159
|
-
const { properties, values } = requestFor(POST_PROPERTIES, PUBLISHED_POST);
|
|
160
|
-
expect(Object.keys(properties)).toContain("seo.title");
|
|
161
|
-
expect(values["seo.title"]).toBe("Hello — SEO");
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
it("offers the model only the fields that are actually empty", () => {
|
|
165
|
-
// The whole invariant, end to end. `tags` and `published_at` used to be
|
|
166
|
-
// in this list, so a populated tag list and a set publication date
|
|
167
|
-
// arrived in the review pre-ticked for replacement.
|
|
168
|
-
expect(plannedFillKeys(requestFor(POST_PROPERTIES, PUBLISHED_POST)))
|
|
169
|
-
.toEqual(["summary", "seo.description"]);
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
it("offers every field of an empty record", () => {
|
|
173
|
-
// The other direction: the agreement must not be bought by hiding
|
|
174
|
-
// fillable fields. `internal_notes` is absent because it is read-only,
|
|
175
|
-
// and `seo` because a map is a container, not a field.
|
|
176
|
-
expect(plannedFillKeys(requestFor(POST_PROPERTIES, {})))
|
|
177
|
-
.toEqual(["title", "summary", "tags", "published_at", "seo.title", "seo.description"]);
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
it("carries a value for every fillable property the record has filled", () => {
|
|
181
|
-
// The drift check, stated as a rule rather than a list: whatever a
|
|
182
|
-
// future property type does to either map, a value the operator can see
|
|
183
|
-
// in the form must reach the service under the key the service will
|
|
184
|
-
// look it up by.
|
|
185
|
-
const { properties, values } = requestFor(POST_PROPERTIES, PUBLISHED_POST);
|
|
186
|
-
const onTheWire = JSON.parse(JSON.stringify(values)) as Record<string, unknown>;
|
|
187
|
-
|
|
188
|
-
for (const [key, property] of Object.entries(properties)) {
|
|
189
|
-
if (!propertySchema(property)) continue; // not a field the service fills
|
|
190
|
-
if (!alreadyFilled(getValueInPath(PUBLISHED_POST, key))) continue;
|
|
191
|
-
expect({ key,
|
|
192
|
-
filled: alreadyFilled(onTheWire[key]) }).toEqual({ key,
|
|
193
|
-
filled: true });
|
|
194
|
-
}
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
it("does not transmit the value of a read-only property", () => {
|
|
198
|
-
// `disabled` already means "the service may not fill this". It has to
|
|
199
|
-
// mean "and it is not context either": the prompt includes every value
|
|
200
|
-
// it is given, so a field owned by a backend hook was being pasted into
|
|
201
|
-
// it verbatim.
|
|
202
|
-
const { values } = requestFor(POST_PROPERTIES, PUBLISHED_POST);
|
|
203
|
-
expect(values).not.toHaveProperty("internal_notes");
|
|
204
|
-
expect(JSON.stringify(values)).not.toContain("backend hook");
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
it("takes the children of a disabled map with it", () => {
|
|
208
|
-
const properties = {
|
|
209
|
-
audit: {
|
|
210
|
-
name: "Audit",
|
|
211
|
-
type: "map",
|
|
212
|
-
admin: { disabled: true },
|
|
213
|
-
properties: {
|
|
214
|
-
author: { name: "Author",
|
|
215
|
-
type: "string" }
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
} as Properties;
|
|
219
|
-
const { values } = requestFor(properties, { audit: { author: "root" } });
|
|
220
|
-
expect(values).toEqual({});
|
|
221
|
-
});
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
describe("omitDisabledValues", () => {
|
|
225
|
-
|
|
226
|
-
it("leaves a record alone when nothing is disabled", () => {
|
|
227
|
-
const values = { a: 1,
|
|
228
|
-
b: 2 };
|
|
229
|
-
expect(omitDisabledValues(values, { a: { type: "number",
|
|
230
|
-
fieldConfigId: "number_field" } })).toBe(values);
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
it("survives a property map carrying a non-object at a path", () => {
|
|
234
|
-
// `getSimplifiedProperty` writes a raw type string at
|
|
235
|
-
// `${path}.${i}.${typeField}` for `oneOf` arrays, and `"disabled" in
|
|
236
|
-
// aString` throws.
|
|
237
|
-
const properties = { "blocks.0.type": "images" } as unknown as Record<string, InputProperty>;
|
|
238
|
-
expect(() => omitDisabledValues({ "blocks.0.type": "images" }, properties)).not.toThrow();
|
|
239
|
-
});
|
|
240
|
-
});
|