@particle-academy/fancy-conformance 0.6.0 → 0.9.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/VERSION +1 -1
- package/package.json +2 -2
- package/suites/dark-slide/table-cell-model/cases.json +1912 -0
- package/suites/dark-slide/table-cell-model/manifest.json +35 -0
- package/suites/flow/subflow-registry/cases.json +126 -0
- package/suites/flow/subflow-registry/manifest.json +28 -0
- package/suites/flow/workflow-props/cases.json +226 -0
- package/suites/flow/workflow-props/manifest.json +28 -0
- package/suites/last-word/docx-constructs/cases.json +5785 -0
- package/suites/last-word/docx-constructs/manifest.json +38 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../../schema/suite-manifest.schema.json",
|
|
3
|
+
"suite": "dark-slide/table-cell-model",
|
|
4
|
+
"title": "How a loose agent-authored table element resolves into per-cell decisions",
|
|
5
|
+
"since": "0.7.0",
|
|
6
|
+
"caseFormat": "table",
|
|
7
|
+
"cases": "cases.json",
|
|
8
|
+
"contract": {
|
|
9
|
+
"summary": "DarkSlide's three engines (PHP, Node, Python) turn one JSON table element into a pptx table. Between the two sits a pure resolver that decides, per CELL, every value the writer then serialises: fill, text colour and weight, alignment, vertical anchor, font size, letter spacing, capitalisation, the four insets, the four borders, and the two span directions. This suite pins that RESOLVER, not the XML. It exists because per-cell control was unreachable from the model in every engine — a:tcPr carried a fill and nothing else, header fill and zebra were hardcoded constants, and a declared column width was thrown away and replaced with an equal split. The resolver is where a port silently diverges, because two engines can emit well-formed XML from different decisions and no byte comparison of DIFFERENT inputs will notice.",
|
|
10
|
+
"functions": {
|
|
11
|
+
"resolvedCell": "resolvedCell(element, theme, row, col) -> the fully-resolved cell at that grid position. `row` counts the HEADER as row 0 when the table has one, so it addresses the emitted grid rather than the input's `rows` array.",
|
|
12
|
+
"gridWidthsEmu": "gridWidthsEmu(element, totalEmu) -> the <a:gridCol w> values, in order, as strings."
|
|
13
|
+
},
|
|
14
|
+
"runShape": {
|
|
15
|
+
"everyValueIsAString": "No golden in this file is a number. Fractional quantities are converted to the INTEGER unit the writer emits — font size and letter spacing in hundredths of a point, padding and border width in EMU — and then stringified. That removes float comparison from the suite entirely, which matters because the four fancy-conformance loaders do NOT agree on how a float golden is compared (PHP, Python and Rust use a scaled 1e-12 epsilon; the TypeScript loader uses exact Object.is). A suite whose verdict depends on which loader ran it is not a conformance suite.",
|
|
16
|
+
"resolvedCell": "{ text, bold, italic, underline, color, fill, align, anchor, fontSizeHundredths, letterSpacingHundredths, caps, padding: {left,right,top,bottom}, borders: {left,right,top,bottom}, colSpan, rowSpan, merged }. Booleans are \"1\" / \"0\". `fill` is null when the cell has none. Each border is null when the cell has none on that side, otherwise { widthEmu, color, style }.",
|
|
17
|
+
"gridWidthsEmu": "An ordered array of strings."
|
|
18
|
+
},
|
|
19
|
+
"reference": "php",
|
|
20
|
+
"referenceNote": "particle-academy/dark-slide is the reference: it shipped first and the trio's contract is 'the same document whichever backend runs it'. Every golden here is the output of RUNNING that engine, never what the value obviously ought to be — the width rows in particular are the accumulate-and-difference result rather than an independent division, and those differ.",
|
|
21
|
+
"implementations": [
|
|
22
|
+
{ "language": "php", "package": "particle-academy/dark-slide", "symbol": "Table\\TableResolver::resolve" },
|
|
23
|
+
{ "language": "node", "package": "@particle-academy/dark-slide", "symbol": "table/table-resolver.ts TableResolver.resolve" },
|
|
24
|
+
{ "language": "python", "package": "fancy-dark-slide", "symbol": "dark_slide.table.table_resolver.resolve" }
|
|
25
|
+
]
|
|
26
|
+
},
|
|
27
|
+
"notes": [
|
|
28
|
+
"PRECEDENCE IS THE POINT, and it is the half a byte comparison cannot reach. The chain is cell > row > column > band (header|stripe|body) > table > theme > default, and the rows here walk it one layer at a time (0009 through 0013) so a port that collapses two layers fails a NAMED case instead of shifting a colour somewhere in a large document. Byte parity across the three engines on a whole reference deck already covers the emission; it covers exactly ONE path through this chain.",
|
|
29
|
+
"AN ABSENT KEY AND A FALSE KEY ARE DIFFERENT, and conflating them is the most likely port bug in the whole model. Absent falls through to the next layer; `false` STOPS the chain and means off. `borders: false` on a table with a header must therefore beat the header band's own borders, and a row that sets `borders: {bottom: false}` must keep the other three sides. Cases 0005, 0006 and 0016 pin all three directions; a port written with `??` / `or` / `.get(k, default)` passes the first and fails the others.",
|
|
30
|
+
"COLUMN WIDTHS ARE TWO MODES CHOSEN BY THE VALUES. Every declared width <= 1 makes them FRACTIONS of the table and undeclared columns share the remainder; any declared width > 1 makes them WEIGHTS and undeclared columns weigh 1. No width anywhere is an equal split. Case 0018 pins the boundary at exactly 1.0, which is the value that decides the mode.",
|
|
31
|
+
"THE WIDTHS MUST SUM TO THE TABLE EXACTLY, so they are accumulated and differenced rather than each rounded independently. Case 0017's goldens are NOT totalEmu * frac rounded per column — 0019 exists specifically because independent rounding gives a different, plausible-looking answer that leaves the grid a few EMU off the frame.",
|
|
32
|
+
"SPANS ARE CLAMPED TO THE GRID, never emitted as written. A colSpan of 99 on a two-column table is a 2 (case 0020). A row with fewer <a:tc> than the grid declares is a corrupt file rather than a narrow table, so the covered cells are still emitted, carrying `merged` and an empty text.",
|
|
33
|
+
"THE SIBLING SUITE IS last-word/docx-constructs, and the two packages fail on the SAME axis — per-cell borders, insets, vertical alignment, merging — because both models were narrower than the XML their writers already emitted. That suite pins WordprocessingML output; this one pins a resolver. They are deliberately not the same shape: the XML cannot be shared (w:tcBorders / w:tcMar / w:vAlign against a:lnL..lnB / marL..marB / @anchor) but the DECISIONS can, and this file is expressed in points-and-hex for that reason. Promoting it to `shared/` is the right move the day last-word resolves through the same model; declaring it shared before then would list an implementation that does not run it, which is the failure this repository exists to prevent."
|
|
34
|
+
]
|
|
35
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../../schema/cases.schema.json",
|
|
3
|
+
"suite": "flow/subflow-registry",
|
|
4
|
+
"cases": [
|
|
5
|
+
{
|
|
6
|
+
"id": "0001-host-kind-survives-nesting",
|
|
7
|
+
"title": "A kind the HOST registered is still resolvable inside the child.",
|
|
8
|
+
"since": "0.8.0",
|
|
9
|
+
"tags": ["inheritance", "regression"],
|
|
10
|
+
"input": {
|
|
11
|
+
"parent": { "@particle-academy/output": "builtin", "host_kind": "host" },
|
|
12
|
+
"injected": null,
|
|
13
|
+
"graphConfig": null
|
|
14
|
+
},
|
|
15
|
+
"expected": { "@particle-academy/output": "builtin", "host_kind": "host" }
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"id": "0002-bare-builtins-are-not-enough",
|
|
19
|
+
"title": "A parent carrying several host kinds passes all of them down, not just the builtins.",
|
|
20
|
+
"since": "0.8.0",
|
|
21
|
+
"tags": ["inheritance", "regression"],
|
|
22
|
+
"input": {
|
|
23
|
+
"parent": {
|
|
24
|
+
"@particle-academy/output": "builtin",
|
|
25
|
+
"@particle-academy/transform": "builtin",
|
|
26
|
+
"agent": "host",
|
|
27
|
+
"invoice_lookup": "host",
|
|
28
|
+
"tenant_notify": "host"
|
|
29
|
+
},
|
|
30
|
+
"injected": null,
|
|
31
|
+
"graphConfig": null
|
|
32
|
+
},
|
|
33
|
+
"expected": {
|
|
34
|
+
"@particle-academy/output": "builtin",
|
|
35
|
+
"@particle-academy/transform": "builtin",
|
|
36
|
+
"agent": "host",
|
|
37
|
+
"invoice_lookup": "host",
|
|
38
|
+
"tenant_notify": "host"
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"id": "0003-host-override-of-a-builtin-reaches-the-child",
|
|
43
|
+
"title": "A host that REPLACED a builtin keeps its own version inside the child.",
|
|
44
|
+
"since": "0.8.0",
|
|
45
|
+
"tags": ["inheritance", "override", "regression"],
|
|
46
|
+
"input": {
|
|
47
|
+
"parent": { "@particle-academy/llm_call": "host", "@particle-academy/output": "builtin" },
|
|
48
|
+
"injected": null,
|
|
49
|
+
"graphConfig": null
|
|
50
|
+
},
|
|
51
|
+
"expected": { "@particle-academy/llm_call": "host", "@particle-academy/output": "builtin" }
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"id": "0004-graph-config-adds-to-the-inherited-set",
|
|
55
|
+
"title": "A registry the graph declares on the node is layered ON TOP of the inherited one.",
|
|
56
|
+
"since": "0.8.0",
|
|
57
|
+
"tags": ["config"],
|
|
58
|
+
"input": {
|
|
59
|
+
"parent": { "@particle-academy/output": "builtin", "host_kind": "host" },
|
|
60
|
+
"injected": null,
|
|
61
|
+
"graphConfig": { "child_only": "child-specific" }
|
|
62
|
+
},
|
|
63
|
+
"expected": {
|
|
64
|
+
"@particle-academy/output": "builtin",
|
|
65
|
+
"host_kind": "host",
|
|
66
|
+
"child_only": "child-specific"
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"id": "0005-graph-config-wins-on-a-conflict",
|
|
71
|
+
"title": "An executor the graph names for its child beats the inherited one of the same kind.",
|
|
72
|
+
"since": "0.8.0",
|
|
73
|
+
"tags": ["config", "precedence"],
|
|
74
|
+
"input": {
|
|
75
|
+
"parent": { "@particle-academy/output": "builtin", "host_kind": "host" },
|
|
76
|
+
"injected": null,
|
|
77
|
+
"graphConfig": { "host_kind": "child-specific" }
|
|
78
|
+
},
|
|
79
|
+
"expected": { "@particle-academy/output": "builtin", "host_kind": "child-specific" }
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
"id": "0006-an-injected-registry-replaces-the-inherited-one",
|
|
83
|
+
"title": "A registry handed to the executor at construction is used INSTEAD of the parent's.",
|
|
84
|
+
"since": "0.8.0",
|
|
85
|
+
"tags": ["injection", "precedence"],
|
|
86
|
+
"input": {
|
|
87
|
+
"parent": { "@particle-academy/output": "builtin", "host_kind": "host" },
|
|
88
|
+
"injected": { "@particle-academy/output": "builtin", "sandboxed_kind": "host" },
|
|
89
|
+
"graphConfig": null
|
|
90
|
+
},
|
|
91
|
+
"expected": { "@particle-academy/output": "builtin", "sandboxed_kind": "host" }
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
"id": "0007-graph-config-still-layers-over-an-injected-registry",
|
|
95
|
+
"title": "Injection replaces the inherited registry; the graph's own still layers on top of that.",
|
|
96
|
+
"since": "0.8.0",
|
|
97
|
+
"tags": ["injection", "config", "precedence"],
|
|
98
|
+
"input": {
|
|
99
|
+
"parent": { "host_kind": "host" },
|
|
100
|
+
"injected": { "@particle-academy/output": "builtin" },
|
|
101
|
+
"graphConfig": { "child_only": "child-specific" }
|
|
102
|
+
},
|
|
103
|
+
"expected": { "@particle-academy/output": "builtin", "child_only": "child-specific" }
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
"id": "0008-an-empty-graph-config-changes-nothing",
|
|
107
|
+
"title": "An empty config registry is not the same as an absent one, and neither empties the child.",
|
|
108
|
+
"since": "0.8.0",
|
|
109
|
+
"tags": ["config", "edge"],
|
|
110
|
+
"input": {
|
|
111
|
+
"parent": { "@particle-academy/output": "builtin", "host_kind": "host" },
|
|
112
|
+
"injected": null,
|
|
113
|
+
"graphConfig": {}
|
|
114
|
+
},
|
|
115
|
+
"expected": { "@particle-academy/output": "builtin", "host_kind": "host" }
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
"id": "0009-an-empty-parent-yields-an-empty-child",
|
|
119
|
+
"title": "Inheritance copies what is there; it never invents builtins the parent did not have.",
|
|
120
|
+
"since": "0.8.0",
|
|
121
|
+
"tags": ["edge"],
|
|
122
|
+
"input": { "parent": {}, "injected": null, "graphConfig": null },
|
|
123
|
+
"expected": {}
|
|
124
|
+
}
|
|
125
|
+
]
|
|
126
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../../schema/suite-manifest.schema.json",
|
|
3
|
+
"suite": "flow/subflow-registry",
|
|
4
|
+
"title": "What executors a subflow's child runs against",
|
|
5
|
+
"since": "0.8.0",
|
|
6
|
+
"caseFormat": "table",
|
|
7
|
+
"cases": "cases.json",
|
|
8
|
+
"contract": {
|
|
9
|
+
"function": "childRegistry(parent: object, injected: object|null, graphConfig: object|null) -> object",
|
|
10
|
+
"summary": "Compose the executor registry a `subflow` node runs its CHILD graph against. `parent` is the registry the parent run is executing with, keyed by kind id, whose values stand in for executors. `injected` is a registry handed to the subflow executor at construction, or null. `graphConfig` is the registry the graph itself declares on the subflow node's `config.executors`, or null. The result is the registry the child must resolve against, keyed by kind id.",
|
|
11
|
+
"reference": "node",
|
|
12
|
+
"referenceNote": "Designed here rather than lifted from an implementation, because at the time this suite was written all four runtimes were WRONG in the same way and none could serve as the reference. `node` is recorded as the reference because @particle-academy/fancy-flow's run-flow.ts is the behavioural source of truth for the engine generally.",
|
|
13
|
+
"implementations": [
|
|
14
|
+
{ "language": "node", "package": "@particle-academy/fancy-flow", "symbol": "subflowExecutor" },
|
|
15
|
+
{ "language": "php", "package": "particle-academy/fancy-flow-php", "symbol": "FancyFlow\\Nodes\\Structural\\SubflowExecutor" },
|
|
16
|
+
{ "language": "python", "package": "fancy-flow", "symbol": "fancy_flow.nodes.structural.SubflowExecutor" },
|
|
17
|
+
{ "language": "rust", "package": "fancy-flow", "symbol": "fancy_flow::nodes::structural::SubflowExecutor" }
|
|
18
|
+
]
|
|
19
|
+
},
|
|
20
|
+
"notes": [
|
|
21
|
+
"This table exists because ALL FOUR runtimes had the same defect at once, and each of the three that were fixed carried its own hand-written test for it. Three copies of an assertion agree right up until someone changes one of them, and nothing anywhere reports the divergence — which is the same shape as the bug the table describes.",
|
|
22
|
+
"Reported against the PHP twin as fancy-flow-php#7 by a consumer running a real graph: a child containing a host-registered kind failed with `No executor registered for kind=<host-kind>`, while the identical graph run at top level succeeded. Same workflow, two behaviours depending on nesting depth.",
|
|
23
|
+
"The TypeScript engine had it with a WORSE default. Its child ran against `config.executors ?? {}` — an EMPTY registry unless the graph happened to carry one — so nesting a graph did not merely lose the host's kinds, it lost everything. PHP and Python fell back to the bare builtins. None of the three warned, because an unregistered kind fails closed with no outputs, which is the correct default and exactly what makes this silent.",
|
|
24
|
+
"The costly case is not a MISSING kind but a REPLACED one. A host that overrides `llm_call` to add tenancy, budgeting or token accounting gets its own version in the parent and the package's version in the child: the same graph billing two different ways depending on where it was invoked from, with nothing failing. Case 0003 is that case.",
|
|
25
|
+
"The contract is deliberately a function over REGISTRIES rather than a graph run. `flow/graph-runs` cannot ship a discrimination probe because the implementation under test there is an entire workflow engine, which this package does not carry and should not grow. Registry composition is a pure function over maps, so this suite CAN be probed — and is, in tests/discrimination.test.ts, by the exact mutant that shipped in production: a child that sees only the builtins.",
|
|
26
|
+
"Values in these maps are marker strings, not executors. `builtin`, `host` and `child-specific` stand for whichever callable a runtime would bind; what the table pins is WHICH one survives into the child, not what it does."
|
|
27
|
+
]
|
|
28
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../../schema/cases.schema.json",
|
|
3
|
+
"suite": "flow/workflow-props",
|
|
4
|
+
"cases": [
|
|
5
|
+
{
|
|
6
|
+
"id": "0001-supplied-value-passes-through",
|
|
7
|
+
"title": "A declared, supplied value arrives in the resolved map unchanged.",
|
|
8
|
+
"since": "0.9.0",
|
|
9
|
+
"tags": ["happy-path"],
|
|
10
|
+
"input": {
|
|
11
|
+
"declared": [{ "name": "topic", "type": "string" }],
|
|
12
|
+
"passed": { "topic": "otters" }
|
|
13
|
+
},
|
|
14
|
+
"expected": { "ok": true, "props": { "topic": "otters" } }
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"id": "0002-default-fills-an-omitted-value",
|
|
18
|
+
"title": "An omitted input with a default resolves to the default.",
|
|
19
|
+
"since": "0.9.0",
|
|
20
|
+
"tags": ["defaults"],
|
|
21
|
+
"input": {
|
|
22
|
+
"declared": [{ "name": "limit", "type": "number", "default": 10 }],
|
|
23
|
+
"passed": {}
|
|
24
|
+
},
|
|
25
|
+
"expected": { "ok": true, "props": { "limit": 10 } }
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"id": "0003-supplied-beats-default",
|
|
29
|
+
"title": "An explicitly supplied value wins over the declared default.",
|
|
30
|
+
"since": "0.9.0",
|
|
31
|
+
"tags": ["defaults"],
|
|
32
|
+
"input": {
|
|
33
|
+
"declared": [{ "name": "limit", "type": "number", "default": 10 }],
|
|
34
|
+
"passed": { "limit": 25 }
|
|
35
|
+
},
|
|
36
|
+
"expected": { "ok": true, "props": { "limit": 25 } }
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"id": "0004-explicit-zero-is-not-replaced-by-a-default",
|
|
40
|
+
"title": "A supplied 0 survives a non-zero default.",
|
|
41
|
+
"since": "0.9.0",
|
|
42
|
+
"tags": ["defaults", "falsy", "trap"],
|
|
43
|
+
"input": {
|
|
44
|
+
"declared": [{ "name": "limit", "type": "number", "default": 10 }],
|
|
45
|
+
"passed": { "limit": 0 }
|
|
46
|
+
},
|
|
47
|
+
"expected": { "ok": true, "props": { "limit": 0 } }
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"id": "0005-explicit-false-is-not-replaced-by-a-default",
|
|
51
|
+
"title": "A supplied false survives a true default.",
|
|
52
|
+
"since": "0.9.0",
|
|
53
|
+
"tags": ["defaults", "falsy", "trap"],
|
|
54
|
+
"input": {
|
|
55
|
+
"declared": [{ "name": "dryRun", "type": "boolean", "default": true }],
|
|
56
|
+
"passed": { "dryRun": false }
|
|
57
|
+
},
|
|
58
|
+
"expected": { "ok": true, "props": { "dryRun": false } }
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"id": "0006-explicit-empty-string-is-not-replaced-by-a-default",
|
|
62
|
+
"title": "A supplied empty string survives a non-empty default.",
|
|
63
|
+
"since": "0.9.0",
|
|
64
|
+
"tags": ["defaults", "falsy", "trap"],
|
|
65
|
+
"input": {
|
|
66
|
+
"declared": [{ "name": "note", "type": "string", "default": "unset" }],
|
|
67
|
+
"passed": { "note": "" }
|
|
68
|
+
},
|
|
69
|
+
"expected": { "ok": true, "props": { "note": "" } }
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"id": "0007-absent-optional-is-absent-not-null",
|
|
73
|
+
"title": "An optional input with no default and no value is missing from the map entirely.",
|
|
74
|
+
"since": "0.9.0",
|
|
75
|
+
"tags": ["absence"],
|
|
76
|
+
"input": {
|
|
77
|
+
"declared": [
|
|
78
|
+
{ "name": "topic", "type": "string" },
|
|
79
|
+
{ "name": "note", "type": "string" }
|
|
80
|
+
],
|
|
81
|
+
"passed": { "topic": "otters" }
|
|
82
|
+
},
|
|
83
|
+
"expected": { "ok": true, "props": { "topic": "otters" } }
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"id": "0008-untyped-declaration-accepts-anything",
|
|
87
|
+
"title": "An input declaring no type accepts a nested object.",
|
|
88
|
+
"since": "0.9.0",
|
|
89
|
+
"tags": ["types"],
|
|
90
|
+
"input": {
|
|
91
|
+
"declared": [{ "name": "payload" }],
|
|
92
|
+
"passed": { "payload": { "nested": [1, 2] } }
|
|
93
|
+
},
|
|
94
|
+
"expected": { "ok": true, "props": { "payload": { "nested": [1, 2] } } }
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
"id": "0009-required-is-satisfied-by-its-default",
|
|
98
|
+
"title": "A required input carrying a default does not need the caller to supply it.",
|
|
99
|
+
"since": "0.9.0",
|
|
100
|
+
"tags": ["required", "defaults"],
|
|
101
|
+
"input": {
|
|
102
|
+
"declared": [{ "name": "limit", "type": "number", "required": true, "default": 5 }],
|
|
103
|
+
"passed": {}
|
|
104
|
+
},
|
|
105
|
+
"expected": { "ok": true, "props": { "limit": 5 } }
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
"id": "0010-array-satisfies-array-not-object",
|
|
109
|
+
"title": "A list satisfies a declared array.",
|
|
110
|
+
"since": "0.9.0",
|
|
111
|
+
"tags": ["types", "trap"],
|
|
112
|
+
"input": {
|
|
113
|
+
"declared": [{ "name": "tags", "type": "array" }],
|
|
114
|
+
"passed": { "tags": ["a", "b"] }
|
|
115
|
+
},
|
|
116
|
+
"expected": { "ok": true, "props": { "tags": ["a", "b"] } }
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
"id": "0011-object-satisfies-object",
|
|
120
|
+
"title": "A map satisfies a declared object.",
|
|
121
|
+
"since": "0.9.0",
|
|
122
|
+
"tags": ["types"],
|
|
123
|
+
"input": {
|
|
124
|
+
"declared": [{ "name": "meta", "type": "object" }],
|
|
125
|
+
"passed": { "meta": { "k": "v" } }
|
|
126
|
+
},
|
|
127
|
+
"expected": { "ok": true, "props": { "meta": { "k": "v" } } }
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
"id": "0012-no-declaration-and-no-props",
|
|
131
|
+
"title": "A workflow that declares nothing, called with nothing, resolves to an empty map.",
|
|
132
|
+
"since": "0.9.0",
|
|
133
|
+
"tags": ["happy-path", "absence"],
|
|
134
|
+
"input": { "declared": null, "passed": null },
|
|
135
|
+
"expected": { "ok": true, "props": {} }
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
"id": "0101-an-unknown-key-fails",
|
|
139
|
+
"title": "A misspelled input name FAILS rather than sitting unread.",
|
|
140
|
+
"since": "0.9.0",
|
|
141
|
+
"tags": ["validation", "regression", "silent-failure"],
|
|
142
|
+
"input": {
|
|
143
|
+
"declared": [{ "name": "topic", "type": "string" }],
|
|
144
|
+
"passed": { "topik": "otters" }
|
|
145
|
+
},
|
|
146
|
+
"expected": { "ok": false, "code": "unknown_input" }
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
"id": "0102-props-passed-to-a-workflow-declaring-none-fails",
|
|
150
|
+
"title": "Passing anything to a workflow that declares no inputs FAILS.",
|
|
151
|
+
"since": "0.9.0",
|
|
152
|
+
"tags": ["validation", "silent-failure"],
|
|
153
|
+
"input": {
|
|
154
|
+
"declared": null,
|
|
155
|
+
"passed": { "topic": "otters" }
|
|
156
|
+
},
|
|
157
|
+
"expected": { "ok": false, "code": "unknown_input" }
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
"id": "0103-unknown-is-reported-before-missing-required",
|
|
161
|
+
"title": "A caller who misspells a required input is told about the word they typed.",
|
|
162
|
+
"since": "0.9.0",
|
|
163
|
+
"tags": ["validation", "ordering"],
|
|
164
|
+
"input": {
|
|
165
|
+
"declared": [{ "name": "topic", "type": "string", "required": true }],
|
|
166
|
+
"passed": { "topik": "otters" }
|
|
167
|
+
},
|
|
168
|
+
"expected": { "ok": false, "code": "unknown_input" }
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
"id": "0104-missing-required-fails",
|
|
172
|
+
"title": "A required input with no default and no value FAILS.",
|
|
173
|
+
"since": "0.9.0",
|
|
174
|
+
"tags": ["validation", "required"],
|
|
175
|
+
"input": {
|
|
176
|
+
"declared": [{ "name": "topic", "type": "string", "required": true }],
|
|
177
|
+
"passed": {}
|
|
178
|
+
},
|
|
179
|
+
"expected": { "ok": false, "code": "missing_required" }
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
"id": "0105-wrong-type-fails",
|
|
183
|
+
"title": "A string supplied where a number is declared FAILS.",
|
|
184
|
+
"since": "0.9.0",
|
|
185
|
+
"tags": ["validation", "types"],
|
|
186
|
+
"input": {
|
|
187
|
+
"declared": [{ "name": "limit", "type": "number" }],
|
|
188
|
+
"passed": { "limit": "ten" }
|
|
189
|
+
},
|
|
190
|
+
"expected": { "ok": false, "code": "type_mismatch" }
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
"id": "0106-object-does-not-satisfy-array",
|
|
194
|
+
"title": "A map supplied where an array is declared FAILS.",
|
|
195
|
+
"since": "0.9.0",
|
|
196
|
+
"tags": ["validation", "types", "trap"],
|
|
197
|
+
"input": {
|
|
198
|
+
"declared": [{ "name": "tags", "type": "array" }],
|
|
199
|
+
"passed": { "tags": { "0": "a" } }
|
|
200
|
+
},
|
|
201
|
+
"expected": { "ok": false, "code": "type_mismatch" }
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
"id": "0107-array-does-not-satisfy-object",
|
|
205
|
+
"title": "A list supplied where an object is declared FAILS.",
|
|
206
|
+
"since": "0.9.0",
|
|
207
|
+
"tags": ["validation", "types", "trap"],
|
|
208
|
+
"input": {
|
|
209
|
+
"declared": [{ "name": "meta", "type": "object" }],
|
|
210
|
+
"passed": { "meta": ["a"] }
|
|
211
|
+
},
|
|
212
|
+
"expected": { "ok": false, "code": "type_mismatch" }
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
"id": "0108-null-does-not-satisfy-a-declared-type",
|
|
216
|
+
"title": "An explicit null supplied where a string is declared FAILS.",
|
|
217
|
+
"since": "0.9.0",
|
|
218
|
+
"tags": ["validation", "types"],
|
|
219
|
+
"input": {
|
|
220
|
+
"declared": [{ "name": "topic", "type": "string" }],
|
|
221
|
+
"passed": { "topic": null }
|
|
222
|
+
},
|
|
223
|
+
"expected": { "ok": false, "code": "type_mismatch" }
|
|
224
|
+
}
|
|
225
|
+
]
|
|
226
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../../schema/suite-manifest.schema.json",
|
|
3
|
+
"suite": "flow/workflow-props",
|
|
4
|
+
"title": "Resolving a caller's props against what a workflow declares",
|
|
5
|
+
"since": "0.9.0",
|
|
6
|
+
"caseFormat": "table",
|
|
7
|
+
"cases": "cases.json",
|
|
8
|
+
"contract": {
|
|
9
|
+
"function": "resolveWorkflowProps(declared: array|null, passed: object|null) -> {ok: true, props: object} | {ok: false, code: string}",
|
|
10
|
+
"summary": "Check the flat, by-name object a caller passed against the `inputs` a workflow declares, and fill in declared defaults. `declared` is the list of input declarations ({name, type?, required?, default?}) or null when the workflow declares none. `passed` is what the caller supplied, or null. On success the result carries the RESOLVED map -- supplied values plus defaults for anything omitted, and nothing else. On failure it carries a stable `code`; the human-readable message is deliberately NOT part of the contract.",
|
|
11
|
+
"reference": "node",
|
|
12
|
+
"referenceNote": "@particle-academy/fancy-flow's src/runtime/workflow-props.ts is the behavioural source of truth, and the rules were designed there with this table written alongside rather than after.",
|
|
13
|
+
"implementations": [
|
|
14
|
+
{ "language": "node", "package": "@particle-academy/fancy-flow", "symbol": "resolveWorkflowProps" },
|
|
15
|
+
{ "language": "php", "package": "particle-academy/fancy-flow-php", "symbol": "FancyFlow\\Runtime\\WorkflowProps::resolve" },
|
|
16
|
+
{ "language": "python", "package": "fancy-flow", "symbol": "fancy_flow.runtime.workflow_props.resolve_workflow_props" }
|
|
17
|
+
]
|
|
18
|
+
},
|
|
19
|
+
"notes": [
|
|
20
|
+
"The CODE is asserted, never the message. Each runtime words its errors idiomatically and an English sentence is not a behaviour -- pinning the prose would hold three implementations to a translation, and would go red on a wording improvement that changed nothing.",
|
|
21
|
+
"The reason this feature exists is case 0101: passing a MISSPELLED key. Before workflow props, `initialInputs` was keyed by node id and an unrecognised key was not an error -- the value simply sat unread, the node saw nothing, and the run reported success with output that was quietly wrong. Every implementation must fail that case, because the silent version is what shipped.",
|
|
22
|
+
"Cases 0004-0006 are the falsy-value trap and they are the ones a re-implementation gets wrong. `0`, `false` and `\"\"` are values a caller MEANT to pass. A default applied with `||`, or with `??` on the wrong side, silently replaces them -- and a declared limit of 0 quietly becoming 10 is not an error anybody observes.",
|
|
23
|
+
"Absent is ABSENT. An input that is not supplied, has no default and is not required must not appear in the resolved map at all -- not as null, not as an empty string. PHP has one absent value and JS has two; letting that difference leak would make `{{ $props.x }}` disagree across runtimes for no useful reason.",
|
|
24
|
+
"`type` is optional and an omitted type accepts ANYTHING (case 0008). 'I am not asserting a shape' must not degrade into 'nothing is allowed', which is the failure mode of a validator written defensively.",
|
|
25
|
+
"Arrays are checked before objects (case 0010). `typeof []` is `\"object\"` in JavaScript, and a declaration saying `array` that accepts `{}` is a check that passes while meaning nothing.",
|
|
26
|
+
"Unknown keys are checked BEFORE required ones (case 0103). A caller who typed `topik` instead of `topic` should be told about `topik` -- the word they wrote -- rather than that `topic` is missing, which describes a key they believe they supplied."
|
|
27
|
+
]
|
|
28
|
+
}
|