@milaboratories/pl-middle-layer 1.66.18 → 1.67.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.
Files changed (130) hide show
  1. package/dist/block_registry/index.cjs +3 -0
  2. package/dist/block_registry/index.d.ts +2 -1
  3. package/dist/block_registry/index.js +2 -1
  4. package/dist/block_registry/location_provider.cjs +117 -0
  5. package/dist/block_registry/location_provider.cjs.map +1 -0
  6. package/dist/block_registry/location_provider.js +114 -0
  7. package/dist/block_registry/location_provider.js.map +1 -0
  8. package/dist/block_registry/registry.cjs +13 -0
  9. package/dist/block_registry/registry.cjs.map +1 -1
  10. package/dist/block_registry/registry.d.ts +14 -1
  11. package/dist/block_registry/registry.d.ts.map +1 -1
  12. package/dist/block_registry/registry.js +13 -0
  13. package/dist/block_registry/registry.js.map +1 -1
  14. package/dist/block_registry/template_provider.cjs +104 -0
  15. package/dist/block_registry/template_provider.cjs.map +1 -0
  16. package/dist/block_registry/template_provider.d.ts +62 -0
  17. package/dist/block_registry/template_provider.d.ts.map +1 -0
  18. package/dist/block_registry/template_provider.js +103 -0
  19. package/dist/block_registry/template_provider.js.map +1 -0
  20. package/dist/block_registry/watcher.cjs +3 -3
  21. package/dist/block_registry/watcher.js +2 -2
  22. package/dist/dev_env/util.js +2 -2
  23. package/dist/dev_env/util.js.map +1 -1
  24. package/dist/index.cjs +10 -0
  25. package/dist/index.d.ts +5 -1
  26. package/dist/index.js +5 -1
  27. package/dist/js_render/computable_context.cjs +1 -1
  28. package/dist/js_render/computable_context.js +1 -1
  29. package/dist/middle_layer/driver_kit.d.ts +1 -1
  30. package/dist/middle_layer/middle_layer.cjs +144 -2
  31. package/dist/middle_layer/middle_layer.cjs.map +1 -1
  32. package/dist/middle_layer/middle_layer.d.ts +67 -1
  33. package/dist/middle_layer/middle_layer.d.ts.map +1 -1
  34. package/dist/middle_layer/middle_layer.js +146 -4
  35. package/dist/middle_layer/middle_layer.js.map +1 -1
  36. package/dist/middle_layer/project.cjs +1 -0
  37. package/dist/middle_layer/project.cjs.map +1 -1
  38. package/dist/middle_layer/project.d.ts +2 -2
  39. package/dist/middle_layer/project.js +4 -4
  40. package/dist/middle_layer/project.js.map +1 -1
  41. package/dist/middle_layer/sharing_list.d.ts +1 -1
  42. package/dist/model/args.cjs +9 -28
  43. package/dist/model/args.cjs.map +1 -1
  44. package/dist/model/args.js +9 -28
  45. package/dist/model/args.js.map +1 -1
  46. package/dist/model/index.cjs +7 -0
  47. package/dist/model/index.d.ts +4 -1
  48. package/dist/model/index.js +4 -1
  49. package/dist/model/project_helper.cjs +111 -0
  50. package/dist/model/project_helper.cjs.map +1 -1
  51. package/dist/model/project_helper.d.ts +76 -1
  52. package/dist/model/project_helper.d.ts.map +1 -1
  53. package/dist/model/project_helper.js +111 -0
  54. package/dist/model/project_helper.js.map +1 -1
  55. package/dist/model/project_model.d.ts +1 -1
  56. package/dist/model/sharing_model.d.ts +1 -1
  57. package/dist/model/template_apply.cjs +67 -0
  58. package/dist/model/template_apply.cjs.map +1 -0
  59. package/dist/model/template_apply.d.ts +50 -0
  60. package/dist/model/template_apply.d.ts.map +1 -0
  61. package/dist/model/template_apply.js +64 -0
  62. package/dist/model/template_apply.js.map +1 -0
  63. package/dist/model/template_export.cjs +82 -0
  64. package/dist/model/template_export.cjs.map +1 -0
  65. package/dist/model/template_export.d.ts +9 -0
  66. package/dist/model/template_export.d.ts.map +1 -0
  67. package/dist/model/template_export.js +82 -0
  68. package/dist/model/template_export.js.map +1 -0
  69. package/dist/model/template_parser.cjs +74 -0
  70. package/dist/model/template_parser.cjs.map +1 -0
  71. package/dist/model/template_parser.d.ts +43 -0
  72. package/dist/model/template_parser.d.ts.map +1 -0
  73. package/dist/model/template_parser.js +72 -0
  74. package/dist/model/template_parser.js.map +1 -0
  75. package/dist/model/template_resolve.cjs +134 -0
  76. package/dist/model/template_resolve.cjs.map +1 -0
  77. package/dist/model/template_resolve.d.ts +193 -0
  78. package/dist/model/template_resolve.d.ts.map +1 -0
  79. package/dist/model/template_resolve.js +133 -0
  80. package/dist/model/template_resolve.js.map +1 -0
  81. package/dist/model/template_serializer.cjs +162 -0
  82. package/dist/model/template_serializer.cjs.map +1 -0
  83. package/dist/model/template_serializer.d.ts +17 -0
  84. package/dist/model/template_serializer.d.ts.map +1 -0
  85. package/dist/model/template_serializer.js +157 -0
  86. package/dist/model/template_serializer.js.map +1 -0
  87. package/dist/mutator/block-pack/block_pack.cjs +1 -1
  88. package/dist/mutator/block-pack/block_pack.js +1 -1
  89. package/dist/mutator/migration.cjs +1 -1
  90. package/dist/mutator/migration.js +1 -1
  91. package/dist/mutator/project.cjs +35 -5
  92. package/dist/mutator/project.cjs.map +1 -1
  93. package/dist/mutator/project.d.ts.map +1 -1
  94. package/dist/mutator/project.js +35 -5
  95. package/dist/mutator/project.js.map +1 -1
  96. package/dist/mutator/template_construct.cjs +77 -0
  97. package/dist/mutator/template_construct.cjs.map +1 -0
  98. package/dist/mutator/template_construct.js +77 -0
  99. package/dist/mutator/template_construct.js.map +1 -0
  100. package/dist/network_check/template.js +3 -3
  101. package/dist/network_check/template.js.map +1 -1
  102. package/package.json +17 -17
  103. package/src/block_registry/index.ts +1 -0
  104. package/src/block_registry/location_provider.test.ts +236 -0
  105. package/src/block_registry/location_provider.ts +124 -0
  106. package/src/block_registry/registry.ts +22 -0
  107. package/src/block_registry/template_provider.test.ts +275 -0
  108. package/src/block_registry/template_provider.ts +168 -0
  109. package/src/middle_layer/middle_layer.ts +192 -1
  110. package/src/middle_layer/project.ts +1 -1
  111. package/src/model/args.test.ts +26 -1
  112. package/src/model/args.ts +16 -31
  113. package/src/model/index.ts +21 -0
  114. package/src/model/project_helper.ts +238 -0
  115. package/src/model/project_helper_params_init.test.ts +249 -0
  116. package/src/model/template_apply.test.ts +65 -0
  117. package/src/model/template_apply.ts +128 -0
  118. package/src/model/template_export.test.ts +255 -0
  119. package/src/model/template_export.ts +136 -0
  120. package/src/model/template_parser.test.ts +264 -0
  121. package/src/model/template_parser.ts +108 -0
  122. package/src/model/template_resolve.test.ts +397 -0
  123. package/src/model/template_resolve.ts +346 -0
  124. package/src/model/template_serializer.test.ts +398 -0
  125. package/src/model/template_serializer.ts +232 -0
  126. package/src/model/template_serializer_fixtures.test.ts +274 -0
  127. package/src/mutator/project-v3.test.ts +11 -11
  128. package/src/mutator/project.ts +92 -5
  129. package/src/mutator/template_construct.test.ts +456 -0
  130. 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
+ });