@signaltree/enterprise 13.1.1 → 13.2.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.
@@ -0,0 +1,282 @@
1
+ # Testing
2
+
3
+ How to wire SignalTree into Angular `TestBed`s without ambient-DI surprises.
4
+
5
+ > **Single hard rule.** Every `TestBed` that constructs a service or component which depends on `AppStore` — directly **or transitively** through any `providedIn: 'root'` consumer (legacy facades, message hubs, resolvers, etc.) — **must** include `provideAppTreeForTesting()` in `providers`. Mocking `AppStore` with `useValue` is not enough on its own when the DI graph still walks through real `providedIn: 'root'` services that inject `APP_TREE`.
6
+
7
+ ## Why
8
+
9
+ In the recommended shape from [`patterns.md`](./patterns.md):
10
+
11
+ ```ts
12
+ // app-tree.ts
13
+ import { InjectionToken, type Provider } from '@angular/core';
14
+
15
+ // (Defined elsewhere in this file — see `patterns.md` for the full shape.)
16
+ declare const createAppTree: () => any;
17
+ export type AppTree = ReturnType<typeof createAppTree>;
18
+
19
+ export const APP_TREE = new InjectionToken<AppTree>('APP_TREE');
20
+
21
+ export function provideAppTree(): Provider[] {
22
+ return [{ provide: APP_TREE, useFactory: () => createAppTree() }];
23
+ }
24
+ ```
25
+
26
+ ```ts
27
+ // app-store.ts
28
+ @Injectable({ providedIn: 'root' })
29
+ export class AppStore {
30
+ readonly tree = inject(APP_TREE);
31
+ readonly $ = this.tree.$;
32
+ readonly ops = {
33
+ /* ... */
34
+ } as const;
35
+ }
36
+ ```
37
+
38
+ `AppStore` is `providedIn: 'root'`, so Angular happily instantiates it inside any `TestBed`. The instantiation then calls `inject(APP_TREE)`, which **fails** because `APP_TREE` is only registered by `provideAppTree()` in production `app.config.ts`. The error surfaces as:
39
+
40
+ ```text
41
+ NG0201: No provider found for `InjectionToken APP_TREE`.
42
+ Source: DynamicTestModule.
43
+ Path: <YourConsumer> -> AppStore -> InjectionToken APP_TREE.
44
+ ```
45
+
46
+ If `AppStore` is consumed transitively (e.g. a legacy `Store` facade migrated to wrap `AppStore`), the path lengthens — `MessagingHub -> Store -> AppStore -> APP_TREE` — and the failure cascades through every test that touches the consumer.
47
+
48
+ ## The recipe
49
+
50
+ Co-locate this with `app-tree.ts` (e.g. `app-tree.testing.ts`):
51
+
52
+ ```ts
53
+ // app-tree.testing.ts
54
+ import { Provider } from '@angular/core';
55
+ import { signalTree } from '@signaltree/core';
56
+ import { APP_TREE, AppTree, createBaseState } from './app-tree';
57
+
58
+ /**
59
+ * Provide a real, isolated AppTree for a TestBed.
60
+ *
61
+ * Pass `overrides` to seed any subtree — useful for tests that need a
62
+ * specific `currentDriver`, `featureFlags`, etc. without going through Ops.
63
+ */
64
+ export function provideAppTreeForTesting(overrides?: (state: ReturnType<typeof createBaseState>) => ReturnType<typeof createBaseState>): Provider[] {
65
+ return [
66
+ {
67
+ provide: APP_TREE,
68
+ useFactory: (): AppTree => {
69
+ const base = createBaseState();
70
+ const seeded = overrides ? overrides(base) : base;
71
+ // Cast: the testing tree skips production enhancers (devTools, batching, etc.)
72
+ // so its inferred type is structurally narrower than `AppTree`. Bake the cast
73
+ // in here so consumers can write `tree: AppTree = TestBed.inject(APP_TREE)`
74
+ // without an `as unknown as AppTree` at every call site.
75
+ return signalTree(seeded) as unknown as AppTree;
76
+ },
77
+ },
78
+ ];
79
+ }
80
+ ```
81
+
82
+ Notes:
83
+
84
+ - **Use a real tree, not a mock.** Mocking the tree itself defeats the test value of the proxy and `entityMap` semantics. Real trees are cheap.
85
+ - **Skip enhancers in tests** by default. `batching()` adds scheduling, `devTools()` opens a connection, `timeTravel()` keeps history — none help unit tests. If a test specifically exercises batching, layer `batching()` in that test only.
86
+ - **`createBaseState()` must be exported from `app-tree.ts`.** This is the seam that lets tests construct an isolated state without copying the production composition.
87
+
88
+ ### ⚠️ Gotcha: do NOT swap a `Nullable<Object>` leaf to an object value via the seed callback
89
+
90
+ Tree shape is determined by the **value at construction time** (see [core.md → "What's a leaf vs a branch?"](./core.md)). If production base state has `currentDriver: null` (a leaf), but a test's `overrides` callback returns `currentDriver: { id: 1, name: 'Ada' }`, the constructed tree silently makes `currentDriver` a **branch**. Production code that calls `tree.$.driver.currentDriver.set(null)` then throws `set is not a function` — but only inside that one spec.
91
+
92
+ **Rule:** the `overrides` callback must preserve the structural shape of the base state. Use it for:
93
+
94
+ - Replacing primitive leaves (`loading: true`, `count: 5`, `currentDriverId: 1`).
95
+ - Patching existing branch contents (`ui: { ...s.ui, theme: 'dark' }` — `ui` is a branch in both prod and seed).
96
+ - Pre-populating `entityMap` slices: use the public API after TestBed setup — `tree.$.drivers.setAll([driver])` or `.upsertOne(driver)`. The internal entity storage shape (`{ entities: { 1: driver } }`) is not a public contract and shouldn't be reached into by tests.
97
+
98
+ For `Nullable<Object>` leaves (typed `XDto | null`, seeded as `null` in prod), seed via `.set()` **after** injection instead:
99
+
100
+ ```ts
101
+ TestBed.configureTestingModule({ providers: [provideAppTreeForTesting()] });
102
+ const tree = TestBed.inject(APP_TREE);
103
+ tree.$.driver.currentDriver.set({ id: 1, name: 'Ada' }); // safe — leaf shape preserved
104
+ ```
105
+
106
+ The same applies to any leaf typed as `Date | null`, `Map<...> | null`, class-instance | null, etc.
107
+
108
+ ## Mocking strategy by test type
109
+
110
+ There are three layers — tree, ops, facade — and only one of them should be mocked per test. The matrix:
111
+
112
+ | Test type | Tree | Ops classes | Components / consumers | Notes |
113
+ | ------------------------------------------- | ------------- | -------------- | ---------------------- | ---------------------------------------------------- |
114
+ | **Ops class spec** (`*.ops.spec.ts`) | real (seeded) | **real (SUT)** | n/a | Mock the underlying HTTP / domain services only. |
115
+ | **Component spec** (template / interaction) | real (seeded) | mocked | real (SUT) | Spy on `ops.<domain>.<method>` to assert dispatch. |
116
+ | **Legacy consumer spec** (using shim) | real (seeded) | mocked or real | real | Provide the legacy facade adapter; do NOT mock both. |
117
+ | **`AppStore` spec itself** (rare) | real | mocked | n/a | Usually unnecessary — `AppStore` is a thin facade. |
118
+
119
+ ### Ops spec example
120
+
121
+ ```ts
122
+ // driver.ops.spec.ts
123
+ import { TestBed } from '@angular/core/testing';
124
+ import { firstValueFrom, of } from 'rxjs';
125
+
126
+ import { DriverOps } from './driver.ops';
127
+ import { provideAppTreeForTesting } from './app-tree.testing';
128
+ import { APP_TREE } from './app-tree';
129
+ import { DriverService } from './driver.service';
130
+
131
+ describe('DriverOps', () => {
132
+ let ops: DriverOps;
133
+
134
+ beforeEach(() => {
135
+ TestBed.configureTestingModule({
136
+ providers: [provideAppTreeForTesting(), { provide: DriverService, useValue: { load$: () => of({ id: 1, name: 'Ada' }) } }],
137
+ });
138
+ ops = TestBed.inject(DriverOps);
139
+ });
140
+
141
+ it('writes the loaded driver to the tree', async () => {
142
+ await firstValueFrom(ops.loadActiveDriver$());
143
+ const tree = TestBed.inject(APP_TREE);
144
+ expect(tree.$.driver.currentDriver()).toEqual({ id: 1, name: 'Ada' });
145
+ });
146
+ });
147
+ ```
148
+
149
+ ### Component spec example
150
+
151
+ ```ts
152
+ // some.component.spec.ts
153
+ import { TestBed } from '@angular/core/testing';
154
+ import { provideAppTreeForTesting } from '../store/tree/app-tree.testing';
155
+ import { APP_TREE } from '../store/tree/app-tree';
156
+ import { DriverOps } from '../store/ops/driver.ops';
157
+ import { SomeComponent } from './some.component';
158
+
159
+ describe('SomeComponent', () => {
160
+ beforeEach(() => {
161
+ TestBed.configureTestingModule({
162
+ imports: [SomeComponent],
163
+ providers: [provideAppTreeForTesting(), { provide: DriverOps, useValue: { clearCurrentDriver: jest.fn() } }],
164
+ });
165
+
166
+ // Seed Nullable<Object> leaves AFTER injection — see gotcha above.
167
+ const tree = TestBed.inject(APP_TREE);
168
+ tree.$.driver.currentDriver.set({ id: 1, name: 'Ada' });
169
+ });
170
+
171
+ // ...
172
+ });
173
+ ```
174
+
175
+ If your seed only touches primitives or already-branch subtrees, you can use the inline callback form instead:
176
+
177
+ ```ts
178
+ // safe — `ui` is a branch in prod and seed; `theme` is a primitive leaf.
179
+ provideAppTreeForTesting((s) => ({ ...s, ui: { ...s.ui, theme: 'dark' } }));
180
+ ```
181
+
182
+ ## Wiring `APP_TREE` once for a large existing test suite
183
+
184
+ For a brown-field migration the simplest fix is to declare `APP_TREE` with a tree-shakable `providedIn: 'root'` factory — every child injector (including each per-spec `TestBed`) then gets a fresh isolated tree by default, and existing specs need **no** changes. See ["Brown-field migrations: declare `APP_TREE` with a tree-shakable factory"](./patterns.md#brown-field-migrations-declare-app_tree-with-a-tree-shakable-factory) in `patterns.md`. Use the recipe below only when the token has no `providedIn: 'root'` default and you instead want one global testing provider.
185
+
186
+ Per-TestBed `provideAppTreeForTesting()` is the right shape for **new** specs. For an existing app where dozens of spec files (and their parameterised setup helpers like `provideMockStore()`, `createTestingModule()`) need it, editing each one is mechanical noise that obscures real changes. Register the provider **once** via `getTestBed().initTestEnvironment(...)`:
187
+
188
+ ```ts
189
+ // test-setup.ts
190
+ import { NgModule } from '@angular/core';
191
+ import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
192
+ import { getTestBed } from '@angular/core/testing';
193
+
194
+ // IMPORTANT: import directly from the testing file, NOT from a barrel.
195
+ // Importing `./signaltree` (the barrel) pulls in AppStore + every Ops class
196
+ // transitively, which pre-loads any service those Ops inject. That breaks
197
+ // specs that rely on `vi.mock(...)` / `jest.mock(...)` hoisting against
198
+ // modules in the Ops dependency graph.
199
+ import { provideAppTreeForTesting } from './app/store/tree/app-tree.testing';
200
+
201
+ @NgModule({ providers: [...provideAppTreeForTesting()] })
202
+ class SignalTreeTestEnvironmentModule {}
203
+
204
+ getTestBed().initTestEnvironment([BrowserTestingModule, SignalTreeTestEnvironmentModule], platformBrowserTesting(), { errorOnUnknownElements: true, errorOnUnknownProperties: true });
205
+ ```
206
+
207
+ Why this works: `useFactory` inside `provideAppTreeForTesting()` runs per child injector, so each `TestBed.configureTestingModule(...)` still gets its own isolated tree — the registration is global but the _value_ is per-spec.
208
+
209
+ **The barrel-import rule is non-negotiable.** Symptom if you ignore it: a spec that has `vi.mock('@some-package')` at the top stops working because `@some-package` was already loaded (transitively, via `index.ts → AppStore → SomeOps → SomeService → @some-package`) before `vi.mock` had a chance to hoist. The error usually surfaces as the real implementation being called instead of the mock, often with a misleading stack trace.
210
+
211
+ **Per-spec overrides still work.** If a single test needs seeded state, call `provideAppTreeForTesting(s => ({...}))` inside its own `providers` — Angular merges the per-spec provider on top of the global one.
212
+
213
+ **When to pick which.** New code or a small migration (≤ 5 spec files): per-TestBed. Existing app with many specs: global. Either way, the recipe is the same `provideAppTreeForTesting()`; only the registration site differs.
214
+
215
+ ## Re-baselining pre-existing-broken legacy specs
216
+
217
+ A migration PR will sometimes inherit specs that were already failing at the base commit — they reference deleted infrastructure (`provideMockStore`, sibling-app fixtures, removed mock services). The migration is not the cause; rewriting them in scope is the only way to land a green PR.
218
+
219
+ **Confirm the breakage is pre-existing first.** From the migration worktree:
220
+
221
+ ```bash
222
+ # Stash your migration work, run the suspect spec against the base commit.
223
+ git stash --include-untracked
224
+ <test-cmd> --testPathPattern=<spec-path>
225
+ git stash pop
226
+ ```
227
+
228
+ If the spec was already red, mark it in the **skill friction log** of your migration report (so the orchestrator surfaces the pre-existing breakage to the user) and use the **minimum-viable rebaseline** below. Do NOT fold a from-scratch rewrite of the spec into the migration commit — that hides genuine regressions in a sea of test churn.
229
+
230
+ ### Minimum-viable rebaseline
231
+
232
+ Replace the broken spec with the smallest spec that proves the component renders and exercises its primary action. Everything else is a follow-up ticket.
233
+
234
+ ```ts skip
235
+ // settings.component.spec.ts — minimum-viable rebaseline
236
+ import { TestBed } from '@angular/core/testing';
237
+ import { provideAppTreeForTesting } from '../../store/tree/app-tree.testing';
238
+ import { SettingsComponent } from './settings.component';
239
+
240
+ describe('SettingsComponent (rebaseline — pre-existing fixtures removed)', () => {
241
+ beforeEach(() => {
242
+ TestBed.configureTestingModule({
243
+ imports: [SettingsComponent],
244
+ providers: [provideAppTreeForTesting()],
245
+ });
246
+ });
247
+
248
+ it('renders', () => {
249
+ const fixture = TestBed.createComponent(SettingsComponent);
250
+ fixture.detectChanges();
251
+ expect(fixture.nativeElement).toBeTruthy();
252
+ });
253
+ });
254
+ ```
255
+
256
+ **Rules:**
257
+
258
+ - Cap the rebaseline at one render-smoke test plus one assertion of the most important user action. Bigger rewrites belong in a follow-up.
259
+ - Add a `// TODO(<ticket>): restore detailed coverage — minimum-viable rebaseline during SignalTree migration` comment so the deliberate gap is visible in code review.
260
+ - If the orchestrator is driving the migration ([`orchestrating-a-migration.md`](./orchestrating-a-migration.md)), the implementer must **ASK before rebaselining** rather than silently rewriting; silent rebaselines defeat the audit trail.
261
+
262
+ ## Common test-bed pitfalls
263
+
264
+ - **Mocking `AppStore` with `useValue`** doesn't stop Angular from also instantiating _other_ root-provided services that themselves inject `APP_TREE`. Always provide `APP_TREE` (via `provideAppTreeForTesting()`), even if `AppStore` is also mocked.
265
+ - **Don't seed via `createAppTree()`.** That production factory often layers `batching()`, `devTools()`, etc. Use `signalTree(createBaseState())` directly so tests stay deterministic.
266
+ - **`takeUntilDestroyed(destroyRef)` inside Ops** is a no-op for `providedIn: 'root'` Ops because root services live for the application's lifetime. In tests, this means subscriptions started by Ops are not cleaned up between specs — either:
267
+ - drive Ops via `firstValueFrom(ops.someMethod$())` so completion is explicit,
268
+ - or scope cancellation with an explicit `Subject<void>` exposed on the Ops class.
269
+ See "Lifetime caveat" in [`patterns.md`](./patterns.md#lifetime-caveat-for-providedin-root-ops).
270
+ - **`ɵNotFound: NG0201` mid-test, halfway through a suite that previously passed** almost always means a new `providedIn: 'root'` consumer started transitively touching `AppStore`. Add `provideAppTreeForTesting()` to the failing test bed; do not mock the new consumer.
271
+
272
+ ## Quick checklist for the agent
273
+
274
+ Before declaring a SignalTree migration "done":
275
+
276
+ 1. ✅ `app-tree.testing.ts` exists alongside `app-tree.ts` exporting `provideAppTreeForTesting()`.
277
+ 2. ✅ `createBaseState()` is exported from `app-tree.ts`.
278
+ 3. ✅ Every `TestBed.configureTestingModule({...})` call in the migrated app that fails with `NG0201: APP_TREE` has `provideAppTreeForTesting()` added to its `providers`.
279
+ 4. ✅ Ops specs use a real tree + real Ops + mocked downstream services.
280
+ 5. ✅ Component specs use a real seeded tree + mocked Ops.
281
+ 6. ✅ Seeds for `Nullable<Object>` leaves use post-injection `.set()`, not the `overrides` callback (see gotcha).
282
+ 7. ✅ Test suite runs to green before the next layer of refactor.