@milaboratories/pl-middle-layer 1.66.19 → 1.67.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/block_registry/index.cjs +3 -0
- package/dist/block_registry/index.d.ts +2 -1
- package/dist/block_registry/index.js +2 -1
- package/dist/block_registry/location_provider.cjs +117 -0
- package/dist/block_registry/location_provider.cjs.map +1 -0
- package/dist/block_registry/location_provider.js +114 -0
- package/dist/block_registry/location_provider.js.map +1 -0
- package/dist/block_registry/registry.cjs +13 -0
- package/dist/block_registry/registry.cjs.map +1 -1
- package/dist/block_registry/registry.d.ts +14 -1
- package/dist/block_registry/registry.d.ts.map +1 -1
- package/dist/block_registry/registry.js +13 -0
- package/dist/block_registry/registry.js.map +1 -1
- package/dist/block_registry/template_provider.cjs +104 -0
- package/dist/block_registry/template_provider.cjs.map +1 -0
- package/dist/block_registry/template_provider.d.ts +62 -0
- package/dist/block_registry/template_provider.d.ts.map +1 -0
- package/dist/block_registry/template_provider.js +103 -0
- package/dist/block_registry/template_provider.js.map +1 -0
- package/dist/block_registry/watcher.cjs +3 -3
- package/dist/block_registry/watcher.js +2 -2
- package/dist/dev_env/util.js +2 -2
- package/dist/dev_env/util.js.map +1 -1
- package/dist/index.cjs +10 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +5 -1
- package/dist/js_render/computable_context.cjs +1 -1
- package/dist/js_render/computable_context.js +1 -1
- package/dist/middle_layer/driver_kit.d.ts +1 -1
- package/dist/middle_layer/middle_layer.cjs +144 -2
- package/dist/middle_layer/middle_layer.cjs.map +1 -1
- package/dist/middle_layer/middle_layer.d.ts +67 -1
- package/dist/middle_layer/middle_layer.d.ts.map +1 -1
- package/dist/middle_layer/middle_layer.js +146 -4
- package/dist/middle_layer/middle_layer.js.map +1 -1
- package/dist/middle_layer/project.cjs +1 -0
- package/dist/middle_layer/project.cjs.map +1 -1
- package/dist/middle_layer/project.d.ts +2 -2
- package/dist/middle_layer/project.js +4 -4
- package/dist/middle_layer/project.js.map +1 -1
- package/dist/middle_layer/sharing_list.d.ts +1 -1
- package/dist/model/args.cjs +9 -28
- package/dist/model/args.cjs.map +1 -1
- package/dist/model/args.js +9 -28
- package/dist/model/args.js.map +1 -1
- package/dist/model/index.cjs +7 -0
- package/dist/model/index.d.ts +4 -1
- package/dist/model/index.js +4 -1
- package/dist/model/project_helper.cjs +111 -0
- package/dist/model/project_helper.cjs.map +1 -1
- package/dist/model/project_helper.d.ts +76 -1
- package/dist/model/project_helper.d.ts.map +1 -1
- package/dist/model/project_helper.js +111 -0
- package/dist/model/project_helper.js.map +1 -1
- package/dist/model/project_model.d.ts +1 -1
- package/dist/model/sharing_model.d.ts +1 -1
- package/dist/model/template_apply.cjs +67 -0
- package/dist/model/template_apply.cjs.map +1 -0
- package/dist/model/template_apply.d.ts +50 -0
- package/dist/model/template_apply.d.ts.map +1 -0
- package/dist/model/template_apply.js +64 -0
- package/dist/model/template_apply.js.map +1 -0
- package/dist/model/template_export.cjs +82 -0
- package/dist/model/template_export.cjs.map +1 -0
- package/dist/model/template_export.d.ts +9 -0
- package/dist/model/template_export.d.ts.map +1 -0
- package/dist/model/template_export.js +82 -0
- package/dist/model/template_export.js.map +1 -0
- package/dist/model/template_parser.cjs +74 -0
- package/dist/model/template_parser.cjs.map +1 -0
- package/dist/model/template_parser.d.ts +43 -0
- package/dist/model/template_parser.d.ts.map +1 -0
- package/dist/model/template_parser.js +72 -0
- package/dist/model/template_parser.js.map +1 -0
- package/dist/model/template_resolve.cjs +134 -0
- package/dist/model/template_resolve.cjs.map +1 -0
- package/dist/model/template_resolve.d.ts +193 -0
- package/dist/model/template_resolve.d.ts.map +1 -0
- package/dist/model/template_resolve.js +133 -0
- package/dist/model/template_resolve.js.map +1 -0
- package/dist/model/template_serializer.cjs +162 -0
- package/dist/model/template_serializer.cjs.map +1 -0
- package/dist/model/template_serializer.d.ts +17 -0
- package/dist/model/template_serializer.d.ts.map +1 -0
- package/dist/model/template_serializer.js +157 -0
- package/dist/model/template_serializer.js.map +1 -0
- package/dist/mutator/block-pack/block_pack.cjs +1 -1
- package/dist/mutator/block-pack/block_pack.js +1 -1
- package/dist/mutator/migration.cjs +1 -1
- package/dist/mutator/migration.js +1 -1
- package/dist/mutator/project.cjs +35 -5
- package/dist/mutator/project.cjs.map +1 -1
- package/dist/mutator/project.d.ts.map +1 -1
- package/dist/mutator/project.js +35 -5
- package/dist/mutator/project.js.map +1 -1
- package/dist/mutator/template_construct.cjs +77 -0
- package/dist/mutator/template_construct.cjs.map +1 -0
- package/dist/mutator/template_construct.js +77 -0
- package/dist/mutator/template_construct.js.map +1 -0
- package/dist/network_check/template.js +3 -3
- package/dist/network_check/template.js.map +1 -1
- package/package.json +17 -17
- package/src/block_registry/index.ts +1 -0
- package/src/block_registry/location_provider.test.ts +236 -0
- package/src/block_registry/location_provider.ts +124 -0
- package/src/block_registry/registry.ts +22 -0
- package/src/block_registry/template_provider.test.ts +275 -0
- package/src/block_registry/template_provider.ts +168 -0
- package/src/middle_layer/middle_layer.ts +192 -1
- package/src/middle_layer/project.ts +1 -1
- package/src/model/args.test.ts +26 -1
- package/src/model/args.ts +16 -31
- package/src/model/index.ts +21 -0
- package/src/model/project_helper.ts +238 -0
- package/src/model/project_helper_params_init.test.ts +249 -0
- package/src/model/template_apply.test.ts +65 -0
- package/src/model/template_apply.ts +128 -0
- package/src/model/template_export.test.ts +255 -0
- package/src/model/template_export.ts +136 -0
- package/src/model/template_parser.test.ts +264 -0
- package/src/model/template_parser.ts +108 -0
- package/src/model/template_resolve.test.ts +397 -0
- package/src/model/template_resolve.ts +346 -0
- package/src/model/template_serializer.test.ts +398 -0
- package/src/model/template_serializer.ts +232 -0
- package/src/model/template_serializer_fixtures.test.ts +274 -0
- package/src/mutator/project-v3.test.ts +11 -11
- package/src/mutator/project.ts +92 -5
- package/src/mutator/template_construct.test.ts +456 -0
- package/src/mutator/template_construct.ts +150 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import canonicalize from "canonicalize";
|
|
3
|
+
import { createPlRef } from "@milaboratories/pl-model-common";
|
|
4
|
+
import type { ProjectStructure } from "./project_model";
|
|
5
|
+
import type { TemplateParamsResult } from "./template_export";
|
|
6
|
+
import { walkProjectForTemplateExport } from "./template_export";
|
|
7
|
+
|
|
8
|
+
function simpleStructure(...ids: string[]): ProjectStructure {
|
|
9
|
+
return {
|
|
10
|
+
groups: [
|
|
11
|
+
{
|
|
12
|
+
id: "g1",
|
|
13
|
+
label: "G1",
|
|
14
|
+
blocks: ids.map((id) => ({ id, label: id, renderingMode: "Heavy" })),
|
|
15
|
+
},
|
|
16
|
+
],
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A provider backed by a plain map; blocks absent from it yield `undefined`. */
|
|
21
|
+
function providerFrom(params: Record<string, TemplateParamsResult>) {
|
|
22
|
+
return (blockId: string): TemplateParamsResult | undefined => params[blockId];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const ok = (value: unknown): TemplateParamsResult => ({ value });
|
|
26
|
+
|
|
27
|
+
describe("order", () => {
|
|
28
|
+
test("entries come out in structure order — no sort, none needed", () => {
|
|
29
|
+
// The structure IS the topological order: a block can only legally reference
|
|
30
|
+
// blocks above it, so emitting in this order gives a template file the ordering
|
|
31
|
+
// it needs — every block after the blocks it references — for free.
|
|
32
|
+
const walk = walkProjectForTemplateExport(
|
|
33
|
+
simpleStructure("samples", "mixcr", "browser"),
|
|
34
|
+
providerFrom({
|
|
35
|
+
samples: ok({ dataset: "bulk-rna" }),
|
|
36
|
+
mixcr: ok({ input: { block: "samples", output: "reads" } }),
|
|
37
|
+
browser: ok({ clonotypes: { block: "mixcr", output: "clonotypes" } }),
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
expect(walk.problems).toEqual([]);
|
|
42
|
+
expect(walk.entries.map((e) => e.blockId)).toEqual(["samples", "mixcr", "browser"]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("structure order wins over reference order", () => {
|
|
46
|
+
// Same three blocks, structure reordered. The walk reports what the project
|
|
47
|
+
// says, so a structure that violates the ordering rule produces a file that
|
|
48
|
+
// reference validation rejects — it is not silently fixed here, because
|
|
49
|
+
// reordering would change which references are even legal.
|
|
50
|
+
const walk = walkProjectForTemplateExport(
|
|
51
|
+
simpleStructure("browser", "samples", "mixcr"),
|
|
52
|
+
providerFrom({ browser: ok({}), samples: ok({}), mixcr: ok({}) }),
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
expect(walk.entries.map((e) => e.blockId)).toEqual(["browser", "samples", "mixcr"]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("groups are flattened in order", () => {
|
|
59
|
+
const structure: ProjectStructure = {
|
|
60
|
+
groups: [
|
|
61
|
+
{ id: "g1", label: "G1", blocks: [{ id: "a", label: "a", renderingMode: "Heavy" }] },
|
|
62
|
+
{
|
|
63
|
+
id: "g2",
|
|
64
|
+
label: "G2",
|
|
65
|
+
blocks: [
|
|
66
|
+
{ id: "b", label: "b", renderingMode: "Heavy" },
|
|
67
|
+
{ id: "c", label: "c", renderingMode: "Heavy" },
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const walk = walkProjectForTemplateExport(
|
|
74
|
+
structure,
|
|
75
|
+
providerFrom({ a: ok({}), b: ok({}), c: ok({}) }),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
expect(walk.entries.map((e) => e.blockId)).toEqual(["a", "b", "c"]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("an empty project walks to an empty template", () => {
|
|
82
|
+
expect(walkProjectForTemplateExport(simpleStructure(), providerFrom({}))).toEqual({
|
|
83
|
+
entries: [],
|
|
84
|
+
problems: [],
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("collecting each block's descriptor output", () => {
|
|
90
|
+
test("params are carried through untouched", () => {
|
|
91
|
+
// The walk does not reshape params — references were already rewritten into
|
|
92
|
+
// template form on the SDK side, so there is nothing kind-specific to do here.
|
|
93
|
+
const params = {
|
|
94
|
+
species: "human",
|
|
95
|
+
input: { block: "samples", output: "reads" },
|
|
96
|
+
thresholds: [0.1, 0.2],
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const walk = walkProjectForTemplateExport(
|
|
100
|
+
simpleStructure("mixcr"),
|
|
101
|
+
providerFrom({ mixcr: ok(params) }),
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
expect(walk.entries).toEqual([{ blockId: "mixcr", params }]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("a block with nothing to project yields empty params", () => {
|
|
108
|
+
// Every block declares the lambda, so "no params at all" is not an outcome the walk
|
|
109
|
+
// can produce. A block whose state carries nothing worth restoring returns `{}`.
|
|
110
|
+
const walk = walkProjectForTemplateExport(
|
|
111
|
+
simpleStructure("pool-explorer"),
|
|
112
|
+
providerFrom({ "pool-explorer": ok({}) }),
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
expect(walk.entries).toEqual([{ blockId: "pool-explorer", params: {} }]);
|
|
116
|
+
expect(walk.problems).toEqual([]);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("what the walk does with params", () => {
|
|
121
|
+
const UUID_A = "3f1b8c2e-5d4a-4c9f-8b17-2a6e0d9f4c31";
|
|
122
|
+
const UUID_B = "9c7e4d10-2b83-4f6a-91d5-7e0c3a8b5f42";
|
|
123
|
+
|
|
124
|
+
/** A canonical global-leaf identifier, i.e. a reference held as a string. */
|
|
125
|
+
const leafId = (blockId: string, name: string) => canonicalize({ __isRef: true, blockId, name })!;
|
|
126
|
+
|
|
127
|
+
test("params are written exactly as the block projected them", () => {
|
|
128
|
+
// The walk parses nothing and rewrites nothing: a template-local entry id IS the block's
|
|
129
|
+
// own id, so a wrapped reference already names the right entry.
|
|
130
|
+
const params = { input: createPlRef(UUID_A, "reads"), species: "hsa" };
|
|
131
|
+
const walk = walkProjectForTemplateExport(
|
|
132
|
+
simpleStructure(UUID_A, UUID_B),
|
|
133
|
+
providerFrom({ [UUID_A]: ok({}), [UUID_B]: ok(params) }),
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
expect(walk.problems).toEqual([]);
|
|
137
|
+
expect(walk.entries[1].params).toBe(params);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("a non-UUID id passes through unchanged too", () => {
|
|
141
|
+
// `Project.addBlock` accepts an explicit id and only *defaults* it to a random UUID, so
|
|
142
|
+
// "UUID" describes the common case, not a constraint.
|
|
143
|
+
const walk = walkProjectForTemplateExport(
|
|
144
|
+
simpleStructure("block1"),
|
|
145
|
+
providerFrom({ block1: ok({}) }),
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
expect(walk.entries).toEqual([{ blockId: "block1", params: {} }]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("a wrapper's contents are never inspected, whatever they are", () => {
|
|
152
|
+
// An identifier as a string, one under escape padding, a whole nested structure — all the
|
|
153
|
+
// same to the walk, which is the property the wrapper exists to buy.
|
|
154
|
+
const params = {
|
|
155
|
+
asObject: createPlRef("a", "reads"),
|
|
156
|
+
asString: leafId("a", "clones"),
|
|
157
|
+
stringified: JSON.stringify(leafId("a", "clones")),
|
|
158
|
+
nested: { deeper: [[createPlRef("a", "x")]] },
|
|
159
|
+
};
|
|
160
|
+
const walk = walkProjectForTemplateExport(
|
|
161
|
+
simpleStructure("a", "b"),
|
|
162
|
+
providerFrom({ a: ok({}), b: ok(params) }),
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
expect(walk.problems).toEqual([]);
|
|
166
|
+
expect(walk.entries[1].params).toBe(params);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("a reference the block did not wrap is written out as data, not refused", () => {
|
|
170
|
+
// The engine exposes the wrapper mechanic and models nothing else, so it has no opinion
|
|
171
|
+
// about an unwrapped `PlRef` — it is a value like any other. Wrapping the right things is
|
|
172
|
+
// the block's statement to make, and getting it wrong yields a template that does not
|
|
173
|
+
// work, the same way a wrong field name would. Pinned so the boundary stays deliberate.
|
|
174
|
+
const params = { input: createPlRef("a", "reads") };
|
|
175
|
+
const walk = walkProjectForTemplateExport(
|
|
176
|
+
simpleStructure("a", "b"),
|
|
177
|
+
providerFrom({ a: ok({}), b: ok(params) }),
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
expect(walk.problems).toEqual([]);
|
|
181
|
+
expect(walk.entries[1].params).toBe(params);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("a reference to a block outside the project is written out too", () => {
|
|
185
|
+
// Same boundary from the other side: telling this apart would mean recognizing which
|
|
186
|
+
// strings are identifiers, which is exactly the knowledge the engine does not hold. It
|
|
187
|
+
// surfaces on apply, as a block wired to nothing.
|
|
188
|
+
const params = { input: createPlRef("deleted", "reads") };
|
|
189
|
+
const walk = walkProjectForTemplateExport(
|
|
190
|
+
simpleStructure("b"),
|
|
191
|
+
providerFrom({ b: ok(params) }),
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
expect(walk.problems).toEqual([]);
|
|
195
|
+
expect(walk.entries[0].params).toBe(params);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe("problems", () => {
|
|
200
|
+
test("a failed derivation is reported against its block, and the walk continues", () => {
|
|
201
|
+
const walk = walkProjectForTemplateExport(
|
|
202
|
+
simpleStructure("a", "b", "c"),
|
|
203
|
+
providerFrom({
|
|
204
|
+
a: ok({ x: 1 }),
|
|
205
|
+
b: { error: "templateParams() threw: not exportable yet" },
|
|
206
|
+
c: ok({ z: 3 }),
|
|
207
|
+
}),
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
// Every offending block is reported at once rather than aborting on the
|
|
211
|
+
// first, so the user fixes them in one pass.
|
|
212
|
+
expect(walk.problems).toEqual([
|
|
213
|
+
{ blockId: "b", error: "templateParams() threw: not exportable yet" },
|
|
214
|
+
]);
|
|
215
|
+
expect(walk.entries.map((e) => e.blockId)).toEqual(["a", "c"]);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("a block with unreadable state is a problem, not a silent skip", () => {
|
|
219
|
+
// `productionGraph` skips blocks it has no args for; export must not, because
|
|
220
|
+
// a template that quietly omits a block does not describe the project — and
|
|
221
|
+
// the surviving entries may still reference the omitted one.
|
|
222
|
+
const walk = walkProjectForTemplateExport(
|
|
223
|
+
simpleStructure("a", "ghost"),
|
|
224
|
+
providerFrom({ a: ok({}) }),
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
expect(walk.entries.map((e) => e.blockId)).toEqual(["a"]);
|
|
228
|
+
expect(walk.problems).toEqual([
|
|
229
|
+
{
|
|
230
|
+
blockId: "ghost",
|
|
231
|
+
error: "Block state is unavailable, so its template params could not be derived",
|
|
232
|
+
},
|
|
233
|
+
]);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test.each([
|
|
237
|
+
{ label: "a string", value: "not-params", expected: "a string" },
|
|
238
|
+
{ label: "a number", value: 42, expected: "a number" },
|
|
239
|
+
{ label: "null", value: null, expected: "null" },
|
|
240
|
+
{ label: "an array", value: [1, 2], expected: "an array" },
|
|
241
|
+
])("non-object params are rejected: $label", ({ value, expected }) => {
|
|
242
|
+
// An entry's `params` must be a mapping. A block kind carries its params type
|
|
243
|
+
// as a TypeScript type only, with no runtime schema, so nothing upstream
|
|
244
|
+
// enforces this — the walk is the only place that can catch it.
|
|
245
|
+
const walk = walkProjectForTemplateExport(
|
|
246
|
+
simpleStructure("odd"),
|
|
247
|
+
providerFrom({ odd: ok(value) }),
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
expect(walk.entries).toEqual([]);
|
|
251
|
+
expect(walk.problems).toEqual([
|
|
252
|
+
{ blockId: "odd", error: `templateParams() must return an object, got ${expected}` },
|
|
253
|
+
]);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type { ProjectStructure } from "./project_model";
|
|
2
|
+
import { allBlocks } from "./project_model_util";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One block's template-descriptor output as the walk receives it.
|
|
6
|
+
*
|
|
7
|
+
* Deliberately the same shape the `__pl_initializationParams_derive` facade callback
|
|
8
|
+
* returns, so a provider can hand the VM's result straight through without
|
|
9
|
+
* reshaping it.
|
|
10
|
+
*/
|
|
11
|
+
export type TemplateParamsResult =
|
|
12
|
+
| { readonly error: string }
|
|
13
|
+
| { readonly error?: undefined; readonly value: unknown };
|
|
14
|
+
|
|
15
|
+
/** One block's contribution to the template being exported. */
|
|
16
|
+
export type TemplateExportEntry = {
|
|
17
|
+
/**
|
|
18
|
+
* The block's project-local id, which is also its template-local id: a template
|
|
19
|
+
* has no id namespace of its own, so the id is reused verbatim and references
|
|
20
|
+
* already stored in params need no translation.
|
|
21
|
+
*/
|
|
22
|
+
readonly blockId: string;
|
|
23
|
+
/**
|
|
24
|
+
* The block's params exactly as it projected them.
|
|
25
|
+
*
|
|
26
|
+
* Always a mapping: a block that declared no `templateParams`, or whose lambda returned
|
|
27
|
+
* something else, is reported as a problem rather than carried here — see the object check
|
|
28
|
+
* in the walk. So nothing downstream has an absent case to decide.
|
|
29
|
+
*/
|
|
30
|
+
readonly params: Record<string, unknown>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Why one block could not be exported. */
|
|
34
|
+
export type TemplateExportProblem = {
|
|
35
|
+
readonly blockId: string;
|
|
36
|
+
readonly error: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Outcome of the walk: the blocks that can be written, and the ones that cannot.
|
|
41
|
+
*
|
|
42
|
+
* Both lists are returned rather than throwing on the first failure, so the
|
|
43
|
+
* caller can report every offending block at once instead of making the user fix
|
|
44
|
+
* them one export at a time. Whether a non-empty `problems` aborts the export is
|
|
45
|
+
* the caller's policy, not the walk's — but note that emitting `entries` while
|
|
46
|
+
* ignoring `problems` can produce a file whose surviving entries reference a
|
|
47
|
+
* dropped block, which is an unusable template.
|
|
48
|
+
*/
|
|
49
|
+
export type TemplateExportWalk = {
|
|
50
|
+
readonly entries: readonly TemplateExportEntry[];
|
|
51
|
+
readonly problems: readonly TemplateExportProblem[];
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Walk a project's blocks in dependency order, collecting each one's
|
|
56
|
+
* template-descriptor output.
|
|
57
|
+
*
|
|
58
|
+
* **No topological sort is performed, because none is needed.** The project
|
|
59
|
+
* structure is already stored in topological order, and that is enforced rather
|
|
60
|
+
* than assumed: `productionGraph` traverses `allBlocks(structure)` and passes the
|
|
61
|
+
* set of blocks seen *so far* as the allowed set to `inferAllReferencedBlocks`, so
|
|
62
|
+
* a reference to a block that is not already above is recorded as a missing
|
|
63
|
+
* reference instead of an upstream. A block can therefore only legally reference
|
|
64
|
+
* blocks earlier in this sequence — which is exactly what a template file needs,
|
|
65
|
+
* since its block order is the instantiation order and the engine creates blocks
|
|
66
|
+
* upstream-first. Emitting entries in structure order satisfies that for free.
|
|
67
|
+
*
|
|
68
|
+
* Groups are flattened in order, so cross-group ordering is the structure's too.
|
|
69
|
+
*
|
|
70
|
+
* A structure that violates the ordering rule is reported as-is, not repaired:
|
|
71
|
+
* reordering would change which references are legal in the first place.
|
|
72
|
+
*
|
|
73
|
+
* Params are written exactly as the block projected them. The walk parses nothing, rewrites
|
|
74
|
+
* nothing and inspects nothing inside them, and neither does anything else between here and the
|
|
75
|
+
* file — which values carry block ids is knowledge of the reference system, and a template
|
|
76
|
+
* engine holds none of it. The block that receives these params on the way back in is what
|
|
77
|
+
* recognizes them; a block that projects the wrong fields produces a template that does not
|
|
78
|
+
* work, the same way one whose `templateParams` returns the wrong shape does.
|
|
79
|
+
*
|
|
80
|
+
* @param structure The project structure — the source of both membership and order
|
|
81
|
+
* @param paramsProvider Yields a block's derived template params. Return
|
|
82
|
+
* `undefined` for a block whose state cannot be read at all; such a block is
|
|
83
|
+
* recorded as a problem rather than skipped, because a template that quietly
|
|
84
|
+
* omits a block does not describe the project it was exported from, and the
|
|
85
|
+
* surviving entries may still reference the omitted one.
|
|
86
|
+
*/
|
|
87
|
+
export function walkProjectForTemplateExport(
|
|
88
|
+
structure: ProjectStructure,
|
|
89
|
+
paramsProvider: (blockId: string) => TemplateParamsResult | undefined,
|
|
90
|
+
): TemplateExportWalk {
|
|
91
|
+
const entries: TemplateExportEntry[] = [];
|
|
92
|
+
const problems: TemplateExportProblem[] = [];
|
|
93
|
+
|
|
94
|
+
for (const { id } of allBlocks(structure)) {
|
|
95
|
+
const derived = paramsProvider(id);
|
|
96
|
+
|
|
97
|
+
if (derived === undefined) {
|
|
98
|
+
problems.push({
|
|
99
|
+
blockId: id,
|
|
100
|
+
error: "Block state is unavailable, so its template params could not be derived",
|
|
101
|
+
});
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (derived.error !== undefined) {
|
|
106
|
+
problems.push({ blockId: id, error: derived.error });
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const params = derived.value;
|
|
111
|
+
|
|
112
|
+
// An entry's `params` must be a mapping. The lambda's declared return type is the
|
|
113
|
+
// block kind's params type, and the kind's parser checks values coming IN, but
|
|
114
|
+
// nothing checks what the lambda hands back on the way out — so a block whose
|
|
115
|
+
// params type is a primitive or a tuple compiles fine and would produce an
|
|
116
|
+
// unwritable entry. This is the only place that can catch it.
|
|
117
|
+
if (typeof params !== "object" || params === null || Array.isArray(params)) {
|
|
118
|
+
problems.push({
|
|
119
|
+
blockId: id,
|
|
120
|
+
error: `templateParams() must return an object, got ${typeName(params)}`,
|
|
121
|
+
});
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
entries.push({ blockId: id, params: params as Record<string, unknown> });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return { entries, problems };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Name the offending value's type for an error message, without printing the value. */
|
|
132
|
+
function typeName(value: unknown): string {
|
|
133
|
+
if (value === null) return "null";
|
|
134
|
+
if (Array.isArray(value)) return "an array";
|
|
135
|
+
return `a ${typeof value}`;
|
|
136
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { describe, expect, test } from "vitest";
|
|
4
|
+
import { PROJECT_TEMPLATE_SCHEMA_V1 } from "@milaboratories/pl-model-common";
|
|
5
|
+
import { parseProjectTemplateV1Yaml } from "./template_parser";
|
|
6
|
+
import { stringifyProjectTemplateV1 } from "./template_serializer";
|
|
7
|
+
|
|
8
|
+
const FIXTURE_DIR = join(import.meta.dirname, "..", "..", "test_fixtures", "template-v1");
|
|
9
|
+
|
|
10
|
+
const KIND = "@platforma-open/milaboratories.demo.kind@^1.0.0";
|
|
11
|
+
|
|
12
|
+
/** The document, or a test failure naming what went wrong instead. */
|
|
13
|
+
function documentOf(text: string) {
|
|
14
|
+
const outcome = parseProjectTemplateV1Yaml(text);
|
|
15
|
+
if (!outcome.ok) throw new Error(`expected a document, got: ${outcome.error}`);
|
|
16
|
+
return outcome.document;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The error message, or a test failure if the text parsed after all. */
|
|
20
|
+
function errorOf(text: string): string {
|
|
21
|
+
const outcome = parseProjectTemplateV1Yaml(text);
|
|
22
|
+
if (outcome.ok) throw new Error("expected a failure, but the text parsed");
|
|
23
|
+
return outcome.error;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("parseProjectTemplateV1Yaml", () => {
|
|
27
|
+
test("reads a file into the document import applies", () => {
|
|
28
|
+
const document = documentOf(
|
|
29
|
+
[
|
|
30
|
+
`schema: ${PROJECT_TEMPLATE_SCHEMA_V1}`,
|
|
31
|
+
"blocks:",
|
|
32
|
+
" - id: first",
|
|
33
|
+
` kind: "${KIND}"`,
|
|
34
|
+
" params:",
|
|
35
|
+
" dataset: bulk-rna",
|
|
36
|
+
" - id: second",
|
|
37
|
+
` kind: "${KIND}"`,
|
|
38
|
+
" params:",
|
|
39
|
+
" input:",
|
|
40
|
+
" __isRef: true",
|
|
41
|
+
" blockId: first",
|
|
42
|
+
" name: reads",
|
|
43
|
+
" readable:",
|
|
44
|
+
" block: first",
|
|
45
|
+
" name: reads",
|
|
46
|
+
"",
|
|
47
|
+
].join("\n"),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
expect(document.blocks).toEqual([
|
|
51
|
+
{ id: "first", kind: KIND, params: { dataset: "bulk-rna" } },
|
|
52
|
+
{
|
|
53
|
+
id: "second",
|
|
54
|
+
kind: KIND,
|
|
55
|
+
// Carried verbatim, both spellings alike: the parser recognizes neither as a
|
|
56
|
+
// reference, because recognizing one is the receiving block's business.
|
|
57
|
+
params: {
|
|
58
|
+
input: { __isRef: true, blockId: "first", name: "reads" },
|
|
59
|
+
readable: { block: "first", name: "reads" },
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("an entry without params reads as empty params", () => {
|
|
66
|
+
// The one thing the parser fills in. Leaving the key absent would make every reader
|
|
67
|
+
// downstream normalize it, and one of them would eventually forget.
|
|
68
|
+
const [entry] = documentOf(
|
|
69
|
+
`schema: ${PROJECT_TEMPLATE_SCHEMA_V1}\nblocks:\n - id: a\n kind: "${KIND}"\n`,
|
|
70
|
+
).blocks;
|
|
71
|
+
|
|
72
|
+
expect(entry.params).toEqual({});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("JSON is accepted, being valid YAML", () => {
|
|
76
|
+
// Not a feature to advertise, but a file someone hand-wrote as JSON should not be
|
|
77
|
+
// rejected for a reason that has nothing to do with its contents.
|
|
78
|
+
const document = documentOf(
|
|
79
|
+
JSON.stringify({
|
|
80
|
+
schema: PROJECT_TEMPLATE_SCHEMA_V1,
|
|
81
|
+
blocks: [{ id: "a", kind: KIND }],
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
expect(document.blocks).toHaveLength(1);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("every golden export file reads back", () => {
|
|
89
|
+
// The other side of the round trip, checked against the files the export tests
|
|
90
|
+
// pin: whatever export writes, this reads. A fixture that stopped parsing here
|
|
91
|
+
// would mean the two halves had drifted apart.
|
|
92
|
+
const files = readdirSync(FIXTURE_DIR).filter((f) => f.endsWith(".yaml"));
|
|
93
|
+
|
|
94
|
+
expect(files.length).toBeGreaterThan(0);
|
|
95
|
+
for (const file of files) {
|
|
96
|
+
const text = readFileSync(join(FIXTURE_DIR, file), "utf-8");
|
|
97
|
+
const outcome = parseProjectTemplateV1Yaml(text);
|
|
98
|
+
|
|
99
|
+
expect(outcome.ok, `${file}: ${outcome.ok ? "" : outcome.error}`).toBe(true);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("a document survives being written and read again", () => {
|
|
104
|
+
// Text → document → text → document. Pins the two text layers against each other
|
|
105
|
+
// rather than against a fixture, so a change to either shows up here.
|
|
106
|
+
const original = documentOf(
|
|
107
|
+
`schema: ${PROJECT_TEMPLATE_SCHEMA_V1}\nblocks:\n - id: a\n kind: "${KIND}"\n params:\n n: 1\n`,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
expect(documentOf(stringifyProjectTemplateV1(original))).toEqual(original);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("what a broken file says", () => {
|
|
115
|
+
test("an empty file is called empty", () => {
|
|
116
|
+
// Blank input, whitespace and comment-only files all parse to null, where the
|
|
117
|
+
// schema would report a type mismatch instead of the actual mistake.
|
|
118
|
+
expect(errorOf("")).toBe("The file is empty.");
|
|
119
|
+
expect(errorOf(" \n\n")).toBe("The file is empty.");
|
|
120
|
+
expect(errorOf("# just a comment\n")).toBe("The file is empty.");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("a YAML syntax error is located, without a code frame", () => {
|
|
124
|
+
// The position is what a person needs; the frame duplicates the editor they are
|
|
125
|
+
// already looking at.
|
|
126
|
+
const error = errorOf(
|
|
127
|
+
`schema: ${PROJECT_TEMPLATE_SCHEMA_V1}\nblocks:\n - id: a\n kind: x\n`,
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
expect(error).toContain("not valid YAML");
|
|
131
|
+
expect(error).toContain("line 4");
|
|
132
|
+
expect(error).not.toContain("\n");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("a repeated key is reported rather than silently dropped", () => {
|
|
136
|
+
// A realistic hand-editing mistake: copy an entry, forget to change a field. YAML
|
|
137
|
+
// would otherwise keep one of the two and the file would apply, wrongly.
|
|
138
|
+
const error = errorOf(
|
|
139
|
+
`schema: ${PROJECT_TEMPLATE_SCHEMA_V1}\nblocks:\n - id: a\n id: b\n kind: "${KIND}"\n`,
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
expect(error).toContain("not valid YAML");
|
|
143
|
+
expect(error).toMatch(/keys must be unique/i);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("tabs are reported as such", () => {
|
|
147
|
+
// Tab indentation is invalid YAML and the failure is otherwise baffling.
|
|
148
|
+
expect(errorOf("schema: x\n\tblocks: []\n")).toMatch(/tabs/i);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("something that is not a mapping says what was expected", () => {
|
|
152
|
+
expect(errorOf("a plain string\n")).toContain("expected a mapping");
|
|
153
|
+
expect(errorOf("- a\n- b\n")).toContain("expected a mapping");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("the wrong kind of file is named as such, not as a type error", () => {
|
|
157
|
+
// The likeliest mistake of all — a file picker was pointed at the wrong file — so
|
|
158
|
+
// it gets its own message rather than the schema's literal-mismatch wording.
|
|
159
|
+
expect(errorOf("schema: template-v2\nblocks: []\n")).toBe(
|
|
160
|
+
`This file says it is 'template-v2', not ${PROJECT_TEMPLATE_SCHEMA_V1}.`,
|
|
161
|
+
);
|
|
162
|
+
expect(errorOf("blocks: []\n")).toBe(
|
|
163
|
+
`This file has no 'schema' field, so it is not a ${PROJECT_TEMPLATE_SCHEMA_V1} template.`,
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("a schema problem is located the way the file is written", () => {
|
|
168
|
+
// `blocks[0].kind`, not the parser's `blocks.0.kind`: the reader finds it by
|
|
169
|
+
// reading, not by counting.
|
|
170
|
+
const error = errorOf(`schema: ${PROJECT_TEMPLATE_SCHEMA_V1}\nblocks:\n - id: a\n`);
|
|
171
|
+
|
|
172
|
+
expect(error).toContain("- blocks[0].kind:");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("every schema problem is listed, and counted", () => {
|
|
176
|
+
// One pass to fix the file, not one pass per problem.
|
|
177
|
+
const error = errorOf(
|
|
178
|
+
[`schema: ${PROJECT_TEMPLATE_SCHEMA_V1}`, "blocks:", " - id: a", " - id: b", ""].join("\n"),
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
expect(error).toContain("2 problems:");
|
|
182
|
+
expect(error).toContain("- blocks[0].kind:");
|
|
183
|
+
expect(error).toContain("- blocks[1].kind:");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("a malformed kind reference explains the grammar", () => {
|
|
187
|
+
const error = errorOf(
|
|
188
|
+
`schema: ${PROJECT_TEMPLATE_SCHEMA_V1}\nblocks:\n - id: a\n kind: no-version\n`,
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
expect(error).toContain("blocks[0].kind:");
|
|
192
|
+
expect(error).toContain("{name}@{selector}");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("two entries with the same id are rejected", () => {
|
|
196
|
+
// Template-local ids are what references name, so a duplicate makes a reference
|
|
197
|
+
// ambiguous — and the apply would map one id to two blocks.
|
|
198
|
+
const error = errorOf(
|
|
199
|
+
[
|
|
200
|
+
`schema: ${PROJECT_TEMPLATE_SCHEMA_V1}`,
|
|
201
|
+
"blocks:",
|
|
202
|
+
" - id: a",
|
|
203
|
+
` kind: "${KIND}"`,
|
|
204
|
+
" - id: a",
|
|
205
|
+
` kind: "${KIND}"`,
|
|
206
|
+
"",
|
|
207
|
+
].join("\n"),
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
expect(error).toContain("Duplicate template-local id: a");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("an unknown field is refused, not ignored", () => {
|
|
214
|
+
// A misspelled key that was silently dropped would apply a file that does not say
|
|
215
|
+
// what the author meant.
|
|
216
|
+
const error = errorOf(
|
|
217
|
+
`schema: ${PROJECT_TEMPLATE_SCHEMA_V1}\nblocks:\n - id: a\n kind: "${KIND}"\n parms: {}\n`,
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
expect(error).toMatch(/Unrecognized key/);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe("scalars in params", () => {
|
|
225
|
+
test("what looks like a string stays a string", () => {
|
|
226
|
+
// Read as YAML 1.2 on purpose. Under 1.1 — PyYAML's default — a bare `yes` is
|
|
227
|
+
// `true` and `1:30` is `90`, so a hand-written file would silently change meaning
|
|
228
|
+
// depending on who wrote it. The export side quotes these anyway, which is the
|
|
229
|
+
// other half of the same decision.
|
|
230
|
+
const [entry] = documentOf(
|
|
231
|
+
[
|
|
232
|
+
`schema: ${PROJECT_TEMPLATE_SCHEMA_V1}`,
|
|
233
|
+
"blocks:",
|
|
234
|
+
" - id: a",
|
|
235
|
+
` kind: "${KIND}"`,
|
|
236
|
+
" params:",
|
|
237
|
+
" answer: yes",
|
|
238
|
+
" duration: 1:30",
|
|
239
|
+
"",
|
|
240
|
+
].join("\n"),
|
|
241
|
+
).blocks;
|
|
242
|
+
|
|
243
|
+
expect(entry.params).toEqual({ answer: "yes", duration: "1:30" });
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("numbers and booleans written as such stay themselves", () => {
|
|
247
|
+
const [entry] = documentOf(
|
|
248
|
+
[
|
|
249
|
+
`schema: ${PROJECT_TEMPLATE_SCHEMA_V1}`,
|
|
250
|
+
"blocks:",
|
|
251
|
+
" - id: a",
|
|
252
|
+
` kind: "${KIND}"`,
|
|
253
|
+
" params:",
|
|
254
|
+
" count: 42",
|
|
255
|
+
" ratio: 0.5",
|
|
256
|
+
" enabled: true",
|
|
257
|
+
" missing: null",
|
|
258
|
+
"",
|
|
259
|
+
].join("\n"),
|
|
260
|
+
).blocks;
|
|
261
|
+
|
|
262
|
+
expect(entry.params).toEqual({ count: 42, ratio: 0.5, enabled: true, missing: null });
|
|
263
|
+
});
|
|
264
|
+
});
|