@symbiote-native/test-utils 0.3.0 → 0.4.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.
@@ -1,135 +0,0 @@
1
- import type { IFakeNode } from './fake-fabric';
2
- /**
3
- * A committed node stripped of per-mount identity. `tag`, `instanceHandle` and `parentFamilyTag`
4
- * differ between two mounts of the same tree by construction, so comparing them would fail every
5
- * correct adapter.
6
- */
7
- export interface ICommittedShape {
8
- viewName: string;
9
- props: Record<string, unknown>;
10
- children: ICommittedShape[];
11
- }
12
- export declare function normalizeCommitted(nodes: readonly IFakeNode[]): ICommittedShape[];
13
- /**
14
- * The result of one comparison. Differences are returned rather than thrown so a caller can assert
15
- * on an EMPTY ARRAY and have its runner print the whole list at once — a thrown error reports the
16
- * first mismatch and hides the rest, and the interesting failures here are plural (six missing keys
17
- * across three nodes was the real one).
18
- */
19
- export interface IEquivalenceResult {
20
- equal: boolean;
21
- differences: string[];
22
- }
23
- /**
24
- * The assertion. Compare two committed trees taken from two separate mounts of the same props.
25
- *
26
- * The committed tree is the right subject and the RETAINED tree is not: anchors live only in the
27
- * retained tree and are exactly what lowering removes, so the two retained trees legitimately
28
- * differ. A committed tree holds no anchors, so it must match exactly, `RCTRawText` children
29
- * included.
30
- */
31
- export declare function compareLoweringEquivalence(componentTree: readonly IFakeNode[], loweredTree: readonly IFakeNode[]): IEquivalenceResult;
32
- /**
33
- * THE SECOND ASSERTION EVERY CASE OWES — an ABSOLUTE expectation, not a comparison.
34
- *
35
- * Find the committed node carrying `testID` and require `expected`'s keys to be present with those
36
- * values. Extra keys are allowed: this asserts that a fold RAN, and pinning the whole payload would
37
- * turn every unrelated prop addition into a failure here instead of in the test that owns it.
38
- *
39
- * Pass the value a fold PRODUCES, never the one the author wrote — `{ nativeID: 'x' }` for an
40
- * authored `id="x"`, `{ ellipsizeMode: 'tail' }` for a `<Text>` that set none. An expectation
41
- * restating the input passes with the fold deleted and is the same false green one level down.
42
- */
43
- export declare function expectCommittedProps(tree: readonly IFakeNode[], testID: string, expected: Record<string, unknown>): IEquivalenceResult;
44
- /**
45
- * THE GUARD AGAINST THE MOST LIKELY FALSE GREEN: both arms taking the SAME path.
46
- *
47
- * Angular is the live instance — its primitives carry a dual selector (`'symbiote-view, View'`) and
48
- * directive matching is resolved per TEMPLATE, so writing the intrinsic inside a template that
49
- * imports the component resolves straight back to the component, silently. The general form is any
50
- * adapter where the "lowered" snippet is hand-written intrinsic markup the renderer happens to route
51
- * through the wrapper anyway. Two identical arms agree perfectly and prove nothing.
52
- *
53
- * The discriminator here is the RETAINED tree: a component form allocates a wrapper the lowered
54
- * form does not, so the counts cannot be equal.
55
- *
56
- * !! THAT PREMISE IS PER-ADAPTER AND IT IS FALSE ON AT LEAST TWO OF THE FIVE. Corrected 2026-09-01,
57
- * after the Solid and React arms hit it independently within the hour. A component only allocates a
58
- * retained node if the FRAMEWORK gives it one:
59
- *
60
- * svelte HALF TRUE, and corrected by the svelte arm the same day: it holds only for a
61
- * CHILDREN-BEARING wrapper, because the anchors are what cost the node. `Switch` and
62
- * `TextInput` render one childless element with `p={descriptor.props}` and BOTH arms
63
- * retain exactly one node — so this control fired on two correct arms before it ever
64
- * reached the two real divergences in that file
65
- * angular a per-component host element counts differ -> this control holds
66
- * solid a component is a plain function returning the same host node — MEASURED
67
- * byte-identical: {nodes:1, anchors:0, renderable:1} on both arms
68
- * react the wrappers render the intrinsic themselves, so both arms build one node
69
- * vue `hostComponent()` is a FunctionalComponent, so expected to match — UNMEASURED
70
- *
71
- * On an adapter where the counts legitimately agree this control fires on a CORRECT arm, which is
72
- * worse than not having it: a control that cries wolf gets deleted, and the real guard goes with it.
73
- *
74
- * So each adapter owes a discriminator whose premise holds FOR IT, stated where it is used. THREE
75
- * SHAPES EXIST, one per adapter family, and none is a fallback for another:
76
- *
77
- * node count this function. Holds where a component allocates a retained node —
78
- * Svelte's anchors, Angular's per-component host. On Svelte it holds only
79
- * for a CHILDREN-BEARING wrapper: `Switch` and `TextInput` render a single
80
- * childless element and both arms retain one node. On ANGULAR it holds only
81
- * for a COMPOSED component: `View`/`Text` are `SymbiotePrimitiveHost`s whose
82
- * component IS the node, so both arms census 1.
83
- * template text Angular's, and it exists because of the trap below. Both arms are built
84
- * from template STRINGS, so the arm can assert that only the lowered one
85
- * spells the intrinsic. Weaker than reading compiled output — it proves the
86
- * author wrote two different tags, not that Angular resolved them
87
- * differently — and it is the only thing left when no counter separates them.
88
- * source spelling only the lowered arm names the BARE intrinsic, since a wrapper renders the
89
- * `-managed` spelling. The strongest of the three — no runtime coincidence
90
- * satisfies it — and available only where the arm can read its own source.
91
- * compiled output `_$createComponent(View, …)` vs `_$createElement("symbiote-view")`. Solid's,
92
- * because its arms are JSX compiled by the runner: there is no source to read
93
- * at assertion time, and this is the same claim reachable from where it stands.
94
- *
95
- * A DISABLED discriminator can be disabled exactly where its trap lives, and only a break-test
96
- * says so. Measured on Angular 2026-09-02: the node-count control was switched off for `View` and
97
- * `Text` because their arms legitimately census equal — and those two are the ONLY primitives
98
- * carrying the dual selector `'symbiote-view, View'`, i.e. the one construct that makes an
99
- * intrinsic resolve back to its component. Handing the lowered arm its imports, which should have
100
- * reproduced the trap, left all nine rows GREEN. The five rows where the control WAS active have
101
- * single-name selectors and could never have shown it.
102
- *
103
- * So: when a control is scoped off for some members, ask whether the hazard it guards is
104
- * concentrated in the members you excluded. Here it was entirely there.
105
- *
106
- * Do not generalise any of them into this file. Which artifact distinguishes the two paths is a fact
107
- * about a framework, and this repo has a standing rule against absorbing those into shared code —
108
- * a shared second control would force one answer onto five adapters, which is the mistake this
109
- * correction exists to undo.
110
- *
111
- * THE SHAPE THAT TRANSFERS, offered as a pattern and deliberately NOT as a function here, for the
112
- * reason the paragraph above gives. The one thing lowering must change on every adapter is the TAG:
113
- * a wrapper renders the `-managed` spelling or a component boundary, and only the lowered path can
114
- * name the bare intrinsic the spec declares. So the question each arm can ask in its own idiom is
115
- * "does ONLY the lowered artifact name `HOST_PRIMITIVES[name].intrinsic`" — Svelte asks it of its
116
- * two source strings, Solid of its compiled output, and an adapter with no compile step has to find
117
- * its own answer. Two properties are worth keeping when you translate it: assert on the ARTIFACT
118
- * rather than on runtime state, so no coincidence at mount can satisfy it, and assert the component
119
- * arm does NOT name the intrinsic, or the check passes on two lowered arms.
120
- *
121
- * Match the tag with a boundary. `symbiote-text` is a prefix of `symbiote-text-input` and
122
- * `symbiote-switch` of `symbiote-switch-managed`, so a bare substring test reads a wrapper's
123
- * `-managed` output as the bare intrinsic and certifies two arms that are the same arm.
124
- *
125
- * Counts are passed IN rather than computed here: `censusRetainedTree` lives in the engine, and
126
- * this package must not depend on it — test-utils is imported by the engine's own suite, so the
127
- * edge would be a cycle. Each caller reads the census from the engine it already imports.
128
- */
129
- export declare function assertArmsAreDistinct(componentRetainedNodes: number, loweredRetainedNodes: number): IEquivalenceResult;
130
- /**
131
- * THE SECOND FALSE GREEN: both arms empty. `committed` is `[]` until `completeRoot` runs, and two
132
- * empty trees compare equal, so a wrong flush count silently compares nothing at all. Every arm must
133
- * prove it committed something before its diff is believed.
134
- */
135
- export declare function assertCommittedSomething(tree: readonly IFakeNode[], armName: string): IEquivalenceResult;
@@ -1,224 +0,0 @@
1
- export function normalizeCommitted(nodes) {
2
- return nodes.map(node => ({
3
- viewName: node.viewName,
4
- props: { ...node.props },
5
- children: normalizeCommitted(node.children),
6
- }));
7
- }
8
- function describePath(path) {
9
- return path.length === 0 ? '<root>' : path.join(' > ');
10
- }
11
- // Props are compared by KEY NAME and value, never by count. Counting is what cost a day: two arms
12
- // agreeing on a total while disagreeing on which keys make it up is a coincidence, not equivalence.
13
- //
14
- // `undefined` needs no special case, and adding one would HIDE a defect. `setProp` deletes a key
15
- // written as `undefined` and no-ops when it was never there, and `setEventListener` derives its
16
- // gate flag from `typeof value === 'function'` — so an absent key and an `undefined` key commit
17
- // identically. An adapter whose diff fails to route a disappeared key is exactly what must fail.
18
- function diffProps(path, component, lowered, differences) {
19
- const keys = new Set([...Object.keys(component), ...Object.keys(lowered)]);
20
- for (const key of [...keys].sort()) {
21
- const inComponent = Object.hasOwn(component, key);
22
- const inLowered = Object.hasOwn(lowered, key);
23
- if (!inLowered) {
24
- differences.push(`${describePath(path)}: lowered is MISSING "${key}" (component has ${JSON.stringify(component[key])}) — a fold the wrapper applied and the lowered path does not`);
25
- continue;
26
- }
27
- if (!inComponent) {
28
- differences.push(`${describePath(path)}: lowered has EXTRA "${key}" = ${JSON.stringify(lowered[key])}`);
29
- continue;
30
- }
31
- const left = JSON.stringify(component[key]);
32
- const right = JSON.stringify(lowered[key]);
33
- if (left !== right) {
34
- differences.push(`${describePath(path)}: "${key}" differs — component ${left}, lowered ${right}`);
35
- }
36
- }
37
- }
38
- function diffShapes(path, component, lowered, differences) {
39
- if (component.length !== lowered.length) {
40
- differences.push(`${describePath(path)}: child count differs — component ${component.length}, lowered ${lowered.length}`);
41
- }
42
- const count = Math.max(component.length, lowered.length);
43
- for (let index = 0; index < count; index += 1) {
44
- const left = component[index];
45
- const right = lowered[index];
46
- if (left === undefined || right === undefined)
47
- continue;
48
- const here = [...path, `${left.viewName}[${index}]`];
49
- if (left.viewName !== right.viewName) {
50
- differences.push(`${describePath(path)}: child ${index} is ${left.viewName} on the component path and ${right.viewName} on the lowered one`);
51
- continue;
52
- }
53
- diffProps(here, left.props, right.props, differences);
54
- diffShapes(here, left.children, right.children, differences);
55
- }
56
- }
57
- /**
58
- * The assertion. Compare two committed trees taken from two separate mounts of the same props.
59
- *
60
- * The committed tree is the right subject and the RETAINED tree is not: anchors live only in the
61
- * retained tree and are exactly what lowering removes, so the two retained trees legitimately
62
- * differ. A committed tree holds no anchors, so it must match exactly, `RCTRawText` children
63
- * included.
64
- */
65
- export function compareLoweringEquivalence(componentTree, loweredTree) {
66
- const differences = [];
67
- diffShapes([], normalizeCommitted(componentTree), normalizeCommitted(loweredTree), differences);
68
- return { equal: differences.length === 0, differences };
69
- }
70
- /**
71
- * THE SECOND ASSERTION EVERY CASE OWES — an ABSOLUTE expectation, not a comparison.
72
- *
73
- * Find the committed node carrying `testID` and require `expected`'s keys to be present with those
74
- * values. Extra keys are allowed: this asserts that a fold RAN, and pinning the whole payload would
75
- * turn every unrelated prop addition into a failure here instead of in the test that owns it.
76
- *
77
- * Pass the value a fold PRODUCES, never the one the author wrote — `{ nativeID: 'x' }` for an
78
- * authored `id="x"`, `{ ellipsizeMode: 'tail' }` for a `<Text>` that set none. An expectation
79
- * restating the input passes with the fold deleted and is the same false green one level down.
80
- */
81
- export function expectCommittedProps(tree, testID, expected) {
82
- const differences = [];
83
- const found = findByTestID(tree, testID);
84
- if (found === undefined) {
85
- return {
86
- equal: false,
87
- differences: [
88
- `no committed node carries testID "${testID}" — the mount did not flush, or the prop never reached the payload`,
89
- ],
90
- };
91
- }
92
- for (const key of Object.keys(expected).sort()) {
93
- const actual = JSON.stringify(found.props[key]);
94
- const wanted = JSON.stringify(expected[key]);
95
- if (actual !== wanted) {
96
- differences.push(`${testID}: "${key}" is ${actual}, expected ${wanted} — the fold that produces it did not run on this path`);
97
- }
98
- }
99
- return { equal: differences.length === 0, differences };
100
- }
101
- function findByTestID(nodes, testID) {
102
- for (const node of nodes) {
103
- if (node.props.testID === testID)
104
- return node;
105
- const found = findByTestID(node.children, testID);
106
- if (found !== undefined)
107
- return found;
108
- }
109
- return undefined;
110
- }
111
- /**
112
- * THE GUARD AGAINST THE MOST LIKELY FALSE GREEN: both arms taking the SAME path.
113
- *
114
- * Angular is the live instance — its primitives carry a dual selector (`'symbiote-view, View'`) and
115
- * directive matching is resolved per TEMPLATE, so writing the intrinsic inside a template that
116
- * imports the component resolves straight back to the component, silently. The general form is any
117
- * adapter where the "lowered" snippet is hand-written intrinsic markup the renderer happens to route
118
- * through the wrapper anyway. Two identical arms agree perfectly and prove nothing.
119
- *
120
- * The discriminator here is the RETAINED tree: a component form allocates a wrapper the lowered
121
- * form does not, so the counts cannot be equal.
122
- *
123
- * !! THAT PREMISE IS PER-ADAPTER AND IT IS FALSE ON AT LEAST TWO OF THE FIVE. Corrected 2026-09-01,
124
- * after the Solid and React arms hit it independently within the hour. A component only allocates a
125
- * retained node if the FRAMEWORK gives it one:
126
- *
127
- * svelte HALF TRUE, and corrected by the svelte arm the same day: it holds only for a
128
- * CHILDREN-BEARING wrapper, because the anchors are what cost the node. `Switch` and
129
- * `TextInput` render one childless element with `p={descriptor.props}` and BOTH arms
130
- * retain exactly one node — so this control fired on two correct arms before it ever
131
- * reached the two real divergences in that file
132
- * angular a per-component host element counts differ -> this control holds
133
- * solid a component is a plain function returning the same host node — MEASURED
134
- * byte-identical: {nodes:1, anchors:0, renderable:1} on both arms
135
- * react the wrappers render the intrinsic themselves, so both arms build one node
136
- * vue `hostComponent()` is a FunctionalComponent, so expected to match — UNMEASURED
137
- *
138
- * On an adapter where the counts legitimately agree this control fires on a CORRECT arm, which is
139
- * worse than not having it: a control that cries wolf gets deleted, and the real guard goes with it.
140
- *
141
- * So each adapter owes a discriminator whose premise holds FOR IT, stated where it is used. THREE
142
- * SHAPES EXIST, one per adapter family, and none is a fallback for another:
143
- *
144
- * node count this function. Holds where a component allocates a retained node —
145
- * Svelte's anchors, Angular's per-component host. On Svelte it holds only
146
- * for a CHILDREN-BEARING wrapper: `Switch` and `TextInput` render a single
147
- * childless element and both arms retain one node. On ANGULAR it holds only
148
- * for a COMPOSED component: `View`/`Text` are `SymbiotePrimitiveHost`s whose
149
- * component IS the node, so both arms census 1.
150
- * template text Angular's, and it exists because of the trap below. Both arms are built
151
- * from template STRINGS, so the arm can assert that only the lowered one
152
- * spells the intrinsic. Weaker than reading compiled output — it proves the
153
- * author wrote two different tags, not that Angular resolved them
154
- * differently — and it is the only thing left when no counter separates them.
155
- * source spelling only the lowered arm names the BARE intrinsic, since a wrapper renders the
156
- * `-managed` spelling. The strongest of the three — no runtime coincidence
157
- * satisfies it — and available only where the arm can read its own source.
158
- * compiled output `_$createComponent(View, …)` vs `_$createElement("symbiote-view")`. Solid's,
159
- * because its arms are JSX compiled by the runner: there is no source to read
160
- * at assertion time, and this is the same claim reachable from where it stands.
161
- *
162
- * A DISABLED discriminator can be disabled exactly where its trap lives, and only a break-test
163
- * says so. Measured on Angular 2026-09-02: the node-count control was switched off for `View` and
164
- * `Text` because their arms legitimately census equal — and those two are the ONLY primitives
165
- * carrying the dual selector `'symbiote-view, View'`, i.e. the one construct that makes an
166
- * intrinsic resolve back to its component. Handing the lowered arm its imports, which should have
167
- * reproduced the trap, left all nine rows GREEN. The five rows where the control WAS active have
168
- * single-name selectors and could never have shown it.
169
- *
170
- * So: when a control is scoped off for some members, ask whether the hazard it guards is
171
- * concentrated in the members you excluded. Here it was entirely there.
172
- *
173
- * Do not generalise any of them into this file. Which artifact distinguishes the two paths is a fact
174
- * about a framework, and this repo has a standing rule against absorbing those into shared code —
175
- * a shared second control would force one answer onto five adapters, which is the mistake this
176
- * correction exists to undo.
177
- *
178
- * THE SHAPE THAT TRANSFERS, offered as a pattern and deliberately NOT as a function here, for the
179
- * reason the paragraph above gives. The one thing lowering must change on every adapter is the TAG:
180
- * a wrapper renders the `-managed` spelling or a component boundary, and only the lowered path can
181
- * name the bare intrinsic the spec declares. So the question each arm can ask in its own idiom is
182
- * "does ONLY the lowered artifact name `HOST_PRIMITIVES[name].intrinsic`" — Svelte asks it of its
183
- * two source strings, Solid of its compiled output, and an adapter with no compile step has to find
184
- * its own answer. Two properties are worth keeping when you translate it: assert on the ARTIFACT
185
- * rather than on runtime state, so no coincidence at mount can satisfy it, and assert the component
186
- * arm does NOT name the intrinsic, or the check passes on two lowered arms.
187
- *
188
- * Match the tag with a boundary. `symbiote-text` is a prefix of `symbiote-text-input` and
189
- * `symbiote-switch` of `symbiote-switch-managed`, so a bare substring test reads a wrapper's
190
- * `-managed` output as the bare intrinsic and certifies two arms that are the same arm.
191
- *
192
- * Counts are passed IN rather than computed here: `censusRetainedTree` lives in the engine, and
193
- * this package must not depend on it — test-utils is imported by the engine's own suite, so the
194
- * edge would be a cycle. Each caller reads the census from the engine it already imports.
195
- */
196
- export function assertArmsAreDistinct(componentRetainedNodes, loweredRetainedNodes) {
197
- if (componentRetainedNodes === loweredRetainedNodes) {
198
- return {
199
- equal: false,
200
- differences: [
201
- `both arms retained ${componentRetainedNodes} nodes — the lowered arm did not lower. ` +
202
- `Check that the intrinsic spelling is not resolving back to the component (a dual ` +
203
- `selector, or a template that still imports the wrapper).`,
204
- ],
205
- };
206
- }
207
- return { equal: true, differences: [] };
208
- }
209
- /**
210
- * THE SECOND FALSE GREEN: both arms empty. `committed` is `[]` until `completeRoot` runs, and two
211
- * empty trees compare equal, so a wrong flush count silently compares nothing at all. Every arm must
212
- * prove it committed something before its diff is believed.
213
- */
214
- export function assertCommittedSomething(tree, armName) {
215
- const count = normalizeCommitted(tree).length;
216
- return count > 0
217
- ? { equal: true, differences: [] }
218
- : {
219
- equal: false,
220
- differences: [
221
- `the ${armName} arm committed nothing — the mount did not flush, so the diff below compares two empty trees`,
222
- ],
223
- };
224
- }