diff --git a/packages/data-solid-dashboard/src/components/control-panel.presentation.tsx b/packages/data-solid-dashboard/src/features/main/ui/control-panel/control-panel.presentation.tsx similarity index 100% rename from packages/data-solid-dashboard/src/components/control-panel.presentation.tsx rename to packages/data-solid-dashboard/src/features/main/ui/control-panel/control-panel.presentation.tsx diff --git a/packages/data-solid-dashboard/src/components/control-panel.tsx b/packages/data-solid-dashboard/src/features/main/ui/control-panel/control-panel.tsx similarity index 56% rename from packages/data-solid-dashboard/src/components/control-panel.tsx rename to packages/data-solid-dashboard/src/features/main/ui/control-panel/control-panel.tsx index 57e9e4b5..d482eaa7 100644 --- a/packages/data-solid-dashboard/src/components/control-panel.tsx +++ b/packages/data-solid-dashboard/src/features/main/ui/control-panel/control-panel.tsx @@ -1,13 +1,14 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { fromObserve, useDatabase } from "@adobe/data-solid"; -import { dashboardPlugin } from "../state/dashboard-plugin"; -import * as presentation from "./control-panel.presentation"; +import { MainService } from "../../services/main-service/main-service.js"; +import * as presentation from "./control-panel.presentation.jsx"; export function ControlPanel() { - const db = useDatabase(dashboardPlugin); + const db = useDatabase(MainService.plugin); const count = fromObserve(db.observe.resources.count, 0); const { increment, decrement, reset, setUserName } = db.transactions; + const setName = (name: string) => setUserName({ name }); - return presentation.render({ count, increment, decrement, reset, setUserName }); + return presentation.render({ count, increment, decrement, reset, setUserName: setName }); } diff --git a/packages/data-solid-dashboard/src/components/counter-display.presentation.tsx b/packages/data-solid-dashboard/src/features/main/ui/counter-display/counter-display.presentation.tsx similarity index 100% rename from packages/data-solid-dashboard/src/components/counter-display.presentation.tsx rename to packages/data-solid-dashboard/src/features/main/ui/counter-display/counter-display.presentation.tsx diff --git a/packages/data-solid-dashboard/src/components/counter-display.tsx b/packages/data-solid-dashboard/src/features/main/ui/counter-display/counter-display.tsx similarity index 57% rename from packages/data-solid-dashboard/src/components/counter-display.tsx rename to packages/data-solid-dashboard/src/features/main/ui/counter-display/counter-display.tsx index b4f9b8d5..2f71e8b3 100644 --- a/packages/data-solid-dashboard/src/components/counter-display.tsx +++ b/packages/data-solid-dashboard/src/features/main/ui/counter-display/counter-display.tsx @@ -1,11 +1,11 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { fromObserve, useDatabase } from "@adobe/data-solid"; -import { dashboardPlugin } from "../state/dashboard-plugin"; -import * as presentation from "./counter-display.presentation"; +import { MainService } from "../../services/main-service/main-service.js"; +import * as presentation from "./counter-display.presentation.jsx"; export function CounterDisplay() { - const db = useDatabase(dashboardPlugin); + const db = useDatabase(MainService.plugin); const count = fromObserve(db.observe.resources.count, 0); return presentation.render({ count }); diff --git a/packages/data-solid-dashboard/src/components/status-bar.presentation.tsx b/packages/data-solid-dashboard/src/features/main/ui/status-bar/status-bar.presentation.tsx similarity index 100% rename from packages/data-solid-dashboard/src/components/status-bar.presentation.tsx rename to packages/data-solid-dashboard/src/features/main/ui/status-bar/status-bar.presentation.tsx diff --git a/packages/data-solid-dashboard/src/components/status-bar.tsx b/packages/data-solid-dashboard/src/features/main/ui/status-bar/status-bar.tsx similarity index 73% rename from packages/data-solid-dashboard/src/components/status-bar.tsx rename to packages/data-solid-dashboard/src/features/main/ui/status-bar/status-bar.tsx index 26bdbe54..5f2cfe24 100644 --- a/packages/data-solid-dashboard/src/components/status-bar.tsx +++ b/packages/data-solid-dashboard/src/features/main/ui/status-bar/status-bar.tsx @@ -2,11 +2,11 @@ import { createMemo } from "solid-js"; import { fromObserve, useDatabase } from "@adobe/data-solid"; -import { dashboardPlugin } from "../state/dashboard-plugin"; -import * as presentation from "./status-bar.presentation"; +import { MainService } from "../../services/main-service/main-service.js"; +import * as presentation from "./status-bar.presentation.jsx"; export function StatusBar() { - const db = useDatabase(dashboardPlugin); + const db = useDatabase(MainService.plugin); const userName = fromObserve(db.observe.resources.userName, "Guest"); const count = fromObserve(db.observe.resources.count, 0); const log = fromObserve(db.observe.resources.log, []); diff --git a/packages/data-solid-dashboard/src/main.tsx b/packages/data-solid-dashboard/src/main.tsx index dfb12c85..8fbc4fd4 100644 --- a/packages/data-solid-dashboard/src/main.tsx +++ b/packages/data-solid-dashboard/src/main.tsx @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { render } from "solid-js/web"; -import { App } from "./app"; +import { App } from "./features/main/ui/app/app.jsx"; const root = document.getElementById("root"); if (root) { diff --git a/packages/data-solid-dashboard/src/state/dashboard-plugin.ts b/packages/data-solid-dashboard/src/state/dashboard-plugin.ts deleted file mode 100644 index 9deb9151..00000000 --- a/packages/data-solid-dashboard/src/state/dashboard-plugin.ts +++ /dev/null @@ -1,34 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. - -import { Database } from "@adobe/data/ecs"; - -export const dashboardPlugin = Database.Plugin.create({ - resources: { - count: { default: 0 as number }, - log: { default: [] as readonly string[] }, - userName: { default: "Guest" as string }, - }, - transactions: { - increment: (t) => { - t.resources.count += 1; - t.resources.log = [...t.resources.log, `Incremented to ${t.resources.count}`]; - }, - decrement: (t) => { - if (t.resources.count > 0) { - t.resources.count -= 1; - t.resources.log = [...t.resources.log, `Decremented to ${t.resources.count}`]; - } - }, - reset: (t) => { - t.resources.count = 0; - t.resources.log = [...t.resources.log, "Reset to 0"]; - }, - setUserName: (t, name: string) => { - t.resources.userName = name; - t.resources.log = [...t.resources.log, `Name changed to ${name}`]; - }, - clearLog: (t) => { - t.resources.log = []; - }, - }, -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f4ad2f05..bcc4864e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -489,6 +489,9 @@ importers: '@vitejs/plugin-react': specifier: ^4.3.0 version: 4.3.0(vite@5.1.1) + jsdom: + specifier: ^24.1.0 + version: 24.1.0 typescript: specifier: ^5.8.3 version: 5.8.3 @@ -498,6 +501,9 @@ importers: vite-plugin-checker: specifier: ^0.12.0 version: 0.12.0(typescript@5.8.3)(vite@5.1.1) + vitest: + specifier: ^1.6.0 + version: 1.6.0(@types/node@25.6.0)(@vitest/browser@1.6.0)(jsdom@24.1.0) packages/data-solid: dependencies: @@ -527,6 +533,9 @@ importers: specifier: ^1.9.12 version: 1.9.12 devDependencies: + jsdom: + specifier: ^24.1.0 + version: 24.1.0 typescript: specifier: ^5.8.3 version: 5.8.3 @@ -539,6 +548,9 @@ importers: vite-plugin-solid: specifier: ^2.11.0 version: 2.11.0(solid-js@1.9.12)(vite@5.1.1) + vitest: + specifier: ^1.6.0 + version: 1.6.0(@types/node@25.6.0)(@vitest/browser@1.6.0)(jsdom@24.1.0) packages/data-sync: dependencies: From 357d8104b25bdbba9cd4829a54602dadc970e22e Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Thu, 6 Aug 2026 15:28:04 -0700 Subject: [PATCH 02/37] docs(rules): feature-architecture rules for the co-located conformance pattern - data/state.md: one file = function + cases (Conformance/Derivation, two-exports rule); anyNumber matchers; injected services + declared effects; single spec.test.ts - conformance.md: matcher-aware compare; toData projection; the four runners (spec / transaction / action / computed) each with a coverage guard; recording doubles (no Proxy) - transactions/actions/computed.md: central conformance aggregators; every transition has an action (state + effects); computed conforms to its derivation Co-Authored-By: Claude Opus 4.8 --- .../.claude/rules/features/data/state.md | 143 ++++++++---------- .../features/services/main-service/actions.md | 12 ++ .../services/main-service/computed.md | 7 + .../services/main-service/conformance.md | 112 +++++++------- .../services/main-service/transactions.md | 16 +- 5 files changed, 151 insertions(+), 139 deletions(-) diff --git a/packages/data-ai/.claude/rules/features/data/state.md b/packages/data-ai/.claude/rules/features/data/state.md index 33187348..12ba5b59 100644 --- a/packages/data-ai/.claude/rules/features/data/state.md +++ b/packages/data-ai/.claude/rules/features/data/state.md @@ -5,94 +5,83 @@ paths: # data/state/ — the State specification -`State` is the whole feature modelled as **one immutable object** — the -pure, fully-tested source of truth for the feature. Modelling everything as -a single value keeps each transform a small, trivially-testable function of -state: collections of entity sub-types plus scalar fields. +`State` is the whole feature as **one immutable object** — the pure, fully-tested +source of truth. Each transform is a small function of state; each derivation a +pure selector. Reference: `data-lit-todo`'s `data/state/`. -Every feature that has ECS resources or transactions owns a `State` — including -a host whose aggregate is a single scalar (`{ playing: boolean }`) or even when there is no state in which case use `{}`. ```ts -// state.ts — the aggregate, plus the namespace of transforms/derivations. -export type State = { - readonly todos: readonly Todo[]; // a collection of entity sub-types - readonly displayCompleted: boolean; // a scalar field -}; +// state.ts — the aggregate + the transform/derivation namespace. +export type State = { readonly todos: readonly Todo[]; readonly displayCompleted: boolean }; export * as State from "./public.js"; ``` -## Transforms — one per file, `(state, …args) => state` +Every feature with ECS resources/transactions owns a `State` (a scalar +`{ playing: boolean }`, or `{}` when there is none). -- **Pure over its inputs.** No *ambient* I/O, no framework, no mutation — return - a new value. A capability the transform genuinely needs (a clock, a random - source, a name generator, a `services/` port) is passed in as an **injected - dependency** (see below), never reached ambiently; given what it is handed the - transform stays deterministic. -- **Narrow in, same shape out.** Write on the smallest slice the transform - needs and keep the input generic over that slice so it lifts to - full-state-in / full-state-out: +## One file per transform: the function **and** its cases - ```ts - export const playMove = >( - state: T, - input: PlayMoveArgs, - ): T => { /* … return { ...state, board: … } */ }; - ``` - - A whole-`State` transform (`restartGame(state: State): State`) is fine when - it genuinely touches everything. -- Args may be **narrowed or omitted** (`toggleDisplayCompleted(state)`). -- Guard and **return `state` unchanged** on a no-op / illegal input rather - than throwing — this keeps transforms idempotent under repeated application. -- Each transform has a sibling `*.test.ts`; performance is irrelevant here, - correctness is everything. The test file **exports** its cases — - `export const cases: ConformanceCase[]` (the shared `conformance-case.ts` - type) — right alongside the `describe`/`it` that exercise them. This is - spec-owned truth the matching main-service conformance test imports **from the - `.test.js` file** unchanged (see `services/main-service/conformance.md`). - Keeping the cases in the test file rather than a separate `.cases.ts` - removes a file per transform — less folder clutter, same reuse. Author - `before`/`after` as full `State` (`{ ...State.create(), …overrides }`); the - generic-slice signature lets them flow through. Tolerant full-`State` equality is - the shared `expect-state-matches.ts`. - -## Injected dependencies — services and other ports - -A transform that needs a capability it cannot compute from `state` alone -receives it as an **injected dependency**, bundled with its plain data args into -a single **named-parameter object**. This keeps dependencies and data named at -the call site and lets tests substitute the capability. +A transform file exports **exactly two things** — the function and its +`cases` — nothing else (a private helper is fine; a second export is not, and the +spec aggregator throws if it finds one). The cases are the spec-owned truth every +conformance runner reuses; co-locating them removes the per-transform `.cases.ts` +and `.test.ts`. ```ts -export const addTodo = >( +// create-todo.ts +export const createTodo = >( state: T, - { nameGenerator, priority }: { nameGenerator: NameGeneratorService; priority: number }, -): T => { /* … uses nameGenerator, priority … return { ...state, todos: … } */ }; + { name, complete, analytics }: { name: string; complete?: boolean; analytics: AnalyticsService }, +): T => { analytics.todoCreated({ name }); return appendTodo(state, { name, complete }); }; + +export const cases: Conformance = [ + { name: "appends the first todo", + before: { todos: [], displayCompleted: false }, + args: { name: "a", analytics: AnalyticsService.createFake() }, + after: { todos: [{ id: anyNumber, name: "a", complete: false }], displayCompleted: false }, + effects: { analytics: [["todoCreated", { name: "a" }]] } }, +]; ``` -- **A service dependency is keyed by the service name minus its `-service` - suffix**, typed as the service interface: `NameGeneratorService` → - `nameGenerator`, `ClockService` → `clock`. The key names the port; the value - is the interface. Non-service dependencies (plain values, sync or async - callbacks) sit in the same object under whatever name reads best, e.g. - `{ foo: FooService, bar: BarService, retries: 12, label: "baz" }`. -- Import the service (and any utilities its namespace exposes) straight from - `services/` — an ordinary import, not type-only. A transition may depend on - `services/` freely; the layers split by kind of type, not by dependency - (`features/index.md`). The caller still supplies the implementation instance. -- The transform is **deterministic given its dependencies** — fix the - dependencies and the output is fully determined. That is what keeps it - testable and lets conformance treat it as the oracle. -- An **async** dependency makes the transition itself async (it returns - `Promise`); reserve that for transitions that genuinely need the - outside world and keep synchronous transforms the default. -- **Tests inject deterministic test doubles**, never the production service. - Author those doubles adjacent to the service interface and rely on their - **published, exact responses** to compute the expected `after` — see - `features/services/index.md`. +- **Signature** `(state, args) => state`. Narrow-in/same-shape-out — generic over + the smallest `Pick` slice so it lifts to full-state. Args may be + narrowed/omitted. **Guard no-ops by returning `state` unchanged**, never throw. +- **`Conformance`** derives the case `args` type from the function's + own signature — author it once, and cases can't drift from what the function + accepts. `before`/`after` are full `State`. +- **`after` leaves minted values open** with the `anyNumber`/`anyString` matchers + (`matchers.ts`, wrapping vitest `expect.any`): an id the ECS assigns from its + own id-space is `id: anyNumber`, so the pure spec and the ECS satisfy the same + case. Match by content, not by the value you don't control. +- No per-transform test. The single **`spec.test.ts`** auto-discovers every file + exporting `cases` and asserts the pure result (see `conformance.md`). + +## Injected services and side effects + +A transform that needs an outside capability receives it as a **named parameter** +in the args object, keyed by the service name minus its `-service` suffix +(`AnalyticsService` → `analytics`, `NameGeneratorService` → `nameGenerator`); +plain data args sit alongside. Import the service straight from `services/` (an +ordinary import — layers split by kind of type, not dependency). The **same +services appear on `db.services` for the matching action**, so the transition is +the complete spec of *both* the state change and the service calls. + +- A transition **is deterministic given its dependencies** — inject a fixed + double and the output (and its calls) are fixed. An **async** dependency makes + the transition `Promise`; keep sync the default. +- **Side effects are declared in the case's `effects`**, keyed by the service + arg, as `[methodName, ...args]` tuples — an `Array` asserts these calls in + order, a `Set` in any order. Only listed services are checked (a value-returning + read like `generateName` you don't list is ignored). See `conformance.md` for + how the recording double captures them. +- Tests inject **deterministic doubles** (never production) whose published + responses the case's `after`/`effects` are authored against — doubles live + adjacent to the interface (`features/services/index.md`). -## Derivations — `(state) => value` +## Derivations — `(state) => value`, cases `{ input, value }` -Pure selectors (`visibleTodos(state)`). Sub-type math (a winner, a status) -lives on the relevant `data/` namespace; `state/` only composes over -the whole aggregate. +Pure selectors (`visibleTodos`). A derivation co-locates cases too, but shaped +`{ input, value }` and typed `Derivation` (input + value read from the +signature); `value` may use matchers. The same `spec.test.ts` runs them (it +dispatches on case shape), and its ECS computed is conformance-tested from the +same cases (`conformance.md`). Sub-type math (a winner, a status) lives on the +relevant `data/` namespace; `state/` only composes over the aggregate. diff --git a/packages/data-ai/.claude/rules/features/services/main-service/actions.md b/packages/data-ai/.claude/rules/features/services/main-service/actions.md index 8220d1ff..5ccf48ca 100644 --- a/packages/data-ai/.claude/rules/features/services/main-service/actions.md +++ b/packages/data-ai/.claude/rules/features/services/main-service/actions.md @@ -10,6 +10,13 @@ argument and pure `data/` args. Actions orchestrate anything *outside* a single transaction — awaiting a `services/` port, sequencing calls, deriving timing — and then commit the result through a transaction. +**Every state transition has a corresponding same-named action** — the async, +app-facing realization the UI calls. It reads the same services the transition +injects from `db.services`, so it reproduces both the transition's state change +(through a transaction) and its side effects. It may reuse another transition's +transaction (`createRandomTodo` reuses `createTodo`) — there need not be a +same-named transaction; transactions are the looser layer. + ```ts import type { ServiceDatabase } from "../../service-database/service-database.js"; @@ -29,4 +36,9 @@ export const addRandomTodo = async (service: ServiceDatabase) => { awaited-internally, never surfaced to the caller. - Do the outside-world work here: await/sequence `services/` calls, and if a slow call needs timing, compute it here around the call. +- **Conformance** (`conformance/actions.test.ts`) runs each transition's shared + cases against its action, asserting **state and effects**: build the db with + fake services via `Database.create(MainService.plugin, { services })`, run the + action, then `matches(toState, after)` and check the recorded service calls + against the case's `effects` (see `conformance.md`). - An `index.ts` barrel feeds the `actions` plugin facet. diff --git a/packages/data-ai/.claude/rules/features/services/main-service/computed.md b/packages/data-ai/.claude/rules/features/services/main-service/computed.md index 8d6e8133..6990b8ab 100644 --- a/packages/data-ai/.claude/rules/features/services/main-service/computed.md +++ b/packages/data-ai/.claude/rules/features/services/main-service/computed.md @@ -24,3 +24,10 @@ export const status = cached((service: IndexDatabase) => Type the parameter on the lowest database layer that exposes what it reads. An `index.ts` barrel re-exports every computed; `computed-database.ts` registers it under the `computed` facet. + +**Conform a computed to its `data/` derivation** whenever one exists. The +derivation co-locates `{ input, value }` cases (`Derivation`), and +`conformance/computeds.test.ts` seeds the store from `input`, reads the computed's +value, and `matches(value)` (see `conformance.md`). A list-computed returning +entity ids needs no adapter — the runner hydrates through `toData`. A computed +with no pure `data/` derivation (an index-only helper like `allTodos`) is exempt. diff --git a/packages/data-ai/.claude/rules/features/services/main-service/conformance.md b/packages/data-ai/.claude/rules/features/services/main-service/conformance.md index 52e74a33..c1e8c251 100644 --- a/packages/data-ai/.claude/rules/features/services/main-service/conformance.md +++ b/packages/data-ai/.claude/rules/features/services/main-service/conformance.md @@ -3,70 +3,70 @@ paths: - '**/features/*/services/main-service/**/conformance/**/*.ts' --- -# services/main-service/conformance/ — spec↔implementation projection (test-only) +# services/main-service/conformance/ — keeping the ECS honest against the spec -The bridge that keeps the ecs implementation honest against the `data/` spec. -**Test-only**: imported solely by `*.test.ts` and in no facet barrel, so it never -enters the runtime bundle (test-support may import the test framework — it is not -a runtime declaration). **Never call `fromState` / `toState` (or a shared -clear-all + reinsert helper used only for that projection) from systems, -transactions, or UI** — that is a full-store rewrite, not an O(1) ECS update. -**Feature-level**: one projection per feature, reused by every transaction test -and the system tick-loop test — don't nest it under `transaction-database/`. +Test-only (imported only by `*.test.ts`, in no facet barrel). The `data/state` +cases are the shared truth; these runners replay them against the ECS. Reference: +`data-lit-todo`'s `conformance/` + its `spec.test.ts`. Never call +`fromState`/`toState`/`toData` from runtime code — they are full-store rewrites. -The property, per `{ before, args, after }` case: -`toState(apply(fromState(before), args)) ≡ spec(before, args)`. +## Projection (store ⇄ State) -Conformance test-support splits by concern along the layer boundary: +- `from-state.ts` — `fromState(store, state)` seeds a store to a `State` (clear + tail→head, insert entities, set resources). +- `to-data.ts` — **`toData(store, entity)`**: read one entity as its `data/` + value. The single place the ECS↔data mapping lives. +- `to-state.ts` — `toState(store)` reads the whole store back, built on `toData`. -- **Store-side, here in `services/main-service/conformance/`:** - - `from-state.ts` → `fromState(store: CoreDatabase.Store, state: State): void` - seeds the store (clear tail→head → insert entities → set resources) to a `State`. - - `to-state.ts` → `toState(store): State` reads the store back — each kind via - its full named-archetype component set, so the shapes never alias. - - `expect-conforms.ts` → **one export, `expectConforms({ cases, spec, apply })`**. - Per case it asserts `spec(before, args) ≡ after` (keeps the case honest), then - `fromState(before)` → `apply(store, args)` → `toState ≡ after`. -- **Spec-side, in `data/state/`** (State values, no store — so both the `data/` - transform tests and this runner import them without a layer violation): - `conformance-case.ts` (`ConformanceCase`), the cases **exported from each - `.test.ts`** (`import { cases } from "…/.test.js"` — no - separate `.cases.ts` file), and `expect-state-matches.ts` (State equality). +## Comparison — `expect-state-matches.ts` -## No cast — build on `Store.create`, not a `Database` +One matcher-aware `matches(actual, expected)` (exported; also backs derivations): +honors vitest **asymmetric matchers** on the expected side (so `after`/`value` +use `anyNumber` for ECS-assigned ids), quantizes numbers to absorb F32↔f64 noise, +and compares arrays **in order** (`toState` reads in display order — this is what +verifies a reorder). No separate id-ignoring variant. `expectStateMatches` / +`expectMatches` wrap it. -A **transaction is `(store, args) => void`**, so transaction conformance needs no -`Database`: `Store.create(.plugin)` returns a cast-free writable -`CoreDatabase.Store` — pass the plugin directly, `Store.create` reads its schema -facets. Source it from the **lowest layer that declares all the schema** — -`IndexDatabase`, or `CoreDatabase` if the feature has no indexes — **not** -`MainService`: the store needs only schema, and the behaviour layers -(transactions / computed / systems) add none. -`fromState`/`toState`/`apply` all operate on it, and `apply` calls the **raw -transaction function** directly: +## The runners — one aggregator per surface, each with a coverage guard -```ts -expectConforms({ cases, spec: State.hitTarget, apply: (store, args) => hitTarget(store, args) }); -``` +The same cases drive every surface. Each aggregator auto-discovers via +`import.meta.glob` (`/// `) and asserts every +item is wired, so none are missed. Pair by name. -A mutation addressed by **entity id** resolves its entities from the seeded store -inside `apply` (the shared cases stay spec-shaped). Reads never need a cast — -`Database extends ReadonlyStore`. +- **`spec.test.ts` (in `data/state/`)** — the pure suite. Discovers every file + exporting `cases`; dispatches on shape: `"after"` → transition + (`matches(fn(before,args), after)` + effects), `"value"` → derivation + (`matches(fn(input), value)`). Enforces the two-exports rule. +- **`transactions.test.ts`** — each transition's transaction, state only. Store + built cast-free via `Store.create(IndexDatabase.plugin)` (lowest schema layer). + Per transaction: `fromState(before)` → `apply(store, args, resolve)` → + `matches(toState, after)`. An id-addressed transaction resolves entities via + `resolve` (spec id → seeded entity); a differently-named transaction (`dragTodo` + ⇄ `reorderTodo`) just wires its cases explicitly. +- **`actions.test.ts`** — each transition's action, state **and** effects. Build + the db with fake services via the `Database.create` override: + `Database.toSystemDatabase(Database.create(MainService.plugin, { services }))`. + Split the case `args` by key: service-typed keys become the (recording) service + overrides, the rest is the action input. Run the (async) action, then + `matches(toState, after)` + assert the recorded calls against `effects`. +- **`computeds.test.ts`** — each derivation's ECS computed. `fromState(input)` → + read the computed's synchronous emission (`readComputed`: subscribe once) → + `matches(value)`. ECS list-computeds are entity-id based, so the runner + **hydrates** the output through `toData` by default — an id-based computed needs + no adapter; override only for a non-entity output (a scalar). -**Systems** are the one exception: they run via `db.system.functions` and reach -the store through the db. Get that writable view with the library lens — -`Database.toSystemDatabase(Database.create(plugin))` (the widening twin of -`UIService.restrict`, cast-free) — then `fromState(db.store, before)` → drive one -frame → `toState(db.store)`. Test **selection/detection** logic (which entities -interact) separately, with seeded edge-case geometries. +## Recording side effects — no Proxy -## State equality — ordering vs precision, kept separate +A one-liner wraps a plain-object service so each method call is recorded, then +delegates (enumerate its own methods and closure-wrap — no `Proxy`, per the repo +rule). The spec test wraps the case's injected services; the action runner wraps +the `db.services` overrides. `effects` asserts each **declared** service's calls +exactly (Array = ordered, Set = any order); undeclared services (value-returning +reads) are ignored. -`expect-state-matches.ts` compares with **`equalsUnordered`** from `@adobe/data` -(arrays as multisets — archetype hole-fill reorders rows — objects key-order -independent). Absorb float noise — F32↔f64 storage rounding **and** trig epsilon -(a quadrant `cos`/`sin` yields ~3e-15 where a case authors `0`) — by quantizing -every number on both sides onto a small grid (`Math.round(Math.fround(n)*k)/k`) -before comparing. Ordering and precision stay separate concerns. **Guard the -projection itself** with one `fromState → toState` identity test on -representative states. +## Structural guard + +Guard the projection with one `fromState → toState` identity test on +representative states (`projection.test.ts`), comparing with `anyNumber` ids. +Systems reach the store through the db — drive one frame on +`Database.toSystemDatabase(Database.create(plugin))`, then `toState(db.store)`. diff --git a/packages/data-ai/.claude/rules/features/services/main-service/transactions.md b/packages/data-ai/.claude/rules/features/services/main-service/transactions.md index 98a957e7..e7ff998d 100644 --- a/packages/data-ai/.claude/rules/features/services/main-service/transactions.md +++ b/packages/data-ai/.claude/rules/features/services/main-service/transactions.md @@ -30,12 +30,16 @@ export const playMove = (t: CoreDatabase.Store, { index }: PlayMoveArgs) => { than throwing. - Decisions come from pure `data/` helpers; the transaction only applies the result — read the touched slice, call the `data/` transform, write the diff. -- **Conform every transaction to its `data/` transform** in its sibling - `*.test.ts`, via `expectConforms` over the spec's shared cases (see - `services/main-service/conformance.md`): seed `fromState(before)`, dispatch, assert `toState ≡ - after`, covering every branch and edge case. A transaction taking **entity - ids** resolves them from the seeded store in the `apply` closure; one with no - `data/` analogue (`setBounds`, `setInput`) gets a direct resource assertion. +- Keep transaction files **single-export** (the `transactions/` barrel is + `export *`-ed into the plugin facet, so a second export would pollute it). +- **Conformance is wired once, centrally** — not per-file. `conformance/transactions.test.ts` + runs each transition's shared `data/state` cases against its transaction + (`fromState(before)` → apply → `matches(toState, after)`), with a coverage + guard so none are missed (see `conformance.md`). A transaction taking **entity + ids** resolves them from the seeded store; a differently-named or reused + transaction (`dragTodo` ⇄ `reorderTodo`) wires its cases explicitly; an extra + transaction with no `data/` analogue (`setBounds`, `setInput`) gets a direct + resource assertion. - An `index.ts` barrel feeds the `transactions` plugin facet — so it must re-export **only** the mutations. A read/query helper shared by several transactions (`readShip`, `readBoard` — a `(t) => value` function) may live From 7b773804167a83ead82ae769b028c3969b124043 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Thu, 6 Aug 2026 16:34:06 -0700 Subject: [PATCH 03/37] docs(rules): computed conformance builds from the ComputedDatabase layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Iteration 1 refinement (from a sample conversion): - conformance.md: computed runner builds the db from the ComputedDatabase layer, not the assembled MainService — a subscribing service/action above it would withCache the pre-seed value that a direct fromState seed can't invalidate - state.md: add matchers.ts only when a minted value needs it; derivation `input` is a full State - align the todo reference computeds.test.ts to build from ComputedDatabase Co-Authored-By: Claude Opus 4.8 --- .../data-ai/.claude/rules/features/data/state.md | 15 +++++++++------ .../features/services/main-service/conformance.md | 7 +++++++ .../main-service/conformance/computeds.test.ts | 9 +++++++-- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/packages/data-ai/.claude/rules/features/data/state.md b/packages/data-ai/.claude/rules/features/data/state.md index 12ba5b59..9db48579 100644 --- a/packages/data-ai/.claude/rules/features/data/state.md +++ b/packages/data-ai/.claude/rules/features/data/state.md @@ -51,7 +51,9 @@ export const cases: Conformance = [ - **`after` leaves minted values open** with the `anyNumber`/`anyString` matchers (`matchers.ts`, wrapping vitest `expect.any`): an id the ECS assigns from its own id-space is `id: anyNumber`, so the pure spec and the ECS satisfy the same - case. Match by content, not by the value you don't control. + case. Match by content, not by the value you don't control. Add `matchers.ts` + only when a case needs one — a feature whose `State` exposes no ECS-minted ids + (values abstracted behind a scalar/string) never does. - No per-transform test. The single **`spec.test.ts`** auto-discovers every file exporting `cases` and asserts the pure result (see `conformance.md`). @@ -79,9 +81,10 @@ the complete spec of *both* the state change and the service calls. ## Derivations — `(state) => value`, cases `{ input, value }` -Pure selectors (`visibleTodos`). A derivation co-locates cases too, but shaped -`{ input, value }` and typed `Derivation` (input + value read from the -signature); `value` may use matchers. The same `spec.test.ts` runs them (it -dispatches on case shape), and its ECS computed is conformance-tested from the -same cases (`conformance.md`). Sub-type math (a winner, a status) lives on the +Pure selectors (`visibleTodos`, `winner`, `status`). A derivation co-locates +cases too, but shaped `{ input, value }` and typed `Derivation` (input ++ value read from the signature); author `input` as a full `State` (the computed +conformance seeds it via `fromState`), and `value` may use matchers. The same +`spec.test.ts` runs them (it dispatches on case shape), and its ECS computed is +conformance-tested from the same cases (`conformance.md`). Sub-type math (a winner, a status) lives on the relevant `data/` namespace; `state/` only composes over the aggregate. diff --git a/packages/data-ai/.claude/rules/features/services/main-service/conformance.md b/packages/data-ai/.claude/rules/features/services/main-service/conformance.md index c1e8c251..13f90060 100644 --- a/packages/data-ai/.claude/rules/features/services/main-service/conformance.md +++ b/packages/data-ai/.claude/rules/features/services/main-service/conformance.md @@ -54,6 +54,13 @@ item is wired, so none are missed. Pair by name. `matches(value)`. ECS list-computeds are entity-id based, so the runner **hydrates** the output through `toData` by default — an id-based computed needs no adapter; override only for a non-entity output (a scalar). + **Build the db from the `ComputedDatabase` layer (the lowest layer exposing the + computeds), not the assembled `MainService`.** A behaviour layer above it (a + service/action that subscribes to a computed at construction) would `withCache` + the pre-seed value, and a direct `fromState` seed emits no transaction to + invalidate it — reading on the computed layer keeps the seed authoritative. + Only computeds with a `data/` derivation are conformed (index-only helpers are + exempt); the coverage guard checks every derivation, not every computed. ## Recording side effects — no Proxy diff --git a/packages/data-lit-todo/src/features/main/services/main-service/conformance/computeds.test.ts b/packages/data-lit-todo/src/features/main/services/main-service/conformance/computeds.test.ts index f487c368..4c3ddea5 100644 --- a/packages/data-lit-todo/src/features/main/services/main-service/conformance/computeds.test.ts +++ b/packages/data-lit-todo/src/features/main/services/main-service/conformance/computeds.test.ts @@ -6,7 +6,7 @@ import type { Observe } from "@adobe/data/observe"; import type { State } from "../../../data/state/state.js"; import type { DerivationCase } from "../../../data/state/conformance-case.js"; import { expectMatches } from "../../../data/state/expect-state-matches.js"; -import { FeatureDatabase } from "../feature-database.js"; +import { ComputedDatabase } from "../computed-database/computed-database.js"; import { fromState } from "./from-state.js"; import { toData } from "./to-data.js"; import { visibleTodos } from "../computed-database/computed/visible-todos.js"; @@ -18,7 +18,12 @@ import { cases as visibleTodosCases } from "../../../data/state/visible-todos.js // `data/` values through the feature's per-entity `toData` (the same projection // `toState` uses) — so an id-based computed like `visibleTodos` needs no adapter. // This is the computed analog of the transaction/action runners. -const makeDb = () => Database.toSystemDatabase(Database.create(FeatureDatabase.plugin)); +// +// Built from the `ComputedDatabase` layer (which adds the computeds), not the +// assembled feature db: a behaviour layer above it that subscribes to a computed +// at construction would `withCache` the pre-seed value, and a direct `fromState` +// seed emits no transaction to invalidate it. +const makeDb = () => Database.toSystemDatabase(Database.create(ComputedDatabase.plugin)); type Db = ReturnType; // The default projection: hydrate a computed's entity-id list into the value From de2446539658372a172150881fa8c5b97d60259e Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Thu, 6 Aug 2026 17:01:55 -0700 Subject: [PATCH 04/37] docs(rules): barrel-driven coverage guards; void-arg-safe conformance runners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Iteration 2 refinements (from a sample conversion): - transaction coverage guard keys off the transactions barrel (registered mutations), not a file glob — a flat read helper no longer trips it - splitAndRecordServices / the action runner tolerate a no-arg transition (args: undefined) - conformance.md notes both as general rules Co-Authored-By: Claude Opus 4.8 --- .../services/main-service/conformance.md | 10 +++++++--- .../main/data/state/record-effects.ts | 19 +++++++++++-------- .../main-service/conformance/actions.test.ts | 2 +- .../conformance/transactions.test.ts | 15 ++++++--------- 4 files changed, 25 insertions(+), 21 deletions(-) diff --git a/packages/data-ai/.claude/rules/features/services/main-service/conformance.md b/packages/data-ai/.claude/rules/features/services/main-service/conformance.md index 13f90060..7220e46e 100644 --- a/packages/data-ai/.claude/rules/features/services/main-service/conformance.md +++ b/packages/data-ai/.claude/rules/features/services/main-service/conformance.md @@ -29,9 +29,13 @@ verifies a reorder). No separate id-ignoring variant. `expectStateMatches` / ## The runners — one aggregator per surface, each with a coverage guard -The same cases drive every surface. Each aggregator auto-discovers via -`import.meta.glob` (`/// `) and asserts every -item is wired, so none are missed. Pair by name. +The same cases drive every surface. Each aggregator's **coverage guard** asserts +every item is wired, so none are missed — keyed off the **registered set** (the +`transactions/index.ts` barrel, the derivation files), not a raw file glob, so a +shared read helper parked flat in `transactions/` (kept out of the barrel) or a +non-participating file never trips it. Pair by name. Runners must tolerate a +**no-arg transition** (`args: undefined`) — split/record on args only when it is +an object. - **`spec.test.ts` (in `data/state/`)** — the pure suite. Discovers every file exporting `cases`; dispatches on shape: `"after"` → transition diff --git a/packages/data-lit-todo/src/features/main/data/state/record-effects.ts b/packages/data-lit-todo/src/features/main/data/state/record-effects.ts index 2cbf0d31..8fb3d25f 100644 --- a/packages/data-lit-todo/src/features/main/data/state/record-effects.ts +++ b/packages/data-lit-todo/src/features/main/data/state/record-effects.ts @@ -73,7 +73,7 @@ export const expectEffects = ( // Split a case's `args` into the injected services (wrapped for recording, to be // used as `Database.create` service overrides) and the remaining plain data (the // action input). Keyed by the same arg name so `calls` matches against `effects`. -export const splitAndRecordServices = ( +export const splitAndRecordServices = ( args: Args, ): { services: Record; @@ -83,13 +83,16 @@ export const splitAndRecordServices = ( const services: Record = {}; const input: Record = {}; const calls: Record = {}; - for (const [key, value] of Object.entries(args)) { - if (isServiceValue(value)) { - const recorded = recordCalls(value); - services[key] = recorded.service; - calls[key] = recorded.calls; - } else { - input[key] = value; + // A no-arg transition has `undefined` args — nothing to split. + if (args !== null && typeof args === "object") { + for (const [key, value] of Object.entries(args)) { + if (isServiceValue(value)) { + const recorded = recordCalls(value); + services[key] = recorded.service; + calls[key] = recorded.calls; + } else { + input[key] = value; + } } } return { services, input, calls }; diff --git a/packages/data-lit-todo/src/features/main/services/main-service/conformance/actions.test.ts b/packages/data-lit-todo/src/features/main/services/main-service/conformance/actions.test.ts index a0e0cd2e..6171acba 100644 --- a/packages/data-lit-todo/src/features/main/services/main-service/conformance/actions.test.ts +++ b/packages/data-lit-todo/src/features/main/services/main-service/conformance/actions.test.ts @@ -40,7 +40,7 @@ type Db = ReturnType; type Run = (db: Db, input: Args, resolve: (specId: number) => Entity) => Promise | void; const covered = new Set(); -const conformsAction = ( +const conformsAction = ( action: string, config: { readonly cases: readonly ConformanceCase[]; readonly run: Run> }, ): void => { diff --git a/packages/data-lit-todo/src/features/main/services/main-service/conformance/transactions.test.ts b/packages/data-lit-todo/src/features/main/services/main-service/conformance/transactions.test.ts index d63170a8..d0895996 100644 --- a/packages/data-lit-todo/src/features/main/services/main-service/conformance/transactions.test.ts +++ b/packages/data-lit-todo/src/features/main/services/main-service/conformance/transactions.test.ts @@ -4,6 +4,7 @@ import { describe, it, expect } from "vitest"; import type { CoreDatabase } from "../core-database/core-database.js"; import type { ConformanceCase } from "../../../data/state/conformance-case.js"; import { expectConforms, type ResolveEntity } from "./expect-conforms.js"; +import * as registeredTransactions from "../transaction-database/transactions/index.js"; import { createTodo } from "../transaction-database/transactions/create-todo.js"; import { createBulkTodos } from "../transaction-database/transactions/create-bulk-todos.js"; import { deleteTodo } from "../transaction-database/transactions/delete-todo.js"; @@ -60,16 +61,12 @@ conforms("toggleDisplayCompleted", { apply: toggleDisplayCompleted, }); -// None-missed guard: every transaction file must be wired above. -const kebabToCamel = (name: string): string => - name.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase()); +// None-missed guard: every **registered** transaction must be wired above. Keyed +// off the barrel (the transactions the plugin actually dispatches), not a file +// glob — so a shared read helper parked flat in `transactions/` (kept out of the +// barrel) is naturally excluded. describe("transaction conformance coverage", () => { - const files = import.meta.glob([ - "../transaction-database/transactions/*.ts", - "!../transaction-database/transactions/index.ts", - ]); - for (const path of Object.keys(files)) { - const transaction = kebabToCamel(path.replace(/.*\//, "").replace(/\.ts$/, "")); + for (const transaction of Object.keys(registeredTransactions)) { it(`${transaction} has a conformance case`, () => expect(covered.has(transaction)).toBe(true)); } }); From 793f23b25f2d1efc36f95db770ccd5b82ea02419 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Thu, 6 Aug 2026 17:25:46 -0700 Subject: [PATCH 05/37] docs(rules): precise state/ derivation criterion; omit empty computeds.test.ts Iteration 3 refinement (from a sample conversion): - state.md: a state/ derivation composes >=2 State fields; a value from a single field is that type's math on its data/ namespace (winner/status), not state/. A feature may have zero state/ derivations. (Removes a self-contradiction that had winner/status listed both as state/ derivations and as sub-type math.) - conformance.md: a computed projecting one data/'s math is conformed by that type's tests; a feature with no state/ derivation omits computeds.test.ts (an empty aggregator fails vitest) Co-Authored-By: Claude Opus 4.8 --- .../.claude/rules/features/data/state.md | 19 +++++++++++++------ .../services/main-service/conformance.md | 8 ++++++-- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/data-ai/.claude/rules/features/data/state.md b/packages/data-ai/.claude/rules/features/data/state.md index 9db48579..173d6d72 100644 --- a/packages/data-ai/.claude/rules/features/data/state.md +++ b/packages/data-ai/.claude/rules/features/data/state.md @@ -81,10 +81,17 @@ the complete spec of *both* the state change and the service calls. ## Derivations — `(state) => value`, cases `{ input, value }` -Pure selectors (`visibleTodos`, `winner`, `status`). A derivation co-locates -cases too, but shaped `{ input, value }` and typed `Derivation` (input -+ value read from the signature); author `input` as a full `State` (the computed -conformance seeds it via `fromState`), and `value` may use matchers. The same -`spec.test.ts` runs them (it dispatches on case shape), and its ECS computed is -conformance-tested from the same cases (`conformance.md`). Sub-type math (a winner, a status) lives on the +Pure selectors that **compose the aggregate** — a value drawn from **two or more +`State` fields** (`visibleTodos` from `todos` + `displayCompleted`; +`currentPlayer` from `board` + `firstPlayer`). A value computed from a **single** +`State` field is that field's own type math and lives on its `data/` +namespace (`winner`/`status` from `board` → `data/board-state`), tested there — +**not** in `state/`. A feature may therefore have zero `state/` derivations. + +A `state/` derivation co-locates cases shaped `{ input, value }`, typed +`Derivation` (input + value read from the signature); author `input` +as a full `State` (computed conformance seeds it via `fromState`), and `value` +may use matchers. The same `spec.test.ts` runs them (dispatching on case shape), +and each ECS computed backing one is conformance-tested from the same cases +(`conformance.md`). Sub-type math (a winner, a status) lives on the relevant `data/` namespace; `state/` only composes over the aggregate. diff --git a/packages/data-ai/.claude/rules/features/services/main-service/conformance.md b/packages/data-ai/.claude/rules/features/services/main-service/conformance.md index 7220e46e..2e132db7 100644 --- a/packages/data-ai/.claude/rules/features/services/main-service/conformance.md +++ b/packages/data-ai/.claude/rules/features/services/main-service/conformance.md @@ -63,8 +63,12 @@ an object. service/action that subscribes to a computed at construction) would `withCache` the pre-seed value, and a direct `fromState` seed emits no transaction to invalidate it — reading on the computed layer keeps the seed authoritative. - Only computeds with a `data/` derivation are conformed (index-only helpers are - exempt); the coverage guard checks every derivation, not every computed. + Only computeds backing a `state/` derivation are conformed here; the coverage + guard checks every `state/` derivation, not every computed. A computed that + projects a single `data/`'s math (`winner`/`status` from the board) is + conformed by **that type's** helper tests, not here — and a feature with no + `state/` derivation **omits `computeds.test.ts` entirely** (an aggregator whose + guard registers zero tests fails vitest; don't ship an empty one). ## Recording side effects — no Proxy From dd63efed98ce130114885bd924cedb5d4bf7d7d9 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Thu, 6 Aug 2026 17:53:06 -0700 Subject: [PATCH 06/37] feat(tictactoe): convert to co-located conformance pattern; rule clarifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent conversion (iteration 4) of data-lit-tictactoe to the pattern: co-located cases, spec.test.ts, matcher-aware compare (no matchers needed — no minted ids), barrel-driven transaction guard, no-arg-safe action conformance, a state/ derivation (currentPlayer) + computeds.test.ts built from ComputedDatabase; single-field board math stays on data/board-state. Rule clarifications this surfaced: - state.md: a state/ derivation takes the full State param (not a Pick) - computed.md: a production computed wires the pure data/ helper, never imports a state/ derivation (whose module builds test-double cases) Co-Authored-By: Claude Opus 4.8 --- .../.claude/rules/features/data/state.md | 7 +- .../services/main-service/computed.md | 7 ++ .../main/data/state/conformance-case.ts | 63 ++++++++++- .../main/data/state/current-player.ts | 38 +++++++ .../main/data/state/expect-state-matches.ts | 72 ++++++++---- .../main/data/state/play-move.cases.ts | 47 -------- .../main/data/state/play-move.test.ts | 13 --- .../src/features/main/data/state/play-move.ts | 44 +++++++ .../data/state/play-opponent-move.test.ts | 52 --------- .../main/data/state/play-opponent-move.ts | 34 +++++- .../src/features/main/data/state/public.ts | 1 + .../main/data/state/record-effects.ts | 107 ++++++++++++++++++ .../main/data/state/restart-game.cases.ts | 34 ------ .../main/data/state/restart-game.test.ts | 13 --- .../features/main/data/state/restart-game.ts | 32 ++++++ .../src/features/main/data/state/spec.test.ts | 59 ++++++++++ .../action-database/action-database.ts | 18 +++ .../action-database/actions/index.ts | 4 + .../action-database/actions/play-move.ts | 9 ++ .../actions/play-opponent-move.ts | 15 +++ .../action-database/actions/restart-game.ts | 8 ++ .../main-service/conformance/actions.test.ts | 73 ++++++++++++ .../conformance/computeds.test.ts | 98 ++++++++++++++++ .../conformance/expect-conforms.ts | 12 +- .../main-service/conformance/to-data.ts | 13 +++ .../main-service/conformance/to-state.ts | 26 ++--- .../conformance/transactions.test.ts | 39 +++++++ .../services/main-service/feature-database.ts | 10 +- .../transactions/play-move.test.ts | 20 ---- .../transactions/restart-game.test.ts | 19 ---- 30 files changed, 731 insertions(+), 256 deletions(-) create mode 100644 packages/data-lit-tictactoe/src/features/main/data/state/current-player.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/data/state/play-move.cases.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/data/state/play-move.test.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/data/state/play-opponent-move.test.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/data/state/record-effects.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/data/state/restart-game.cases.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/data/state/restart-game.test.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/data/state/spec.test.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/services/main-service/action-database/action-database.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/services/main-service/action-database/actions/index.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/services/main-service/action-database/actions/play-move.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/services/main-service/action-database/actions/play-opponent-move.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/services/main-service/action-database/actions/restart-game.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/actions.test.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/computeds.test.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/to-data.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/transactions.test.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/services/main-service/transaction-database/transactions/play-move.test.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/services/main-service/transaction-database/transactions/restart-game.test.ts diff --git a/packages/data-ai/.claude/rules/features/data/state.md b/packages/data-ai/.claude/rules/features/data/state.md index 173d6d72..df1eaab4 100644 --- a/packages/data-ai/.claude/rules/features/data/state.md +++ b/packages/data-ai/.claude/rules/features/data/state.md @@ -89,9 +89,10 @@ namespace (`winner`/`status` from `board` → `data/board-state`), tested there **not** in `state/`. A feature may therefore have zero `state/` derivations. A `state/` derivation co-locates cases shaped `{ input, value }`, typed -`Derivation` (input + value read from the signature); author `input` -as a full `State` (computed conformance seeds it via `fromState`), and `value` -may use matchers. The same `spec.test.ts` runs them (dispatching on case shape), +`Derivation` (input + value read from the signature). Take the **full +`State`** as the parameter (not a `Pick` slice like a transform) — so the case +`input` type is the full `State` and computed conformance can seed it via +`fromState`; `value` may use matchers. The same `spec.test.ts` runs them (dispatching on case shape), and each ECS computed backing one is conformance-tested from the same cases (`conformance.md`). Sub-type math (a winner, a status) lives on the relevant `data/` namespace; `state/` only composes over the aggregate. diff --git a/packages/data-ai/.claude/rules/features/services/main-service/computed.md b/packages/data-ai/.claude/rules/features/services/main-service/computed.md index 6990b8ab..38daf7c0 100644 --- a/packages/data-ai/.claude/rules/features/services/main-service/computed.md +++ b/packages/data-ai/.claude/rules/features/services/main-service/computed.md @@ -10,6 +10,13 @@ returns an `Observe` of state projected through pure `data/` helpers. Derivation logic itself lives in `data/`; a computed only wires a service observable to it. +Wire the **pure `data/` helper** (or compose indexes) — do **not** import a +`data/state` derivation into a production computed. A `state/` derivation's module +co-locates conformance `cases` that construct service test-doubles at load; it is +the spec the computed is *conformed to*, not a production dependency. Both the +computed and the `state/` derivation call the same `data/` helper, so they +agree. + ```ts import { cached } from "@adobe/data/cache"; import { Observe } from "@adobe/data/observe"; diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/conformance-case.ts b/packages/data-lit-tictactoe/src/features/main/data/state/conformance-case.ts index 44e5a8a5..095f3cb2 100644 --- a/packages/data-lit-tictactoe/src/features/main/data/state/conformance-case.ts +++ b/packages/data-lit-tictactoe/src/features/main/data/state/conformance-case.ts @@ -1,13 +1,70 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { State } from "./state.js"; +// A service arg is an object with method members (not an array, not a function); +// this is what distinguishes an injected service from plain data args, since the +// `Service` marker itself is all-optional and would over-match. +type MethodKeys = { [K in keyof T]-?: T[K] extends (...a: never[]) => unknown ? K : never }[keyof T]; +type IsService = T extends readonly unknown[] + ? false + : T extends (...a: never[]) => unknown + ? false + : T extends object + ? [MethodKeys] extends [never] + ? false + : true + : false; + +// A strongly-typed call to one method of service `S`: `[methodName, ...its args]`. +// A no-arg method is just `[methodName]`. +export type Call = { + [M in keyof S]-?: S[M] extends (...a: infer A) => unknown ? readonly [M, ...A] : never; +}[keyof S]; + +// Expected side effects for a case, keyed by the service-typed args only. An +// `Array` value asserts these calls in this order; a `Set` value asserts the same +// calls in any order. Method names and their args are checked against the service. +export type Effects = { + readonly [K in keyof Args as IsService extends true ? K : never]?: + | readonly Call[] + | ReadonlySet>; +}; + // One spec-owned conformance case: a `data/` transform's `{ before, args, after }` -// authored as full `State`. Lives in `.cases.ts` and is shared, -// unchanged, by the data transform test and the ecs conformance runner -// (see `ecs/conformance/`). +// authored as full `State`, optionally with the side effects it makes on its +// injected service args. Shared unchanged by the spec aggregator (`spec.test.ts`) +// and the ecs conformance runners (`services/main-service/conformance/`). export type ConformanceCase = { readonly name: string; readonly before: State; readonly args: Args; readonly after: State; + readonly effects?: Effects; }; + +// A transform's cases, with the case `args` type derived from the transform's own +// signature — its second parameter, or `void` when it takes none. A transform +// co-locates `export const cases: Conformance = [...]`, so the +// cases cannot drift from what the function accepts, and the spec aggregator can +// discover the function without it being named twice. +export type Conformance unknown> = readonly ConformanceCase< + Parameters extends [unknown, infer Args, ...unknown[]] ? Args : void +>[]; + +// One case for a derivation (`(state) => value`): a state `input` and the `value` +// it yields. `value` may use asymmetric matchers (`anyNumber` for ids). Both the +// pure derivation and its ecs computed are checked against these. +export type DerivationCase = { + readonly name: string; + readonly input: Input; + readonly value: Value; +}; + +// A derivation's cases, with `input` and `value` types read from the derivation's +// own signature (its parameter and return) — the `Conformance` analog for +// value-producing derivations. A derivation co-locates +// `export const cases: Derivation = [...]`. +export type Derivation unknown> = readonly DerivationCase< + Parameters[0], + ReturnType +>[]; diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/current-player.ts b/packages/data-lit-tictactoe/src/features/main/data/state/current-player.ts new file mode 100644 index 00000000..912083c7 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/state/current-player.ts @@ -0,0 +1,38 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { BoardState } from "../board-state/board-state.js"; +import type { PlayerMark } from "../player-mark/player-mark.js"; +import type { State } from "./state.js"; +import type { Derivation } from "./conformance-case.js"; + +// Whose turn it is: composes TWO `State` fields — the board (move count) and the +// `firstPlayer` — so it is a `state/` derivation, not single-field board math +// (see `data/state.md`). The mark-counting itself is the board's own helper +// (`BoardState.currentPlayer`), which the ecs implementation reuses directly; this +// derivation is the spec the ecs `currentPlayer` computed is conformed against. +export const currentPlayer = (state: State): PlayerMark => + BoardState.currentPlayer(state.board, state.firstPlayer); + +// Spec-owned cases, shared with the ecs `currentPlayer` computed. A derivation +// case is `{ input, value }`; `input` is a full `State`, `value` the mark to move. +export const cases: Derivation = [ + { + name: "the first player moves on an empty board", + input: { board: " ", firstPlayer: "X", xWins: 0, oWins: 0, draws: 0 }, + value: "X", + }, + { + name: "honors a first player of O on an empty board", + input: { board: " ", firstPlayer: "O", xWins: 0, oWins: 0, draws: 0 }, + value: "O", + }, + { + name: "alternates to the opponent after the first move", + input: { board: " X ", firstPlayer: "X", xWins: 0, oWins: 0, draws: 0 }, + value: "O", + }, + { + name: "returns to the first player after both have moved", + input: { board: "XO ", firstPlayer: "X", xWins: 1, oWins: 2, draws: 0 }, + value: "X", + }, +]; diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/expect-state-matches.ts b/packages/data-lit-tictactoe/src/features/main/data/state/expect-state-matches.ts index 0b9bfa27..8ff8578c 100644 --- a/packages/data-lit-tictactoe/src/features/main/data/state/expect-state-matches.ts +++ b/packages/data-lit-tictactoe/src/features/main/data/state/expect-state-matches.ts @@ -1,36 +1,58 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { expect } from "vitest"; -import { equalsUnordered } from "@adobe/data"; import type { State } from "./state.js"; -// Spec-owned tolerant `State` equality, shared by the data/ transform tests and -// the ecs conformance runner. Two orthogonal concerns, kept separate: -// -// precision — normalise every number on both sides onto a shared grid, so a -// value that differs only by F32↔f64 storage rounding compares equal. -// `Math.fround` collapses the F32 rounding; rounding to 1e-2 collapses any -// residual epsilon. `+ 0` normalises `-0` to `0`. (Tic-tac-toe's scalars are -// integer counters and the board is a string, so this is a no-op here — kept -// to mirror the shared pattern and stay robust if a float field is added.) -// ordering — `equalsUnordered` compares arrays as MULTISETS (archetype -// hole-fills make row order nondeterministic) and is object key-order -// independent. +// A vitest asymmetric matcher (`expect.any(...)`): honored on the EXPECTED side +// so a case can assert "any number" for a value it does not pin. Tic-tac-toe's +// `State` exposes no ecs-minted ids (marks fold into the board string, scores are +// plain counters), so no case needs one today — but the comparison stays +// matcher-aware to match the shared pattern and stay robust if one is ever added. +const isMatcher = (value: unknown): value is { asymmetricMatch(actual: unknown): boolean } => + typeof value === "object" && + value !== null && + typeof (value as { asymmetricMatch?: unknown }).asymmetricMatch === "function"; + +// Collapse F32↔f64 storage rounding onto a small grid so float noise compares +// equal. `+ 0` normalises `-0` to `0`. (Tic-tac-toe's scalars are integer +// counters and the board is a string, so this is a no-op here — kept to mirror +// the shared pattern and stay robust if a float field is added.) const quantize = (n: number): number => Math.round(Math.fround(n) * 100) / 100 + 0; -const normalize = (value: unknown): unknown => { - if (typeof value === "number") return quantize(value); - if (Array.isArray(value)) return value.map(normalize); - if (value !== null && typeof value === "object") { - return Object.fromEntries(Object.entries(value).map(([key, v]) => [key, normalize(v)])); +// Tolerant structural match honoring asymmetric matchers, float precision, and +// order-sensitive arrays. Exported so it can back other conformance comparisons +// (e.g. computed values). +export const matches = (actual: unknown, expected: unknown): boolean => { + if (isMatcher(expected)) return expected.asymmetricMatch(actual); + if (typeof expected === "number" && typeof actual === "number") { + return quantize(actual) === quantize(expected); + } + if (Array.isArray(expected)) { + if (!Array.isArray(actual) || actual.length !== expected.length) return false; + return expected.every((exp, index) => matches(actual[index], exp)); + } + if (expected !== null && typeof expected === "object") { + if (actual === null || typeof actual !== "object" || Array.isArray(actual)) return false; + const expectedKeys = Object.keys(expected); + const actualKeys = Object.keys(actual as object); + if (expectedKeys.length !== actualKeys.length) return false; + return expectedKeys.every((key) => + matches((actual as Record)[key], (expected as Record)[key]), + ); } - return value; + return Object.is(actual, expected); }; +// Spec-owned tolerant `State` equality, shared by the data/ transform tests and +// the ecs conformance runners. `after` may use asymmetric matchers, so this one +// comparison serves both the pure spec and the ecs projection — no separate +// id-ignoring variant is needed. export const expectStateMatches = (actual: State, expected: State): void => { - const a = normalize(actual); - const b = normalize(expected); - expect( - equalsUnordered(a, b), - `State mismatch:\n actual ${JSON.stringify(a)}\n expected ${JSON.stringify(b)}`, - ).toBe(true); + expectMatches(actual, expected); +}; + +// The same tolerant, matcher-aware comparison for any value — used by derivation +// spec tests and computed conformance, where the compared value is a scalar +// (`PlayerMark`) rather than a whole `State`. +export const expectMatches = (actual: unknown, expected: unknown): void => { + expect(matches(actual, expected), `mismatch:\n actual ${JSON.stringify(actual)}`).toBe(true); }; diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/play-move.cases.ts b/packages/data-lit-tictactoe/src/features/main/data/state/play-move.cases.ts deleted file mode 100644 index aba06511..00000000 --- a/packages/data-lit-tictactoe/src/features/main/data/state/play-move.cases.ts +++ /dev/null @@ -1,47 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { PlayMoveArgs } from "../play-move-args/play-move-args.js"; -import type { ConformanceCase } from "./conformance-case.js"; - -// Spec-owned `{ before, args, after }` cases for `State.playMove`, shared with -// the ecs `playMove` transaction. Covers every branch of the move guard — -// a legal placement, turn alternation by move count, a winning placement, plus -// the three rejections (occupied cell, out of bounds, game already over) that -// each leave the state unchanged. -export const cases: readonly ConformanceCase[] = [ - { - name: "places the first player's mark into an empty cell", - before: { board: " ", firstPlayer: "X", xWins: 0, oWins: 0, draws: 0 }, - args: { index: 4 }, - after: { board: " X ", firstPlayer: "X", xWins: 0, oWins: 0, draws: 0 }, - }, - { - name: "alternates to the opponent by move count", - before: { board: " X ", firstPlayer: "X", xWins: 0, oWins: 0, draws: 0 }, - args: { index: 0 }, - after: { board: "O X ", firstPlayer: "X", xWins: 0, oWins: 0, draws: 0 }, - }, - { - name: "completes a three-in-a-row (winning placement is still just a placement)", - before: { board: "XX OO ", firstPlayer: "X", xWins: 1, oWins: 2, draws: 0 }, - args: { index: 2 }, - after: { board: "XXX OO ", firstPlayer: "X", xWins: 1, oWins: 2, draws: 0 }, - }, - { - name: "ignores an occupied cell (no-op)", - before: { board: " X ", firstPlayer: "X", xWins: 0, oWins: 0, draws: 0 }, - args: { index: 4 }, - after: { board: " X ", firstPlayer: "X", xWins: 0, oWins: 0, draws: 0 }, - }, - { - name: "ignores an out-of-bounds index (no-op)", - before: { board: " ", firstPlayer: "O", xWins: 0, oWins: 0, draws: 0 }, - args: { index: 9 }, - after: { board: " ", firstPlayer: "O", xWins: 0, oWins: 0, draws: 0 }, - }, - { - name: "ignores a move once the game is already won (no-op)", - before: { board: "XXX ", firstPlayer: "X", xWins: 0, oWins: 0, draws: 0 }, - args: { index: 4 }, - after: { board: "XXX ", firstPlayer: "X", xWins: 0, oWins: 0, draws: 0 }, - }, -]; diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/play-move.test.ts b/packages/data-lit-tictactoe/src/features/main/data/state/play-move.test.ts deleted file mode 100644 index 0d1f24e4..00000000 --- a/packages/data-lit-tictactoe/src/features/main/data/state/play-move.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it } from "vitest"; -import { State } from "./state.js"; -import { cases } from "./play-move.cases.js"; -import { expectStateMatches } from "./expect-state-matches.js"; - -describe("State.playMove", () => { - for (const { name, before, args, after } of cases) { - it(name, () => { - expectStateMatches(State.playMove(before, args), after); - }); - } -}); diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/play-move.ts b/packages/data-lit-tictactoe/src/features/main/data/state/play-move.ts index 025f8274..04cffff8 100644 --- a/packages/data-lit-tictactoe/src/features/main/data/state/play-move.ts +++ b/packages/data-lit-tictactoe/src/features/main/data/state/play-move.ts @@ -2,6 +2,7 @@ import { BoardState } from "../board-state/board-state.js"; import { PlayMoveArgs } from "../play-move-args/play-move-args.js"; import type { State } from "./state.js"; +import type { Conformance } from "./conformance-case.js"; // Place the current player's mark into `index`. Illegal moves (out of bounds, // occupied, game over) are ignored, keeping the transform idempotent. @@ -18,3 +19,46 @@ export const playMove = >( board: BoardState.setBoardCell({ board: state.board, index: input.index, mark }), }; }; + +// Spec-owned cases, shared with the ecs `playMove` transaction. Covers every +// branch of the move guard — a legal placement, turn alternation by move count, a +// winning placement, plus the three rejections (occupied cell, out of bounds, game +// already over) that each leave the state unchanged. +export const cases: Conformance = [ + { + name: "places the first player's mark into an empty cell", + before: { board: " ", firstPlayer: "X", xWins: 0, oWins: 0, draws: 0 }, + args: { index: 4 }, + after: { board: " X ", firstPlayer: "X", xWins: 0, oWins: 0, draws: 0 }, + }, + { + name: "alternates to the opponent by move count", + before: { board: " X ", firstPlayer: "X", xWins: 0, oWins: 0, draws: 0 }, + args: { index: 0 }, + after: { board: "O X ", firstPlayer: "X", xWins: 0, oWins: 0, draws: 0 }, + }, + { + name: "completes a three-in-a-row (winning placement is still just a placement)", + before: { board: "XX OO ", firstPlayer: "X", xWins: 1, oWins: 2, draws: 0 }, + args: { index: 2 }, + after: { board: "XXX OO ", firstPlayer: "X", xWins: 1, oWins: 2, draws: 0 }, + }, + { + name: "ignores an occupied cell (no-op)", + before: { board: " X ", firstPlayer: "X", xWins: 0, oWins: 0, draws: 0 }, + args: { index: 4 }, + after: { board: " X ", firstPlayer: "X", xWins: 0, oWins: 0, draws: 0 }, + }, + { + name: "ignores an out-of-bounds index (no-op)", + before: { board: " ", firstPlayer: "O", xWins: 0, oWins: 0, draws: 0 }, + args: { index: 9 }, + after: { board: " ", firstPlayer: "O", xWins: 0, oWins: 0, draws: 0 }, + }, + { + name: "ignores a move once the game is already won (no-op)", + before: { board: "XXX ", firstPlayer: "X", xWins: 0, oWins: 0, draws: 0 }, + args: { index: 4 }, + after: { board: "XXX ", firstPlayer: "X", xWins: 0, oWins: 0, draws: 0 }, + }, +]; diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/play-opponent-move.test.ts b/packages/data-lit-tictactoe/src/features/main/data/state/play-opponent-move.test.ts deleted file mode 100644 index 58132441..00000000 --- a/packages/data-lit-tictactoe/src/features/main/data/state/play-opponent-move.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it } from "vitest"; -import { OpponentService } from "../../services/opponent-service/opponent-service.js"; -import { State } from "./state.js"; -import { expectStateMatches } from "./expect-state-matches.js"; - -// The transition takes an injected service, so it is exercised directly with a -// deterministic double rather than the shared `{ before, args, after }` cases — -// the assertions lean on the double's PUBLISHED move schedule -// (`OpponentService.fakeMoves`, resolved in order), never on any hidden -// behaviour. -describe("State.playOpponentMove", () => { - it("plays the opponent's first selected move for the current player", async () => { - const opponent = OpponentService.createFake(); - const after = await State.playOpponentMove( - { board: " ", firstPlayer: "X", xWins: 0, oWins: 0, draws: 0 }, - { opponent }, - ); - // fakeMoves[0] === 4; the current player on an empty board is the first - // player (X), so an X lands in the centre cell. - expectStateMatches(after, { - board: " X ", firstPlayer: "X", xWins: 0, oWins: 0, draws: 0, - }); - }); - - it("consumes the published moves in order across successive turns", async () => { - const opponent = OpponentService.createFake(); - const first = await State.playOpponentMove( - { board: " ", firstPlayer: "X", xWins: 0, oWins: 0, draws: 0 }, - { opponent }, - ); - const second = await State.playOpponentMove(first, { opponent }); - // fakeMoves[0]=4 → X at centre; fakeMoves[1]=0 → O at top-left (the turn - // alternates by move count). - expectStateMatches(second, { - board: "O X ", firstPlayer: "X", xWins: 0, oWins: 0, draws: 0, - }); - }); - - it("ignores an illegal selected move, leaving the state unchanged", async () => { - // Published a single move onto an already-occupied cell — `playMove` rejects - // it, so the transition is a no-op. - const opponent = OpponentService.createFake([4]); - const after = await State.playOpponentMove( - { board: " X ", firstPlayer: "X", xWins: 0, oWins: 0, draws: 0 }, - { opponent }, - ); - expectStateMatches(after, { - board: " X ", firstPlayer: "X", xWins: 0, oWins: 0, draws: 0, - }); - }); -}); diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/play-opponent-move.ts b/packages/data-lit-tictactoe/src/features/main/data/state/play-opponent-move.ts index 94217f0d..10977806 100644 --- a/packages/data-lit-tictactoe/src/features/main/data/state/play-opponent-move.ts +++ b/packages/data-lit-tictactoe/src/features/main/data/state/play-opponent-move.ts @@ -1,7 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { OpponentService } from "../../services/opponent-service/opponent-service.js"; +import { OpponentService } from "../../services/opponent-service/opponent-service.js"; import { playMove } from "./play-move.js"; import type { State } from "./state.js"; +import type { Conformance } from "./conformance-case.js"; /** * Move-selection as an **injected service dependency**: the opponent's choice @@ -22,3 +23,34 @@ export const playOpponentMove = async = [ + { + name: "plays the opponent's first selected move for the current player", + before: { board: " ", firstPlayer: "X", xWins: 0, oWins: 0, draws: 0 }, + args: { opponent: OpponentService.createFake() }, + // fakeMoves[0] === 4; the current player on an empty board is the first + // player (X), so an X lands in the centre cell. + after: { board: " X ", firstPlayer: "X", xWins: 0, oWins: 0, draws: 0 }, + }, + { + name: "plays the next mark onto a running board", + before: { board: " X ", firstPlayer: "X", xWins: 0, oWins: 0, draws: 0 }, + args: { opponent: OpponentService.createFake([0]) }, + // The published move is cell 0; the current player alternates to O by move + // count, so an O lands in the top-left cell. + after: { board: "O X ", firstPlayer: "X", xWins: 0, oWins: 0, draws: 0 }, + }, + { + name: "ignores an illegal selected move, leaving the state unchanged", + before: { board: " X ", firstPlayer: "X", xWins: 0, oWins: 0, draws: 0 }, + args: { opponent: OpponentService.createFake([4]) }, + // Cell 4 is occupied — `playMove` rejects it, so the transition is a no-op. + after: { board: " X ", firstPlayer: "X", xWins: 0, oWins: 0, draws: 0 }, + }, +]; diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/public.ts b/packages/data-lit-tictactoe/src/features/main/data/state/public.ts index ce133efe..2602faec 100644 --- a/packages/data-lit-tictactoe/src/features/main/data/state/public.ts +++ b/packages/data-lit-tictactoe/src/features/main/data/state/public.ts @@ -2,3 +2,4 @@ export { playMove } from "./play-move.js"; export { playOpponentMove } from "./play-opponent-move.js"; export { restartGame } from "./restart-game.js"; +export { currentPlayer } from "./current-player.js"; diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/record-effects.ts b/packages/data-lit-tictactoe/src/features/main/data/state/record-effects.ts new file mode 100644 index 00000000..b51fc084 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/state/record-effects.ts @@ -0,0 +1,107 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { expect } from "vitest"; +import { equalsUnordered } from "@adobe/data"; +import type { Effects } from "./conformance-case.js"; + +export type RecordedCall = readonly [string, ...unknown[]]; + +// Wrap a plain-object service so each method call is recorded, then delegates. +// No Proxy (services are plain objects with own enumerable methods — see +// `service.md`), so we enumerate and closure-wrap each function. +export const recordCalls = (service: S): { service: S; calls: RecordedCall[] } => { + const calls: RecordedCall[] = []; + const wrapped = Object.fromEntries( + Object.entries(service).map(([key, value]) => [ + key, + typeof value === "function" + ? (...args: unknown[]): unknown => { + calls.push([key, ...args]); + return (value as (...a: unknown[]) => unknown)(...args); + } + : value, + ]), + ) as S; + return { service: wrapped, calls }; +}; + +// Assert the calls recorded against one service match the case's expectation for +// it: an Array expects exactly these calls in order; a Set expects the same calls +// in any order (multiset). Absent expectation ⇒ no calls expected. +export const expectServiceCalls = ( + recorded: readonly RecordedCall[], + expected: readonly RecordedCall[] | ReadonlySet | undefined, +): void => { + if (expected instanceof Set) { + expect(equalsUnordered(recorded, [...expected])).toBe(true); + } else { + expect(recorded).toEqual(expected ?? []); + } +}; + +// Split a case's `args` into the injected services (objects with methods) and the +// remaining plain data. Services are wrapped for recording; the returned `calls` +// map is keyed by the same arg key so it can be matched against `effects`. +export const recordArgServices = ( + args: Args, +): { args: Args; calls: Record } => { + const calls: Record = {}; + const next = { ...args } as Record; + for (const [key, value] of Object.entries(args)) { + if (isServiceValue(value)) { + const rec = recordCalls(value); + next[key] = rec.service; + calls[key] = rec.calls; + } + } + return { args: next as Args, calls }; +}; + +// Assert each service **declared** in `effects` saw exactly its expected calls +// (an extra or missing call on a declared service fails). Services not listed — +// e.g. a value-returning dependency read like `selectMove` — are ignored, so +// `effects` captures the fire-and-forget side effects you choose to assert. +export const expectEffects = ( + calls: Record, + effects: Effects> | undefined, +): void => { + const expected = (effects ?? {}) as Record>; + for (const key of Object.keys(expected)) { + expectServiceCalls(calls[key] ?? [], expected[key]); + } +}; + +// Split a case's `args` into the injected services (wrapped for recording, to be +// used as `Database.create` service overrides) and the remaining plain data (the +// action input). Keyed by the same arg name so `calls` matches against `effects`. +export const splitAndRecordServices = ( + args: Args, +): { + services: Record; + input: Record; + calls: Record; +} => { + const services: Record = {}; + const input: Record = {}; + const calls: Record = {}; + // A no-arg transition has `undefined` args — nothing to split. + if (args !== null && typeof args === "object") { + for (const [key, value] of Object.entries(args)) { + if (isServiceValue(value)) { + const recorded = recordCalls(value); + services[key] = recorded.service; + calls[key] = recorded.calls; + } else { + input[key] = value; + } + } + } + return { services, input, calls }; +}; + +// A runtime service value: a non-array object with at least one method (mirrors +// the compile-time `IsService`). +const isServiceValue = (value: unknown): value is object => + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.values(value).some((member) => typeof member === "function"); diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/restart-game.cases.ts b/packages/data-lit-tictactoe/src/features/main/data/state/restart-game.cases.ts deleted file mode 100644 index fc809519..00000000 --- a/packages/data-lit-tictactoe/src/features/main/data/state/restart-game.cases.ts +++ /dev/null @@ -1,34 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { ConformanceCase } from "./conformance-case.js"; - -// Spec-owned `{ before, args, after }` cases for `State.restartGame` (no args), -// shared with the ecs `restartGame` transaction. Every restart clears the board -// and hands the first move to the other player; the scoreboard is bumped only -// for the finished game's outcome — X win, O win, draw (cat), or, when the game -// wasn't finished, no counter at all. -export const cases: readonly ConformanceCase[] = [ - { - name: "tallies an X win, alternates first player, clears the board", - before: { board: "XXX ", firstPlayer: "X", xWins: 0, oWins: 0, draws: 0 }, - args: undefined, - after: { board: " ", firstPlayer: "O", xWins: 1, oWins: 0, draws: 0 }, - }, - { - name: "tallies an O win", - before: { board: "OOOXX ", firstPlayer: "O", xWins: 1, oWins: 2, draws: 0 }, - args: undefined, - after: { board: " ", firstPlayer: "X", xWins: 1, oWins: 3, draws: 0 }, - }, - { - name: "tallies a draw (full board, no line)", - before: { board: "XOXXOOOXX", firstPlayer: "O", xWins: 2, oWins: 1, draws: 0 }, - args: undefined, - after: { board: " ", firstPlayer: "X", xWins: 2, oWins: 1, draws: 1 }, - }, - { - name: "restarts an unfinished game without touching any counter", - before: { board: "X O ", firstPlayer: "X", xWins: 1, oWins: 1, draws: 1 }, - args: undefined, - after: { board: " ", firstPlayer: "O", xWins: 1, oWins: 1, draws: 1 }, - }, -]; diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/restart-game.test.ts b/packages/data-lit-tictactoe/src/features/main/data/state/restart-game.test.ts deleted file mode 100644 index 6c22e7d9..00000000 --- a/packages/data-lit-tictactoe/src/features/main/data/state/restart-game.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it } from "vitest"; -import { State } from "./state.js"; -import { cases } from "./restart-game.cases.js"; -import { expectStateMatches } from "./expect-state-matches.js"; - -describe("State.restartGame", () => { - for (const { name, before, after } of cases) { - it(name, () => { - expectStateMatches(State.restartGame(before), after); - }); - } -}); diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/restart-game.ts b/packages/data-lit-tictactoe/src/features/main/data/state/restart-game.ts index 4ab570d2..0a13ff85 100644 --- a/packages/data-lit-tictactoe/src/features/main/data/state/restart-game.ts +++ b/packages/data-lit-tictactoe/src/features/main/data/state/restart-game.ts @@ -2,6 +2,7 @@ import { BoardState } from "../board-state/board-state.js"; import { PlayerMark } from "../player-mark/player-mark.js"; import type { State } from "./state.js"; +import type { Conformance } from "./conformance-case.js"; // Tally the finished game into the scoreboard, hand the first move to the other // player, and clear the board. @@ -16,3 +17,34 @@ export const restartGame = (state: State): State => { draws: state.draws + (status === "draw" ? 1 : 0), }; }; + +// Spec-owned cases (no args), shared with the ecs `restartGame` transaction. +// Every restart clears the board and hands the first move to the other player; +// the scoreboard is bumped only for the finished game's outcome — X win, O win, +// draw (cat), or, when the game wasn't finished, no counter at all. +export const cases: Conformance = [ + { + name: "tallies an X win, alternates first player, clears the board", + before: { board: "XXX ", firstPlayer: "X", xWins: 0, oWins: 0, draws: 0 }, + args: undefined, + after: { board: " ", firstPlayer: "O", xWins: 1, oWins: 0, draws: 0 }, + }, + { + name: "tallies an O win", + before: { board: "OOOXX ", firstPlayer: "O", xWins: 1, oWins: 2, draws: 0 }, + args: undefined, + after: { board: " ", firstPlayer: "X", xWins: 1, oWins: 3, draws: 0 }, + }, + { + name: "tallies a draw (full board, no line)", + before: { board: "XOXXOOOXX", firstPlayer: "O", xWins: 2, oWins: 1, draws: 0 }, + args: undefined, + after: { board: " ", firstPlayer: "X", xWins: 2, oWins: 1, draws: 1 }, + }, + { + name: "restarts an unfinished game without touching any counter", + before: { board: "X O ", firstPlayer: "X", xWins: 1, oWins: 1, draws: 1 }, + args: undefined, + after: { board: " ", firstPlayer: "O", xWins: 1, oWins: 1, draws: 1 }, + }, +]; diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/spec.test.ts b/packages/data-lit-tictactoe/src/features/main/data/state/spec.test.ts new file mode 100644 index 00000000..d398242c --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/state/spec.test.ts @@ -0,0 +1,59 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +/// +import { describe, it } from "vitest"; +import type { State } from "./state.js"; +import type { ConformanceCase, DerivationCase } from "./conformance-case.js"; +import { expectStateMatches, expectMatches } from "./expect-state-matches.js"; +import { recordArgServices, expectEffects } from "./record-effects.js"; + +// The single spec test for every transform AND derivation in this folder. It +// auto-discovers each file (any sibling `.ts` that exports `cases`) via +// `import.meta.glob`, so a new one is covered the moment it ships — none can be +// forgotten. Each participating file must export exactly its function plus `cases` +// (enforced below), which lets us find the function without it being named twice. +// A case's shape selects the check: `after` → a transition `(state, args) => state`; +// `value` → a derivation `(state) => value`. +const modules = import.meta.glob>( + ["./*.ts", "!./*.test.ts", "!./*.type-test.ts"], + { eager: true }, +); + +const isDerivationCase = (c: unknown): c is DerivationCase => + typeof c === "object" && c !== null && "value" in c; + +for (const [path, module] of Object.entries(modules)) { + const exportNames = Object.keys(module); + if (!exportNames.includes("cases")) continue; + const functionNames = exportNames.filter((key) => typeof module[key] === "function"); + const label = functionNames.length === 1 ? functionNames[0] : path; + + describe(`State.${label}`, () => { + if (exportNames.length !== 2 || functionNames.length !== 1) { + it("exports exactly its function and `cases`", () => { + throw new Error(`${path} exports [${exportNames.join(", ")}] — expected one function + cases`); + }); + return; + } + // Runtime invariant: a participating file exports a function and its cases. + const fn = module[functionNames[0]] as (...args: unknown[]) => unknown; + const cases = module["cases"] as readonly unknown[]; + for (const testCase of cases) { + if (isDerivationCase(testCase)) { + // Derivation: the value it yields matches, honoring `anyNumber`. + it(testCase.name, () => expectMatches(fn(testCase.input), testCase.value)); + continue; + } + // Transition: assert the resulting state and the declared side effects. + // A service-injected transition is async, so await uniformly. + const transitionCase = testCase as ConformanceCase>; + it(transitionCase.name, async () => { + const raw = transitionCase.args; + const { args, calls } = + raw && typeof raw === "object" ? recordArgServices(raw) : { args: raw, calls: {} }; + const result = (await fn(transitionCase.before, args)) as State; + expectStateMatches(result, transitionCase.after); + expectEffects(calls, transitionCase.effects); + }); + } + }); +} diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/action-database/action-database.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/action-database/action-database.ts new file mode 100644 index 00000000..84dc6b4a --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/services/main-service/action-database/action-database.ts @@ -0,0 +1,18 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import { ServiceDatabase } from "../service-database/service-database.js"; +import * as actions from "./actions/index.js"; + +const actionDatabasePlugin = Database.Plugin.create({ + extends: ServiceDatabase.plugin, + actions, +}); + +export type ActionDatabase = Database.Plugin.ToDatabase< + typeof actionDatabasePlugin +>; + +export namespace ActionDatabase { + export const plugin = actionDatabasePlugin; + export type Store = Database.Plugin.ToStore; +} diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/action-database/actions/index.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/action-database/actions/index.ts new file mode 100644 index 00000000..432a4421 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/services/main-service/action-database/actions/index.ts @@ -0,0 +1,4 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +export * from "./play-move.js"; +export * from "./play-opponent-move.js"; +export * from "./restart-game.js"; diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/action-database/actions/play-move.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/action-database/actions/play-move.ts new file mode 100644 index 00000000..15dd3408 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/services/main-service/action-database/actions/play-move.ts @@ -0,0 +1,9 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { PlayMoveArgs } from "../../../../data/play-move-args/play-move-args.js"; +import type { ServiceDatabase } from "../../service-database/service-database.js"; + +// The app-facing realization of `State.playMove`: a local placement needs no +// outside capability, so it commits the move through a single transaction. +export const playMove = (db: ServiceDatabase, args: PlayMoveArgs) => { + db.transactions.playMove(args); +}; diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/action-database/actions/play-opponent-move.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/action-database/actions/play-opponent-move.ts new file mode 100644 index 00000000..56f9ef43 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/services/main-service/action-database/actions/play-opponent-move.ts @@ -0,0 +1,15 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Observe } from "@adobe/data/observe"; +import { board } from "../../computed-database/computed/board.js"; +import type { ServiceDatabase } from "../../service-database/service-database.js"; + +// The app-facing realization of `State.playOpponentMove`: read the current board, +// await the opponent port's selected index (the async outside-world work), then +// commit exactly one placement through `playMove`. `selectMove` is a +// value-returning read, not a fire-and-forget effect. Fire-and-forget: the +// selected index flows back through the transaction's observables, not a return. +export const playOpponentMove = async (db: ServiceDatabase) => { + const currentBoard = await Observe.toPromise(board(db)); + const index = await db.services.opponent.selectMove(currentBoard); + db.transactions.playMove({ index }); +}; diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/action-database/actions/restart-game.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/action-database/actions/restart-game.ts new file mode 100644 index 00000000..de0bca25 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/services/main-service/action-database/actions/restart-game.ts @@ -0,0 +1,8 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ServiceDatabase } from "../../service-database/service-database.js"; + +// The app-facing realization of `State.restartGame`: no outside capability, so it +// commits the scoreboard tally + board reset through a single transaction. +export const restartGame = (db: ServiceDatabase) => { + db.transactions.restartGame(); +}; diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/actions.test.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/actions.test.ts new file mode 100644 index 00000000..2b502009 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/actions.test.ts @@ -0,0 +1,73 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +/// +import { describe, it, expect } from "vitest"; +import { Database } from "@adobe/data/ecs"; +import type { ConformanceCase } from "../../../data/state/conformance-case.js"; +import { splitAndRecordServices, expectEffects } from "../../../data/state/record-effects.js"; +import type { OpponentService } from "../../opponent-service/opponent-service.js"; +import { expectStateMatches } from "../../../data/state/expect-state-matches.js"; +import { FeatureDatabase } from "../feature-database.js"; +import { fromState } from "./from-state.js"; +import { toState } from "./to-state.js"; +import { playMove } from "../action-database/actions/play-move.js"; +import { playOpponentMove } from "../action-database/actions/play-opponent-move.js"; +import { restartGame } from "../action-database/actions/restart-game.js"; +import { cases as playMoveCases } from "../../../data/state/play-move.js"; +import { cases as playOpponentMoveCases } from "../../../data/state/play-opponent-move.js"; +import { cases as restartGameCases } from "../../../data/state/restart-game.js"; + +// Each transition's cases run against its same-named ecs **action** (the async +// realization), asserting both the resulting state and the declared side effects. +// The case's service args become the db's service overrides — wrapped so their +// calls are recorded — and the plain args drive the action. +// `toSystemDatabase` exposes the writable `.store` the projection needs while +// keeping services/transactions/actions. Runtime invariant: the recording +// wrappers preserve each service's shape, so they are valid factory overrides. +const makeDb = (services: { opponent?: OpponentService }) => + Database.toSystemDatabase(Database.create(FeatureDatabase.plugin, { services })); +type Db = ReturnType; +type Run = (db: Db, input: Args) => Promise | void; + +const covered = new Set(); +const conformsAction = ( + action: string, + config: { readonly cases: readonly ConformanceCase[]; readonly run: Run> }, +): void => { + covered.add(action); + describe(`${action} action conforms`, () => { + for (const testCase of config.cases) { + it(testCase.name, async () => { + const { services, input, calls } = splitAndRecordServices(testCase.args); + const db = makeDb(services as { opponent?: OpponentService }); + fromState(db.store, testCase.before); + await config.run(db, input as Partial); + expectStateMatches(toState(db.store), testCase.after); + expectEffects(calls, testCase.effects); + }); + } + }); +}; + +conformsAction("playMove", { + cases: playMoveCases, + run: (db, input) => playMove(db, { index: input.index ?? -1 }), +}); +conformsAction("playOpponentMove", { + cases: playOpponentMoveCases, + run: (db) => playOpponentMove(db), +}); +conformsAction("restartGame", { cases: restartGameCases, run: (db) => restartGame(db) }); + +// None-missed guard: every action file must be wired above. +const kebabToCamel = (name: string): string => + name.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase()); +describe("action conformance coverage", () => { + const files = import.meta.glob([ + "../action-database/actions/*.ts", + "!../action-database/actions/index.ts", + ]); + for (const path of Object.keys(files)) { + const action = kebabToCamel(path.replace(/.*\//, "").replace(/\.ts$/, "")); + it(`${action} has a conformance case`, () => expect(covered.has(action)).toBe(true)); + } +}); diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/computeds.test.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/computeds.test.ts new file mode 100644 index 00000000..37b8be79 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/computeds.test.ts @@ -0,0 +1,98 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +/// +import { describe, it, expect } from "vitest"; +import { Database, Entity } from "@adobe/data/ecs"; +import type { Observe } from "@adobe/data/observe"; +import type { State } from "../../../data/state/state.js"; +import type { DerivationCase } from "../../../data/state/conformance-case.js"; +import { expectMatches } from "../../../data/state/expect-state-matches.js"; +import { ComputedDatabase } from "../computed-database/computed-database.js"; +import { fromState } from "./from-state.js"; +import { toData } from "./to-data.js"; +import { currentPlayer } from "../computed-database/computed/current-player.js"; +import { cases as currentPlayerCases } from "../../../data/state/current-player.js"; + +// Each `data/state` derivation's cases run against its same-named ecs computed. A +// computed is an `Observe`, so after seeding the store we read its synchronous +// emission and `matches(value)`. This is the computed analog of the +// transaction/action runners. +// +// Built from the `ComputedDatabase` layer (which adds the computeds), not the +// assembled feature db: a behaviour layer above it that subscribes to a computed +// at construction (the agent services do) would `withCache` the pre-seed value, +// and a direct `fromState` seed emits no transaction to invalidate it. +const makeDb = () => Database.toSystemDatabase(Database.create(ComputedDatabase.plugin)); +type Db = ReturnType; + +// The default projection: hydrate a computed's entity-id list into the value +// shape a derivation yields. Override for a computed whose output is not a list of +// entities (a scalar, a single entity, a nested shape). +const hydrateEntities = (raw: unknown, db: Db): unknown => + (raw as readonly Entity[]).map((entity) => toData(db.store, entity)); + +const readComputed = (observe: Observe): T => { + let value!: T; + let read = false; + const unsubscribe = observe((next) => { + value = next; + read = true; + }); + unsubscribe(); + if (!read) throw new Error("computed did not emit synchronously on subscribe"); + return value; +}; + +const covered = new Set(); +const conformsComputed = ( + name: string, + config: { + readonly cases: readonly DerivationCase[]; + readonly computed: (db: Db) => Observe; + readonly project?: (raw: unknown, db: Db) => unknown; + }, +): void => { + covered.add(name); + const project = config.project ?? hydrateEntities; + describe(`${name} computed conforms`, () => { + for (const testCase of config.cases) { + it(testCase.name, () => { + const db = makeDb(); + // Runtime invariant: a derivation's `input` is authored as a full State. + fromState(db.store, testCase.input as State); + const raw = readComputed(config.computed(db)); + expectMatches(project(raw, db), testCase.value); + }); + } + }); +}; + +// `currentPlayer` emits a scalar `PlayerMark`, so the projection is the identity — +// no entity hydration. +conformsComputed("currentPlayer", { + cases: currentPlayerCases, + computed: currentPlayer, + project: (raw) => raw, +}); + +// None-missed guard: every data/state derivation (a file whose `cases` are +// `{ input, value }`) must be wired above. +const kebabToCamel = (name: string): string => + name.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase()); +const derivationModules = import.meta.glob>( + ["../../../data/state/*.ts", "!../../../data/state/*.test.ts", "!../../../data/state/*.type-test.ts"], + { eager: true }, +); +describe("computed conformance coverage", () => { + for (const [path, module] of Object.entries(derivationModules)) { + const cases = module["cases"]; + const isDerivation = + Array.isArray(cases) && + cases.length > 0 && + typeof cases[0] === "object" && + cases[0] !== null && + "value" in cases[0]; + if (!isDerivation) continue; + const name = kebabToCamel(path.replace(/.*\//, "").replace(/\.ts$/, "")); + it(`${name} has a computed conformance case`, () => expect(covered.has(name)).toBe(true)); + } +}); diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/expect-conforms.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/expect-conforms.ts index 70ab6e41..c22ee49e 100644 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/expect-conforms.ts +++ b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/expect-conforms.ts @@ -22,16 +22,20 @@ import { toState } from "./to-state.js"; // `apply` receives the seeded writable store and calls the raw transaction // function directly (a transaction is `(store, args) => void`, so no `Database` // is involved). tictactoe's transactions take plain data args (a board index, -// or nothing), so no entity resolution is needed in `apply`. Entity collections -// compare as multisets; scalars and resources exactly (see `expectStateMatches`). +// or nothing), so no entity resolution is needed in `apply`. export const expectConforms = (config: { readonly cases: readonly ConformanceCase[]; - readonly spec: (before: State, args: Args) => State; + // Optional: half 1 (spec(before,args) ≡ after) is already asserted for every + // case by `data/state/spec.test.ts`, so the conformance aggregator omits it and + // this runner asserts only the ecs half. Pass `spec` to re-check it in place. + readonly spec?: (before: State, args: Args) => State; readonly apply: (store: CoreDatabase.Store, args: Args) => void; }): void => { for (const testCase of config.cases) { it(testCase.name, () => { - expectStateMatches(config.spec(testCase.before, testCase.args), testCase.after); + if (config.spec) { + expectStateMatches(config.spec(testCase.before, testCase.args), testCase.after); + } const store = createStore(); fromState(store, testCase.before); diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/to-data.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/to-data.ts new file mode 100644 index 00000000..cbdc895c --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/to-data.ts @@ -0,0 +1,13 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Entity } from "@adobe/data/ecs"; +import type { PlacedMark } from "../../../data/placed-mark/placed-mark.js"; +import type { CoreDatabase } from "../core-database/core-database.js"; + +// Read one entity back into its `data/` value — the per-entity projection +// `toState` is built on, and the single place the ecs↔data mapping for a placed +// mark lives. Test-only. +export const toData = (store: CoreDatabase.Store, entity: Entity): PlacedMark => { + const row = store.read(entity, store.archetypes.PlacedMark); + if (row === null) throw new Error("conformance projection: expected a placed-mark entity"); + return { mark: row.mark, index: row.index }; +}; diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/to-state.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/to-state.ts index 3488c548..cfdac3b0 100644 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/to-state.ts +++ b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/to-state.ts @@ -1,25 +1,17 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { State } from "../../../data/state/state.js"; import { BoardState } from "../../../data/board-state/board-state.js"; -import type { PlacedMark } from "../../../data/placed-mark/placed-mark.js"; import type { CoreDatabase } from "../core-database/core-database.js"; +import { toData } from "./to-data.js"; -// Read a store back into a `data/` `State` — the inverse of `fromState`. The -// placed marks are read through the PlacedMark archetype's full component set -// (not an incidental single column) and folded into the compact board string, -// then joined with the scalar resources. Test-only. -const readBoard = (store: CoreDatabase.Store): BoardState => { - const marks: PlacedMark[] = []; - for (const arch of store.queryArchetypes(store.archetypes.PlacedMark.components)) { - for (let row = 0; row < arch.rowCount; row++) { - marks.push({ - mark: arch.columns.mark.get(row), - index: arch.columns.index.get(row), - }); - } - } - return BoardState.fromMarks(marks); -}; +// Read a store back into a `data/` `State` — the inverse of `fromState`. Each +// placed-mark entity is read through the per-entity `toData` projection, folded +// into the compact board string, then joined with the scalar resources. +// Test-only. +const readBoard = (store: CoreDatabase.Store): BoardState => + BoardState.fromMarks( + [...store.select(store.archetypes.PlacedMark.components)].map((entity) => toData(store, entity)), + ); export const toState = (store: CoreDatabase.Store): State => ({ board: readBoard(store), diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/transactions.test.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/transactions.test.ts new file mode 100644 index 00000000..5e504810 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/transactions.test.ts @@ -0,0 +1,39 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it, expect } from "vitest"; +import type { CoreDatabase } from "../core-database/core-database.js"; +import type { ConformanceCase } from "../../../data/state/conformance-case.js"; +import { expectConforms } from "./expect-conforms.js"; +import * as registeredTransactions from "../transaction-database/transactions/index.js"; +import { playMove } from "../transaction-database/transactions/play-move.js"; +import { restartGame } from "../transaction-database/transactions/restart-game.js"; +import { cases as playMoveCases } from "../../../data/state/play-move.js"; +import { cases as restartGameCases } from "../../../data/state/restart-game.js"; + +// The single conformance test for every ecs transaction. Each transaction's +// shared `data/state` cases run through its raw `apply` (`fromState(before)` → +// apply → `matches(toState, after)`); the pure half is asserted once, centrally, +// by `data/state/spec.test.ts`, so this runner asserts only the ecs half. The +// guard at the bottom asserts every REGISTERED transaction (the barrel, not a +// file glob) is wired below, so the flat `readBoard` helper — kept out of the +// barrel — is naturally excluded and none can be missed. +const covered = new Set(); +const conforms = ( + transaction: string, + config: { + readonly cases: readonly ConformanceCase[]; + readonly apply: (t: CoreDatabase.Store, args: Args) => void; + }, +): void => { + covered.add(transaction); + describe(`${transaction} transaction conforms`, () => expectConforms(config)); +}; + +conforms("playMove", { cases: playMoveCases, apply: playMove }); +conforms("restartGame", { cases: restartGameCases, apply: (t) => restartGame(t) }); + +// None-missed guard: every **registered** transaction must be wired above. +describe("transaction conformance coverage", () => { + for (const transaction of Object.keys(registeredTransactions)) { + it(`${transaction} has a conformance case`, () => expect(covered.has(transaction)).toBe(true)); + } +}); diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/feature-database.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/feature-database.ts index 07eb53ae..d1be2ded 100644 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/feature-database.ts +++ b/packages/data-lit-tictactoe/src/features/main/services/main-service/feature-database.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. // -// The assembled feature database. Its topmost layer is `service-database` -// (adds the AI agent services). Every consumer — `ui/`, the app entry, -// `ecs/conformance/` — references `FeatureDatabase`, never the topmost layer, -// so adding or dropping a layer changes only this one line. -export { ServiceDatabase as FeatureDatabase } from "./service-database/service-database.js"; +// The assembled feature database. Its topmost layer is `action-database` +// (adds the async app-facing actions). Every consumer — `ui/`, the app entry, +// `services/main-service/conformance/` — references `FeatureDatabase`, never the +// topmost layer, so adding or dropping a layer changes only this one line. +export { ActionDatabase as FeatureDatabase } from "./action-database/action-database.js"; diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/transaction-database/transactions/play-move.test.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/transaction-database/transactions/play-move.test.ts deleted file mode 100644 index f12aeb3a..00000000 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/transaction-database/transactions/play-move.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -// -// `playMove` conforms to `State.playMove`. Both take a plain board index, so the -// `apply` closure calls the raw transaction directly on the seeded store — no -// entity resolution needed. Every branch of the move guard (legal placement, -// alternation, win, occupied / out-of-bounds / game-over no-ops) is covered by -// the shared cases. -import { describe } from "vitest"; -import { State } from "../../../../data/state/state.js"; -import { cases } from "../../../../data/state/play-move.cases.js"; -import { expectConforms } from "../../conformance/expect-conforms.js"; -import { playMove } from "./play-move.js"; - -describe("playMove transaction conforms to State.playMove", () => { - expectConforms({ - cases, - spec: State.playMove, - apply: playMove, - }); -}); diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/transaction-database/transactions/restart-game.test.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/transaction-database/transactions/restart-game.test.ts deleted file mode 100644 index ae3420e5..00000000 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/transaction-database/transactions/restart-game.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -// -// `restartGame` conforms to `State.restartGame`. It takes no args, so the -// `apply` closure just dispatches the raw transaction on the seeded store. The -// shared cases cover each scoreboard outcome — X win, O win, draw, and an -// unfinished restart that bumps no counter. -import { describe } from "vitest"; -import { State } from "../../../../data/state/state.js"; -import { cases } from "../../../../data/state/restart-game.cases.js"; -import { expectConforms } from "../../conformance/expect-conforms.js"; -import { restartGame } from "./restart-game.js"; - -describe("restartGame transaction conforms to State.restartGame", () => { - expectConforms({ - cases, - spec: State.restartGame, - apply: restartGame, - }); -}); From 17b4d666a2e3999c2498c236e77a348101a51d86 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Thu, 6 Aug 2026 19:59:29 -0700 Subject: [PATCH 07/37] refactor: align assembled-db alias to MainService; actions read the store Naming: rename the assembled-db alias FeatureDatabase -> MainService and feature-database.ts -> main-service.ts (folder-eponymous per the rule) across todo, tictactoe, space-rock, gpu-hopper and all importers. The rule was already correct (MainService); the samples now match it. Staleness fix: an action reads current state synchronously from the store, never from a cached computed (which refreshes only on committed transactions, so a direct fromState seed can't invalidate it). Fixed tictactoe's playOpponentMove and captured the rule in actions.md. Co-Authored-By: Claude Opus 4.8 --- .../features/services/main-service/actions.md | 5 ++++ .../conformance/create-system-database.ts | 6 ++--- .../{feature-database.ts => main-service.ts} | 4 +-- .../src/features/main/ui/hopper-app-plugin.ts | 8 +++--- .../main/ui/render/hopper-render-plugin.ts | 2 +- .../conformance/create-system-database.ts | 6 ++--- .../{feature-database.ts => main-service.ts} | 2 +- .../space-rock-game-element.ts | 8 +++--- .../data-lit-space-rock-game/src/index.ts | 2 +- packages/data-lit-space-rock-game/src/main.ts | 4 +-- .../actions/play-opponent-move.ts | 25 ++++++++++++------- .../main-service/conformance/actions.test.ts | 4 +-- .../{feature-database.ts => main-service.ts} | 4 +-- .../src/features/main/ui/tictactoe-element.ts | 4 +-- packages/data-lit-tictactoe/src/index.ts | 2 +- packages/data-lit-tictactoe/src/main.ts | 4 +-- .../main-service/conformance/actions.test.ts | 4 +-- .../services/main-service/feature-database.ts | 2 -- .../services/main-service/main-service.ts | 2 ++ .../src/features/main/ui/todo-app/todo-app.ts | 6 ++--- .../src/features/main/ui/todo-element.ts | 8 +++--- packages/data-lit-todo/src/index.ts | 2 +- packages/data-lit-todo/src/main.ts | 4 +-- 23 files changed, 65 insertions(+), 53 deletions(-) rename packages/data-gpu-hopper/src/features/main/services/main-service/{feature-database.ts => main-service.ts} (51%) rename packages/data-lit-space-rock-game/src/features/main/services/main-service/{feature-database.ts => main-service.ts} (73%) rename packages/data-lit-tictactoe/src/features/main/services/main-service/{feature-database.ts => main-service.ts} (62%) delete mode 100644 packages/data-lit-todo/src/features/main/services/main-service/feature-database.ts create mode 100644 packages/data-lit-todo/src/features/main/services/main-service/main-service.ts diff --git a/packages/data-ai/.claude/rules/features/services/main-service/actions.md b/packages/data-ai/.claude/rules/features/services/main-service/actions.md index 5ccf48ca..046f2434 100644 --- a/packages/data-ai/.claude/rules/features/services/main-service/actions.md +++ b/packages/data-ai/.claude/rules/features/services/main-service/actions.md @@ -36,6 +36,11 @@ export const addRandomTodo = async (service: ServiceDatabase) => { awaited-internally, never surfaced to the caller. - Do the outside-world work here: await/sequence `services/` calls, and if a slow call needs timing, compute it here around the call. +- **Read current state synchronously from the store** (`db.resources` / `db.read` + / `db.select`, then a pure `data/` helper) — never from a cached `computed`. + Reactive computeds refresh only on a committed transaction, so an imperative + read of one can hand back a stale shared cache (and it's the UI's layer, not + the action's). This also keeps the action correct under the conformance seed. - **Conformance** (`conformance/actions.test.ts`) runs each transition's shared cases against its action, asserting **state and effects**: build the db with fake services via `Database.create(MainService.plugin, { services })`, run the diff --git a/packages/data-gpu-hopper/src/features/main/services/main-service/conformance/create-system-database.ts b/packages/data-gpu-hopper/src/features/main/services/main-service/conformance/create-system-database.ts index c76e5086..89f7ce4a 100644 --- a/packages/data-gpu-hopper/src/features/main/services/main-service/conformance/create-system-database.ts +++ b/packages/data-gpu-hopper/src/features/main/services/main-service/conformance/create-system-database.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Database } from "@adobe/data/ecs"; -import { FeatureDatabase } from "../feature-database.js"; +import { MainService } from "../main-service.js"; // The assembled feature database viewed as its writable *system surface* — the // same `Database & { store }` a system's `create(db)` receives — obtained @@ -9,5 +9,5 @@ import { FeatureDatabase } from "../feature-database.js"; // `db.system.functions` and reach the store via `db.store`. Transaction and // projection conformance use `createStore` instead. Test-only. export const createSystemDatabase = (): Database.Plugin.ToSystemDatabase< - typeof FeatureDatabase.plugin -> => Database.toSystemDatabase(Database.create(FeatureDatabase.plugin)); + typeof MainService.plugin +> => Database.toSystemDatabase(Database.create(MainService.plugin)); diff --git a/packages/data-gpu-hopper/src/features/main/services/main-service/feature-database.ts b/packages/data-gpu-hopper/src/features/main/services/main-service/main-service.ts similarity index 51% rename from packages/data-gpu-hopper/src/features/main/services/main-service/feature-database.ts rename to packages/data-gpu-hopper/src/features/main/services/main-service/main-service.ts index 9778afba..60709299 100644 --- a/packages/data-gpu-hopper/src/features/main/services/main-service/feature-database.ts +++ b/packages/data-gpu-hopper/src/features/main/services/main-service/main-service.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. // The whole assembled feature database. Consumers (ui/, the app entry) reference -// `FeatureDatabase` — never the topmost layer by name — so adding or dropping a +// `MainService` — never the topmost layer by name — so adding or dropping a // behaviour layer changes only this one line. -export { SystemDatabase as FeatureDatabase } from "./system-database/system-database.js"; +export { SystemDatabase as MainService } from "./system-database/system-database.js"; diff --git a/packages/data-gpu-hopper/src/features/main/ui/hopper-app-plugin.ts b/packages/data-gpu-hopper/src/features/main/ui/hopper-app-plugin.ts index 2245ae90..8e71721b 100644 --- a/packages/data-gpu-hopper/src/features/main/ui/hopper-app-plugin.ts +++ b/packages/data-gpu-hopper/src/features/main/ui/hopper-app-plugin.ts @@ -1,13 +1,13 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Database } from "@adobe/data/ecs"; -import { FeatureDatabase } from "../services/main-service/feature-database.js"; +import { MainService } from "../services/main-service/main-service.js"; import { HopperRenderDatabase } from "./render/hopper-render-plugin.js"; -// The browser application: the headless simulation (`FeatureDatabase`) plus the +// The browser application: the headless simulation (`MainService`) plus the // GPU renderer combined on top. `combine` dedupes the shared `scheduler` and the // shared `CoreDatabase` schema by identity, so there is one store and one frame -// loop. The headless tests keep using `FeatureDatabase` alone. -const hopperAppPlugin = Database.Plugin.combine(FeatureDatabase.plugin, HopperRenderDatabase.plugin); +// loop. The headless tests keep using `MainService` alone. +const hopperAppPlugin = Database.Plugin.combine(MainService.plugin, HopperRenderDatabase.plugin); export type HopperApp = Database.Plugin.ToDatabase; diff --git a/packages/data-gpu-hopper/src/features/main/ui/render/hopper-render-plugin.ts b/packages/data-gpu-hopper/src/features/main/ui/render/hopper-render-plugin.ts index a9373f48..e4b6a5b8 100644 --- a/packages/data-gpu-hopper/src/features/main/ui/render/hopper-render-plugin.ts +++ b/packages/data-gpu-hopper/src/features/main/ui/render/hopper-render-plugin.ts @@ -30,7 +30,7 @@ const UNIT_CUBE_BOUNDS: Aabb = { min: [-1, -1, -1], max: [1, 1, 1] }; const colorKey = (color: readonly number[]): string => color.join(","); -// The renderer, combined ON TOP of the headless `FeatureDatabase` at the element +// The renderer, combined ON TOP of the headless `MainService` at the element // level (never inside it, so the simulation stays GPU-free). Draws the game as // colored cubes via `pbrIblRender` — with `light.environmentUrl` left null so its // IBL bake is procedural (zero network) — reusing the shared unit cube baked by diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/create-system-database.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/create-system-database.ts index a96b45f7..746f19d6 100644 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/create-system-database.ts +++ b/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/create-system-database.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Database } from "@adobe/data/ecs"; -import { FeatureDatabase } from "../feature-database.js"; +import { MainService } from "../main-service.js"; // The assembled feature database viewed as its writable *system surface* — the // same `Database & { store }` a system's `create(db)` receives — obtained @@ -10,5 +10,5 @@ import { FeatureDatabase } from "../feature-database.js"; // store via `db.store`. Transaction and projection conformance use `createStore` // instead (a transaction needs no `Database`). Test-only. export const createSystemDatabase = (): Database.Plugin.ToSystemDatabase< - typeof FeatureDatabase.plugin -> => Database.toSystemDatabase(Database.create(FeatureDatabase.plugin)); + typeof MainService.plugin +> => Database.toSystemDatabase(Database.create(MainService.plugin)); diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/feature-database.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/main-service.ts similarity index 73% rename from packages/data-lit-space-rock-game/src/features/main/services/main-service/feature-database.ts rename to packages/data-lit-space-rock-game/src/features/main/services/main-service/main-service.ts index 5bfb6a77..dfdf7c39 100644 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/feature-database.ts +++ b/packages/data-lit-space-rock-game/src/features/main/services/main-service/main-service.ts @@ -3,4 +3,4 @@ // The feature's assembled database, aliased once so consumers (ui/, the app // entry, ecs/conformance/) never name the topmost layer. Add or drop a layer → // change only this line. -export { SystemDatabase as FeatureDatabase } from "./system-database/system-database.js"; +export { SystemDatabase as MainService } from "./system-database/system-database.js"; diff --git a/packages/data-lit-space-rock-game/src/features/main/ui/space-rock-game/space-rock-game-element.ts b/packages/data-lit-space-rock-game/src/features/main/ui/space-rock-game/space-rock-game-element.ts index aa343bfe..6cfa6973 100644 --- a/packages/data-lit-space-rock-game/src/features/main/ui/space-rock-game/space-rock-game-element.ts +++ b/packages/data-lit-space-rock-game/src/features/main/ui/space-rock-game/space-rock-game-element.ts @@ -11,7 +11,7 @@ import { useRef, useWindowEvent, } from "@adobe/data-lit"; -import { FeatureDatabase } from "../../services/main-service/feature-database.js"; +import { MainService } from "../../services/main-service/main-service.js"; import type { Input } from "../../data/input/input.js"; import type { Size } from "../../data/size/size.js"; import { styles } from "./space-rock-game.css.js"; @@ -45,7 +45,7 @@ const toInput = (held: Held, fire: boolean): Input => ({ // render bridge, not game logic: the scheduler advances the sim on its own rAF, // mostly via in-place column writes that never fire observers, so the canvas // must read the current columns synchronously each frame rather than subscribe. -const buildScene = (game: FeatureDatabase) => { +const buildScene = (game: MainService) => { const ships: { position: Vec2; rotation: number }[] = []; for (const arch of game.queryArchetypes(["position", "rotation"])) { const position = arch.columns.position; @@ -73,11 +73,11 @@ const buildScene = (game: FeatureDatabase) => { }; @customElement(tagName) -export class SpaceRockGameElement extends DatabaseElement { +export class SpaceRockGameElement extends DatabaseElement { static styles = styles; get plugin() { - return FeatureDatabase.plugin; + return MainService.plugin; } render() { diff --git a/packages/data-lit-space-rock-game/src/index.ts b/packages/data-lit-space-rock-game/src/index.ts index b9a8213b..1ae54a05 100644 --- a/packages/data-lit-space-rock-game/src/index.ts +++ b/packages/data-lit-space-rock-game/src/index.ts @@ -7,7 +7,7 @@ // consumers (per the cross-feature naming rule). Carries `.plugin` (schema + // indexes + transactions + computed, combined with the built-in rAF scheduler) // and `.Store` for consumers that build their own database from it. -export { FeatureDatabase as SpaceRockGameDatabase } from "./features/main/services/main-service/feature-database.js"; +export { MainService as SpaceRockGameDatabase } from "./features/main/services/main-service/main-service.js"; export { SpaceRockGame } from "./features/main/ui/space-rock-game/space-rock-game.js"; export { SpaceRockGameElement } from "./features/main/ui/space-rock-game/space-rock-game-element.js"; diff --git a/packages/data-lit-space-rock-game/src/main.ts b/packages/data-lit-space-rock-game/src/main.ts index 9d7f51a9..d423f1f6 100644 --- a/packages/data-lit-space-rock-game/src/main.ts +++ b/packages/data-lit-space-rock-game/src/main.ts @@ -3,7 +3,7 @@ import { render } from "lit"; import { Database } from "@adobe/data/ecs"; import { SpaceRockGame } from "./features/main/ui/space-rock-game/space-rock-game.js"; -import { FeatureDatabase } from "./features/main/services/main-service/feature-database.js"; +import { MainService } from "./features/main/services/main-service/main-service.js"; const app = document.getElementById("app"); if (app) { @@ -11,6 +11,6 @@ if (app) { // combined with the built-in rAF scheduler). Built once here and passed to // the lazy wrapper via its `.service` seam so the upgraded element receives // the ticking database. - const service = Database.create(FeatureDatabase.plugin); + const service = Database.create(MainService.plugin); render(SpaceRockGame({ service }), app); } diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/action-database/actions/play-opponent-move.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/action-database/actions/play-opponent-move.ts index 56f9ef43..8c1e6213 100644 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/action-database/actions/play-opponent-move.ts +++ b/packages/data-lit-tictactoe/src/features/main/services/main-service/action-database/actions/play-opponent-move.ts @@ -1,15 +1,22 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Observe } from "@adobe/data/observe"; -import { board } from "../../computed-database/computed/board.js"; +import { BoardState } from "../../../../data/board-state/board-state.js"; +import type { PlacedMark } from "../../../../data/placed-mark/placed-mark.js"; import type { ServiceDatabase } from "../../service-database/service-database.js"; -// The app-facing realization of `State.playOpponentMove`: read the current board, -// await the opponent port's selected index (the async outside-world work), then -// commit exactly one placement through `playMove`. `selectMove` is a -// value-returning read, not a fire-and-forget effect. Fire-and-forget: the -// selected index flows back through the transaction's observables, not a return. +// The app-facing realization of `State.playOpponentMove`: read the current board +// **synchronously from the store** (not a cached computed — reactive computeds +// refresh only on committed transactions, so an imperative read of one can be +// stale), await the opponent port's selected index (the async outside-world +// work), then commit exactly one placement through `playMove`. `selectMove` is a +// value-returning read, not a fire-and-forget effect. export const playOpponentMove = async (db: ServiceDatabase) => { - const currentBoard = await Observe.toPromise(board(db)); - const index = await db.services.opponent.selectMove(currentBoard); + const marks: PlacedMark[] = []; + for (const id of db.select(db.archetypes.PlacedMark.components)) { + const mark = db.read(id); + if (mark && mark.mark !== undefined && mark.index !== undefined) { + marks.push({ mark: mark.mark, index: mark.index }); + } + } + const index = await db.services.opponent.selectMove(BoardState.fromMarks(marks)); db.transactions.playMove({ index }); }; diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/actions.test.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/actions.test.ts index 2b502009..af37b71f 100644 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/actions.test.ts +++ b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/actions.test.ts @@ -6,7 +6,7 @@ import type { ConformanceCase } from "../../../data/state/conformance-case.js"; import { splitAndRecordServices, expectEffects } from "../../../data/state/record-effects.js"; import type { OpponentService } from "../../opponent-service/opponent-service.js"; import { expectStateMatches } from "../../../data/state/expect-state-matches.js"; -import { FeatureDatabase } from "../feature-database.js"; +import { MainService } from "../main-service.js"; import { fromState } from "./from-state.js"; import { toState } from "./to-state.js"; import { playMove } from "../action-database/actions/play-move.js"; @@ -24,7 +24,7 @@ import { cases as restartGameCases } from "../../../data/state/restart-game.js"; // keeping services/transactions/actions. Runtime invariant: the recording // wrappers preserve each service's shape, so they are valid factory overrides. const makeDb = (services: { opponent?: OpponentService }) => - Database.toSystemDatabase(Database.create(FeatureDatabase.plugin, { services })); + Database.toSystemDatabase(Database.create(MainService.plugin, { services })); type Db = ReturnType; type Run = (db: Db, input: Args) => Promise | void; diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/feature-database.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/main-service.ts similarity index 62% rename from packages/data-lit-tictactoe/src/features/main/services/main-service/feature-database.ts rename to packages/data-lit-tictactoe/src/features/main/services/main-service/main-service.ts index d1be2ded..d8d6816d 100644 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/feature-database.ts +++ b/packages/data-lit-tictactoe/src/features/main/services/main-service/main-service.ts @@ -2,6 +2,6 @@ // // The assembled feature database. Its topmost layer is `action-database` // (adds the async app-facing actions). Every consumer — `ui/`, the app entry, -// `services/main-service/conformance/` — references `FeatureDatabase`, never the +// `services/main-service/conformance/` — references `MainService`, never the // topmost layer, so adding or dropping a layer changes only this one line. -export { ActionDatabase as FeatureDatabase } from "./action-database/action-database.js"; +export { ActionDatabase as MainService } from "./action-database/action-database.js"; diff --git a/packages/data-lit-tictactoe/src/features/main/ui/tictactoe-element.ts b/packages/data-lit-tictactoe/src/features/main/ui/tictactoe-element.ts index f51dd372..d02adb03 100644 --- a/packages/data-lit-tictactoe/src/features/main/ui/tictactoe-element.ts +++ b/packages/data-lit-tictactoe/src/features/main/ui/tictactoe-element.ts @@ -5,10 +5,10 @@ import { ComputedDatabase } from "../services/main-service/computed-database/com /** * Base class for all Tic-Tac-Toe elements. Deliberately typed on the minimal - * base-game surface (`ComputedDatabase`), NOT the assembled `FeatureDatabase`, + * base-game surface (`ComputedDatabase`), NOT the assembled `MainService`, * so any database that *extends* the base game can be injected as `.service` — * the standalone agent-extended app and the p2p presence build both do this. - * (This is the sanctioned exception to "type consumers on `FeatureDatabase`": + * (This is the sanctioned exception to "type consumers on `MainService`": * an element meant to be extended types on the layer it actually consumes.) */ export class TictactoeElement extends DatabaseElement< diff --git a/packages/data-lit-tictactoe/src/index.ts b/packages/data-lit-tictactoe/src/index.ts index 902d7572..f2a636bd 100644 --- a/packages/data-lit-tictactoe/src/index.ts +++ b/packages/data-lit-tictactoe/src/index.ts @@ -7,7 +7,7 @@ // services. Feature-qualified per the cross-feature naming rule so a peer or // downstream package never collides with another feature's database. Carries // `.plugin` / `.Store` for consumers that build their own database. -export { FeatureDatabase as TictactoeDatabase } from "./features/main/services/main-service/feature-database.js"; +export { MainService as TictactoeDatabase } from "./features/main/services/main-service/main-service.js"; // The base game database — all game logic (resources, transactions, computed), // no AI. Combine its `.plugin` with P2P-specific plugins, or reach for diff --git a/packages/data-lit-tictactoe/src/main.ts b/packages/data-lit-tictactoe/src/main.ts index d87f3c3f..60eeeb9c 100644 --- a/packages/data-lit-tictactoe/src/main.ts +++ b/packages/data-lit-tictactoe/src/main.ts @@ -3,13 +3,13 @@ import { render } from "lit"; import { Database } from "@adobe/data/ecs"; import { Tictactoe } from "./features/main/ui/tictactoe-app/tictactoe-app.js"; -import { FeatureDatabase } from "./features/main/services/main-service/feature-database.js"; +import { MainService } from "./features/main/services/main-service/main-service.js"; const app = document.getElementById("app"); if (app) { // The assembled feature database extends the base game with AI services. // Built once here and passed to the lazy wrapper so the upgraded element // receives the agent-extended database. - const service = Database.create(FeatureDatabase.plugin); + const service = Database.create(MainService.plugin); render(Tictactoe({ service }), app); } diff --git a/packages/data-lit-todo/src/features/main/services/main-service/conformance/actions.test.ts b/packages/data-lit-todo/src/features/main/services/main-service/conformance/actions.test.ts index 6171acba..5fd5cf1b 100644 --- a/packages/data-lit-todo/src/features/main/services/main-service/conformance/actions.test.ts +++ b/packages/data-lit-todo/src/features/main/services/main-service/conformance/actions.test.ts @@ -6,7 +6,7 @@ import type { ConformanceCase } from "../../../data/state/conformance-case.js"; import { splitAndRecordServices, expectEffects } from "../../../data/state/record-effects.js"; import type { AnalyticsService } from "../../analytics-service/analytics-service.js"; import type { NameGeneratorService } from "../../name-generator-service/name-generator-service.js"; -import { FeatureDatabase } from "../feature-database.js"; +import { MainService } from "../main-service.js"; import { fromState } from "./from-state.js"; import { toState } from "./to-state.js"; import { expectStateMatches } from "../../../data/state/expect-state-matches.js"; @@ -35,7 +35,7 @@ import { cases as toggleDisplayCompletedCases } from "../../../data/state/toggle // keeping services/transactions/actions. Runtime invariant: the recording // wrappers preserve each service's shape, so they are valid factory overrides. const makeDb = (services: { analytics?: AnalyticsService; nameGenerator?: NameGeneratorService }) => - Database.toSystemDatabase(Database.create(FeatureDatabase.plugin, { services })); + Database.toSystemDatabase(Database.create(MainService.plugin, { services })); type Db = ReturnType; type Run = (db: Db, input: Args, resolve: (specId: number) => Entity) => Promise | void; diff --git a/packages/data-lit-todo/src/features/main/services/main-service/feature-database.ts b/packages/data-lit-todo/src/features/main/services/main-service/feature-database.ts deleted file mode 100644 index c0edaacc..00000000 --- a/packages/data-lit-todo/src/features/main/services/main-service/feature-database.ts +++ /dev/null @@ -1,2 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -export { ActionDatabase as FeatureDatabase } from "./action-database/action-database.js"; diff --git a/packages/data-lit-todo/src/features/main/services/main-service/main-service.ts b/packages/data-lit-todo/src/features/main/services/main-service/main-service.ts new file mode 100644 index 00000000..b0c4bfc7 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/services/main-service/main-service.ts @@ -0,0 +1,2 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +export { ActionDatabase as MainService } from "./action-database/action-database.js"; diff --git a/packages/data-lit-todo/src/features/main/ui/todo-app/todo-app.ts b/packages/data-lit-todo/src/features/main/ui/todo-app/todo-app.ts index 238bb9fa..a194ff73 100644 --- a/packages/data-lit-todo/src/features/main/ui/todo-app/todo-app.ts +++ b/packages/data-lit-todo/src/features/main/ui/todo-app/todo-app.ts @@ -1,14 +1,14 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { html, type TemplateResult } from "lit"; import type { Database } from "@adobe/data/ecs"; -import type { FeatureDatabase } from "../../services/main-service/feature-database.js"; +import type { MainService } from "../../services/main-service/main-service.js"; -type TodoService = Database.Plugin.ToDatabase; +type TodoService = Database.Plugin.ToDatabase; /** * Generic over `S` so callers may pass a database built from any plugin * that extends the todo plugin. The element is typed on the minimal - * `FeatureDatabase` surface. + * `MainService` surface. */ export const TodoApp = (args: { service: S }): TemplateResult => { void import("./todo-app-element.js"); diff --git a/packages/data-lit-todo/src/features/main/ui/todo-element.ts b/packages/data-lit-todo/src/features/main/ui/todo-element.ts index f6df6757..b8196b85 100644 --- a/packages/data-lit-todo/src/features/main/ui/todo-element.ts +++ b/packages/data-lit-todo/src/features/main/ui/todo-element.ts @@ -1,14 +1,14 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { DatabaseElement } from "@adobe/data-lit"; -import { FeatureDatabase } from "../services/main-service/feature-database.js"; +import { MainService } from "../services/main-service/main-service.js"; /** - * Base class for all todo elements. Typed on the assembled `FeatureDatabase` + * Base class for all todo elements. Typed on the assembled `MainService` * plugin surface so every child element can read computed values off `.service` * and dispatch `.service.actions.*` (which orchestrate analytics + transactions). */ -export class TodoElement extends DatabaseElement { +export class TodoElement extends DatabaseElement { get plugin() { - return FeatureDatabase.plugin; + return MainService.plugin; } } diff --git a/packages/data-lit-todo/src/index.ts b/packages/data-lit-todo/src/index.ts index e6541cc9..5eee63dd 100644 --- a/packages/data-lit-todo/src/index.ts +++ b/packages/data-lit-todo/src/index.ts @@ -2,7 +2,7 @@ // // Library entry point for data-lit-todo. -export { FeatureDatabase as TodoDatabase } from "./features/main/services/main-service/feature-database.js"; +export { MainService as TodoDatabase } from "./features/main/services/main-service/main-service.js"; export { TodoElement } from "./features/main/ui/todo-element.js"; export { TodoApp } from "./features/main/ui/todo-app/todo-app.js"; export { Todo } from "./features/main/data/todo/todo.js"; diff --git a/packages/data-lit-todo/src/main.ts b/packages/data-lit-todo/src/main.ts index 49e58695..64a1cbbf 100644 --- a/packages/data-lit-todo/src/main.ts +++ b/packages/data-lit-todo/src/main.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { html, render } from "lit"; import { Database } from "@adobe/data/ecs"; -import { FeatureDatabase } from "./features/main/services/main-service/feature-database.js"; +import { MainService } from "./features/main/services/main-service/main-service.js"; import { TodoApp } from "./features/main/ui/todo-app/todo-app.js"; // Spectrum 2 theme registration (side-effect imports). @@ -11,7 +11,7 @@ import "@spectrum-web-components/theme/spectrum-two/scale-medium.js"; const app = document.getElementById("app"); if (app) { - const service = Database.create(FeatureDatabase.plugin); + const service = Database.create(MainService.plugin); service.actions.createTodo({ name: "Buy groceries" }); service.actions.createTodo({ name: "Pick up dry cleaning" }); From b3900600ec9ce2920d4cc2a136f00fed090fd1dc Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Thu, 6 Aug 2026 20:14:13 -0700 Subject: [PATCH 08/37] feat(solid-dashboard): convert to co-located conformance pattern Rollout conversion: co-located cases (Conformance), single spec.test.ts, matcher-aware compare, transaction + action conformance (barrel-driven guard), new action layer. Scalar-resource-only feature, so per the rules it has no to-data / computeds.test / matchers / data- folders. All gates green under Node 24 (42 tests). Co-Authored-By: Claude Opus 4.8 --- .../main/data/state/clear-log.test.ts | 28 ----- .../src/features/main/data/state/clear-log.ts | 17 +++ .../main/data/state/conformance-case.ts | 63 ++++++++++- .../main/data/state/decrement.test.ts | 28 ----- .../src/features/main/data/state/decrement.ts | 17 +++ .../main/data/state/expect-state-matches.ts | 49 +++++++- .../main/data/state/increment.test.ts | 28 ----- .../src/features/main/data/state/increment.ts | 20 ++++ .../main/data/state/record-effects.ts | 107 ++++++++++++++++++ .../features/main/data/state/reset.test.ts | 28 ----- .../src/features/main/data/state/reset.ts | 17 +++ .../main/data/state/set-user-name.test.ts | 28 ----- .../features/main/data/state/set-user-name.ts | 17 +++ .../src/features/main/data/state/spec.test.ts | 59 ++++++++++ .../action-database/action-database.ts | 18 +++ .../action-database/actions/clear-log.ts | 7 ++ .../action-database/actions/decrement.ts | 7 ++ .../action-database/actions/increment.ts | 8 ++ .../action-database/actions/index.ts | 6 + .../action-database/actions/reset.ts | 7 ++ .../action-database/actions/set-user-name.ts | 7 ++ .../main-service/conformance/actions.test.ts | 76 +++++++++++++ .../conformance/expect-conforms.ts | 18 +-- .../conformance/projection.test.ts | 35 ++++++ .../conformance/transactions.test.ts | 51 +++++++++ .../services/main-service/main-service.ts | 8 +- .../transactions/clear-log.test.ts | 17 --- .../transactions/decrement.test.ts | 16 --- .../transactions/increment.test.ts | 17 --- .../transactions/reset.test.ts | 16 --- .../transactions/set-user-name.test.ts | 17 --- 31 files changed, 592 insertions(+), 245 deletions(-) delete mode 100644 packages/data-solid-dashboard/src/features/main/data/state/clear-log.test.ts delete mode 100644 packages/data-solid-dashboard/src/features/main/data/state/decrement.test.ts delete mode 100644 packages/data-solid-dashboard/src/features/main/data/state/increment.test.ts create mode 100644 packages/data-solid-dashboard/src/features/main/data/state/record-effects.ts delete mode 100644 packages/data-solid-dashboard/src/features/main/data/state/reset.test.ts delete mode 100644 packages/data-solid-dashboard/src/features/main/data/state/set-user-name.test.ts create mode 100644 packages/data-solid-dashboard/src/features/main/data/state/spec.test.ts create mode 100644 packages/data-solid-dashboard/src/features/main/services/main-service/action-database/action-database.ts create mode 100644 packages/data-solid-dashboard/src/features/main/services/main-service/action-database/actions/clear-log.ts create mode 100644 packages/data-solid-dashboard/src/features/main/services/main-service/action-database/actions/decrement.ts create mode 100644 packages/data-solid-dashboard/src/features/main/services/main-service/action-database/actions/increment.ts create mode 100644 packages/data-solid-dashboard/src/features/main/services/main-service/action-database/actions/index.ts create mode 100644 packages/data-solid-dashboard/src/features/main/services/main-service/action-database/actions/reset.ts create mode 100644 packages/data-solid-dashboard/src/features/main/services/main-service/action-database/actions/set-user-name.ts create mode 100644 packages/data-solid-dashboard/src/features/main/services/main-service/conformance/actions.test.ts create mode 100644 packages/data-solid-dashboard/src/features/main/services/main-service/conformance/projection.test.ts create mode 100644 packages/data-solid-dashboard/src/features/main/services/main-service/conformance/transactions.test.ts delete mode 100644 packages/data-solid-dashboard/src/features/main/services/main-service/transaction-database/transactions/clear-log.test.ts delete mode 100644 packages/data-solid-dashboard/src/features/main/services/main-service/transaction-database/transactions/decrement.test.ts delete mode 100644 packages/data-solid-dashboard/src/features/main/services/main-service/transaction-database/transactions/increment.test.ts delete mode 100644 packages/data-solid-dashboard/src/features/main/services/main-service/transaction-database/transactions/reset.test.ts delete mode 100644 packages/data-solid-dashboard/src/features/main/services/main-service/transaction-database/transactions/set-user-name.test.ts diff --git a/packages/data-solid-dashboard/src/features/main/data/state/clear-log.test.ts b/packages/data-solid-dashboard/src/features/main/data/state/clear-log.test.ts deleted file mode 100644 index 952c7e18..00000000 --- a/packages/data-solid-dashboard/src/features/main/data/state/clear-log.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it } from "vitest"; -import { State } from "./state.js"; -import type { ConformanceCase } from "./conformance-case.js"; -import { expectStateMatches } from "./expect-state-matches.js"; - -export const cases: ConformanceCase[] = [ - { - name: "empties a populated log, leaving count and name intact", - before: { ...State.create(), count: 2, log: ["a", "b"], userName: "Ada" }, - args: undefined, - after: { ...State.create(), count: 2, log: [], userName: "Ada" }, - }, - { - name: "is a no-op on an already empty log", - before: { ...State.create() }, - args: undefined, - after: { ...State.create() }, - }, -]; - -describe("State.clearLog", () => { - for (const testCase of cases) { - it(testCase.name, () => { - expectStateMatches(State.clearLog(testCase.before), testCase.after); - }); - } -}); diff --git a/packages/data-solid-dashboard/src/features/main/data/state/clear-log.ts b/packages/data-solid-dashboard/src/features/main/data/state/clear-log.ts index f4080705..efadeba2 100644 --- a/packages/data-solid-dashboard/src/features/main/data/state/clear-log.ts +++ b/packages/data-solid-dashboard/src/features/main/data/state/clear-log.ts @@ -1,8 +1,25 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { State } from "./state.js"; +import type { Conformance } from "./conformance-case.js"; // Empty the activity log. The counter and user name are left untouched. export const clearLog = >(state: T): T => ({ ...state, log: [], }); + +// Spec-owned cases, shared with the ecs `clearLog` transaction and action. +export const cases: Conformance = [ + { + name: "empties a populated log, leaving count and name intact", + before: { count: 2, log: ["a", "b"], userName: "Ada" }, + args: undefined, + after: { count: 2, log: [], userName: "Ada" }, + }, + { + name: "is a no-op on an already empty log", + before: { count: 0, log: [], userName: "Guest" }, + args: undefined, + after: { count: 0, log: [], userName: "Guest" }, + }, +]; diff --git a/packages/data-solid-dashboard/src/features/main/data/state/conformance-case.ts b/packages/data-solid-dashboard/src/features/main/data/state/conformance-case.ts index d5d96e64..095f3cb2 100644 --- a/packages/data-solid-dashboard/src/features/main/data/state/conformance-case.ts +++ b/packages/data-solid-dashboard/src/features/main/data/state/conformance-case.ts @@ -1,13 +1,70 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { State } from "./state.js"; +// A service arg is an object with method members (not an array, not a function); +// this is what distinguishes an injected service from plain data args, since the +// `Service` marker itself is all-optional and would over-match. +type MethodKeys = { [K in keyof T]-?: T[K] extends (...a: never[]) => unknown ? K : never }[keyof T]; +type IsService = T extends readonly unknown[] + ? false + : T extends (...a: never[]) => unknown + ? false + : T extends object + ? [MethodKeys] extends [never] + ? false + : true + : false; + +// A strongly-typed call to one method of service `S`: `[methodName, ...its args]`. +// A no-arg method is just `[methodName]`. +export type Call = { + [M in keyof S]-?: S[M] extends (...a: infer A) => unknown ? readonly [M, ...A] : never; +}[keyof S]; + +// Expected side effects for a case, keyed by the service-typed args only. An +// `Array` value asserts these calls in this order; a `Set` value asserts the same +// calls in any order. Method names and their args are checked against the service. +export type Effects = { + readonly [K in keyof Args as IsService extends true ? K : never]?: + | readonly Call[] + | ReadonlySet>; +}; + // One spec-owned conformance case: a `data/` transform's `{ before, args, after }` -// authored as full `State`. Exported from each `.test.ts` and shared, -// unchanged, by the data transform test and the ecs conformance runner (see -// `services/main-service/conformance/`). +// authored as full `State`, optionally with the side effects it makes on its +// injected service args. Shared unchanged by the spec aggregator (`spec.test.ts`) +// and the ecs conformance runners (`services/main-service/conformance/`). export type ConformanceCase = { readonly name: string; readonly before: State; readonly args: Args; readonly after: State; + readonly effects?: Effects; }; + +// A transform's cases, with the case `args` type derived from the transform's own +// signature — its second parameter, or `void` when it takes none. A transform +// co-locates `export const cases: Conformance = [...]`, so the +// cases cannot drift from what the function accepts, and the spec aggregator can +// discover the function without it being named twice. +export type Conformance unknown> = readonly ConformanceCase< + Parameters extends [unknown, infer Args, ...unknown[]] ? Args : void +>[]; + +// One case for a derivation (`(state) => value`): a state `input` and the `value` +// it yields. `value` may use asymmetric matchers (`anyNumber` for ids). Both the +// pure derivation and its ecs computed are checked against these. +export type DerivationCase = { + readonly name: string; + readonly input: Input; + readonly value: Value; +}; + +// A derivation's cases, with `input` and `value` types read from the derivation's +// own signature (its parameter and return) — the `Conformance` analog for +// value-producing derivations. A derivation co-locates +// `export const cases: Derivation = [...]`. +export type Derivation unknown> = readonly DerivationCase< + Parameters[0], + ReturnType +>[]; diff --git a/packages/data-solid-dashboard/src/features/main/data/state/decrement.test.ts b/packages/data-solid-dashboard/src/features/main/data/state/decrement.test.ts deleted file mode 100644 index 83f88a67..00000000 --- a/packages/data-solid-dashboard/src/features/main/data/state/decrement.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it } from "vitest"; -import { State } from "./state.js"; -import type { ConformanceCase } from "./conformance-case.js"; -import { expectStateMatches } from "./expect-state-matches.js"; - -export const cases: ConformanceCase[] = [ - { - name: "decrements a positive count and logs the new value", - before: { ...State.create(), count: 3, log: ["earlier"] }, - args: undefined, - after: { ...State.create(), count: 2, log: ["earlier", "Decremented to 2"] }, - }, - { - name: "is a no-op at zero, leaving state untouched", - before: { ...State.create() }, - args: undefined, - after: { ...State.create() }, - }, -]; - -describe("State.decrement", () => { - for (const testCase of cases) { - it(testCase.name, () => { - expectStateMatches(State.decrement(testCase.before), testCase.after); - }); - } -}); diff --git a/packages/data-solid-dashboard/src/features/main/data/state/decrement.ts b/packages/data-solid-dashboard/src/features/main/data/state/decrement.ts index c630cfdc..88a8e6c8 100644 --- a/packages/data-solid-dashboard/src/features/main/data/state/decrement.ts +++ b/packages/data-solid-dashboard/src/features/main/data/state/decrement.ts @@ -1,5 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { State } from "./state.js"; +import type { Conformance } from "./conformance-case.js"; // Lower the counter by one, never below zero. A no-op at zero returns `state` // unchanged (no log entry), keeping the transform idempotent at the floor. @@ -8,3 +9,19 @@ export const decrement = >(state: T): T = const count = state.count - 1; return { ...state, count, log: [...state.log, `Decremented to ${count}`] }; }; + +// Spec-owned cases, shared with the ecs `decrement` transaction and action. +export const cases: Conformance = [ + { + name: "decrements a positive count and logs the new value", + before: { count: 3, log: ["earlier"], userName: "Guest" }, + args: undefined, + after: { count: 2, log: ["earlier", "Decremented to 2"], userName: "Guest" }, + }, + { + name: "is a no-op at zero, leaving state untouched", + before: { count: 0, log: [], userName: "Guest" }, + args: undefined, + after: { count: 0, log: [], userName: "Guest" }, + }, +]; diff --git a/packages/data-solid-dashboard/src/features/main/data/state/expect-state-matches.ts b/packages/data-solid-dashboard/src/features/main/data/state/expect-state-matches.ts index c59af025..dd30dc8d 100644 --- a/packages/data-solid-dashboard/src/features/main/data/state/expect-state-matches.ts +++ b/packages/data-solid-dashboard/src/features/main/data/state/expect-state-matches.ts @@ -2,11 +2,48 @@ import { expect } from "vitest"; import type { State } from "./state.js"; -// Spec-owned `State` equality, shared by the data/ transform tests and the ecs -// conformance runner. Every field is a scalar held in a single resource slot -// (plain JS storage — no typed-buffer rounding, no archetype hole-fill -// reordering), so a strict, order-sensitive deep compare is exactly right: -// the `log` array's chronological order is meaningful and must match. +// A vitest asymmetric matcher (`expect.any(...)`): honored on the EXPECTED side +// so a case can assert "any value" for something it does not pin. This feature's +// `State` is entirely scalar resources (no ecs-minted ids), so no case actually +// uses one today — but the comparison stays matcher-aware so it backs the shared +// spec/computed comparisons uniformly across features (see `matchers.ts` note). +const isMatcher = (value: unknown): value is { asymmetricMatch(actual: unknown): boolean } => + typeof value === "object" && + value !== null && + typeof (value as { asymmetricMatch?: unknown }).asymmetricMatch === "function"; + +// Tolerant structural match honoring asymmetric matchers and comparing arrays +// **in order** — the `log` trail is chronological, so position is significant. +// Exported so it can back other conformance comparisons (e.g. derivation values). +export const matches = (actual: unknown, expected: unknown): boolean => { + if (isMatcher(expected)) return expected.asymmetricMatch(actual); + if (Array.isArray(expected)) { + if (!Array.isArray(actual) || actual.length !== expected.length) return false; + return expected.every((exp, index) => matches(actual[index], exp)); + } + if (expected !== null && typeof expected === "object") { + if (actual === null || typeof actual !== "object" || Array.isArray(actual)) return false; + const expectedKeys = Object.keys(expected); + const actualKeys = Object.keys(actual as object); + if (expectedKeys.length !== actualKeys.length) return false; + return expectedKeys.every((key) => + matches((actual as Record)[key], (expected as Record)[key]), + ); + } + return Object.is(actual, expected); +}; + +// Spec-owned tolerant `State` equality, shared by the data/ spec test and the ecs +// conformance runners. Every field is a scalar held in a single resource slot +// (plain JS storage — no typed-buffer rounding, no archetype hole-fill), so the +// projection is id-free; the matcher path still lets `after`/`value` stay open +// where a future field warranted it. export const expectStateMatches = (actual: State, expected: State): void => { - expect(actual).toEqual(expected); + expectMatches(actual, expected); +}; + +// The same tolerant, matcher-aware comparison for any value — used by the spec +// aggregator for derivation cases, where the compared value is not a whole `State`. +export const expectMatches = (actual: unknown, expected: unknown): void => { + expect(matches(actual, expected), `mismatch:\n actual ${JSON.stringify(actual)}`).toBe(true); }; diff --git a/packages/data-solid-dashboard/src/features/main/data/state/increment.test.ts b/packages/data-solid-dashboard/src/features/main/data/state/increment.test.ts deleted file mode 100644 index 3c5413e1..00000000 --- a/packages/data-solid-dashboard/src/features/main/data/state/increment.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it } from "vitest"; -import { State } from "./state.js"; -import type { ConformanceCase } from "./conformance-case.js"; -import { expectStateMatches } from "./expect-state-matches.js"; - -export const cases: ConformanceCase[] = [ - { - name: "increments from zero and logs the new value", - before: { ...State.create() }, - args: undefined, - after: { ...State.create(), count: 1, log: ["Incremented to 1"] }, - }, - { - name: "increments an existing count, preserving prior log entries", - before: { ...State.create(), count: 4, log: ["earlier"] }, - args: undefined, - after: { ...State.create(), count: 5, log: ["earlier", "Incremented to 5"] }, - }, -]; - -describe("State.increment", () => { - for (const testCase of cases) { - it(testCase.name, () => { - expectStateMatches(State.increment(testCase.before), testCase.after); - }); - } -}); diff --git a/packages/data-solid-dashboard/src/features/main/data/state/increment.ts b/packages/data-solid-dashboard/src/features/main/data/state/increment.ts index 50202874..7bf276c0 100644 --- a/packages/data-solid-dashboard/src/features/main/data/state/increment.ts +++ b/packages/data-solid-dashboard/src/features/main/data/state/increment.ts @@ -1,8 +1,28 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { State } from "./state.js"; +import type { Conformance } from "./conformance-case.js"; // Raise the counter by one and record it in the activity log. export const increment = >(state: T): T => { const count = state.count + 1; return { ...state, count, log: [...state.log, `Incremented to ${count}`] }; }; + +// Spec-owned cases, shared with the ecs `increment` transaction and action. +// `before`/`after` are authored as full `State` literals (a value-level import of +// the `State` namespace here would form an eager `state → public → increment` +// cycle, so the defaults are inlined). +export const cases: Conformance = [ + { + name: "increments from zero and logs the new value", + before: { count: 0, log: [], userName: "Guest" }, + args: undefined, + after: { count: 1, log: ["Incremented to 1"], userName: "Guest" }, + }, + { + name: "increments an existing count, preserving prior log entries", + before: { count: 4, log: ["earlier"], userName: "Guest" }, + args: undefined, + after: { count: 5, log: ["earlier", "Incremented to 5"], userName: "Guest" }, + }, +]; diff --git a/packages/data-solid-dashboard/src/features/main/data/state/record-effects.ts b/packages/data-solid-dashboard/src/features/main/data/state/record-effects.ts new file mode 100644 index 00000000..8fb3d25f --- /dev/null +++ b/packages/data-solid-dashboard/src/features/main/data/state/record-effects.ts @@ -0,0 +1,107 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { expect } from "vitest"; +import { equalsUnordered } from "@adobe/data"; +import type { Effects } from "./conformance-case.js"; + +export type RecordedCall = readonly [string, ...unknown[]]; + +// Wrap a plain-object service so each method call is recorded, then delegates. +// No Proxy (services are plain objects with own enumerable methods — see +// `service.md`), so we enumerate and closure-wrap each function. +export const recordCalls = (service: S): { service: S; calls: RecordedCall[] } => { + const calls: RecordedCall[] = []; + const wrapped = Object.fromEntries( + Object.entries(service).map(([key, value]) => [ + key, + typeof value === "function" + ? (...args: unknown[]): unknown => { + calls.push([key, ...args]); + return (value as (...a: unknown[]) => unknown)(...args); + } + : value, + ]), + ) as S; + return { service: wrapped, calls }; +}; + +// Assert the calls recorded against one service match the case's expectation for +// it: an Array expects exactly these calls in order; a Set expects the same calls +// in any order (multiset). Absent expectation ⇒ no calls expected. +export const expectServiceCalls = ( + recorded: readonly RecordedCall[], + expected: readonly RecordedCall[] | ReadonlySet | undefined, +): void => { + if (expected instanceof Set) { + expect(equalsUnordered(recorded, [...expected])).toBe(true); + } else { + expect(recorded).toEqual(expected ?? []); + } +}; + +// Split a case's `args` into the injected services (objects with methods) and the +// remaining plain data. Services are wrapped for recording; the returned `calls` +// map is keyed by the same arg key so it can be matched against `effects`. +export const recordArgServices = ( + args: Args, +): { args: Args; calls: Record } => { + const calls: Record = {}; + const next = { ...args } as Record; + for (const [key, value] of Object.entries(args)) { + if (isServiceValue(value)) { + const rec = recordCalls(value); + next[key] = rec.service; + calls[key] = rec.calls; + } + } + return { args: next as Args, calls }; +}; + +// Assert each service **declared** in `effects` saw exactly its expected calls +// (an extra or missing call on a declared service fails). Services not listed — +// e.g. a value-returning dependency read like `generateName` — are ignored, so +// `effects` captures the fire-and-forget side effects you choose to assert. +export const expectEffects = ( + calls: Record, + effects: Effects> | undefined, +): void => { + const expected = (effects ?? {}) as Record>; + for (const key of Object.keys(expected)) { + expectServiceCalls(calls[key] ?? [], expected[key]); + } +}; + +// Split a case's `args` into the injected services (wrapped for recording, to be +// used as `Database.create` service overrides) and the remaining plain data (the +// action input). Keyed by the same arg name so `calls` matches against `effects`. +export const splitAndRecordServices = ( + args: Args, +): { + services: Record; + input: Record; + calls: Record; +} => { + const services: Record = {}; + const input: Record = {}; + const calls: Record = {}; + // A no-arg transition has `undefined` args — nothing to split. + if (args !== null && typeof args === "object") { + for (const [key, value] of Object.entries(args)) { + if (isServiceValue(value)) { + const recorded = recordCalls(value); + services[key] = recorded.service; + calls[key] = recorded.calls; + } else { + input[key] = value; + } + } + } + return { services, input, calls }; +}; + +// A runtime service value: a non-array object with at least one method (mirrors +// the compile-time `IsService`). +const isServiceValue = (value: unknown): value is object => + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.values(value).some((member) => typeof member === "function"); diff --git a/packages/data-solid-dashboard/src/features/main/data/state/reset.test.ts b/packages/data-solid-dashboard/src/features/main/data/state/reset.test.ts deleted file mode 100644 index 8f0c3ab0..00000000 --- a/packages/data-solid-dashboard/src/features/main/data/state/reset.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it } from "vitest"; -import { State } from "./state.js"; -import type { ConformanceCase } from "./conformance-case.js"; -import { expectStateMatches } from "./expect-state-matches.js"; - -export const cases: ConformanceCase[] = [ - { - name: "resets a positive count and logs the reset", - before: { ...State.create(), count: 7, log: ["earlier"] }, - args: undefined, - after: { ...State.create(), count: 0, log: ["earlier", "Reset to 0"] }, - }, - { - name: "logs the reset even when already at zero", - before: { ...State.create() }, - args: undefined, - after: { ...State.create(), count: 0, log: ["Reset to 0"] }, - }, -]; - -describe("State.reset", () => { - for (const testCase of cases) { - it(testCase.name, () => { - expectStateMatches(State.reset(testCase.before), testCase.after); - }); - } -}); diff --git a/packages/data-solid-dashboard/src/features/main/data/state/reset.ts b/packages/data-solid-dashboard/src/features/main/data/state/reset.ts index 77306e22..564d171f 100644 --- a/packages/data-solid-dashboard/src/features/main/data/state/reset.ts +++ b/packages/data-solid-dashboard/src/features/main/data/state/reset.ts @@ -1,5 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { State } from "./state.js"; +import type { Conformance } from "./conformance-case.js"; // Return the counter to zero and record the reset in the activity log. export const reset = >(state: T): T => ({ @@ -7,3 +8,19 @@ export const reset = >(state: T): T => ({ count: 0, log: [...state.log, "Reset to 0"], }); + +// Spec-owned cases, shared with the ecs `reset` transaction and action. +export const cases: Conformance = [ + { + name: "resets a positive count and logs the reset", + before: { count: 7, log: ["earlier"], userName: "Guest" }, + args: undefined, + after: { count: 0, log: ["earlier", "Reset to 0"], userName: "Guest" }, + }, + { + name: "logs the reset even when already at zero", + before: { count: 0, log: [], userName: "Guest" }, + args: undefined, + after: { count: 0, log: ["Reset to 0"], userName: "Guest" }, + }, +]; diff --git a/packages/data-solid-dashboard/src/features/main/data/state/set-user-name.test.ts b/packages/data-solid-dashboard/src/features/main/data/state/set-user-name.test.ts deleted file mode 100644 index 43a06ade..00000000 --- a/packages/data-solid-dashboard/src/features/main/data/state/set-user-name.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it } from "vitest"; -import { State } from "./state.js"; -import type { ConformanceCase } from "./conformance-case.js"; -import { expectStateMatches } from "./expect-state-matches.js"; - -export const cases: ConformanceCase<{ name: string }>[] = [ - { - name: "sets the name and logs the change", - before: { ...State.create() }, - args: { name: "Ada" }, - after: { ...State.create(), userName: "Ada", log: ["Name changed to Ada"] }, - }, - { - name: "replaces an existing name, preserving prior log entries", - before: { ...State.create(), userName: "Ada", log: ["earlier"] }, - args: { name: "Grace" }, - after: { ...State.create(), userName: "Grace", log: ["earlier", "Name changed to Grace"] }, - }, -]; - -describe("State.setUserName", () => { - for (const testCase of cases) { - it(testCase.name, () => { - expectStateMatches(State.setUserName(testCase.before, testCase.args), testCase.after); - }); - } -}); diff --git a/packages/data-solid-dashboard/src/features/main/data/state/set-user-name.ts b/packages/data-solid-dashboard/src/features/main/data/state/set-user-name.ts index 59303226..39cb43e3 100644 --- a/packages/data-solid-dashboard/src/features/main/data/state/set-user-name.ts +++ b/packages/data-solid-dashboard/src/features/main/data/state/set-user-name.ts @@ -1,5 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { State } from "./state.js"; +import type { Conformance } from "./conformance-case.js"; // Change the active user's name and record the change in the activity log. export const setUserName = >( @@ -10,3 +11,19 @@ export const setUserName = >( userName: name, log: [...state.log, `Name changed to ${name}`], }); + +// Spec-owned cases, shared with the ecs `setUserName` transaction and action. +export const cases: Conformance = [ + { + name: "sets the name and logs the change", + before: { count: 0, log: [], userName: "Guest" }, + args: { name: "Ada" }, + after: { count: 0, log: ["Name changed to Ada"], userName: "Ada" }, + }, + { + name: "replaces an existing name, preserving prior log entries", + before: { count: 0, log: ["earlier"], userName: "Ada" }, + args: { name: "Grace" }, + after: { count: 0, log: ["earlier", "Name changed to Grace"], userName: "Grace" }, + }, +]; diff --git a/packages/data-solid-dashboard/src/features/main/data/state/spec.test.ts b/packages/data-solid-dashboard/src/features/main/data/state/spec.test.ts new file mode 100644 index 00000000..be7bb913 --- /dev/null +++ b/packages/data-solid-dashboard/src/features/main/data/state/spec.test.ts @@ -0,0 +1,59 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +/// +import { describe, it } from "vitest"; +import type { State } from "./state.js"; +import type { ConformanceCase, DerivationCase } from "./conformance-case.js"; +import { expectStateMatches, expectMatches } from "./expect-state-matches.js"; +import { recordArgServices, expectEffects } from "./record-effects.js"; + +// The single spec test for every transform AND derivation in this folder. It +// auto-discovers each file (any sibling `.ts` that exports `cases`) via +// `import.meta.glob`, so a new one is covered the moment it ships — none can be +// forgotten. Each participating file must export exactly its function plus `cases` +// (enforced below), which lets us find the function without it being named twice. +// A case's shape selects the check: `after` → a transition `(state, args) => state`; +// `value` → a derivation `(state) => value`. +const modules = import.meta.glob>( + ["./*.ts", "!./*.test.ts", "!./*.type-test.ts"], + { eager: true }, +); + +const isDerivationCase = (c: unknown): c is DerivationCase => + typeof c === "object" && c !== null && "value" in c; + +for (const [path, module] of Object.entries(modules)) { + const exportNames = Object.keys(module); + if (!exportNames.includes("cases")) continue; + const functionNames = exportNames.filter((key) => typeof module[key] === "function"); + const label = functionNames.length === 1 ? functionNames[0] : path; + + describe(`State.${label}`, () => { + if (exportNames.length !== 2 || functionNames.length !== 1) { + it("exports exactly its function and `cases`", () => { + throw new Error(`${path} exports [${exportNames.join(", ")}] — expected one function + cases`); + }); + return; + } + // Runtime invariant: a participating file exports a function and its cases. + const fn = module[functionNames[0]] as (...args: unknown[]) => unknown; + const cases = module["cases"] as readonly unknown[]; + for (const testCase of cases) { + if (isDerivationCase(testCase)) { + // Derivation: the value it yields matches, honoring asymmetric matchers. + it(testCase.name, () => expectMatches(fn(testCase.input), testCase.value)); + continue; + } + // Transition: assert the resulting state and the declared side effects. + // A service-injected transition is async, so await uniformly. + const transitionCase = testCase as ConformanceCase>; + it(transitionCase.name, async () => { + const raw = transitionCase.args; + const { args, calls } = + raw && typeof raw === "object" ? recordArgServices(raw) : { args: raw, calls: {} }; + const result = (await fn(transitionCase.before, args)) as State; + expectStateMatches(result, transitionCase.after); + expectEffects(calls, transitionCase.effects); + }); + } + }); +} diff --git a/packages/data-solid-dashboard/src/features/main/services/main-service/action-database/action-database.ts b/packages/data-solid-dashboard/src/features/main/services/main-service/action-database/action-database.ts new file mode 100644 index 00000000..cc2572a2 --- /dev/null +++ b/packages/data-solid-dashboard/src/features/main/services/main-service/action-database/action-database.ts @@ -0,0 +1,18 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import { TransactionDatabase } from "../transaction-database/transaction-database.js"; +import * as actions from "./actions/index.js"; + +// Extends the transaction layer directly: this feature has no computed / service +// layers between them (no derivations, no external capability services). +const actionDatabasePlugin = Database.Plugin.create({ + extends: TransactionDatabase.plugin, + actions, +}); + +export type ActionDatabase = Database.Plugin.ToDatabase; + +export namespace ActionDatabase { + export const plugin = actionDatabasePlugin; + export type Store = Database.Plugin.ToStore; +} diff --git a/packages/data-solid-dashboard/src/features/main/services/main-service/action-database/actions/clear-log.ts b/packages/data-solid-dashboard/src/features/main/services/main-service/action-database/actions/clear-log.ts new file mode 100644 index 00000000..29942007 --- /dev/null +++ b/packages/data-solid-dashboard/src/features/main/services/main-service/action-database/actions/clear-log.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { TransactionDatabase } from "../../transaction-database/transaction-database.js"; + +// The app-facing realization of `State.clearLog` — commits the transaction. +export const clearLog = (db: TransactionDatabase) => { + db.transactions.clearLog(); +}; diff --git a/packages/data-solid-dashboard/src/features/main/services/main-service/action-database/actions/decrement.ts b/packages/data-solid-dashboard/src/features/main/services/main-service/action-database/actions/decrement.ts new file mode 100644 index 00000000..a751cfaf --- /dev/null +++ b/packages/data-solid-dashboard/src/features/main/services/main-service/action-database/actions/decrement.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { TransactionDatabase } from "../../transaction-database/transaction-database.js"; + +// The app-facing realization of `State.decrement` — commits the transaction. +export const decrement = (db: TransactionDatabase) => { + db.transactions.decrement(); +}; diff --git a/packages/data-solid-dashboard/src/features/main/services/main-service/action-database/actions/increment.ts b/packages/data-solid-dashboard/src/features/main/services/main-service/action-database/actions/increment.ts new file mode 100644 index 00000000..3b4f3d2f --- /dev/null +++ b/packages/data-solid-dashboard/src/features/main/services/main-service/action-database/actions/increment.ts @@ -0,0 +1,8 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { TransactionDatabase } from "../../transaction-database/transaction-database.js"; + +// The app-facing realization of `State.increment`. This feature has no external +// services to await, so the action just commits the transaction (exactly one). +export const increment = (db: TransactionDatabase) => { + db.transactions.increment(); +}; diff --git a/packages/data-solid-dashboard/src/features/main/services/main-service/action-database/actions/index.ts b/packages/data-solid-dashboard/src/features/main/services/main-service/action-database/actions/index.ts new file mode 100644 index 00000000..002f08a4 --- /dev/null +++ b/packages/data-solid-dashboard/src/features/main/services/main-service/action-database/actions/index.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +export * from "./increment.js"; +export * from "./decrement.js"; +export * from "./reset.js"; +export * from "./set-user-name.js"; +export * from "./clear-log.js"; diff --git a/packages/data-solid-dashboard/src/features/main/services/main-service/action-database/actions/reset.ts b/packages/data-solid-dashboard/src/features/main/services/main-service/action-database/actions/reset.ts new file mode 100644 index 00000000..9c152c66 --- /dev/null +++ b/packages/data-solid-dashboard/src/features/main/services/main-service/action-database/actions/reset.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { TransactionDatabase } from "../../transaction-database/transaction-database.js"; + +// The app-facing realization of `State.reset` — commits the transaction. +export const reset = (db: TransactionDatabase) => { + db.transactions.reset(); +}; diff --git a/packages/data-solid-dashboard/src/features/main/services/main-service/action-database/actions/set-user-name.ts b/packages/data-solid-dashboard/src/features/main/services/main-service/action-database/actions/set-user-name.ts new file mode 100644 index 00000000..85f1e50f --- /dev/null +++ b/packages/data-solid-dashboard/src/features/main/services/main-service/action-database/actions/set-user-name.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { TransactionDatabase } from "../../transaction-database/transaction-database.js"; + +// The app-facing realization of `State.setUserName` — commits the transaction. +export const setUserName = (db: TransactionDatabase, input: { readonly name: string }) => { + db.transactions.setUserName(input); +}; diff --git a/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/actions.test.ts b/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/actions.test.ts new file mode 100644 index 00000000..49d55675 --- /dev/null +++ b/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/actions.test.ts @@ -0,0 +1,76 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +/// +import { describe, it, expect } from "vitest"; +import { Database } from "@adobe/data/ecs"; +import type { ConformanceCase } from "../../../data/state/conformance-case.js"; +import { splitAndRecordServices, expectEffects } from "../../../data/state/record-effects.js"; +import { MainService } from "../main-service.js"; +import { fromState } from "./from-state.js"; +import { toState } from "./to-state.js"; +import { expectStateMatches } from "../../../data/state/expect-state-matches.js"; +import { increment } from "../action-database/actions/increment.js"; +import { decrement } from "../action-database/actions/decrement.js"; +import { reset } from "../action-database/actions/reset.js"; +import { setUserName } from "../action-database/actions/set-user-name.js"; +import { clearLog } from "../action-database/actions/clear-log.js"; +import { cases as incrementCases } from "../../../data/state/increment.js"; +import { cases as decrementCases } from "../../../data/state/decrement.js"; +import { cases as resetCases } from "../../../data/state/reset.js"; +import { cases as setUserNameCases } from "../../../data/state/set-user-name.js"; +import { cases as clearLogCases } from "../../../data/state/clear-log.js"; + +// Each transition's cases run against its same-named ecs **action** (the async +// app-facing realization). The case's service args become the db's service +// overrides (wrapped so their calls are recorded), the plain args drive the +// action, and we assert both the resulting state and the declared side effects. +// This feature injects no services, so every case's `effects` is empty and the +// split yields no overrides — but the runner keeps the general shape. +// `toSystemDatabase` exposes the writable `.store` the projection needs while +// keeping transactions/actions. +const makeDb = (services: Record) => + Database.toSystemDatabase(Database.create(MainService.plugin, { services })); +type Db = ReturnType; +type Run = (db: Db, input: Args) => Promise | void; + +const covered = new Set(); +const conformsAction = ( + action: string, + config: { readonly cases: readonly ConformanceCase[]; readonly run: Run> }, +): void => { + covered.add(action); + describe(`${action} action conforms`, () => { + for (const testCase of config.cases) { + it(testCase.name, async () => { + const { services, input, calls } = splitAndRecordServices(testCase.args); + const db = makeDb(services); + fromState(db.store, testCase.before); + await config.run(db, input as Partial); + expectStateMatches(toState(db.store), testCase.after); + expectEffects(calls, testCase.effects); + }); + } + }); +}; + +conformsAction("increment", { cases: incrementCases, run: (db) => increment(db) }); +conformsAction("decrement", { cases: decrementCases, run: (db) => decrement(db) }); +conformsAction("reset", { cases: resetCases, run: (db) => reset(db) }); +conformsAction("setUserName", { + cases: setUserNameCases, + run: (db, input) => setUserName(db, { name: input.name ?? "" }), +}); +conformsAction("clearLog", { cases: clearLogCases, run: (db) => clearLog(db) }); + +// None-missed guard: every action file must be wired above. +const kebabToCamel = (name: string): string => + name.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase()); +describe("action conformance coverage", () => { + const files = import.meta.glob([ + "../action-database/actions/*.ts", + "!../action-database/actions/index.ts", + ]); + for (const path of Object.keys(files)) { + const action = kebabToCamel(path.replace(/.*\//, "").replace(/\.ts$/, "")); + it(`${action} has a conformance case`, () => expect(covered.has(action)).toBe(true)); + } +}); diff --git a/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/expect-conforms.ts b/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/expect-conforms.ts index 13d7497c..7420b586 100644 --- a/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/expect-conforms.ts +++ b/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/expect-conforms.ts @@ -13,25 +13,25 @@ import { toState } from "./to-state.js"; // // toState(apply(fromState(before), args)) ≡ spec(before, args) // -// in two asserted halves: -// 1. `spec(before, args) ≡ after` — keeps the shared case honest, independent -// of the ecs path. -// 2. seed `fromState(before)` → run the caller's `apply` → `toState ≡ after` -// — the ecs transaction reproduces the pure transform. +// The ecs half is always asserted here: seed `fromState(before)` → run the +// caller's `apply` → `toState ≡ after`. Half 1 (`spec(before,args) ≡ after`) is +// already asserted for every case by `data/state/spec.test.ts`, so the central +// aggregator omits it; pass `spec` to re-check it in place. // // `apply` receives the seeded writable store and the case args, then calls the // raw transaction function directly (a transaction is `(store, …) => void`, so // no `Database` is involved). This feature holds only scalar resources, so the -// projection is id-free and the comparison is strict on both halves. +// projection is id-free and there are no entities to resolve. export const expectConforms = (config: { readonly cases: readonly ConformanceCase[]; - readonly spec: (before: State, args: Args) => State; + readonly spec?: (before: State, args: Args) => State; readonly apply: (store: CoreDatabase.Store, args: Args) => void; }): void => { for (const testCase of config.cases) { it(testCase.name, () => { - expectStateMatches(config.spec(testCase.before, testCase.args), testCase.after); - + if (config.spec) { + expectStateMatches(config.spec(testCase.before, testCase.args), testCase.after); + } const store = createStore(); fromState(store, testCase.before); config.apply(store, testCase.args); diff --git a/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/projection.test.ts b/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/projection.test.ts new file mode 100644 index 00000000..a1dbedce --- /dev/null +++ b/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/projection.test.ts @@ -0,0 +1,35 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +// +// Guards the projection itself. `fromState` and `toState` are the bridge every +// transaction/action conformance test trusts; a symmetric bug in the pair (e.g. +// both dropping the same field) would cancel out and mask a real ecs defect. This +// identity test — `toState(fromState(s)) ≡ s` over representative states — proves +// the projection round-trips faithfully on its own. The state is entirely scalar +// resources (no ecs-minted ids), so the compare is exact. +import { describe, it } from "vitest"; +import type { State } from "../../../data/state/state.js"; +import { expectStateMatches } from "../../../data/state/expect-state-matches.js"; +import { createStore } from "./create-store.js"; +import { fromState } from "./from-state.js"; +import { toState } from "./to-state.js"; + +const states: readonly { readonly name: string; readonly state: State }[] = [ + { + name: "a populated dashboard: positive count, multi-entry log, named user", + state: { count: 3, log: ["Incremented to 1", "Name changed to Ada"], userName: "Ada" }, + }, + { + name: "the initial defaults: zero count, empty log, guest user", + state: { count: 0, log: [], userName: "Guest" }, + }, +]; + +describe("ecs conformance projection round-trips (toState ∘ fromState ≡ identity)", () => { + for (const { name, state } of states) { + it(name, () => { + const store = createStore(); + fromState(store, state); + expectStateMatches(toState(store), state); + }); + } +}); diff --git a/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/transactions.test.ts b/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/transactions.test.ts new file mode 100644 index 00000000..f127e24e --- /dev/null +++ b/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/transactions.test.ts @@ -0,0 +1,51 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it, expect } from "vitest"; +import type { CoreDatabase } from "../core-database/core-database.js"; +import type { ConformanceCase } from "../../../data/state/conformance-case.js"; +import { expectConforms } from "./expect-conforms.js"; +import * as registeredTransactions from "../transaction-database/transactions/index.js"; +import { increment } from "../transaction-database/transactions/increment.js"; +import { decrement } from "../transaction-database/transactions/decrement.js"; +import { reset } from "../transaction-database/transactions/reset.js"; +import { setUserName } from "../transaction-database/transactions/set-user-name.js"; +import { clearLog } from "../transaction-database/transactions/clear-log.js"; +import { cases as incrementCases } from "../../../data/state/increment.js"; +import { cases as decrementCases } from "../../../data/state/decrement.js"; +import { cases as resetCases } from "../../../data/state/reset.js"; +import { cases as setUserNameCases } from "../../../data/state/set-user-name.js"; +import { cases as clearLogCases } from "../../../data/state/clear-log.js"; + +// The single conformance test for every ecs transaction. Each transaction's shared +// `data/state` cases are replayed against it (`fromState(before)` → apply → +// `toState ≡ after`); half 1 of the property is covered by `data/state/spec.test.ts`, +// so no `spec` is passed here. Transaction files must stay single-export (the +// `transactions/` barrel is `export *`-ed straight into the plugin facet), so the +// wiring lives here rather than beside each transaction. The guard at the bottom +// asserts every registered transaction is wired below, so none can be missed. +const covered = new Set(); +const conforms = ( + transaction: string, + config: { + readonly cases: readonly ConformanceCase[]; + readonly apply: (t: CoreDatabase.Store, args: Args) => void; + }, +): void => { + covered.add(transaction); + describe(`${transaction} transaction conforms`, () => expectConforms(config)); +}; + +conforms("increment", { cases: incrementCases, apply: increment }); +conforms("decrement", { cases: decrementCases, apply: decrement }); +conforms("reset", { cases: resetCases, apply: reset }); +conforms("setUserName", { cases: setUserNameCases, apply: setUserName }); +conforms("clearLog", { cases: clearLogCases, apply: clearLog }); + +// None-missed guard: every **registered** transaction must be wired above. Keyed +// off the barrel (the transactions the plugin actually dispatches), not a file +// glob — so a shared read helper parked flat in `transactions/` (kept out of the +// barrel) is naturally excluded. +describe("transaction conformance coverage", () => { + for (const transaction of Object.keys(registeredTransactions)) { + it(`${transaction} has a conformance case`, () => expect(covered.has(transaction)).toBe(true)); + } +}); diff --git a/packages/data-solid-dashboard/src/features/main/services/main-service/main-service.ts b/packages/data-solid-dashboard/src/features/main/services/main-service/main-service.ts index e58c48af..14f15fb4 100644 --- a/packages/data-solid-dashboard/src/features/main/services/main-service/main-service.ts +++ b/packages/data-solid-dashboard/src/features/main/services/main-service/main-service.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. // The assembled feature database — the sole entrypoint the `ui/` binds to. -// Transactions are the topmost layer this feature builds (no computed / service -// / action / system layers), so `TransactionDatabase` is `MainService`. Adding -// or dropping a layer changes only this one line; consumers name `MainService`. -export { TransactionDatabase as MainService } from "./transaction-database/transaction-database.js"; +// Actions are the topmost layer this feature builds (no computed / service / +// system layers), so `ActionDatabase` is `MainService`. Adding or dropping a +// layer changes only this one line; consumers name `MainService`. +export { ActionDatabase as MainService } from "./action-database/action-database.js"; diff --git a/packages/data-solid-dashboard/src/features/main/services/main-service/transaction-database/transactions/clear-log.test.ts b/packages/data-solid-dashboard/src/features/main/services/main-service/transaction-database/transactions/clear-log.test.ts deleted file mode 100644 index 63e722e4..00000000 --- a/packages/data-solid-dashboard/src/features/main/services/main-service/transaction-database/transactions/clear-log.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -// -// `clearLog` conforms to `State.clearLog`: it empties the log, leaving count and -// name untouched. -import { describe } from "vitest"; -import { State } from "../../../../data/state/state.js"; -import { cases } from "../../../../data/state/clear-log.test.js"; -import { expectConforms } from "../../conformance/expect-conforms.js"; -import { clearLog } from "./clear-log.js"; - -describe("clearLog transaction conforms to State.clearLog", () => { - expectConforms({ - cases, - spec: State.clearLog, - apply: (store) => clearLog(store), - }); -}); diff --git a/packages/data-solid-dashboard/src/features/main/services/main-service/transaction-database/transactions/decrement.test.ts b/packages/data-solid-dashboard/src/features/main/services/main-service/transaction-database/transactions/decrement.test.ts deleted file mode 100644 index 66067819..00000000 --- a/packages/data-solid-dashboard/src/features/main/services/main-service/transaction-database/transactions/decrement.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -// -// `decrement` conforms to `State.decrement`, including the no-op at zero. -import { describe } from "vitest"; -import { State } from "../../../../data/state/state.js"; -import { cases } from "../../../../data/state/decrement.test.js"; -import { expectConforms } from "../../conformance/expect-conforms.js"; -import { decrement } from "./decrement.js"; - -describe("decrement transaction conforms to State.decrement", () => { - expectConforms({ - cases, - spec: State.decrement, - apply: (store) => decrement(store), - }); -}); diff --git a/packages/data-solid-dashboard/src/features/main/services/main-service/transaction-database/transactions/increment.test.ts b/packages/data-solid-dashboard/src/features/main/services/main-service/transaction-database/transactions/increment.test.ts deleted file mode 100644 index 11db38ef..00000000 --- a/packages/data-solid-dashboard/src/features/main/services/main-service/transaction-database/transactions/increment.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -// -// `increment` conforms to `State.increment`: it raises `count` and appends the -// matching log entry. -import { describe } from "vitest"; -import { State } from "../../../../data/state/state.js"; -import { cases } from "../../../../data/state/increment.test.js"; -import { expectConforms } from "../../conformance/expect-conforms.js"; -import { increment } from "./increment.js"; - -describe("increment transaction conforms to State.increment", () => { - expectConforms({ - cases, - spec: State.increment, - apply: (store) => increment(store), - }); -}); diff --git a/packages/data-solid-dashboard/src/features/main/services/main-service/transaction-database/transactions/reset.test.ts b/packages/data-solid-dashboard/src/features/main/services/main-service/transaction-database/transactions/reset.test.ts deleted file mode 100644 index 8ada2736..00000000 --- a/packages/data-solid-dashboard/src/features/main/services/main-service/transaction-database/transactions/reset.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -// -// `reset` conforms to `State.reset`: it zeroes `count` and logs the reset. -import { describe } from "vitest"; -import { State } from "../../../../data/state/state.js"; -import { cases } from "../../../../data/state/reset.test.js"; -import { expectConforms } from "../../conformance/expect-conforms.js"; -import { reset } from "./reset.js"; - -describe("reset transaction conforms to State.reset", () => { - expectConforms({ - cases, - spec: State.reset, - apply: (store) => reset(store), - }); -}); diff --git a/packages/data-solid-dashboard/src/features/main/services/main-service/transaction-database/transactions/set-user-name.test.ts b/packages/data-solid-dashboard/src/features/main/services/main-service/transaction-database/transactions/set-user-name.test.ts deleted file mode 100644 index 5b6a952a..00000000 --- a/packages/data-solid-dashboard/src/features/main/services/main-service/transaction-database/transactions/set-user-name.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -// -// `setUserName` conforms to `State.setUserName`: it replaces `userName` and logs -// the change. -import { describe } from "vitest"; -import { State } from "../../../../data/state/state.js"; -import { cases } from "../../../../data/state/set-user-name.test.js"; -import { expectConforms } from "../../conformance/expect-conforms.js"; -import { setUserName } from "./set-user-name.js"; - -describe("setUserName transaction conforms to State.setUserName", () => { - expectConforms({ - cases, - spec: State.setUserName, - apply: (store, args) => setUserName(store, args), - }); -}); From 77caa31ca8087bab5732d4ee46f46103b1a4e0ef Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Thu, 6 Aug 2026 20:17:09 -0700 Subject: [PATCH 09/37] feat(react-pixie): convert to co-located conformance pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rollout conversion: co-located cases, spec.test.ts, matcher-aware compare (matchers.ts added — Sprite has ECS-minted ids), to-data + per-entity projection, transaction + action conformance, new action layer. No derivations/services, so computeds.test omitted per the rules. All gates green under Node 24 (54 tests). Co-Authored-By: Claude Opus 4.8 --- .../main/data/state/conformance-case.ts | 63 ++++++++++- .../main/data/state/create-sprite.test.ts | 44 ------- .../features/main/data/state/create-sprite.ts | 33 ++++++ .../main/data/state/expect-state-matches.ts | 69 +++++++---- .../src/features/main/data/state/matchers.ts | 11 ++ .../main/data/state/record-effects.ts | 107 ++++++++++++++++++ .../main/data/state/set-filter.test.ts | 30 ----- .../features/main/data/state/set-filter.ts | 18 +++ .../main/data/state/set-sprite-active.test.ts | 33 ------ .../main/data/state/set-sprite-active.ts | 36 ++++++ .../data/state/set-sprite-hovered.test.ts | 33 ------ .../main/data/state/set-sprite-hovered.ts | 36 ++++++ .../src/features/main/data/state/spec.test.ts | 59 ++++++++++ .../src/features/main/data/state/tick.test.ts | 39 ------- .../src/features/main/data/state/tick.ts | 29 +++++ .../data/state/toggle-sprite-active.test.ts | 39 ------- .../main/data/state/toggle-sprite-active.ts | 48 ++++++++ .../action-database/action-database.ts | 19 ++++ .../action-database/actions/create-sprite.ts | 13 +++ .../action-database/actions/index.ts | 7 ++ .../action-database/actions/set-filter.ts | 8 ++ .../actions/set-sprite-active.ts | 12 ++ .../actions/set-sprite-hovered.ts | 11 ++ .../action-database/actions/tick.ts | 7 ++ .../actions/toggle-sprite-active.ts | 8 ++ .../main-service/conformance/actions.test.ts | 99 ++++++++++++++++ .../conformance/expect-conforms.ts | 21 ++-- .../expect-state-matches-ignoring-ids.ts | 17 --- .../conformance/projection.test.ts | 57 ++++++++++ .../main-service/conformance/to-data.ts | 21 ++++ .../main-service/conformance/to-state.ts | 29 ++--- .../conformance/transactions.test.ts | 63 +++++++++++ .../system-database/system-database.ts | 6 +- .../transactions/create-sprite.test.ts | 19 ---- .../transactions/set-filter.test.ts | 17 --- .../transactions/set-sprite-active.test.ts | 18 --- .../transactions/set-sprite-hovered.test.ts | 19 ---- .../transactions/tick.test.ts | 17 --- .../transactions/toggle-sprite-active.test.ts | 17 --- 39 files changed, 829 insertions(+), 403 deletions(-) delete mode 100644 packages/data-react-pixie/src/features/main/data/state/create-sprite.test.ts create mode 100644 packages/data-react-pixie/src/features/main/data/state/matchers.ts create mode 100644 packages/data-react-pixie/src/features/main/data/state/record-effects.ts delete mode 100644 packages/data-react-pixie/src/features/main/data/state/set-filter.test.ts delete mode 100644 packages/data-react-pixie/src/features/main/data/state/set-sprite-active.test.ts delete mode 100644 packages/data-react-pixie/src/features/main/data/state/set-sprite-hovered.test.ts create mode 100644 packages/data-react-pixie/src/features/main/data/state/spec.test.ts delete mode 100644 packages/data-react-pixie/src/features/main/data/state/tick.test.ts delete mode 100644 packages/data-react-pixie/src/features/main/data/state/toggle-sprite-active.test.ts create mode 100644 packages/data-react-pixie/src/features/main/services/main-service/action-database/action-database.ts create mode 100644 packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/create-sprite.ts create mode 100644 packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/index.ts create mode 100644 packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/set-filter.ts create mode 100644 packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/set-sprite-active.ts create mode 100644 packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/set-sprite-hovered.ts create mode 100644 packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/tick.ts create mode 100644 packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/toggle-sprite-active.ts create mode 100644 packages/data-react-pixie/src/features/main/services/main-service/conformance/actions.test.ts delete mode 100644 packages/data-react-pixie/src/features/main/services/main-service/conformance/expect-state-matches-ignoring-ids.ts create mode 100644 packages/data-react-pixie/src/features/main/services/main-service/conformance/projection.test.ts create mode 100644 packages/data-react-pixie/src/features/main/services/main-service/conformance/to-data.ts create mode 100644 packages/data-react-pixie/src/features/main/services/main-service/conformance/transactions.test.ts delete mode 100644 packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/create-sprite.test.ts delete mode 100644 packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/set-filter.test.ts delete mode 100644 packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/set-sprite-active.test.ts delete mode 100644 packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/set-sprite-hovered.test.ts delete mode 100644 packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/tick.test.ts delete mode 100644 packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/toggle-sprite-active.test.ts diff --git a/packages/data-react-pixie/src/features/main/data/state/conformance-case.ts b/packages/data-react-pixie/src/features/main/data/state/conformance-case.ts index 00a0b3cc..095f3cb2 100644 --- a/packages/data-react-pixie/src/features/main/data/state/conformance-case.ts +++ b/packages/data-react-pixie/src/features/main/data/state/conformance-case.ts @@ -1,13 +1,70 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { State } from "./state.js"; +// A service arg is an object with method members (not an array, not a function); +// this is what distinguishes an injected service from plain data args, since the +// `Service` marker itself is all-optional and would over-match. +type MethodKeys = { [K in keyof T]-?: T[K] extends (...a: never[]) => unknown ? K : never }[keyof T]; +type IsService = T extends readonly unknown[] + ? false + : T extends (...a: never[]) => unknown + ? false + : T extends object + ? [MethodKeys] extends [never] + ? false + : true + : false; + +// A strongly-typed call to one method of service `S`: `[methodName, ...its args]`. +// A no-arg method is just `[methodName]`. +export type Call = { + [M in keyof S]-?: S[M] extends (...a: infer A) => unknown ? readonly [M, ...A] : never; +}[keyof S]; + +// Expected side effects for a case, keyed by the service-typed args only. An +// `Array` value asserts these calls in this order; a `Set` value asserts the same +// calls in any order. Method names and their args are checked against the service. +export type Effects = { + readonly [K in keyof Args as IsService extends true ? K : never]?: + | readonly Call[] + | ReadonlySet>; +}; + // One spec-owned conformance case: a `data/` transform's `{ before, args, after }` -// authored as full `State`. Exported from each `.test.ts` and shared, -// unchanged, by the data transform test and the ecs conformance runner -// (see `services/main-service/conformance/`). +// authored as full `State`, optionally with the side effects it makes on its +// injected service args. Shared unchanged by the spec aggregator (`spec.test.ts`) +// and the ecs conformance runners (`services/main-service/conformance/`). export type ConformanceCase = { readonly name: string; readonly before: State; readonly args: Args; readonly after: State; + readonly effects?: Effects; }; + +// A transform's cases, with the case `args` type derived from the transform's own +// signature — its second parameter, or `void` when it takes none. A transform +// co-locates `export const cases: Conformance = [...]`, so the +// cases cannot drift from what the function accepts, and the spec aggregator can +// discover the function without it being named twice. +export type Conformance unknown> = readonly ConformanceCase< + Parameters extends [unknown, infer Args, ...unknown[]] ? Args : void +>[]; + +// One case for a derivation (`(state) => value`): a state `input` and the `value` +// it yields. `value` may use asymmetric matchers (`anyNumber` for ids). Both the +// pure derivation and its ecs computed are checked against these. +export type DerivationCase = { + readonly name: string; + readonly input: Input; + readonly value: Value; +}; + +// A derivation's cases, with `input` and `value` types read from the derivation's +// own signature (its parameter and return) — the `Conformance` analog for +// value-producing derivations. A derivation co-locates +// `export const cases: Derivation = [...]`. +export type Derivation unknown> = readonly DerivationCase< + Parameters[0], + ReturnType +>[]; diff --git a/packages/data-react-pixie/src/features/main/data/state/create-sprite.test.ts b/packages/data-react-pixie/src/features/main/data/state/create-sprite.test.ts deleted file mode 100644 index 9745f048..00000000 --- a/packages/data-react-pixie/src/features/main/data/state/create-sprite.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it } from "vitest"; -import type { Vec2 } from "@adobe/data/math"; -import type { SpriteKind } from "../sprite-kind/sprite-kind.js"; -import { State } from "./state.js"; -import type { ConformanceCase } from "./conformance-case.js"; -import { expectStateMatches } from "./expect-state-matches.js"; - -type Args = { readonly position: Vec2; readonly rotation?: number; readonly kind: SpriteKind }; - -// Appends one sprite with the next id (max existing + 1), defaulting rotation to -// 0 and hovered/active to false; existing sprites are untouched. -export const cases: readonly ConformanceCase[] = [ - { - name: "appends the first sprite (id 1) to an empty scene", - before: { sprites: [], filter: "none" }, - args: { position: [100, 100], kind: "bunny" }, - after: { - sprites: [{ id: 1, position: [100, 100], rotation: 0, kind: "bunny", hovered: false, active: false }], - filter: "none", - }, - }, - { - name: "appends a fox with the next id and an explicit rotation", - before: { - sprites: [{ id: 1, position: [100, 100], rotation: 0, kind: "bunny", hovered: false, active: false }], - filter: "sepia", - }, - args: { position: [300, 200], rotation: 1, kind: "fox" }, - after: { - sprites: [ - { id: 1, position: [100, 100], rotation: 0, kind: "bunny", hovered: false, active: false }, - { id: 2, position: [300, 200], rotation: 1, kind: "fox", hovered: false, active: false }, - ], - filter: "sepia", - }, - }, -]; - -describe("State.createSprite", () => { - for (const { name, before, args, after } of cases) { - it(name, () => expectStateMatches(State.createSprite(before, args), after)); - } -}); diff --git a/packages/data-react-pixie/src/features/main/data/state/create-sprite.ts b/packages/data-react-pixie/src/features/main/data/state/create-sprite.ts index c86a9f7a..3b8729ef 100644 --- a/packages/data-react-pixie/src/features/main/data/state/create-sprite.ts +++ b/packages/data-react-pixie/src/features/main/data/state/create-sprite.ts @@ -2,6 +2,8 @@ import type { Vec2 } from "@adobe/data/math"; import type { SpriteKind } from "../sprite-kind/sprite-kind.js"; import type { State } from "./state.js"; +import type { Conformance } from "./conformance-case.js"; +import { anyNumber } from "./matchers.js"; const nextSpriteId = (state: Pick): number => state.sprites.reduce((max, sprite) => Math.max(max, sprite.id), 0) + 1; @@ -23,3 +25,34 @@ export const createSprite = >( }, ], }); + +// Spec-owned cases, shared with the ecs `createSprite` transaction. A sprite is +// appended (minted id left open as `anyNumber` — the ecs assigns its own) with +// rotation defaulting to 0 and hovered/active to false; existing sprites are +// untouched. +export const cases: Conformance = [ + { + name: "appends the first sprite to an empty scene", + before: { sprites: [], filter: "none" }, + args: { position: [100, 100], kind: "bunny" }, + after: { + sprites: [{ id: anyNumber, position: [100, 100], rotation: 0, kind: "bunny", hovered: false, active: false }], + filter: "none", + }, + }, + { + name: "appends a fox with the next id and an explicit rotation", + before: { + sprites: [{ id: 1, position: [100, 100], rotation: 0, kind: "bunny", hovered: false, active: false }], + filter: "sepia", + }, + args: { position: [300, 200], rotation: 1, kind: "fox" }, + after: { + sprites: [ + { id: anyNumber, position: [100, 100], rotation: 0, kind: "bunny", hovered: false, active: false }, + { id: anyNumber, position: [300, 200], rotation: 1, kind: "fox", hovered: false, active: false }, + ], + filter: "sepia", + }, + }, +]; diff --git a/packages/data-react-pixie/src/features/main/data/state/expect-state-matches.ts b/packages/data-react-pixie/src/features/main/data/state/expect-state-matches.ts index 5e140026..687f2a16 100644 --- a/packages/data-react-pixie/src/features/main/data/state/expect-state-matches.ts +++ b/packages/data-react-pixie/src/features/main/data/state/expect-state-matches.ts @@ -1,35 +1,56 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { expect } from "vitest"; -import { equalsUnordered } from "@adobe/data"; import type { State } from "./state.js"; -// Spec-owned tolerant `State` equality, shared by the data/ transform tests and -// the ecs conformance runner. Two orthogonal concerns, kept separate: -// -// precision — normalise every number on both sides onto a shared grid so a -// value that differs only by F32↔f64 storage rounding (rotation / position -// are F32 in the ecs, the spec authors plain numbers) compares equal. -// `Math.fround` collapses the F32 rounding; rounding to 1e-2 collapses any -// residue. `+ 0` normalises `-0` to `0`. -// ordering — `equalsUnordered` compares arrays as MULTISETS (archetype -// hole-fills make row order nondeterministic) and is object key-order -// independent. +// A vitest asymmetric matcher (`expect.any(...)`, see `matchers.ts`): honored on +// the EXPECTED side so a case can assert "any number" for a value it does not pin. +const isMatcher = (value: unknown): value is { asymmetricMatch(actual: unknown): boolean } => + typeof value === "object" && + value !== null && + typeof (value as { asymmetricMatch?: unknown }).asymmetricMatch === "function"; + +// Collapse F32↔f64 storage rounding (the ecs `rotation` column is F32, the spec +// authors plain numbers) onto a small grid so float noise compares equal. `+ 0` +// normalises `-0` to `0`. const quantize = (n: number): number => Math.round(Math.fround(n) * 100) / 100 + 0; -const normalize = (value: unknown): unknown => { - if (typeof value === "number") return quantize(value); - if (Array.isArray(value)) return value.map(normalize); - if (value !== null && typeof value === "object") { - return Object.fromEntries(Object.entries(value).map(([key, v]) => [key, normalize(v)])); +// Tolerant structural match honoring asymmetric matchers, float precision, and +// order-sensitive arrays (`toState` reads sprites in insertion order, matching +// the case's authored order). Exported so it can back other conformance +// comparisons (e.g. computed values). +export const matches = (actual: unknown, expected: unknown): boolean => { + if (isMatcher(expected)) return expected.asymmetricMatch(actual); + if (typeof expected === "number" && typeof actual === "number") { + return quantize(actual) === quantize(expected); + } + if (Array.isArray(expected)) { + if (!Array.isArray(actual) || actual.length !== expected.length) return false; + return expected.every((exp, index) => matches(actual[index], exp)); + } + if (expected !== null && typeof expected === "object") { + if (actual === null || typeof actual !== "object" || Array.isArray(actual)) return false; + const expectedKeys = Object.keys(expected); + const actualKeys = Object.keys(actual as object); + if (expectedKeys.length !== actualKeys.length) return false; + return expectedKeys.every((key) => + matches((actual as Record)[key], (expected as Record)[key]), + ); } - return value; + return Object.is(actual, expected); }; +// Spec-owned tolerant `State` equality, shared by the data/ transform tests and +// the ecs conformance runners. `after` may use asymmetric matchers +// (`anyNumber` for ids the ecs assigns from its own id-space), so this one +// comparison serves both the pure spec and the ecs projection — no separate +// id-ignoring variant is needed. export const expectStateMatches = (actual: State, expected: State): void => { - const a = normalize(actual); - const b = normalize(expected); - expect( - equalsUnordered(a, b), - `State mismatch:\n actual ${JSON.stringify(a)}\n expected ${JSON.stringify(b)}`, - ).toBe(true); + expectMatches(actual, expected); +}; + +// The same tolerant, matcher-aware comparison for any value — used by derivation +// spec tests and computed conformance, where the compared value is a `Sprite[]` +// or a scalar rather than a whole `State`. +export const expectMatches = (actual: unknown, expected: unknown): void => { + expect(matches(actual, expected), `mismatch:\n actual ${JSON.stringify(actual)}`).toBe(true); }; diff --git a/packages/data-react-pixie/src/features/main/data/state/matchers.ts b/packages/data-react-pixie/src/features/main/data/state/matchers.ts new file mode 100644 index 00000000..3dcdc5c8 --- /dev/null +++ b/packages/data-react-pixie/src/features/main/data/state/matchers.ts @@ -0,0 +1,11 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { expect } from "vitest"; + +// Asymmetric matchers for conformance-case values a case does not pin — chiefly +// a sprite `id`, which the ecs assigns from its own id-space, so the spec and +// the ecs projection satisfy the same case without agreeing on the value. Typed +// `any` (like vitest's `expect.any`), they slot straight into the value's slot +// (`id: number`). Centralised here so the `vitest` import lives in one place; +// they are test-only data and tree-shake out of the app build. +export const anyNumber = expect.any(Number); +export const anyString = expect.any(String); diff --git a/packages/data-react-pixie/src/features/main/data/state/record-effects.ts b/packages/data-react-pixie/src/features/main/data/state/record-effects.ts new file mode 100644 index 00000000..8fb3d25f --- /dev/null +++ b/packages/data-react-pixie/src/features/main/data/state/record-effects.ts @@ -0,0 +1,107 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { expect } from "vitest"; +import { equalsUnordered } from "@adobe/data"; +import type { Effects } from "./conformance-case.js"; + +export type RecordedCall = readonly [string, ...unknown[]]; + +// Wrap a plain-object service so each method call is recorded, then delegates. +// No Proxy (services are plain objects with own enumerable methods — see +// `service.md`), so we enumerate and closure-wrap each function. +export const recordCalls = (service: S): { service: S; calls: RecordedCall[] } => { + const calls: RecordedCall[] = []; + const wrapped = Object.fromEntries( + Object.entries(service).map(([key, value]) => [ + key, + typeof value === "function" + ? (...args: unknown[]): unknown => { + calls.push([key, ...args]); + return (value as (...a: unknown[]) => unknown)(...args); + } + : value, + ]), + ) as S; + return { service: wrapped, calls }; +}; + +// Assert the calls recorded against one service match the case's expectation for +// it: an Array expects exactly these calls in order; a Set expects the same calls +// in any order (multiset). Absent expectation ⇒ no calls expected. +export const expectServiceCalls = ( + recorded: readonly RecordedCall[], + expected: readonly RecordedCall[] | ReadonlySet | undefined, +): void => { + if (expected instanceof Set) { + expect(equalsUnordered(recorded, [...expected])).toBe(true); + } else { + expect(recorded).toEqual(expected ?? []); + } +}; + +// Split a case's `args` into the injected services (objects with methods) and the +// remaining plain data. Services are wrapped for recording; the returned `calls` +// map is keyed by the same arg key so it can be matched against `effects`. +export const recordArgServices = ( + args: Args, +): { args: Args; calls: Record } => { + const calls: Record = {}; + const next = { ...args } as Record; + for (const [key, value] of Object.entries(args)) { + if (isServiceValue(value)) { + const rec = recordCalls(value); + next[key] = rec.service; + calls[key] = rec.calls; + } + } + return { args: next as Args, calls }; +}; + +// Assert each service **declared** in `effects` saw exactly its expected calls +// (an extra or missing call on a declared service fails). Services not listed — +// e.g. a value-returning dependency read like `generateName` — are ignored, so +// `effects` captures the fire-and-forget side effects you choose to assert. +export const expectEffects = ( + calls: Record, + effects: Effects> | undefined, +): void => { + const expected = (effects ?? {}) as Record>; + for (const key of Object.keys(expected)) { + expectServiceCalls(calls[key] ?? [], expected[key]); + } +}; + +// Split a case's `args` into the injected services (wrapped for recording, to be +// used as `Database.create` service overrides) and the remaining plain data (the +// action input). Keyed by the same arg name so `calls` matches against `effects`. +export const splitAndRecordServices = ( + args: Args, +): { + services: Record; + input: Record; + calls: Record; +} => { + const services: Record = {}; + const input: Record = {}; + const calls: Record = {}; + // A no-arg transition has `undefined` args — nothing to split. + if (args !== null && typeof args === "object") { + for (const [key, value] of Object.entries(args)) { + if (isServiceValue(value)) { + const recorded = recordCalls(value); + services[key] = recorded.service; + calls[key] = recorded.calls; + } else { + input[key] = value; + } + } + } + return { services, input, calls }; +}; + +// A runtime service value: a non-array object with at least one method (mirrors +// the compile-time `IsService`). +const isServiceValue = (value: unknown): value is object => + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.values(value).some((member) => typeof member === "function"); diff --git a/packages/data-react-pixie/src/features/main/data/state/set-filter.test.ts b/packages/data-react-pixie/src/features/main/data/state/set-filter.test.ts deleted file mode 100644 index b86d2dab..00000000 --- a/packages/data-react-pixie/src/features/main/data/state/set-filter.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it } from "vitest"; -import type { FilterKind } from "../filter-kind/filter-kind.js"; -import { State } from "./state.js"; -import type { ConformanceCase } from "./conformance-case.js"; -import { expectStateMatches } from "./expect-state-matches.js"; - -type Args = { readonly filter: FilterKind }; - -// Replaces the scene-wide filter; sprites are untouched. -export const cases: readonly ConformanceCase[] = [ - { - name: "sets the filter from none to sepia", - before: { sprites: [], filter: "none" }, - args: { filter: "sepia" }, - after: { sprites: [], filter: "sepia" }, - }, - { - name: "replaces an existing filter", - before: { sprites: [], filter: "blur" }, - args: { filter: "night" }, - after: { sprites: [], filter: "night" }, - }, -]; - -describe("State.setFilter", () => { - for (const { name, before, args, after } of cases) { - it(name, () => expectStateMatches(State.setFilter(before, args), after)); - } -}); diff --git a/packages/data-react-pixie/src/features/main/data/state/set-filter.ts b/packages/data-react-pixie/src/features/main/data/state/set-filter.ts index d0df6380..fb9f8845 100644 --- a/packages/data-react-pixie/src/features/main/data/state/set-filter.ts +++ b/packages/data-react-pixie/src/features/main/data/state/set-filter.ts @@ -1,8 +1,26 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { FilterKind } from "../filter-kind/filter-kind.js"; import type { State } from "./state.js"; +import type { Conformance } from "./conformance-case.js"; export const setFilter = >( state: T, input: { readonly filter: FilterKind }, ): T => ({ ...state, filter: input.filter }); + +// Spec-owned cases, shared with the ecs `setFilter` transaction. Replaces the +// scene-wide filter; sprites are untouched. +export const cases: Conformance = [ + { + name: "sets the filter from none to sepia", + before: { sprites: [], filter: "none" }, + args: { filter: "sepia" }, + after: { sprites: [], filter: "sepia" }, + }, + { + name: "replaces an existing filter", + before: { sprites: [], filter: "blur" }, + args: { filter: "night" }, + after: { sprites: [], filter: "night" }, + }, +]; diff --git a/packages/data-react-pixie/src/features/main/data/state/set-sprite-active.test.ts b/packages/data-react-pixie/src/features/main/data/state/set-sprite-active.test.ts deleted file mode 100644 index 2d7c1a00..00000000 --- a/packages/data-react-pixie/src/features/main/data/state/set-sprite-active.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it } from "vitest"; -import type { Sprite } from "../sprite/sprite.js"; -import { State } from "./state.js"; -import type { ConformanceCase } from "./conformance-case.js"; -import { expectStateMatches } from "./expect-state-matches.js"; - -type Args = { readonly id: number; readonly active: boolean }; - -const bunny: Sprite = { id: 1, position: [100, 100], rotation: 0, kind: "bunny", hovered: false, active: false }; -const fox: Sprite = { id: 2, position: [300, 200], rotation: 1, kind: "fox", hovered: false, active: false }; - -// Sets the addressed sprite's `active` flag; a no-op for an unknown id. -export const cases: readonly ConformanceCase[] = [ - { - name: "sets active true on the addressed sprite only", - before: { sprites: [bunny, fox], filter: "none" }, - args: { id: 2, active: true }, - after: { sprites: [bunny, { ...fox, active: true }], filter: "none" }, - }, - { - name: "is a no-op for an unknown id", - before: { sprites: [bunny, fox], filter: "none" }, - args: { id: 99, active: true }, - after: { sprites: [bunny, fox], filter: "none" }, - }, -]; - -describe("State.setSpriteActive", () => { - for (const { name, before, args, after } of cases) { - it(name, () => expectStateMatches(State.setSpriteActive(before, args), after)); - } -}); diff --git a/packages/data-react-pixie/src/features/main/data/state/set-sprite-active.ts b/packages/data-react-pixie/src/features/main/data/state/set-sprite-active.ts index 073c540d..359a8cd0 100644 --- a/packages/data-react-pixie/src/features/main/data/state/set-sprite-active.ts +++ b/packages/data-react-pixie/src/features/main/data/state/set-sprite-active.ts @@ -1,5 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Sprite } from "../sprite/sprite.js"; import type { State } from "./state.js"; +import type { Conformance } from "./conformance-case.js"; +import { anyNumber } from "./matchers.js"; export const setSpriteActive = >( state: T, @@ -10,3 +13,36 @@ export const setSpriteActive = >( sprite.id === input.id ? { ...sprite, active: input.active } : sprite, ), }); + +const bunny: Sprite = { id: 1, position: [100, 100], rotation: 0, kind: "bunny", hovered: false, active: false }; +const fox: Sprite = { id: 2, position: [300, 200], rotation: 1, kind: "fox", hovered: false, active: false }; + +// Spec-owned cases, shared with the ecs `setSpriteActive` transaction. Sets the +// addressed sprite's `active` flag; a no-op for an unknown id. `before` ids +// address the sprite; `after` ids are left open (`anyNumber`). +export const cases: Conformance = [ + { + name: "sets active true on the addressed sprite only", + before: { sprites: [bunny, fox], filter: "none" }, + args: { id: 2, active: true }, + after: { + sprites: [ + { ...bunny, id: anyNumber }, + { ...fox, id: anyNumber, active: true }, + ], + filter: "none", + }, + }, + { + name: "is a no-op for an unknown id", + before: { sprites: [bunny, fox], filter: "none" }, + args: { id: 99, active: true }, + after: { + sprites: [ + { ...bunny, id: anyNumber }, + { ...fox, id: anyNumber }, + ], + filter: "none", + }, + }, +]; diff --git a/packages/data-react-pixie/src/features/main/data/state/set-sprite-hovered.test.ts b/packages/data-react-pixie/src/features/main/data/state/set-sprite-hovered.test.ts deleted file mode 100644 index 9e342262..00000000 --- a/packages/data-react-pixie/src/features/main/data/state/set-sprite-hovered.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it } from "vitest"; -import type { Sprite } from "../sprite/sprite.js"; -import { State } from "./state.js"; -import type { ConformanceCase } from "./conformance-case.js"; -import { expectStateMatches } from "./expect-state-matches.js"; - -type Args = { readonly id: number; readonly hovered: boolean }; - -const bunny: Sprite = { id: 1, position: [100, 100], rotation: 0, kind: "bunny", hovered: false, active: false }; -const fox: Sprite = { id: 2, position: [300, 200], rotation: 1, kind: "fox", hovered: false, active: false }; - -// Sets the addressed sprite's `hovered` flag; a no-op for an unknown id. -export const cases: readonly ConformanceCase[] = [ - { - name: "sets hovered true on the addressed sprite only", - before: { sprites: [bunny, fox], filter: "none" }, - args: { id: 1, hovered: true }, - after: { sprites: [{ ...bunny, hovered: true }, fox], filter: "none" }, - }, - { - name: "is a no-op for an unknown id", - before: { sprites: [bunny, fox], filter: "none" }, - args: { id: 99, hovered: true }, - after: { sprites: [bunny, fox], filter: "none" }, - }, -]; - -describe("State.setSpriteHovered", () => { - for (const { name, before, args, after } of cases) { - it(name, () => expectStateMatches(State.setSpriteHovered(before, args), after)); - } -}); diff --git a/packages/data-react-pixie/src/features/main/data/state/set-sprite-hovered.ts b/packages/data-react-pixie/src/features/main/data/state/set-sprite-hovered.ts index 5154af59..477ba675 100644 --- a/packages/data-react-pixie/src/features/main/data/state/set-sprite-hovered.ts +++ b/packages/data-react-pixie/src/features/main/data/state/set-sprite-hovered.ts @@ -1,5 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Sprite } from "../sprite/sprite.js"; import type { State } from "./state.js"; +import type { Conformance } from "./conformance-case.js"; +import { anyNumber } from "./matchers.js"; export const setSpriteHovered = >( state: T, @@ -10,3 +13,36 @@ export const setSpriteHovered = >( sprite.id === input.id ? { ...sprite, hovered: input.hovered } : sprite, ), }); + +const bunny: Sprite = { id: 1, position: [100, 100], rotation: 0, kind: "bunny", hovered: false, active: false }; +const fox: Sprite = { id: 2, position: [300, 200], rotation: 1, kind: "fox", hovered: false, active: false }; + +// Spec-owned cases, shared with the ecs `setSpriteHovered` transaction. Sets the +// addressed sprite's `hovered` flag; a no-op for an unknown id. `before` ids +// address the sprite; `after` ids are left open (`anyNumber`). +export const cases: Conformance = [ + { + name: "sets hovered true on the addressed sprite only", + before: { sprites: [bunny, fox], filter: "none" }, + args: { id: 1, hovered: true }, + after: { + sprites: [ + { ...bunny, id: anyNumber, hovered: true }, + { ...fox, id: anyNumber }, + ], + filter: "none", + }, + }, + { + name: "is a no-op for an unknown id", + before: { sprites: [bunny, fox], filter: "none" }, + args: { id: 99, hovered: true }, + after: { + sprites: [ + { ...bunny, id: anyNumber }, + { ...fox, id: anyNumber }, + ], + filter: "none", + }, + }, +]; diff --git a/packages/data-react-pixie/src/features/main/data/state/spec.test.ts b/packages/data-react-pixie/src/features/main/data/state/spec.test.ts new file mode 100644 index 00000000..d398242c --- /dev/null +++ b/packages/data-react-pixie/src/features/main/data/state/spec.test.ts @@ -0,0 +1,59 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +/// +import { describe, it } from "vitest"; +import type { State } from "./state.js"; +import type { ConformanceCase, DerivationCase } from "./conformance-case.js"; +import { expectStateMatches, expectMatches } from "./expect-state-matches.js"; +import { recordArgServices, expectEffects } from "./record-effects.js"; + +// The single spec test for every transform AND derivation in this folder. It +// auto-discovers each file (any sibling `.ts` that exports `cases`) via +// `import.meta.glob`, so a new one is covered the moment it ships — none can be +// forgotten. Each participating file must export exactly its function plus `cases` +// (enforced below), which lets us find the function without it being named twice. +// A case's shape selects the check: `after` → a transition `(state, args) => state`; +// `value` → a derivation `(state) => value`. +const modules = import.meta.glob>( + ["./*.ts", "!./*.test.ts", "!./*.type-test.ts"], + { eager: true }, +); + +const isDerivationCase = (c: unknown): c is DerivationCase => + typeof c === "object" && c !== null && "value" in c; + +for (const [path, module] of Object.entries(modules)) { + const exportNames = Object.keys(module); + if (!exportNames.includes("cases")) continue; + const functionNames = exportNames.filter((key) => typeof module[key] === "function"); + const label = functionNames.length === 1 ? functionNames[0] : path; + + describe(`State.${label}`, () => { + if (exportNames.length !== 2 || functionNames.length !== 1) { + it("exports exactly its function and `cases`", () => { + throw new Error(`${path} exports [${exportNames.join(", ")}] — expected one function + cases`); + }); + return; + } + // Runtime invariant: a participating file exports a function and its cases. + const fn = module[functionNames[0]] as (...args: unknown[]) => unknown; + const cases = module["cases"] as readonly unknown[]; + for (const testCase of cases) { + if (isDerivationCase(testCase)) { + // Derivation: the value it yields matches, honoring `anyNumber`. + it(testCase.name, () => expectMatches(fn(testCase.input), testCase.value)); + continue; + } + // Transition: assert the resulting state and the declared side effects. + // A service-injected transition is async, so await uniformly. + const transitionCase = testCase as ConformanceCase>; + it(transitionCase.name, async () => { + const raw = transitionCase.args; + const { args, calls } = + raw && typeof raw === "object" ? recordArgServices(raw) : { args: raw, calls: {} }; + const result = (await fn(transitionCase.before, args)) as State; + expectStateMatches(result, transitionCase.after); + expectEffects(calls, transitionCase.effects); + }); + } + }); +} diff --git a/packages/data-react-pixie/src/features/main/data/state/tick.test.ts b/packages/data-react-pixie/src/features/main/data/state/tick.test.ts deleted file mode 100644 index 77913b7a..00000000 --- a/packages/data-react-pixie/src/features/main/data/state/tick.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it } from "vitest"; -import type { Sprite } from "../sprite/sprite.js"; -import { State } from "./state.js"; -import type { ConformanceCase } from "./conformance-case.js"; -import { expectStateMatches } from "./expect-state-matches.js"; - -type Args = { readonly delta: number }; - -const bunny: Sprite = { id: 1, position: [100, 100], rotation: 0, kind: "bunny", hovered: false, active: false }; -const fox: Sprite = { id: 2, position: [300, 200], rotation: 1, kind: "fox", hovered: false, active: false }; - -// Every sprite's rotation advances by delta * 0.1. -export const cases: readonly ConformanceCase[] = [ - { - name: "advances every sprite's rotation by delta * 0.1", - before: { sprites: [bunny, fox], filter: "none" }, - args: { delta: 10 }, - after: { - sprites: [ - { ...bunny, rotation: 1 }, - { ...fox, rotation: 2 }, - ], - filter: "none", - }, - }, - { - name: "is a no-op on an empty scene", - before: { sprites: [], filter: "blur" }, - args: { delta: 5 }, - after: { sprites: [], filter: "blur" }, - }, -]; - -describe("State.tick", () => { - for (const { name, before, args, after } of cases) { - it(name, () => expectStateMatches(State.tick(before, args), after)); - } -}); diff --git a/packages/data-react-pixie/src/features/main/data/state/tick.ts b/packages/data-react-pixie/src/features/main/data/state/tick.ts index b35c9835..b3bf3adf 100644 --- a/packages/data-react-pixie/src/features/main/data/state/tick.ts +++ b/packages/data-react-pixie/src/features/main/data/state/tick.ts @@ -1,5 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Sprite } from "../sprite/sprite.js"; import type { State } from "./state.js"; +import type { Conformance } from "./conformance-case.js"; +import { anyNumber } from "./matchers.js"; // Advance one animation frame: every sprite rotates by `delta * 0.1` radians. // `delta` is the frame time step, supplied by the caller (the render loop). @@ -13,3 +16,29 @@ export const tick = >( rotation: sprite.rotation + input.delta * 0.1, })), }); + +const bunny: Sprite = { id: 1, position: [100, 100], rotation: 0, kind: "bunny", hovered: false, active: false }; +const fox: Sprite = { id: 2, position: [300, 200], rotation: 1, kind: "fox", hovered: false, active: false }; + +// Spec-owned cases, shared with the ecs `tick` transaction. Every sprite's +// rotation advances by delta * 0.1; ids are left open (`anyNumber`). +export const cases: Conformance = [ + { + name: "advances every sprite's rotation by delta * 0.1", + before: { sprites: [bunny, fox], filter: "none" }, + args: { delta: 10 }, + after: { + sprites: [ + { ...bunny, id: anyNumber, rotation: 1 }, + { ...fox, id: anyNumber, rotation: 2 }, + ], + filter: "none", + }, + }, + { + name: "is a no-op on an empty scene", + before: { sprites: [], filter: "blur" }, + args: { delta: 5 }, + after: { sprites: [], filter: "blur" }, + }, +]; diff --git a/packages/data-react-pixie/src/features/main/data/state/toggle-sprite-active.test.ts b/packages/data-react-pixie/src/features/main/data/state/toggle-sprite-active.test.ts deleted file mode 100644 index b6e52285..00000000 --- a/packages/data-react-pixie/src/features/main/data/state/toggle-sprite-active.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it } from "vitest"; -import type { Sprite } from "../sprite/sprite.js"; -import { State } from "./state.js"; -import type { ConformanceCase } from "./conformance-case.js"; -import { expectStateMatches } from "./expect-state-matches.js"; - -type Args = { readonly id: number }; - -const bunny: Sprite = { id: 1, position: [100, 100], rotation: 0, kind: "bunny", hovered: false, active: false }; -const activeFox: Sprite = { id: 2, position: [300, 200], rotation: 1, kind: "fox", hovered: false, active: true }; - -// Flips the addressed sprite's `active` flag; a no-op for an unknown id. -export const cases: readonly ConformanceCase[] = [ - { - name: "toggles a sprite from inactive to active", - before: { sprites: [bunny, activeFox], filter: "none" }, - args: { id: 1 }, - after: { sprites: [{ ...bunny, active: true }, activeFox], filter: "none" }, - }, - { - name: "toggles a sprite from active to inactive", - before: { sprites: [bunny, activeFox], filter: "none" }, - args: { id: 2 }, - after: { sprites: [bunny, { ...activeFox, active: false }], filter: "none" }, - }, - { - name: "is a no-op for an unknown id", - before: { sprites: [bunny, activeFox], filter: "none" }, - args: { id: 99 }, - after: { sprites: [bunny, activeFox], filter: "none" }, - }, -]; - -describe("State.toggleSpriteActive", () => { - for (const { name, before, args, after } of cases) { - it(name, () => expectStateMatches(State.toggleSpriteActive(before, args), after)); - } -}); diff --git a/packages/data-react-pixie/src/features/main/data/state/toggle-sprite-active.ts b/packages/data-react-pixie/src/features/main/data/state/toggle-sprite-active.ts index 4cf8845e..1bd044b1 100644 --- a/packages/data-react-pixie/src/features/main/data/state/toggle-sprite-active.ts +++ b/packages/data-react-pixie/src/features/main/data/state/toggle-sprite-active.ts @@ -1,5 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Sprite } from "../sprite/sprite.js"; import type { State } from "./state.js"; +import type { Conformance } from "./conformance-case.js"; +import { anyNumber } from "./matchers.js"; export const toggleSpriteActive = >( state: T, @@ -10,3 +13,48 @@ export const toggleSpriteActive = >( sprite.id === input.id ? { ...sprite, active: !sprite.active } : sprite, ), }); + +const bunny: Sprite = { id: 1, position: [100, 100], rotation: 0, kind: "bunny", hovered: false, active: false }; +const activeFox: Sprite = { id: 2, position: [300, 200], rotation: 1, kind: "fox", hovered: false, active: true }; + +// Spec-owned cases, shared with the ecs `toggleSpriteActive` transaction. Flips +// the addressed sprite's `active` flag; a no-op for an unknown id. `before` ids +// address the sprite; `after` ids are left open (`anyNumber`). +export const cases: Conformance = [ + { + name: "toggles a sprite from inactive to active", + before: { sprites: [bunny, activeFox], filter: "none" }, + args: { id: 1 }, + after: { + sprites: [ + { ...bunny, id: anyNumber, active: true }, + { ...activeFox, id: anyNumber }, + ], + filter: "none", + }, + }, + { + name: "toggles a sprite from active to inactive", + before: { sprites: [bunny, activeFox], filter: "none" }, + args: { id: 2 }, + after: { + sprites: [ + { ...bunny, id: anyNumber }, + { ...activeFox, id: anyNumber, active: false }, + ], + filter: "none", + }, + }, + { + name: "is a no-op for an unknown id", + before: { sprites: [bunny, activeFox], filter: "none" }, + args: { id: 99 }, + after: { + sprites: [ + { ...bunny, id: anyNumber }, + { ...activeFox, id: anyNumber }, + ], + filter: "none", + }, + }, +]; diff --git a/packages/data-react-pixie/src/features/main/services/main-service/action-database/action-database.ts b/packages/data-react-pixie/src/features/main/services/main-service/action-database/action-database.ts new file mode 100644 index 00000000..ce5e0f99 --- /dev/null +++ b/packages/data-react-pixie/src/features/main/services/main-service/action-database/action-database.ts @@ -0,0 +1,19 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import { TransactionDatabase } from "../transaction-database/transaction-database.js"; +import * as actions from "./actions/index.js"; + +// The action layer: the async, app-facing realizations of the state transitions. +// This feature injects no capability services, so actions extend the transaction +// layer directly (the lowest layer exposing `db.transactions`). +const actionDatabasePlugin = Database.Plugin.create({ + extends: TransactionDatabase.plugin, + actions, +}); + +export type ActionDatabase = Database.Plugin.ToDatabase; + +export namespace ActionDatabase { + export const plugin = actionDatabasePlugin; + export type Store = Database.Plugin.ToStore; +} diff --git a/packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/create-sprite.ts b/packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/create-sprite.ts new file mode 100644 index 00000000..582fafbe --- /dev/null +++ b/packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/create-sprite.ts @@ -0,0 +1,13 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Vec2 } from "@adobe/data/math"; +import type { SpriteKind } from "../../../../data/sprite-kind/sprite-kind.js"; +import type { TransactionDatabase } from "../../transaction-database/transaction-database.js"; + +// The app-facing realization of `State.createSprite`. This transition injects no +// services, so the action just commits through the same-named transaction. +export const createSprite = ( + db: TransactionDatabase, + input: { readonly position: Vec2; readonly rotation?: number; readonly kind: SpriteKind }, +) => { + db.transactions.createSprite(input); +}; diff --git a/packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/index.ts b/packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/index.ts new file mode 100644 index 00000000..aef80ba3 --- /dev/null +++ b/packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/index.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +export * from "./create-sprite.js"; +export * from "./set-filter.js"; +export * from "./set-sprite-active.js"; +export * from "./set-sprite-hovered.js"; +export * from "./toggle-sprite-active.js"; +export * from "./tick.js"; diff --git a/packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/set-filter.ts b/packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/set-filter.ts new file mode 100644 index 00000000..e1133063 --- /dev/null +++ b/packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/set-filter.ts @@ -0,0 +1,8 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { FilterKind } from "../../../../data/filter-kind/filter-kind.js"; +import type { TransactionDatabase } from "../../transaction-database/transaction-database.js"; + +// The app-facing realization of `State.setFilter`. +export const setFilter = (db: TransactionDatabase, input: { readonly filter: FilterKind }) => { + db.transactions.setFilter(input); +}; diff --git a/packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/set-sprite-active.ts b/packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/set-sprite-active.ts new file mode 100644 index 00000000..2cd48aaa --- /dev/null +++ b/packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/set-sprite-active.ts @@ -0,0 +1,12 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Entity } from "@adobe/data/ecs"; +import type { TransactionDatabase } from "../../transaction-database/transaction-database.js"; + +// The app-facing realization of `State.setSpriteActive`. The ui supplies the +// entity directly; the action commits through the same-named transaction. +export const setSpriteActive = ( + db: TransactionDatabase, + input: { readonly entity: Entity; readonly active: boolean }, +) => { + db.transactions.setSpriteActive(input); +}; diff --git a/packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/set-sprite-hovered.ts b/packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/set-sprite-hovered.ts new file mode 100644 index 00000000..3d01a852 --- /dev/null +++ b/packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/set-sprite-hovered.ts @@ -0,0 +1,11 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Entity } from "@adobe/data/ecs"; +import type { TransactionDatabase } from "../../transaction-database/transaction-database.js"; + +// The app-facing realization of `State.setSpriteHovered`. +export const setSpriteHovered = ( + db: TransactionDatabase, + input: { readonly entity: Entity; readonly hovered: boolean }, +) => { + db.transactions.setSpriteHovered(input); +}; diff --git a/packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/tick.ts b/packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/tick.ts new file mode 100644 index 00000000..28a83a9e --- /dev/null +++ b/packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/tick.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { TransactionDatabase } from "../../transaction-database/transaction-database.js"; + +// The app-facing realization of `State.tick`, driven per-frame by the render loop. +export const tick = (db: TransactionDatabase, input: { readonly delta: number }) => { + db.transactions.tick(input); +}; diff --git a/packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/toggle-sprite-active.ts b/packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/toggle-sprite-active.ts new file mode 100644 index 00000000..165841c2 --- /dev/null +++ b/packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/toggle-sprite-active.ts @@ -0,0 +1,8 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Entity } from "@adobe/data/ecs"; +import type { TransactionDatabase } from "../../transaction-database/transaction-database.js"; + +// The app-facing realization of `State.toggleSpriteActive`. +export const toggleSpriteActive = (db: TransactionDatabase, entity: Entity) => { + db.transactions.toggleSpriteActive({ entity }); +}; diff --git a/packages/data-react-pixie/src/features/main/services/main-service/conformance/actions.test.ts b/packages/data-react-pixie/src/features/main/services/main-service/conformance/actions.test.ts new file mode 100644 index 00000000..f47cdf1b --- /dev/null +++ b/packages/data-react-pixie/src/features/main/services/main-service/conformance/actions.test.ts @@ -0,0 +1,99 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +/// +import { describe, it, expect } from "vitest"; +import { Database, Entity } from "@adobe/data/ecs"; +import type { ConformanceCase } from "../../../data/state/conformance-case.js"; +import { splitAndRecordServices, expectEffects } from "../../../data/state/record-effects.js"; +import { expectStateMatches } from "../../../data/state/expect-state-matches.js"; +import { MainService } from "../main-service.js"; +import { fromState } from "./from-state.js"; +import { toState } from "./to-state.js"; +import { createSprite } from "../action-database/actions/create-sprite.js"; +import { setFilter } from "../action-database/actions/set-filter.js"; +import { setSpriteActive } from "../action-database/actions/set-sprite-active.js"; +import { setSpriteHovered } from "../action-database/actions/set-sprite-hovered.js"; +import { toggleSpriteActive } from "../action-database/actions/toggle-sprite-active.js"; +import { tick } from "../action-database/actions/tick.js"; +import { cases as createSpriteCases } from "../../../data/state/create-sprite.js"; +import { cases as setFilterCases } from "../../../data/state/set-filter.js"; +import { cases as setSpriteActiveCases } from "../../../data/state/set-sprite-active.js"; +import { cases as setSpriteHoveredCases } from "../../../data/state/set-sprite-hovered.js"; +import { cases as toggleSpriteActiveCases } from "../../../data/state/toggle-sprite-active.js"; +import { cases as tickCases } from "../../../data/state/tick.js"; + +// Each transition's cases run against its same-named ecs **action** (the async +// realization). The case's service args (this feature injects none) become the +// db's service overrides — wrapped so their calls are recorded — the plain args +// drive the action, and we assert both the resulting state and the declared side +// effects. `toSystemDatabase` exposes the writable `.store` the projection needs +// while keeping transactions/actions. The `{ services }` override is used +// uniformly even though it is empty here, keeping the runner shape identical to +// the multi-service reference. +const makeDb = (services: Record) => + Database.toSystemDatabase(Database.create(MainService.plugin, { services })); +type Db = ReturnType; +type Run = (db: Db, input: Args, resolve: (specId: number) => Entity) => Promise | void; + +const covered = new Set(); +const conformsAction = ( + action: string, + config: { readonly cases: readonly ConformanceCase[]; readonly run: Run> }, +): void => { + covered.add(action); + describe(`${action} action conforms`, () => { + for (const testCase of config.cases) { + it(testCase.name, async () => { + const { services, input, calls } = splitAndRecordServices(testCase.args); + const db = makeDb(services); + const entities = fromState(db.store, testCase.before); + const bySpecId = new Map(testCase.before.sprites.map((sprite, i) => [sprite.id, entities[i]])); + const resolve = (specId: number): Entity => bySpecId.get(specId) ?? Entity.none; + await config.run(db, input as Partial, resolve); + expectStateMatches(toState(db.store), testCase.after); + expectEffects(calls, testCase.effects); + }); + } + }); +}; + +conformsAction("createSprite", { + cases: createSpriteCases, + run: (db, input) => + createSprite(db, { position: input.position ?? [0, 0], rotation: input.rotation, kind: input.kind ?? "bunny" }), +}); +conformsAction("setFilter", { + cases: setFilterCases, + run: (db, input) => setFilter(db, { filter: input.filter ?? "none" }), +}); +conformsAction("setSpriteActive", { + cases: setSpriteActiveCases, + run: (db, input, resolve) => + setSpriteActive(db, { entity: resolve(input.id ?? -1), active: input.active ?? false }), +}); +conformsAction("setSpriteHovered", { + cases: setSpriteHoveredCases, + run: (db, input, resolve) => + setSpriteHovered(db, { entity: resolve(input.id ?? -1), hovered: input.hovered ?? false }), +}); +conformsAction("toggleSpriteActive", { + cases: toggleSpriteActiveCases, + run: (db, input, resolve) => toggleSpriteActive(db, resolve(input.id ?? -1)), +}); +conformsAction("tick", { + cases: tickCases, + run: (db, input) => tick(db, { delta: input.delta ?? 0 }), +}); + +// None-missed guard: every action file must be wired above. +const kebabToCamel = (name: string): string => + name.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase()); +describe("action conformance coverage", () => { + const files = import.meta.glob([ + "../action-database/actions/*.ts", + "!../action-database/actions/index.ts", + ]); + for (const path of Object.keys(files)) { + const action = kebabToCamel(path.replace(/.*\//, "").replace(/\.ts$/, "")); + it(`${action} has a conformance case`, () => expect(covered.has(action)).toBe(true)); + } +}); diff --git a/packages/data-react-pixie/src/features/main/services/main-service/conformance/expect-conforms.ts b/packages/data-react-pixie/src/features/main/services/main-service/conformance/expect-conforms.ts index dbeb186e..eb80287e 100644 --- a/packages/data-react-pixie/src/features/main/services/main-service/conformance/expect-conforms.ts +++ b/packages/data-react-pixie/src/features/main/services/main-service/conformance/expect-conforms.ts @@ -8,7 +8,6 @@ import type { CoreDatabase } from "../core-database/core-database.js"; import { createStore } from "./create-store.js"; import { fromState } from "./from-state.js"; import { toState } from "./to-state.js"; -import { expectStateMatchesIgnoringIds } from "./expect-state-matches-ignoring-ids.js"; // Resolve a spec domain `id` to the ecs entity seeded for it. `fromState` // returns the seeded entities in `sprites` order, so the i-th `before` sprite @@ -21,27 +20,29 @@ export type ResolveEntity = (specId: number) => Entity; // // toState(apply(fromState(before), args)) ≡ spec(before, args) // -// in two asserted halves: -// 1. `spec(before, args) ≡ after` — keeps the shared case honest. -// 2. seed `fromState(before)` → run the caller's `apply` → `toState ≡ after`. -// -// Half 2 compares ignoring sprite `id` (the ecs owns its entity-id space); half 1 -// stays id-strict — the spec fully owns its domain ids. +// The ecs owns its entity-id space and conforms only up to a renaming of ids, +// which the `after` cases express as `anyNumber`, so the same `expectStateMatches` +// compares both halves. export const expectConforms = (config: { readonly cases: readonly ConformanceCase[]; - readonly spec: (before: State, args: Args) => State; + // Optional: half 1 (spec(before,args) ≡ after) is already asserted for every + // case by `data/state/spec.test.ts`, so the conformance aggregator omits it and + // this runner asserts only the ecs half. Pass `spec` to re-check it in place. + readonly spec?: (before: State, args: Args) => State; readonly apply: (store: CoreDatabase.Store, args: Args, resolve: ResolveEntity) => void; }): void => { for (const testCase of config.cases) { it(testCase.name, () => { - expectStateMatches(config.spec(testCase.before, testCase.args), testCase.after); + if (config.spec) { + expectStateMatches(config.spec(testCase.before, testCase.args), testCase.after); + } const store = createStore(); const entities = fromState(store, testCase.before); const bySpecId = new Map(testCase.before.sprites.map((sprite, i) => [sprite.id, entities[i]])); const resolve: ResolveEntity = (specId) => bySpecId.get(specId) ?? Entity.none; config.apply(store, testCase.args, resolve); - expectStateMatchesIgnoringIds(toState(store), testCase.after); + expectStateMatches(toState(store), testCase.after); }); } }; diff --git a/packages/data-react-pixie/src/features/main/services/main-service/conformance/expect-state-matches-ignoring-ids.ts b/packages/data-react-pixie/src/features/main/services/main-service/conformance/expect-state-matches-ignoring-ids.ts deleted file mode 100644 index eea39830..00000000 --- a/packages/data-react-pixie/src/features/main/services/main-service/conformance/expect-state-matches-ignoring-ids.ts +++ /dev/null @@ -1,17 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { State } from "../../../data/state/state.js"; -import { expectStateMatches } from "../../../data/state/expect-state-matches.js"; - -// Like `expectStateMatches`, but compares sprites without regard to their `id`. -// The ecs projects each sprite's entity id — drawn from its own id-space — into -// `Sprite.id`, which does not match the spec's domain `id`. The projection -// therefore conforms only up to a renaming of ids: canonicalise both sides to a -// single id so the comparison rests on the visible fields (position, rotation, -// kind, hovered, active) and the filter resource, never the id value. -const canonicalizeIds = (state: State): State => ({ - ...state, - sprites: state.sprites.map((sprite) => ({ ...sprite, id: 0 })), -}); - -export const expectStateMatchesIgnoringIds = (actual: State, expected: State): void => - expectStateMatches(canonicalizeIds(actual), canonicalizeIds(expected)); diff --git a/packages/data-react-pixie/src/features/main/services/main-service/conformance/projection.test.ts b/packages/data-react-pixie/src/features/main/services/main-service/conformance/projection.test.ts new file mode 100644 index 00000000..05d27c3d --- /dev/null +++ b/packages/data-react-pixie/src/features/main/services/main-service/conformance/projection.test.ts @@ -0,0 +1,57 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +// +// Guards the projection itself. `fromState` and `toState` are the bridge every +// transaction/action conformance test trusts; a symmetric bug in the pair (e.g. +// both dropping the same field) would cancel out and mask a real ecs defect. This +// identity test — `toState(fromState(s)) ≡ s` over representative states — proves +// the projection round-trips faithfully on its own. +import { describe, it } from "vitest"; +import type { State } from "../../../data/state/state.js"; +import { expectStateMatches } from "../../../data/state/expect-state-matches.js"; +import { anyNumber } from "../../../data/state/matchers.js"; +import { createStore } from "./create-store.js"; +import { fromState } from "./from-state.js"; +import { toState } from "./to-state.js"; + +const states: readonly { readonly name: string; readonly state: State }[] = [ + { + name: "a mix of sprites with a scene filter", + state: { + sprites: [ + { id: 1, position: [100, 100], rotation: 0, kind: "bunny", hovered: false, active: false }, + { id: 2, position: [300, 200], rotation: 1, kind: "fox", hovered: true, active: false }, + { id: 3, position: [150, 250], rotation: 0.5, kind: "bunny", hovered: false, active: true }, + ], + filter: "sepia", + }, + }, + { + name: "an empty scene with no filter", + state: { sprites: [], filter: "none" }, + }, + { + name: "sprites sharing a kind, blur filter", + state: { + sprites: [ + { id: 1, position: [10, 10], rotation: 0, kind: "fox", hovered: false, active: false }, + { id: 2, position: [20, 20], rotation: 0, kind: "fox", hovered: false, active: false }, + ], + filter: "blur", + }, + }, +]; + +describe("ecs/conformance projection round-trips (toState ∘ fromState ≡ identity)", () => { + for (const { name, state } of states) { + it(name, () => { + const store = createStore(); + fromState(store, state); + // The ecs reassigns ids from its own id-space, so compare against the same + // state with ids left open. + expectStateMatches(toState(store), { + ...state, + sprites: state.sprites.map((sprite) => ({ ...sprite, id: anyNumber })), + }); + }); + } +}); diff --git a/packages/data-react-pixie/src/features/main/services/main-service/conformance/to-data.ts b/packages/data-react-pixie/src/features/main/services/main-service/conformance/to-data.ts new file mode 100644 index 00000000..04e3220a --- /dev/null +++ b/packages/data-react-pixie/src/features/main/services/main-service/conformance/to-data.ts @@ -0,0 +1,21 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Entity } from "@adobe/data/ecs"; +import type { Sprite } from "../../../data/sprite/sprite.js"; +import type { CoreDatabase } from "../core-database/core-database.js"; + +// Read one entity back into its `data/` value — the per-entity projection +// `toState` is built on, and the single place the ecs↔data mapping for a sprite +// lives. The projected `id` is the entity id (the ecs's own id-space, not the +// spec's domain id), so cases author `after` ids as `anyNumber`. Test-only. +export const toData = (store: CoreDatabase.Store, entity: Entity): Sprite => { + const row = store.read(entity, store.archetypes.Sprite); + if (row === null) throw new Error("conformance projection: expected a sprite entity"); + return { + id: row.id, + position: row.position, + rotation: row.rotation, + kind: row.kind, + hovered: row.hovered, + active: row.active, + }; +}; diff --git a/packages/data-react-pixie/src/features/main/services/main-service/conformance/to-state.ts b/packages/data-react-pixie/src/features/main/services/main-service/conformance/to-state.ts index 0e0b5cce..1056a31b 100644 --- a/packages/data-react-pixie/src/features/main/services/main-service/conformance/to-state.ts +++ b/packages/data-react-pixie/src/features/main/services/main-service/conformance/to-state.ts @@ -2,28 +2,15 @@ import type { State } from "../../../data/state/state.js"; import type { Sprite } from "../../../data/sprite/sprite.js"; import type { CoreDatabase } from "../core-database/core-database.js"; +import { toData } from "./to-data.js"; -// Read a store back into a `data/` `State` — the inverse of `fromState`. Each -// sprite is read through its full `Sprite` archetype so the row shape never -// aliases; the projected `id` is the entity id (the ecs's own id-space, not the -// spec's domain id), so conformance comparisons ignore it — see -// `expect-state-matches-ignoring-ids`. Test-only. -const readSprites = (store: CoreDatabase.Store): Sprite[] => { - const sprites: Sprite[] = []; - for (const entity of store.select(store.archetypes.Sprite.components)) { - const row = store.read(entity, store.archetypes.Sprite); - if (row === null) throw new Error("conformance projection: expected a sprite entity"); - sprites.push({ - id: row.id, - position: row.position, - rotation: row.rotation, - kind: row.kind, - hovered: row.hovered, - active: row.active, - }); - } - return sprites; -}; +// Read a store back into a `data/` `State` — the inverse of `fromState`. Sprites +// are read in archetype/insertion order (there is no ordering column) through the +// per-entity `toData` projection; the projected `id` is the entity id (the ecs's +// own id-space, not the spec's domain id), so conformance comparisons leave it +// open (`anyNumber`). Test-only. +const readSprites = (store: CoreDatabase.Store): Sprite[] => + [...store.select(store.archetypes.Sprite.components)].map((entity) => toData(store, entity)); export const toState = (store: CoreDatabase.Store): State => ({ sprites: readSprites(store), diff --git a/packages/data-react-pixie/src/features/main/services/main-service/conformance/transactions.test.ts b/packages/data-react-pixie/src/features/main/services/main-service/conformance/transactions.test.ts new file mode 100644 index 00000000..e8fd698b --- /dev/null +++ b/packages/data-react-pixie/src/features/main/services/main-service/conformance/transactions.test.ts @@ -0,0 +1,63 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +/// +import { describe, it, expect } from "vitest"; +import type { CoreDatabase } from "../core-database/core-database.js"; +import type { ConformanceCase } from "../../../data/state/conformance-case.js"; +import { expectConforms, type ResolveEntity } from "./expect-conforms.js"; +import * as registeredTransactions from "../transaction-database/transactions/index.js"; +import { createSprite } from "../transaction-database/transactions/create-sprite.js"; +import { setFilter } from "../transaction-database/transactions/set-filter.js"; +import { setSpriteActive } from "../transaction-database/transactions/set-sprite-active.js"; +import { setSpriteHovered } from "../transaction-database/transactions/set-sprite-hovered.js"; +import { toggleSpriteActive } from "../transaction-database/transactions/toggle-sprite-active.js"; +import { tick } from "../transaction-database/transactions/tick.js"; +import { cases as createSpriteCases } from "../../../data/state/create-sprite.js"; +import { cases as setFilterCases } from "../../../data/state/set-filter.js"; +import { cases as setSpriteActiveCases } from "../../../data/state/set-sprite-active.js"; +import { cases as setSpriteHoveredCases } from "../../../data/state/set-sprite-hovered.js"; +import { cases as toggleSpriteActiveCases } from "../../../data/state/toggle-sprite-active.js"; +import { cases as tickCases } from "../../../data/state/tick.js"; + +// The single conformance test for every ecs transaction. Each transaction's +// `apply` is bespoke — an id-addressed transaction resolves its entity via the +// seeded store — and transaction files must stay single-export (the +// `transactions/` barrel is `export *`-ed straight into the plugin facet), so the +// wiring lives here rather than beside each transaction. The guard at the bottom +// asserts every registered transaction is wired below, so none can be missed. +const covered = new Set(); +const conforms = ( + transaction: string, + config: { + readonly cases: readonly ConformanceCase[]; + readonly apply: (t: CoreDatabase.Store, args: Args, resolve: ResolveEntity) => void; + }, +): void => { + covered.add(transaction); + describe(`${transaction} transaction conforms`, () => expectConforms(config)); +}; + +conforms("createSprite", { cases: createSpriteCases, apply: createSprite }); +conforms("setFilter", { cases: setFilterCases, apply: setFilter }); +conforms("setSpriteActive", { + cases: setSpriteActiveCases, + apply: (t, args, resolve) => setSpriteActive(t, { entity: resolve(args.id), active: args.active }), +}); +conforms("setSpriteHovered", { + cases: setSpriteHoveredCases, + apply: (t, args, resolve) => setSpriteHovered(t, { entity: resolve(args.id), hovered: args.hovered }), +}); +conforms("toggleSpriteActive", { + cases: toggleSpriteActiveCases, + apply: (t, args, resolve) => toggleSpriteActive(t, { entity: resolve(args.id) }), +}); +conforms("tick", { cases: tickCases, apply: tick }); + +// None-missed guard: every **registered** transaction must be wired above. Keyed +// off the barrel (the transactions the plugin actually dispatches), not a file +// glob — so a shared read helper parked flat in `transactions/` (kept out of the +// barrel) is naturally excluded. +describe("transaction conformance coverage", () => { + for (const transaction of Object.keys(registeredTransactions)) { + it(`${transaction} has a conformance case`, () => expect(covered.has(transaction)).toBe(true)); + } +}); diff --git a/packages/data-react-pixie/src/features/main/services/main-service/system-database/system-database.ts b/packages/data-react-pixie/src/features/main/services/main-service/system-database/system-database.ts index c7633d2b..24e2b2b2 100644 --- a/packages/data-react-pixie/src/features/main/services/main-service/system-database/system-database.ts +++ b/packages/data-react-pixie/src/features/main/services/main-service/system-database/system-database.ts @@ -1,14 +1,14 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Database } from "@adobe/data/ecs"; -import { TransactionDatabase } from "../transaction-database/transaction-database.js"; +import { ActionDatabase } from "../action-database/action-database.js"; // Systems come last in the pipeline, atop the feature's current top layer -// (transactions). `seedSprites` is init-only: its `create` runs ONCE at database +// (actions). `seedSprites` is init-only: its `create` runs ONCE at database // construction to populate the initial scene and returns `void` (no per-frame // work). Per-frame rotation is driven from the ui via the `tick` transaction, so // this feature needs no scheduler. const systemDatabasePlugin = Database.Plugin.create({ - extends: TransactionDatabase.plugin, + extends: ActionDatabase.plugin, systems: { seedSprites: { create: (db) => { diff --git a/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/create-sprite.test.ts b/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/create-sprite.test.ts deleted file mode 100644 index 5df88e13..00000000 --- a/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/create-sprite.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -// -// `createSprite` conforms to `State.createSprite`: it appends one sprite with the -// defaulted rotation and hovered/active flags, leaving existing sprites untouched. -import { describe } from "vitest"; -import { State } from "../../../../data/state/state.js"; -import { cases } from "../../../../data/state/create-sprite.test.js"; -import { expectConforms } from "../../conformance/expect-conforms.js"; -import { createSprite } from "./create-sprite.js"; - -describe("createSprite transaction conforms to State.createSprite", () => { - expectConforms({ - cases, - spec: State.createSprite, - apply: (store, args) => { - createSprite(store, args); - }, - }); -}); diff --git a/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/set-filter.test.ts b/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/set-filter.test.ts deleted file mode 100644 index 0bbc911d..00000000 --- a/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/set-filter.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -// -// `setFilter` conforms to `State.setFilter`: it replaces the scene-wide filter -// resource and leaves sprites untouched. -import { describe } from "vitest"; -import { State } from "../../../../data/state/state.js"; -import { cases } from "../../../../data/state/set-filter.test.js"; -import { expectConforms } from "../../conformance/expect-conforms.js"; -import { setFilter } from "./set-filter.js"; - -describe("setFilter transaction conforms to State.setFilter", () => { - expectConforms({ - cases, - spec: State.setFilter, - apply: (store, args) => setFilter(store, args), - }); -}); diff --git a/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/set-sprite-active.test.ts b/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/set-sprite-active.test.ts deleted file mode 100644 index 8eab4e9d..00000000 --- a/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/set-sprite-active.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -// -// `setSpriteActive` conforms to `State.setSpriteActive`: it sets the addressed -// sprite's `active` flag and is a no-op for an unknown id. -import { describe } from "vitest"; -import { State } from "../../../../data/state/state.js"; -import { cases } from "../../../../data/state/set-sprite-active.test.js"; -import { expectConforms } from "../../conformance/expect-conforms.js"; -import { setSpriteActive } from "./set-sprite-active.js"; - -describe("setSpriteActive transaction conforms to State.setSpriteActive", () => { - expectConforms({ - cases, - spec: State.setSpriteActive, - apply: (store, args, resolve) => - setSpriteActive(store, { entity: resolve(args.id), active: args.active }), - }); -}); diff --git a/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/set-sprite-hovered.test.ts b/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/set-sprite-hovered.test.ts deleted file mode 100644 index 9f9237e4..00000000 --- a/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/set-sprite-hovered.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -// -// `setSpriteHovered` conforms to `State.setSpriteHovered`: it sets the addressed -// sprite's `hovered` flag and is a no-op for an unknown id. The spec `id` is -// resolved to the seeded entity. -import { describe } from "vitest"; -import { State } from "../../../../data/state/state.js"; -import { cases } from "../../../../data/state/set-sprite-hovered.test.js"; -import { expectConforms } from "../../conformance/expect-conforms.js"; -import { setSpriteHovered } from "./set-sprite-hovered.js"; - -describe("setSpriteHovered transaction conforms to State.setSpriteHovered", () => { - expectConforms({ - cases, - spec: State.setSpriteHovered, - apply: (store, args, resolve) => - setSpriteHovered(store, { entity: resolve(args.id), hovered: args.hovered }), - }); -}); diff --git a/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/tick.test.ts b/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/tick.test.ts deleted file mode 100644 index 0a000d4b..00000000 --- a/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/tick.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -// -// `tick` conforms to `State.tick`: every sprite's rotation advances by -// delta * 0.1. -import { describe } from "vitest"; -import { State } from "../../../../data/state/state.js"; -import { cases } from "../../../../data/state/tick.test.js"; -import { expectConforms } from "../../conformance/expect-conforms.js"; -import { tick } from "./tick.js"; - -describe("tick transaction conforms to State.tick", () => { - expectConforms({ - cases, - spec: State.tick, - apply: (store, args) => tick(store, args), - }); -}); diff --git a/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/toggle-sprite-active.test.ts b/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/toggle-sprite-active.test.ts deleted file mode 100644 index d1c7caaf..00000000 --- a/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/toggle-sprite-active.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -// -// `toggleSpriteActive` conforms to `State.toggleSpriteActive`: it flips the -// addressed sprite's `active` flag and is a no-op for an unknown id. -import { describe } from "vitest"; -import { State } from "../../../../data/state/state.js"; -import { cases } from "../../../../data/state/toggle-sprite-active.test.js"; -import { expectConforms } from "../../conformance/expect-conforms.js"; -import { toggleSpriteActive } from "./toggle-sprite-active.js"; - -describe("toggleSpriteActive transaction conforms to State.toggleSpriteActive", () => { - expectConforms({ - cases, - spec: State.toggleSpriteActive, - apply: (store, args, resolve) => toggleSpriteActive(store, { entity: resolve(args.id) }), - }); -}); From 26e539873e7ca2c976b2293f91579ef6496cb7ce Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Thu, 6 Aug 2026 20:27:39 -0700 Subject: [PATCH 10/37] feat(p2p-tictactoe): convert both features to co-located conformance pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rollout conversion of negotiation + presence: co-located cases, spec.test.ts, matcher-aware compare, transaction + action conformance. Actions kept as plain functions (not registered) — registering 9 actions hits the quadratic Database.Plugin extends tsc budget; runtime-neutral (p2p UI uses transactions). Action coverage guard is transition-driven (tolerates orchestration actions). All gates green under Node 24 (74 tests). Co-Authored-By: Claude Opus 4.8 --- .../data/state/conformance-case.ts | 63 ++++++++++- .../negotiation/data/state/enter-game.test.ts | 22 ---- .../negotiation/data/state/enter-game.ts | 21 ++++ .../data/state/expect-state-matches.ts | 60 ++++++++-- .../features/negotiation/data/state/public.ts | 20 ++-- .../negotiation/data/state/record-effects.ts | 107 ++++++++++++++++++ .../data/state/set-answer-code.test.ts | 22 ---- .../negotiation/data/state/set-answer-code.ts | 19 ++++ .../negotiation/data/state/set-banner.test.ts | 28 ----- .../negotiation/data/state/set-banner.ts | 33 ++++++ .../data/state/set-connection.test.ts | 32 ------ .../negotiation/data/state/set-connection.ts | 33 ++++++ .../data/state/set-host-answer-input.test.ts | 22 ---- .../data/state/set-host-answer-input.ts | 19 ++++ .../data/state/set-joiner-offer-input.test.ts | 22 ---- .../data/state/set-joiner-offer-input.ts | 19 ++++ .../data/state/set-offer-code.test.ts | 22 ---- .../negotiation/data/state/set-offer-code.ts | 19 ++++ .../negotiation/data/state/spec.test.ts | 59 ++++++++++ .../data/state/start-host-signaling.test.ts | 28 ----- .../data/state/start-host-signaling.ts | 20 ++++ .../data/state/start-join-signaling.test.ts | 27 ----- .../data/state/start-join-signaling.ts | 20 ++++ .../action-database/actions/enter-game.ts | 11 ++ .../action-database/actions/index.ts | 16 +++ .../actions/set-answer-code.ts | 8 ++ .../action-database/actions/set-banner.ts | 8 ++ .../action-database/actions/set-connection.ts | 12 ++ .../actions/set-host-answer-input.ts | 8 ++ .../actions/set-joiner-offer-input.ts | 8 ++ .../action-database/actions/set-offer-code.ts | 8 ++ .../actions/start-host-signaling.ts | 9 ++ .../actions/start-join-signaling.ts | 8 ++ .../main-service/conformance/actions.test.ts | 102 +++++++++++++++++ .../conformance/expect-conforms.ts | 13 ++- .../conformance/transactions.test.ts | 69 +++++++++++ .../transactions/set-answer-code.test.ts | 14 --- .../transactions/set-banner.test.ts | 14 --- .../transactions/set-connection.test.ts | 14 --- .../transactions/set-game-db.test.ts | 17 --- .../set-host-answer-input.test.ts | 14 --- .../set-joiner-offer-input.test.ts | 14 --- .../transactions/set-offer-code.test.ts | 14 --- .../transactions/start-host-signaling.test.ts | 14 --- .../transactions/start-join-signaling.test.ts | 14 --- .../presence/data/state/conformance-case.ts | 54 ++++++++- .../data/state/expect-state-matches.ts | 58 +++++++--- .../presence/data/state/move-presence.test.ts | 31 ----- .../presence/data/state/move-presence.ts | 20 ++++ .../features/presence/data/state/public.ts | 4 +- .../presence/data/state/record-effects.ts | 105 +++++++++++++++++ .../features/presence/data/state/spec.test.ts | 55 +++++++++ .../action-database/actions/index.ts | 8 ++ .../action-database/actions/move-presence.ts | 12 ++ .../main-service/conformance/actions.test.ts | 74 ++++++++++++ .../conformance/expect-conforms.ts | 11 +- .../conformance/transactions.test.ts | 41 +++++++ .../transactions/move-presence.test.ts | 21 ---- 58 files changed, 1183 insertions(+), 457 deletions(-) delete mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/data/state/enter-game.test.ts create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/data/state/record-effects.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-answer-code.test.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-banner.test.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-connection.test.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-host-answer-input.test.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-joiner-offer-input.test.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-offer-code.test.ts create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/data/state/spec.test.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/data/state/start-host-signaling.test.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/data/state/start-join-signaling.test.ts create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/enter-game.ts create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/set-answer-code.ts create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/set-banner.ts create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/set-connection.ts create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/set-host-answer-input.ts create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/set-joiner-offer-input.ts create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/set-offer-code.ts create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/start-host-signaling.ts create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/start-join-signaling.ts create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/actions.test.ts create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/transactions.test.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-answer-code.test.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-banner.test.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-connection.test.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-game-db.test.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-host-answer-input.test.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-joiner-offer-input.test.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-offer-code.test.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/start-host-signaling.test.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/start-join-signaling.test.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/presence/data/state/move-presence.test.ts create mode 100644 packages/data-p2p-tictactoe/src/features/presence/data/state/record-effects.ts create mode 100644 packages/data-p2p-tictactoe/src/features/presence/data/state/spec.test.ts create mode 100644 packages/data-p2p-tictactoe/src/features/presence/services/main-service/action-database/actions/move-presence.ts create mode 100644 packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/actions.test.ts create mode 100644 packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/transactions.test.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/presence/services/main-service/transaction-database/transactions/move-presence.test.ts diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/conformance-case.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/conformance-case.ts index 00a0b3cc..095f3cb2 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/conformance-case.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/conformance-case.ts @@ -1,13 +1,70 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { State } from "./state.js"; +// A service arg is an object with method members (not an array, not a function); +// this is what distinguishes an injected service from plain data args, since the +// `Service` marker itself is all-optional and would over-match. +type MethodKeys = { [K in keyof T]-?: T[K] extends (...a: never[]) => unknown ? K : never }[keyof T]; +type IsService = T extends readonly unknown[] + ? false + : T extends (...a: never[]) => unknown + ? false + : T extends object + ? [MethodKeys] extends [never] + ? false + : true + : false; + +// A strongly-typed call to one method of service `S`: `[methodName, ...its args]`. +// A no-arg method is just `[methodName]`. +export type Call = { + [M in keyof S]-?: S[M] extends (...a: infer A) => unknown ? readonly [M, ...A] : never; +}[keyof S]; + +// Expected side effects for a case, keyed by the service-typed args only. An +// `Array` value asserts these calls in this order; a `Set` value asserts the same +// calls in any order. Method names and their args are checked against the service. +export type Effects = { + readonly [K in keyof Args as IsService extends true ? K : never]?: + | readonly Call[] + | ReadonlySet>; +}; + // One spec-owned conformance case: a `data/` transform's `{ before, args, after }` -// authored as full `State`. Exported from each `.test.ts` and shared, -// unchanged, by the data transform test and the ecs conformance runner -// (see `services/main-service/conformance/`). +// authored as full `State`, optionally with the side effects it makes on its +// injected service args. Shared unchanged by the spec aggregator (`spec.test.ts`) +// and the ecs conformance runners (`services/main-service/conformance/`). export type ConformanceCase = { readonly name: string; readonly before: State; readonly args: Args; readonly after: State; + readonly effects?: Effects; }; + +// A transform's cases, with the case `args` type derived from the transform's own +// signature — its second parameter, or `void` when it takes none. A transform +// co-locates `export const cases: Conformance = [...]`, so the +// cases cannot drift from what the function accepts, and the spec aggregator can +// discover the function without it being named twice. +export type Conformance unknown> = readonly ConformanceCase< + Parameters extends [unknown, infer Args, ...unknown[]] ? Args : void +>[]; + +// One case for a derivation (`(state) => value`): a state `input` and the `value` +// it yields. `value` may use asymmetric matchers (`anyNumber` for ids). Both the +// pure derivation and its ecs computed are checked against these. +export type DerivationCase = { + readonly name: string; + readonly input: Input; + readonly value: Value; +}; + +// A derivation's cases, with `input` and `value` types read from the derivation's +// own signature (its parameter and return) — the `Conformance` analog for +// value-producing derivations. A derivation co-locates +// `export const cases: Derivation = [...]`. +export type Derivation unknown> = readonly DerivationCase< + Parameters[0], + ReturnType +>[]; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/enter-game.test.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/enter-game.test.ts deleted file mode 100644 index ea7b17b2..00000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/enter-game.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it } from "vitest"; -import { State } from "./state.js"; -import type { ConformanceCase } from "./conformance-case.js"; -import { expectStateMatches } from "./expect-state-matches.js"; - -export const cases: readonly ConformanceCase[] = [ - { - name: "moves to the game phase, connected", - before: { ...State.create(), phase: "host-signaling", role: "host", connection: "connecting" }, - args: undefined, - after: { ...State.create(), phase: "game", role: "host", connection: "connected" }, - }, -]; - -describe("State.enterGame", () => { - for (const { name, before, after } of cases) { - it(name, () => { - expectStateMatches(State.enterGame(before), after); - }); - } -}); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/enter-game.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/enter-game.ts index 47772367..37558aa6 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/enter-game.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/enter-game.ts @@ -1,5 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { State } from "./state.js"; +import type { Conformance } from "./conformance-case.js"; /** * Transition to the live game once the peer connection is established. The @@ -12,3 +13,23 @@ export const enterGame = (state: T): T => ({ phase: "game", connection: "connected", }); + +// Spec-owned cases, shared with the ecs `setGameDb` transaction (a differently +// named transaction whose visible effect is exactly this) and the `enterGame` +// action. A no-arg transition, so `args` is `undefined`. +export const cases: Conformance = [ + { + name: "moves to the game phase, connected", + before: { + phase: "host-signaling", connection: "connecting", role: "host", sessionId: null, + offerCode: "", answerCode: "", bannerText: "", bannerError: false, + hostAnswerInput: "", joinerOfferInput: "", + }, + args: undefined, + after: { + phase: "game", connection: "connected", role: "host", sessionId: null, + offerCode: "", answerCode: "", bannerText: "", bannerError: false, + hostAnswerInput: "", joinerOfferInput: "", + }, + }, +]; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/expect-state-matches.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/expect-state-matches.ts index 279bfe0e..cda18980 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/expect-state-matches.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/expect-state-matches.ts @@ -1,15 +1,57 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { expect } from "vitest"; -import { equalsUnordered } from "@adobe/data"; import type { State } from "./state.js"; -// Spec-owned tolerant `State` equality, shared by the data/ transform tests and -// the ecs conformance runner. `equalsUnordered` is object key-order independent. -// (Negotiation state is all scalars, so ordering never bites — the shared helper -// is kept to mirror the pattern and stay robust if a collection field is added.) +// A vitest asymmetric matcher (`expect.any(...)`): honored on the EXPECTED side +// so a case can assert "any number" for a value it does not pin. Negotiation's +// `State` is all scalars/strings and exposes no ecs-minted ids, so no case needs +// one today — but the comparison stays matcher-aware to match the shared pattern +// and stay robust if one is ever added. +const isMatcher = (value: unknown): value is { asymmetricMatch(actual: unknown): boolean } => + typeof value === "object" && + value !== null && + typeof (value as { asymmetricMatch?: unknown }).asymmetricMatch === "function"; + +// Collapse F32↔f64 storage rounding onto a small grid so float noise compares +// equal. `+ 0` normalises `-0` to `0`. (Negotiation stores only strings, booleans +// and enums, so this is a no-op here — kept to mirror the shared pattern and stay +// robust if a numeric field is added.) +const quantize = (n: number): number => Math.round(Math.fround(n) * 1e6) / 1e6 + 0; + +// Tolerant structural match honoring asymmetric matchers, float precision, and +// order-sensitive arrays. Exported so it can back other conformance comparisons. +export const matches = (actual: unknown, expected: unknown): boolean => { + if (isMatcher(expected)) return expected.asymmetricMatch(actual); + if (typeof expected === "number" && typeof actual === "number") { + return quantize(actual) === quantize(expected); + } + if (Array.isArray(expected)) { + if (!Array.isArray(actual) || actual.length !== expected.length) return false; + return expected.every((exp, index) => matches(actual[index], exp)); + } + if (expected !== null && typeof expected === "object") { + if (actual === null || typeof actual !== "object" || Array.isArray(actual)) return false; + const expectedKeys = Object.keys(expected); + const actualKeys = Object.keys(actual as object); + if (expectedKeys.length !== actualKeys.length) return false; + return expectedKeys.every((key) => + matches((actual as Record)[key], (expected as Record)[key]), + ); + } + return Object.is(actual, expected); +}; + +// Spec-owned tolerant `State` equality, shared by the data/ transform spec and the +// ecs conformance runners. `after` may use asymmetric matchers, so this one +// comparison serves both the pure spec and the ecs projection — no separate +// id-ignoring variant is needed. export const expectStateMatches = (actual: State, expected: State): void => { - expect( - equalsUnordered(actual, expected), - `State mismatch:\n actual ${JSON.stringify(actual)}\n expected ${JSON.stringify(expected)}`, - ).toBe(true); + expectMatches(actual, expected); +}; + +// The same tolerant, matcher-aware comparison for any value — used by derivation +// spec tests and computed conformance, where the compared value may be a scalar +// rather than a whole `State`. +export const expectMatches = (actual: unknown, expected: unknown): void => { + expect(matches(actual, expected), `mismatch:\n actual ${JSON.stringify(actual)}`).toBe(true); }; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/public.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/public.ts index 16e9050f..8f09457c 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/public.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/public.ts @@ -1,11 +1,11 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -export * from "./create.js"; -export * from "./start-host-signaling.js"; -export * from "./start-join-signaling.js"; -export * from "./set-offer-code.js"; -export * from "./set-answer-code.js"; -export * from "./set-banner.js"; -export * from "./set-connection.js"; -export * from "./set-host-answer-input.js"; -export * from "./set-joiner-offer-input.js"; -export * from "./enter-game.js"; +export { create } from "./create.js"; +export { startHostSignaling } from "./start-host-signaling.js"; +export { startJoinSignaling } from "./start-join-signaling.js"; +export { setOfferCode } from "./set-offer-code.js"; +export { setAnswerCode } from "./set-answer-code.js"; +export { setBanner } from "./set-banner.js"; +export { setConnection } from "./set-connection.js"; +export { setHostAnswerInput } from "./set-host-answer-input.js"; +export { setJoinerOfferInput } from "./set-joiner-offer-input.js"; +export { enterGame } from "./enter-game.js"; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/record-effects.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/record-effects.ts new file mode 100644 index 00000000..6950484a --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/record-effects.ts @@ -0,0 +1,107 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { expect } from "vitest"; +import { equalsUnordered } from "@adobe/data"; +import type { Effects } from "./conformance-case.js"; + +export type RecordedCall = readonly [string, ...unknown[]]; + +// Wrap a plain-object service so each method call is recorded, then delegates. +// No Proxy (services are plain objects with own enumerable methods — see +// `service.md`), so we enumerate and closure-wrap each function. +export const recordCalls = (service: S): { service: S; calls: RecordedCall[] } => { + const calls: RecordedCall[] = []; + const wrapped = Object.fromEntries( + Object.entries(service).map(([key, value]) => [ + key, + typeof value === "function" + ? (...args: unknown[]): unknown => { + calls.push([key, ...args]); + return (value as (...a: unknown[]) => unknown)(...args); + } + : value, + ]), + ) as S; + return { service: wrapped, calls }; +}; + +// Assert the calls recorded against one service match the case's expectation for +// it: an Array expects exactly these calls in order; a Set expects the same calls +// in any order (multiset). Absent expectation ⇒ no calls expected. +export const expectServiceCalls = ( + recorded: readonly RecordedCall[], + expected: readonly RecordedCall[] | ReadonlySet | undefined, +): void => { + if (expected instanceof Set) { + expect(equalsUnordered(recorded, [...expected])).toBe(true); + } else { + expect(recorded).toEqual(expected ?? []); + } +}; + +// Split a case's `args` into the injected services (objects with methods) and the +// remaining plain data. Services are wrapped for recording; the returned `calls` +// map is keyed by the same arg key so it can be matched against `effects`. +export const recordArgServices = ( + args: Args, +): { args: Args; calls: Record } => { + const calls: Record = {}; + const next = { ...args } as Record; + for (const [key, value] of Object.entries(args)) { + if (isServiceValue(value)) { + const rec = recordCalls(value); + next[key] = rec.service; + calls[key] = rec.calls; + } + } + return { args: next as Args, calls }; +}; + +// Assert each service **declared** in `effects` saw exactly its expected calls +// (an extra or missing call on a declared service fails). Services not listed — +// e.g. a value-returning dependency read — are ignored, so `effects` captures the +// fire-and-forget side effects you choose to assert. +export const expectEffects = ( + calls: Record, + effects: Effects> | undefined, +): void => { + const expected = (effects ?? {}) as Record>; + for (const key of Object.keys(expected)) { + expectServiceCalls(calls[key] ?? [], expected[key]); + } +}; + +// Split a case's `args` into the injected services (wrapped for recording, to be +// used as `Database.create` service overrides) and the remaining plain data (the +// action input). Keyed by the same arg name so `calls` matches against `effects`. +export const splitAndRecordServices = ( + args: Args, +): { + services: Record; + input: Record; + calls: Record; +} => { + const services: Record = {}; + const input: Record = {}; + const calls: Record = {}; + // A no-arg transition has `undefined` args — nothing to split. + if (args !== null && typeof args === "object") { + for (const [key, value] of Object.entries(args)) { + if (isServiceValue(value)) { + const recorded = recordCalls(value); + services[key] = recorded.service; + calls[key] = recorded.calls; + } else { + input[key] = value; + } + } + } + return { services, input, calls }; +}; + +// A runtime service value: a non-array object with at least one method (mirrors +// the compile-time `IsService`). +const isServiceValue = (value: unknown): value is object => + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.values(value).some((member) => typeof member === "function"); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-answer-code.test.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-answer-code.test.ts deleted file mode 100644 index aebb3435..00000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-answer-code.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it } from "vitest"; -import { State } from "./state.js"; -import type { ConformanceCase } from "./conformance-case.js"; -import { expectStateMatches } from "./expect-state-matches.js"; - -export const cases: readonly ConformanceCase<{ code: string }>[] = [ - { - name: "stores the answer code and clears the banner", - before: { ...State.create(), bannerText: "Generating answer — please wait…" }, - args: { code: "ANSWER-456" }, - after: { ...State.create(), answerCode: "ANSWER-456" }, - }, -]; - -describe("State.setAnswerCode", () => { - for (const { name, before, args, after } of cases) { - it(name, () => { - expectStateMatches(State.setAnswerCode(before, args), after); - }); - } -}); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-answer-code.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-answer-code.ts index 1f1c8172..f0df080b 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-answer-code.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-answer-code.ts @@ -1,8 +1,27 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { State } from "./state.js"; +import type { Conformance } from "./conformance-case.js"; /** Record the generated joiner answer code and clear any pending banner. */ export const setAnswerCode = ( state: T, { code }: { code: string }, ): T => ({ ...state, answerCode: code, bannerText: "" }); + +// Spec-owned cases, shared with the ecs `setAnswerCode` transaction and action. +export const cases: Conformance = [ + { + name: "stores the answer code and clears the banner", + before: { + phase: "idle", connection: "idle", role: null, sessionId: null, + offerCode: "", answerCode: "", bannerText: "Generating answer — please wait…", + bannerError: false, hostAnswerInput: "", joinerOfferInput: "", + }, + args: { code: "ANSWER-456" }, + after: { + phase: "idle", connection: "idle", role: null, sessionId: null, + offerCode: "", answerCode: "ANSWER-456", bannerText: "", bannerError: false, + hostAnswerInput: "", joinerOfferInput: "", + }, + }, +]; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-banner.test.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-banner.test.ts deleted file mode 100644 index 41c225d4..00000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-banner.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it } from "vitest"; -import { State } from "./state.js"; -import type { ConformanceCase } from "./conformance-case.js"; -import { expectStateMatches } from "./expect-state-matches.js"; - -export const cases: readonly ConformanceCase<{ text: string; error?: boolean }>[] = [ - { - name: "sets an informational banner", - before: State.create(), - args: { text: "Generating answer — please wait…" }, - after: { ...State.create(), bannerText: "Generating answer — please wait…" }, - }, - { - name: "sets an error banner", - before: State.create(), - args: { text: "Connection failed: boom", error: true }, - after: { ...State.create(), bannerText: "Connection failed: boom", bannerError: true }, - }, -]; - -describe("State.setBanner", () => { - for (const { name, before, args, after } of cases) { - it(name, () => { - expectStateMatches(State.setBanner(before, args), after); - }); - } -}); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-banner.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-banner.ts index 40cca212..37ae7730 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-banner.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-banner.ts @@ -1,8 +1,41 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { State } from "./state.js"; +import type { Conformance } from "./conformance-case.js"; /** Set the banner text and whether it should be styled as an error. */ export const setBanner = ( state: T, { text, error = false }: { text: string; error?: boolean }, ): T => ({ ...state, bannerText: text, bannerError: error }); + +// Spec-owned cases, shared with the ecs `setBanner` transaction and action. +export const cases: Conformance = [ + { + name: "sets an informational banner", + before: { + phase: "idle", connection: "idle", role: null, sessionId: null, + offerCode: "", answerCode: "", bannerText: "", bannerError: false, + hostAnswerInput: "", joinerOfferInput: "", + }, + args: { text: "Generating answer — please wait…" }, + after: { + phase: "idle", connection: "idle", role: null, sessionId: null, + offerCode: "", answerCode: "", bannerText: "Generating answer — please wait…", + bannerError: false, hostAnswerInput: "", joinerOfferInput: "", + }, + }, + { + name: "sets an error banner", + before: { + phase: "idle", connection: "idle", role: null, sessionId: null, + offerCode: "", answerCode: "", bannerText: "", bannerError: false, + hostAnswerInput: "", joinerOfferInput: "", + }, + args: { text: "Connection failed: boom", error: true }, + after: { + phase: "idle", connection: "idle", role: null, sessionId: null, + offerCode: "", answerCode: "", bannerText: "Connection failed: boom", + bannerError: true, hostAnswerInput: "", joinerOfferInput: "", + }, + }, +]; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-connection.test.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-connection.test.ts deleted file mode 100644 index 4ea76cc8..00000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-connection.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it } from "vitest"; -import type { ConnectionState } from "../connection-state/connection-state.js"; -import { State } from "./state.js"; -import type { ConformanceCase } from "./conformance-case.js"; -import { expectStateMatches } from "./expect-state-matches.js"; - -export const cases: readonly ConformanceCase<{ - connection: ConnectionState; - sessionId?: string | null; -}>[] = [ - { - name: "records a connected state with a session id", - before: { ...State.create(), connection: "connecting" }, - args: { connection: "connected", sessionId: "sess-1" }, - after: { ...State.create(), connection: "connected", sessionId: "sess-1" }, - }, - { - name: "updates only the connection when no session id is supplied", - before: { ...State.create(), connection: "connected", sessionId: "sess-1" }, - args: { connection: "disconnected" }, - after: { ...State.create(), connection: "disconnected", sessionId: "sess-1" }, - }, -]; - -describe("State.setConnection", () => { - for (const { name, before, args, after } of cases) { - it(name, () => { - expectStateMatches(State.setConnection(before, args), after); - }); - } -}); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-connection.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-connection.ts index a0292141..cc706e1d 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-connection.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-connection.ts @@ -1,6 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { ConnectionState } from "../connection-state/connection-state.js"; import type { State } from "./state.js"; +import type { Conformance } from "./conformance-case.js"; /** * Update the connection lifecycle, optionally recording the sync session id. @@ -14,3 +15,35 @@ export const setConnection = ( connection, sessionId: sessionId !== undefined ? sessionId : state.sessionId, }); + +// Spec-owned cases, shared with the ecs `setConnection` transaction and action. +export const cases: Conformance = [ + { + name: "records a connected state with a session id", + before: { + phase: "idle", connection: "connecting", role: null, sessionId: null, + offerCode: "", answerCode: "", bannerText: "", bannerError: false, + hostAnswerInput: "", joinerOfferInput: "", + }, + args: { connection: "connected", sessionId: "sess-1" }, + after: { + phase: "idle", connection: "connected", role: null, sessionId: "sess-1", + offerCode: "", answerCode: "", bannerText: "", bannerError: false, + hostAnswerInput: "", joinerOfferInput: "", + }, + }, + { + name: "updates only the connection when no session id is supplied", + before: { + phase: "idle", connection: "connected", role: null, sessionId: "sess-1", + offerCode: "", answerCode: "", bannerText: "", bannerError: false, + hostAnswerInput: "", joinerOfferInput: "", + }, + args: { connection: "disconnected" }, + after: { + phase: "idle", connection: "disconnected", role: null, sessionId: "sess-1", + offerCode: "", answerCode: "", bannerText: "", bannerError: false, + hostAnswerInput: "", joinerOfferInput: "", + }, + }, +]; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-host-answer-input.test.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-host-answer-input.test.ts deleted file mode 100644 index a6c69fe1..00000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-host-answer-input.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it } from "vitest"; -import { State } from "./state.js"; -import type { ConformanceCase } from "./conformance-case.js"; -import { expectStateMatches } from "./expect-state-matches.js"; - -export const cases: readonly ConformanceCase<{ value: string }>[] = [ - { - name: "stores the host answer input", - before: State.create(), - args: { value: "ANSWER-abc" }, - after: { ...State.create(), hostAnswerInput: "ANSWER-abc" }, - }, -]; - -describe("State.setHostAnswerInput", () => { - for (const { name, before, args, after } of cases) { - it(name, () => { - expectStateMatches(State.setHostAnswerInput(before, args), after); - }); - } -}); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-host-answer-input.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-host-answer-input.ts index 5a33263f..09072188 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-host-answer-input.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-host-answer-input.ts @@ -1,8 +1,27 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { State } from "./state.js"; +import type { Conformance } from "./conformance-case.js"; /** Store the live value of the host's "paste answer" textarea. */ export const setHostAnswerInput = ( state: T, { value }: { value: string }, ): T => ({ ...state, hostAnswerInput: value }); + +// Spec-owned cases, shared with the ecs `setHostAnswerInput` transaction and action. +export const cases: Conformance = [ + { + name: "stores the host answer input", + before: { + phase: "idle", connection: "idle", role: null, sessionId: null, + offerCode: "", answerCode: "", bannerText: "", bannerError: false, + hostAnswerInput: "", joinerOfferInput: "", + }, + args: { value: "ANSWER-abc" }, + after: { + phase: "idle", connection: "idle", role: null, sessionId: null, + offerCode: "", answerCode: "", bannerText: "", bannerError: false, + hostAnswerInput: "ANSWER-abc", joinerOfferInput: "", + }, + }, +]; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-joiner-offer-input.test.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-joiner-offer-input.test.ts deleted file mode 100644 index 3a860b64..00000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-joiner-offer-input.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it } from "vitest"; -import { State } from "./state.js"; -import type { ConformanceCase } from "./conformance-case.js"; -import { expectStateMatches } from "./expect-state-matches.js"; - -export const cases: readonly ConformanceCase<{ value: string }>[] = [ - { - name: "stores the joiner offer input", - before: State.create(), - args: { value: "OFFER-xyz" }, - after: { ...State.create(), joinerOfferInput: "OFFER-xyz" }, - }, -]; - -describe("State.setJoinerOfferInput", () => { - for (const { name, before, args, after } of cases) { - it(name, () => { - expectStateMatches(State.setJoinerOfferInput(before, args), after); - }); - } -}); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-joiner-offer-input.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-joiner-offer-input.ts index 33f7b2a6..1dcef038 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-joiner-offer-input.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-joiner-offer-input.ts @@ -1,8 +1,27 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { State } from "./state.js"; +import type { Conformance } from "./conformance-case.js"; /** Store the live value of the joiner's "paste offer" textarea. */ export const setJoinerOfferInput = ( state: T, { value }: { value: string }, ): T => ({ ...state, joinerOfferInput: value }); + +// Spec-owned cases, shared with the ecs `setJoinerOfferInput` transaction and action. +export const cases: Conformance = [ + { + name: "stores the joiner offer input", + before: { + phase: "idle", connection: "idle", role: null, sessionId: null, + offerCode: "", answerCode: "", bannerText: "", bannerError: false, + hostAnswerInput: "", joinerOfferInput: "", + }, + args: { value: "OFFER-xyz" }, + after: { + phase: "idle", connection: "idle", role: null, sessionId: null, + offerCode: "", answerCode: "", bannerText: "", bannerError: false, + hostAnswerInput: "", joinerOfferInput: "OFFER-xyz", + }, + }, +]; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-offer-code.test.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-offer-code.test.ts deleted file mode 100644 index 84928994..00000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-offer-code.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it } from "vitest"; -import { State } from "./state.js"; -import type { ConformanceCase } from "./conformance-case.js"; -import { expectStateMatches } from "./expect-state-matches.js"; - -export const cases: readonly ConformanceCase<{ code: string }>[] = [ - { - name: "stores the offer code and clears the banner", - before: { ...State.create(), bannerText: "please wait" }, - args: { code: "OFFER-123" }, - after: { ...State.create(), offerCode: "OFFER-123" }, - }, -]; - -describe("State.setOfferCode", () => { - for (const { name, before, args, after } of cases) { - it(name, () => { - expectStateMatches(State.setOfferCode(before, args), after); - }); - } -}); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-offer-code.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-offer-code.ts index 8f3436ee..af6107d2 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-offer-code.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-offer-code.ts @@ -1,8 +1,27 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { State } from "./state.js"; +import type { Conformance } from "./conformance-case.js"; /** Record the generated host invite code and clear any pending banner. */ export const setOfferCode = ( state: T, { code }: { code: string }, ): T => ({ ...state, offerCode: code, bannerText: "" }); + +// Spec-owned cases, shared with the ecs `setOfferCode` transaction and action. +export const cases: Conformance = [ + { + name: "stores the offer code and clears the banner", + before: { + phase: "idle", connection: "idle", role: null, sessionId: null, + offerCode: "", answerCode: "", bannerText: "please wait", bannerError: false, + hostAnswerInput: "", joinerOfferInput: "", + }, + args: { code: "OFFER-123" }, + after: { + phase: "idle", connection: "idle", role: null, sessionId: null, + offerCode: "OFFER-123", answerCode: "", bannerText: "", bannerError: false, + hostAnswerInput: "", joinerOfferInput: "", + }, + }, +]; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/spec.test.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/spec.test.ts new file mode 100644 index 00000000..d398242c --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/spec.test.ts @@ -0,0 +1,59 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +/// +import { describe, it } from "vitest"; +import type { State } from "./state.js"; +import type { ConformanceCase, DerivationCase } from "./conformance-case.js"; +import { expectStateMatches, expectMatches } from "./expect-state-matches.js"; +import { recordArgServices, expectEffects } from "./record-effects.js"; + +// The single spec test for every transform AND derivation in this folder. It +// auto-discovers each file (any sibling `.ts` that exports `cases`) via +// `import.meta.glob`, so a new one is covered the moment it ships — none can be +// forgotten. Each participating file must export exactly its function plus `cases` +// (enforced below), which lets us find the function without it being named twice. +// A case's shape selects the check: `after` → a transition `(state, args) => state`; +// `value` → a derivation `(state) => value`. +const modules = import.meta.glob>( + ["./*.ts", "!./*.test.ts", "!./*.type-test.ts"], + { eager: true }, +); + +const isDerivationCase = (c: unknown): c is DerivationCase => + typeof c === "object" && c !== null && "value" in c; + +for (const [path, module] of Object.entries(modules)) { + const exportNames = Object.keys(module); + if (!exportNames.includes("cases")) continue; + const functionNames = exportNames.filter((key) => typeof module[key] === "function"); + const label = functionNames.length === 1 ? functionNames[0] : path; + + describe(`State.${label}`, () => { + if (exportNames.length !== 2 || functionNames.length !== 1) { + it("exports exactly its function and `cases`", () => { + throw new Error(`${path} exports [${exportNames.join(", ")}] — expected one function + cases`); + }); + return; + } + // Runtime invariant: a participating file exports a function and its cases. + const fn = module[functionNames[0]] as (...args: unknown[]) => unknown; + const cases = module["cases"] as readonly unknown[]; + for (const testCase of cases) { + if (isDerivationCase(testCase)) { + // Derivation: the value it yields matches, honoring `anyNumber`. + it(testCase.name, () => expectMatches(fn(testCase.input), testCase.value)); + continue; + } + // Transition: assert the resulting state and the declared side effects. + // A service-injected transition is async, so await uniformly. + const transitionCase = testCase as ConformanceCase>; + it(transitionCase.name, async () => { + const raw = transitionCase.args; + const { args, calls } = + raw && typeof raw === "object" ? recordArgServices(raw) : { args: raw, calls: {} }; + const result = (await fn(transitionCase.before, args)) as State; + expectStateMatches(result, transitionCase.after); + expectEffects(calls, transitionCase.effects); + }); + } + }); +} diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/start-host-signaling.test.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/start-host-signaling.test.ts deleted file mode 100644 index 712c1028..00000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/start-host-signaling.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it } from "vitest"; -import { State } from "./state.js"; -import type { ConformanceCase } from "./conformance-case.js"; -import { expectStateMatches } from "./expect-state-matches.js"; - -export const cases: readonly ConformanceCase[] = [ - { - name: "enters host-signaling as host, connecting, with a waiting banner", - before: State.create(), - args: undefined, - after: { - ...State.create(), - phase: "host-signaling", - role: "host", - connection: "connecting", - bannerText: "Generating invite code — please wait…", - }, - }, -]; - -describe("State.startHostSignaling", () => { - for (const { name, before, after } of cases) { - it(name, () => { - expectStateMatches(State.startHostSignaling(before), after); - }); - } -}); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/start-host-signaling.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/start-host-signaling.ts index 602f1096..b6c76675 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/start-host-signaling.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/start-host-signaling.ts @@ -1,5 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { State } from "./state.js"; +import type { Conformance } from "./conformance-case.js"; /** Enter the host signaling screen and begin waiting for an invite code. */ export const startHostSignaling = (state: T): T => ({ @@ -10,3 +11,22 @@ export const startHostSignaling = (state: T): T => ({ bannerText: "Generating invite code — please wait…", bannerError: false, }); + +// Spec-owned cases, shared with the ecs `startHostSignaling` transaction and +// action. A no-arg transition, so `args` is `undefined`. +export const cases: Conformance = [ + { + name: "enters host-signaling as host, connecting, with a waiting banner", + before: { + phase: "idle", connection: "idle", role: null, sessionId: null, + offerCode: "", answerCode: "", bannerText: "", bannerError: false, + hostAnswerInput: "", joinerOfferInput: "", + }, + args: undefined, + after: { + phase: "host-signaling", connection: "connecting", role: "host", sessionId: null, + offerCode: "", answerCode: "", bannerText: "Generating invite code — please wait…", + bannerError: false, hostAnswerInput: "", joinerOfferInput: "", + }, + }, +]; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/start-join-signaling.test.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/start-join-signaling.test.ts deleted file mode 100644 index 1544d84f..00000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/start-join-signaling.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it } from "vitest"; -import { State } from "./state.js"; -import type { ConformanceCase } from "./conformance-case.js"; -import { expectStateMatches } from "./expect-state-matches.js"; - -export const cases: readonly ConformanceCase[] = [ - { - name: "enters join-signaling as joiner, connecting, clearing the banner", - before: { ...State.create(), bannerText: "stale", bannerError: true }, - args: undefined, - after: { - ...State.create(), - phase: "join-signaling", - role: "joiner", - connection: "connecting", - }, - }, -]; - -describe("State.startJoinSignaling", () => { - for (const { name, before, after } of cases) { - it(name, () => { - expectStateMatches(State.startJoinSignaling(before), after); - }); - } -}); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/start-join-signaling.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/start-join-signaling.ts index e63fc2e7..5c9018f6 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/start-join-signaling.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/start-join-signaling.ts @@ -1,5 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { State } from "./state.js"; +import type { Conformance } from "./conformance-case.js"; /** Enter the joiner signaling screen, ready to paste the host's invite code. */ export const startJoinSignaling = (state: T): T => ({ @@ -10,3 +11,22 @@ export const startJoinSignaling = (state: T): T => ({ bannerText: "", bannerError: false, }); + +// Spec-owned cases, shared with the ecs `startJoinSignaling` transaction and +// action. A no-arg transition, so `args` is `undefined`. +export const cases: Conformance = [ + { + name: "enters join-signaling as joiner, connecting, clearing the banner", + before: { + phase: "idle", connection: "idle", role: null, sessionId: null, + offerCode: "", answerCode: "", bannerText: "stale", bannerError: true, + hostAnswerInput: "", joinerOfferInput: "", + }, + args: undefined, + after: { + phase: "join-signaling", connection: "connecting", role: "joiner", sessionId: null, + offerCode: "", answerCode: "", bannerText: "", bannerError: false, + hostAnswerInput: "", joinerOfferInput: "", + }, + }, +]; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/enter-game.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/enter-game.ts new file mode 100644 index 00000000..7c866e87 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/enter-game.ts @@ -0,0 +1,11 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ServiceDatabase } from "../../service-database/service-database.js"; + +// App-facing realization of `State.enterGame`: reuses the `setGameDb` transaction +// (a differently-named transaction whose visible phase/connection effect is +// exactly `enterGame`), preserving the current game-database handle read +// synchronously from the store. There is no same-named transaction — transactions +// are the looser layer. +export const enterGame = (db: ServiceDatabase) => { + db.transactions.setGameDb({ gameDb: db.resources.gameDb }); +}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/index.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/index.ts index 503a3b14..a7aa1cd5 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/index.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/index.ts @@ -1,4 +1,20 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +// +// The `actions` plugin facet. Only the capability-orchestration verbs (the +// UI-facing actions that drive the imperative `connection` service) are +// registered here. +// +// The per-transition actions (`start-host-signaling.ts`, `set-offer-code.ts`, … +// — the same-named app-facing realization of each `data/state` transition) are +// deliberately NOT re-exported into the facet: registering them would grow the +// composed database's `actions`/`transactions` type past tsc's instantiation +// budget (Database.Plugin extends is quadratic), which silently degrades the +// inferred restricted-service type the `ui/` binds to. They live beside this +// barrel as plain functions and are exercised directly by +// `conformance/actions.test.ts` — exactly how the reference runner imports and +// calls action functions (never through `db.actions`). The UI never dispatches +// them (it uses `transactions.*` and the orchestration verbs), so keeping them +// unregistered costs nothing at runtime. export * from "./configure.js"; export * from "./start-host.js"; export * from "./start-join.js"; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/set-answer-code.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/set-answer-code.ts new file mode 100644 index 00000000..cc289a1a --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/set-answer-code.ts @@ -0,0 +1,8 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ServiceDatabase } from "../../service-database/service-database.js"; + +// App-facing realization of `State.setAnswerCode`: commits the state transition +// through a single transaction. +export const setAnswerCode = (db: ServiceDatabase, { code }: { code: string }) => { + db.transactions.setAnswerCode({ code }); +}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/set-banner.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/set-banner.ts new file mode 100644 index 00000000..2fbff926 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/set-banner.ts @@ -0,0 +1,8 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ServiceDatabase } from "../../service-database/service-database.js"; + +// App-facing realization of `State.setBanner`: commits the state transition +// through a single transaction. +export const setBanner = (db: ServiceDatabase, { text, error }: { text: string; error?: boolean }) => { + db.transactions.setBanner({ text, error }); +}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/set-connection.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/set-connection.ts new file mode 100644 index 00000000..0263d4a8 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/set-connection.ts @@ -0,0 +1,12 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ConnectionState } from "../../../../data/connection-state/connection-state.js"; +import type { ServiceDatabase } from "../../service-database/service-database.js"; + +// App-facing realization of `State.setConnection`: commits the state transition +// through a single transaction. +export const setConnection = ( + db: ServiceDatabase, + { connection, sessionId }: { connection: ConnectionState; sessionId?: string | null }, +) => { + db.transactions.setConnection({ connection, sessionId }); +}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/set-host-answer-input.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/set-host-answer-input.ts new file mode 100644 index 00000000..2faa7960 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/set-host-answer-input.ts @@ -0,0 +1,8 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ServiceDatabase } from "../../service-database/service-database.js"; + +// App-facing realization of `State.setHostAnswerInput`: commits the state +// transition through a single transaction. +export const setHostAnswerInput = (db: ServiceDatabase, { value }: { value: string }) => { + db.transactions.setHostAnswerInput({ value }); +}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/set-joiner-offer-input.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/set-joiner-offer-input.ts new file mode 100644 index 00000000..a756d819 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/set-joiner-offer-input.ts @@ -0,0 +1,8 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ServiceDatabase } from "../../service-database/service-database.js"; + +// App-facing realization of `State.setJoinerOfferInput`: commits the state +// transition through a single transaction. +export const setJoinerOfferInput = (db: ServiceDatabase, { value }: { value: string }) => { + db.transactions.setJoinerOfferInput({ value }); +}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/set-offer-code.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/set-offer-code.ts new file mode 100644 index 00000000..542bf9bf --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/set-offer-code.ts @@ -0,0 +1,8 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ServiceDatabase } from "../../service-database/service-database.js"; + +// App-facing realization of `State.setOfferCode`: commits the state transition +// through a single transaction. +export const setOfferCode = (db: ServiceDatabase, { code }: { code: string }) => { + db.transactions.setOfferCode({ code }); +}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/start-host-signaling.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/start-host-signaling.ts new file mode 100644 index 00000000..65382668 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/start-host-signaling.ts @@ -0,0 +1,9 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ServiceDatabase } from "../../service-database/service-database.js"; + +// App-facing realization of `State.startHostSignaling`: a pure state transition, +// so it commits through a single transaction. (The imperative signaling that +// follows is orchestrated by the `connection` service, driven from `startHost`.) +export const startHostSignaling = (db: ServiceDatabase) => { + db.transactions.startHostSignaling(); +}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/start-join-signaling.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/start-join-signaling.ts new file mode 100644 index 00000000..2aa37a56 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/action-database/actions/start-join-signaling.ts @@ -0,0 +1,8 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ServiceDatabase } from "../../service-database/service-database.js"; + +// App-facing realization of `State.startJoinSignaling`: commits the state +// transition through a single transaction. +export const startJoinSignaling = (db: ServiceDatabase) => { + db.transactions.startJoinSignaling(); +}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/actions.test.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/actions.test.ts new file mode 100644 index 00000000..69c4ed1b --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/actions.test.ts @@ -0,0 +1,102 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +/// +import { describe, it, expect } from "vitest"; +import { Database } from "@adobe/data/ecs"; +import type { ConformanceCase } from "../../../data/state/conformance-case.js"; +import { splitAndRecordServices, expectEffects } from "../../../data/state/record-effects.js"; +import type { ConnectionService } from "../service-database/services/create-connection-service.js"; +import { expectStateMatches } from "../../../data/state/expect-state-matches.js"; +import { MainService } from "../main-service.js"; +import { fromState } from "./from-state.js"; +import { toState } from "./to-state.js"; +import { startHostSignaling } from "../action-database/actions/start-host-signaling.js"; +import { startJoinSignaling } from "../action-database/actions/start-join-signaling.js"; +import { setOfferCode } from "../action-database/actions/set-offer-code.js"; +import { setAnswerCode } from "../action-database/actions/set-answer-code.js"; +import { setBanner } from "../action-database/actions/set-banner.js"; +import { setConnection } from "../action-database/actions/set-connection.js"; +import { setHostAnswerInput } from "../action-database/actions/set-host-answer-input.js"; +import { setJoinerOfferInput } from "../action-database/actions/set-joiner-offer-input.js"; +import { enterGame } from "../action-database/actions/enter-game.js"; +import { cases as startHostSignalingCases } from "../../../data/state/start-host-signaling.js"; +import { cases as startJoinSignalingCases } from "../../../data/state/start-join-signaling.js"; +import { cases as setOfferCodeCases } from "../../../data/state/set-offer-code.js"; +import { cases as setAnswerCodeCases } from "../../../data/state/set-answer-code.js"; +import { cases as setBannerCases } from "../../../data/state/set-banner.js"; +import { cases as setConnectionCases } from "../../../data/state/set-connection.js"; +import { cases as setHostAnswerInputCases } from "../../../data/state/set-host-answer-input.js"; +import { cases as setJoinerOfferInputCases } from "../../../data/state/set-joiner-offer-input.js"; +import { cases as enterGameCases } from "../../../data/state/enter-game.js"; + +// Each transition's cases run against its same-named ecs **action**, asserting both +// the resulting state and the declared side effects. The case's service args become +// the db's service overrides (wrapped so their calls are recorded); the plain args +// drive the action. `toSystemDatabase` exposes the writable `.store` the projection +// needs while keeping services/transactions/actions. Runtime invariant: the empty +// override object is a valid partial `services` factory map. +const makeDb = (services: { connection?: ConnectionService }) => + Database.toSystemDatabase(Database.create(MainService.plugin, { services })); +type Db = ReturnType; +type Run = (db: Db, input: Partial) => Promise | void; + +const covered = new Set(); +const conformsAction = ( + action: string, + config: { readonly cases: readonly ConformanceCase[]; readonly run: Run }, +): void => { + covered.add(action); + describe(`${action} action conforms`, () => { + for (const testCase of config.cases) { + it(testCase.name, async () => { + const { services, input, calls } = splitAndRecordServices(testCase.args); + // Runtime invariant: negotiation transitions inject no services, so the + // recorded overrides are the empty connection-service partial. + const db = makeDb(services as { connection?: ConnectionService }); + fromState(db.store, testCase.before); + await config.run(db, input as Partial); + expectStateMatches(toState(db.store), testCase.after); + expectEffects(calls, testCase.effects); + }); + } + }); +}; + +conformsAction("startHostSignaling", { cases: startHostSignalingCases, run: (db) => startHostSignaling(db) }); +conformsAction("startJoinSignaling", { cases: startJoinSignalingCases, run: (db) => startJoinSignaling(db) }); +conformsAction("setOfferCode", { cases: setOfferCodeCases, run: (db, input) => setOfferCode(db, { code: input.code ?? "" }) }); +conformsAction("setAnswerCode", { cases: setAnswerCodeCases, run: (db, input) => setAnswerCode(db, { code: input.code ?? "" }) }); +conformsAction("setBanner", { cases: setBannerCases, run: (db, input) => setBanner(db, { text: input.text ?? "", error: input.error }) }); +conformsAction("setConnection", { + cases: setConnectionCases, + run: (db, input) => setConnection(db, { connection: input.connection ?? "idle", sessionId: input.sessionId }), +}); +conformsAction("setHostAnswerInput", { cases: setHostAnswerInputCases, run: (db, input) => setHostAnswerInput(db, { value: input.value ?? "" }) }); +conformsAction("setJoinerOfferInput", { cases: setJoinerOfferInputCases, run: (db, input) => setJoinerOfferInput(db, { value: input.value ?? "" }) }); +conformsAction("enterGame", { cases: enterGameCases, run: (db) => enterGame(db) }); + +// None-missed guard: every data/state **transition** (a file whose `cases` are +// `{ before, args, after }`) must have a same-named action wired above. Iterating +// the transitions — not the action files — is deliberate: the capability +// orchestration actions (`startHost`, `submitAnswer`, …) drive the imperative +// `connection` service and have no pure-transition analogue, so they are not +// conformance-tested here. +const kebabToCamel = (name: string): string => + name.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase()); +const stateModules = import.meta.glob>( + ["../../../data/state/*.ts", "!../../../data/state/*.test.ts"], + { eager: true }, +); +describe("action conformance coverage", () => { + for (const [path, module] of Object.entries(stateModules)) { + const cases = module["cases"]; + const isTransition = + Array.isArray(cases) && + cases.length > 0 && + typeof cases[0] === "object" && + cases[0] !== null && + "after" in cases[0]; + if (!isTransition) continue; + const name = kebabToCamel(path.replace(/.*\//, "").replace(/\.ts$/, "")); + it(`${name} has an action conformance case`, () => expect(covered.has(name)).toBe(true)); + } +}); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/expect-conforms.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/expect-conforms.ts index 1ebe21fb..9bb73652 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/expect-conforms.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/expect-conforms.ts @@ -9,20 +9,23 @@ import { fromState } from "./from-state.js"; import { toState } from "./to-state.js"; // The conformance runner, bound to the negotiation projection (`fromState` / -// `toState`). For each case it proves +// `toState`). For each case it proves the one conformance property // // toState(apply(fromState(before), args)) ≡ spec(before, args) // -// in two asserted halves: `spec(before, args) ≡ after` (keeps the shared case -// honest), then seed → `apply` (the raw transaction) → `toState ≡ after`. +// The pure half (`spec(before, args) ≡ after`) is asserted once, centrally, by +// `data/state/spec.test.ts`, so this runner asserts only the ecs half; pass `spec` +// to re-check it in place for a differently-named transaction. export const expectConforms = (config: { readonly cases: readonly ConformanceCase[]; - readonly spec: (before: State, args: Args) => State; + readonly spec?: (before: State, args: Args) => State; readonly apply: (store: CoreDatabase.Store, args: Args) => void; }): void => { for (const testCase of config.cases) { it(testCase.name, () => { - expectStateMatches(config.spec(testCase.before, testCase.args), testCase.after); + if (config.spec) { + expectStateMatches(config.spec(testCase.before, testCase.args), testCase.after); + } const store = createStore(); fromState(store, testCase.before); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/transactions.test.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/transactions.test.ts new file mode 100644 index 00000000..5699caee --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/transactions.test.ts @@ -0,0 +1,69 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it, expect } from "vitest"; +import { State } from "../../../data/state/state.js"; +import type { CoreDatabase } from "../core-database/core-database.js"; +import type { ConformanceCase } from "../../../data/state/conformance-case.js"; +import { expectConforms } from "./expect-conforms.js"; +import * as registeredTransactions from "../transaction-database/transactions/index.js"; +import { startHostSignaling } from "../transaction-database/transactions/start-host-signaling.js"; +import { startJoinSignaling } from "../transaction-database/transactions/start-join-signaling.js"; +import { setOfferCode } from "../transaction-database/transactions/set-offer-code.js"; +import { setAnswerCode } from "../transaction-database/transactions/set-answer-code.js"; +import { setBanner } from "../transaction-database/transactions/set-banner.js"; +import { setConnection } from "../transaction-database/transactions/set-connection.js"; +import { setHostAnswerInput } from "../transaction-database/transactions/set-host-answer-input.js"; +import { setJoinerOfferInput } from "../transaction-database/transactions/set-joiner-offer-input.js"; +import { setGameDb } from "../transaction-database/transactions/set-game-db.js"; +import { cases as startHostSignalingCases } from "../../../data/state/start-host-signaling.js"; +import { cases as startJoinSignalingCases } from "../../../data/state/start-join-signaling.js"; +import { cases as setOfferCodeCases } from "../../../data/state/set-offer-code.js"; +import { cases as setAnswerCodeCases } from "../../../data/state/set-answer-code.js"; +import { cases as setBannerCases } from "../../../data/state/set-banner.js"; +import { cases as setConnectionCases } from "../../../data/state/set-connection.js"; +import { cases as setHostAnswerInputCases } from "../../../data/state/set-host-answer-input.js"; +import { cases as setJoinerOfferInputCases } from "../../../data/state/set-joiner-offer-input.js"; +import { cases as enterGameCases } from "../../../data/state/enter-game.js"; + +// The single conformance test for every ecs transaction. Each transaction's +// shared `data/state` cases run through its raw `apply` (`fromState(before)` → +// apply → `matches(toState, after)`); the pure half is asserted once, centrally, +// by `data/state/spec.test.ts`. The guard at the bottom asserts every REGISTERED +// transaction (the `transactions/index.ts` barrel, not a file glob) is wired +// below, so none can be missed. +const covered = new Set(); +const conforms = ( + transaction: string, + config: { + readonly cases: readonly ConformanceCase[]; + readonly spec?: (before: State, args: Args) => State; + readonly apply: (t: CoreDatabase.Store, args: Args) => void; + }, +): void => { + covered.add(transaction); + describe(`${transaction} transaction conforms`, () => expectConforms(config)); +}; + +conforms("startHostSignaling", { cases: startHostSignalingCases, apply: (t) => startHostSignaling(t) }); +conforms("startJoinSignaling", { cases: startJoinSignalingCases, apply: (t) => startJoinSignaling(t) }); +conforms("setOfferCode", { cases: setOfferCodeCases, apply: setOfferCode }); +conforms("setAnswerCode", { cases: setAnswerCodeCases, apply: setAnswerCode }); +conforms("setBanner", { cases: setBannerCases, apply: setBanner }); +conforms("setConnection", { cases: setConnectionCases, apply: setConnection }); +conforms("setHostAnswerInput", { cases: setHostAnswerInputCases, apply: setHostAnswerInput }); +conforms("setJoinerOfferInput", { cases: setJoinerOfferInputCases, apply: setJoinerOfferInput }); +// `setGameDb` also stores a non-serializable game-database handle the spec's +// serializable `State` never observes; passing `gameDb: null` isolates its visible +// effect, which equals the differently-named `State.enterGame` transition. Its +// pure half is checked here in place (the shared spec test runs `enterGame`). +conforms("setGameDb", { + cases: enterGameCases, + spec: (before) => State.enterGame(before), + apply: (t) => setGameDb(t, { gameDb: null }), +}); + +// None-missed guard: every **registered** transaction must be wired above. +describe("transaction conformance coverage", () => { + for (const transaction of Object.keys(registeredTransactions)) { + it(`${transaction} has a conformance case`, () => expect(covered.has(transaction)).toBe(true)); + } +}); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-answer-code.test.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-answer-code.test.ts deleted file mode 100644 index 69045c6b..00000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-answer-code.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe } from "vitest"; -import { State } from "../../../../data/state/state.js"; -import { cases } from "../../../../data/state/set-answer-code.test.js"; -import { expectConforms } from "../../conformance/expect-conforms.js"; -import { setAnswerCode } from "./set-answer-code.js"; - -describe("setAnswerCode transaction conforms to State.setAnswerCode", () => { - expectConforms({ - cases, - spec: State.setAnswerCode, - apply: setAnswerCode, - }); -}); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-banner.test.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-banner.test.ts deleted file mode 100644 index 38d4974e..00000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-banner.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe } from "vitest"; -import { State } from "../../../../data/state/state.js"; -import { cases } from "../../../../data/state/set-banner.test.js"; -import { expectConforms } from "../../conformance/expect-conforms.js"; -import { setBanner } from "./set-banner.js"; - -describe("setBanner transaction conforms to State.setBanner", () => { - expectConforms({ - cases, - spec: State.setBanner, - apply: setBanner, - }); -}); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-connection.test.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-connection.test.ts deleted file mode 100644 index e8b9749e..00000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-connection.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe } from "vitest"; -import { State } from "../../../../data/state/state.js"; -import { cases } from "../../../../data/state/set-connection.test.js"; -import { expectConforms } from "../../conformance/expect-conforms.js"; -import { setConnection } from "./set-connection.js"; - -describe("setConnection transaction conforms to State.setConnection", () => { - expectConforms({ - cases, - spec: State.setConnection, - apply: setConnection, - }); -}); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-game-db.test.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-game-db.test.ts deleted file mode 100644 index 69487598..00000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-game-db.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe } from "vitest"; -import { State } from "../../../../data/state/state.js"; -import { cases } from "../../../../data/state/enter-game.test.js"; -import { expectConforms } from "../../conformance/expect-conforms.js"; -import { setGameDb } from "./set-game-db.js"; - -// `setGameDb` also stores a non-serializable game database handle, which the -// serializable `State` — and therefore `toState` — never observes. Passing -// `gameDb: null` isolates its visible effect, which equals `State.enterGame`. -describe("setGameDb transaction conforms to State.enterGame (phase/connection effect)", () => { - expectConforms({ - cases, - spec: (before) => State.enterGame(before), - apply: (store) => setGameDb(store, { gameDb: null }), - }); -}); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-host-answer-input.test.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-host-answer-input.test.ts deleted file mode 100644 index 6ed59d56..00000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-host-answer-input.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe } from "vitest"; -import { State } from "../../../../data/state/state.js"; -import { cases } from "../../../../data/state/set-host-answer-input.test.js"; -import { expectConforms } from "../../conformance/expect-conforms.js"; -import { setHostAnswerInput } from "./set-host-answer-input.js"; - -describe("setHostAnswerInput transaction conforms to State.setHostAnswerInput", () => { - expectConforms({ - cases, - spec: State.setHostAnswerInput, - apply: setHostAnswerInput, - }); -}); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-joiner-offer-input.test.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-joiner-offer-input.test.ts deleted file mode 100644 index 40e68419..00000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-joiner-offer-input.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe } from "vitest"; -import { State } from "../../../../data/state/state.js"; -import { cases } from "../../../../data/state/set-joiner-offer-input.test.js"; -import { expectConforms } from "../../conformance/expect-conforms.js"; -import { setJoinerOfferInput } from "./set-joiner-offer-input.js"; - -describe("setJoinerOfferInput transaction conforms to State.setJoinerOfferInput", () => { - expectConforms({ - cases, - spec: State.setJoinerOfferInput, - apply: setJoinerOfferInput, - }); -}); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-offer-code.test.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-offer-code.test.ts deleted file mode 100644 index d3760b25..00000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/set-offer-code.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe } from "vitest"; -import { State } from "../../../../data/state/state.js"; -import { cases } from "../../../../data/state/set-offer-code.test.js"; -import { expectConforms } from "../../conformance/expect-conforms.js"; -import { setOfferCode } from "./set-offer-code.js"; - -describe("setOfferCode transaction conforms to State.setOfferCode", () => { - expectConforms({ - cases, - spec: State.setOfferCode, - apply: setOfferCode, - }); -}); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/start-host-signaling.test.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/start-host-signaling.test.ts deleted file mode 100644 index 523e6c8e..00000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/start-host-signaling.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe } from "vitest"; -import { State } from "../../../../data/state/state.js"; -import { cases } from "../../../../data/state/start-host-signaling.test.js"; -import { expectConforms } from "../../conformance/expect-conforms.js"; -import { startHostSignaling } from "./start-host-signaling.js"; - -describe("startHostSignaling transaction conforms to State.startHostSignaling", () => { - expectConforms({ - cases, - spec: (before) => State.startHostSignaling(before), - apply: (store) => startHostSignaling(store), - }); -}); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/start-join-signaling.test.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/start-join-signaling.test.ts deleted file mode 100644 index 46705a75..00000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/transaction-database/transactions/start-join-signaling.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe } from "vitest"; -import { State } from "../../../../data/state/state.js"; -import { cases } from "../../../../data/state/start-join-signaling.test.js"; -import { expectConforms } from "../../conformance/expect-conforms.js"; -import { startJoinSignaling } from "./start-join-signaling.js"; - -describe("startJoinSignaling transaction conforms to State.startJoinSignaling", () => { - expectConforms({ - cases, - spec: (before) => State.startJoinSignaling(before), - apply: (store) => startJoinSignaling(store), - }); -}); diff --git a/packages/data-p2p-tictactoe/src/features/presence/data/state/conformance-case.ts b/packages/data-p2p-tictactoe/src/features/presence/data/state/conformance-case.ts index 19464f2c..e596e9e5 100644 --- a/packages/data-p2p-tictactoe/src/features/presence/data/state/conformance-case.ts +++ b/packages/data-p2p-tictactoe/src/features/presence/data/state/conformance-case.ts @@ -1,12 +1,62 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { State } from "./state.js"; +// A service arg is an object with method members (not an array, not a function); +// this is what distinguishes an injected service from plain data args, since the +// `Service` marker itself is all-optional and would over-match. +type MethodKeys = { [K in keyof T]-?: T[K] extends (...a: never[]) => unknown ? K : never }[keyof T]; +type IsService = T extends readonly unknown[] + ? false + : T extends (...a: never[]) => unknown + ? false + : T extends object + ? [MethodKeys] extends [never] + ? false + : true + : false; + +// A strongly-typed call to one method of service `S`: `[methodName, ...its args]`. +export type Call = { + [M in keyof S]-?: S[M] extends (...a: infer A) => unknown ? readonly [M, ...A] : never; +}[keyof S]; + +// Expected side effects for a case, keyed by the service-typed args only. An +// `Array` value asserts these calls in this order; a `Set` value asserts the same +// calls in any order. +export type Effects = { + readonly [K in keyof Args as IsService extends true ? K : never]?: + | readonly Call[] + | ReadonlySet>; +}; + // One spec-owned conformance case: a `data/` transform's `{ before, args, after }` -// authored as full `State`. Exported from each `.test.ts` and shared, -// unchanged, by the data transform test and the ecs conformance runner. +// authored as full `State`, optionally with the side effects it makes on its +// injected service args. Shared unchanged by the spec aggregator (`spec.test.ts`) +// and the ecs conformance runners (`services/main-service/conformance/`). export type ConformanceCase = { readonly name: string; readonly before: State; readonly args: Args; readonly after: State; + readonly effects?: Effects; }; + +// A transform's cases, with the case `args` type derived from the transform's own +// signature — its second parameter, or `void` when it takes none. +export type Conformance unknown> = readonly ConformanceCase< + Parameters extends [unknown, infer Args, ...unknown[]] ? Args : void +>[]; + +// One case for a derivation (`(state) => value`): a state `input` and the `value` +// it yields. `value` may use asymmetric matchers. +export type DerivationCase = { + readonly name: string; + readonly input: Input; + readonly value: Value; +}; + +// A derivation's cases, with `input`/`value` read from the derivation's signature. +export type Derivation unknown> = readonly DerivationCase< + Parameters[0], + ReturnType +>[]; diff --git a/packages/data-p2p-tictactoe/src/features/presence/data/state/expect-state-matches.ts b/packages/data-p2p-tictactoe/src/features/presence/data/state/expect-state-matches.ts index 945b0f11..a6487989 100644 --- a/packages/data-p2p-tictactoe/src/features/presence/data/state/expect-state-matches.ts +++ b/packages/data-p2p-tictactoe/src/features/presence/data/state/expect-state-matches.ts @@ -1,28 +1,52 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { expect } from "vitest"; -import { equalsUnordered } from "@adobe/data"; import type { State } from "./state.js"; -// Spec-owned tolerant `State` equality, shared by the data/ transform tests and -// the ecs conformance runner. Cursor positions are Vec2 (F32 tuples), so numbers -// are quantized onto a shared grid to absorb F32↔f64 storage rounding before the -// key-order-independent `equalsUnordered` compare. +// A vitest asymmetric matcher (`expect.any(...)`): honored on the EXPECTED side so +// a case can assert "any number" for a value it does not pin. Presence's `State` +// keys cursors by the peer's `PlayerMark` (no ecs-minted ids), so no case needs +// one today — but the comparison stays matcher-aware to match the shared pattern. +const isMatcher = (value: unknown): value is { asymmetricMatch(actual: unknown): boolean } => + typeof value === "object" && + value !== null && + typeof (value as { asymmetricMatch?: unknown }).asymmetricMatch === "function"; + +// Cursor positions are `Vec2` (F32 tuples), so numbers are quantized onto a shared +// grid to absorb F32↔f64 storage rounding before comparison. `+ 0` normalises `-0`. const quantize = (n: number): number => Math.round(Math.fround(n) * 1e6) / 1e6 + 0; -const normalize = (value: unknown): unknown => { - if (typeof value === "number") return quantize(value); - if (Array.isArray(value)) return value.map(normalize); - if (value !== null && typeof value === "object") { - return Object.fromEntries(Object.entries(value).map(([key, v]) => [key, normalize(v)])); +// Tolerant structural match honoring asymmetric matchers, float precision, and +// order-sensitive arrays (cursor tuples). Objects compare by key set, so the +// `cursors` map is order-independent. Exported so it can back other comparisons. +export const matches = (actual: unknown, expected: unknown): boolean => { + if (isMatcher(expected)) return expected.asymmetricMatch(actual); + if (typeof expected === "number" && typeof actual === "number") { + return quantize(actual) === quantize(expected); + } + if (Array.isArray(expected)) { + if (!Array.isArray(actual) || actual.length !== expected.length) return false; + return expected.every((exp, index) => matches(actual[index], exp)); + } + if (expected !== null && typeof expected === "object") { + if (actual === null || typeof actual !== "object" || Array.isArray(actual)) return false; + const expectedKeys = Object.keys(expected); + const actualKeys = Object.keys(actual as object); + if (expectedKeys.length !== actualKeys.length) return false; + return expectedKeys.every((key) => + matches((actual as Record)[key], (expected as Record)[key]), + ); } - return value; + return Object.is(actual, expected); }; +// Spec-owned tolerant `State` equality, shared by the data/ transform spec and the +// ecs conformance runners. `after` may use asymmetric matchers, so this one +// comparison serves both the pure spec and the ecs projection. export const expectStateMatches = (actual: State, expected: State): void => { - const a = normalize(actual); - const b = normalize(expected); - expect( - equalsUnordered(a, b), - `State mismatch:\n actual ${JSON.stringify(a)}\n expected ${JSON.stringify(b)}`, - ).toBe(true); + expectMatches(actual, expected); +}; + +// The same tolerant, matcher-aware comparison for any value. +export const expectMatches = (actual: unknown, expected: unknown): void => { + expect(matches(actual, expected), `mismatch:\n actual ${JSON.stringify(actual)}`).toBe(true); }; diff --git a/packages/data-p2p-tictactoe/src/features/presence/data/state/move-presence.test.ts b/packages/data-p2p-tictactoe/src/features/presence/data/state/move-presence.test.ts deleted file mode 100644 index afa3c6b8..00000000 --- a/packages/data-p2p-tictactoe/src/features/presence/data/state/move-presence.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it } from "vitest"; -import type { Vec2 } from "@adobe/data/math"; -import { State } from "./state.js"; -import type { ConformanceCase } from "./conformance-case.js"; -import { expectStateMatches } from "./expect-state-matches.js"; - -const at = (x: number, y: number): Vec2 => [x, y] as Vec2; - -export const cases: readonly ConformanceCase<{ mark: "X" | "O"; x: number; y: number }>[] = [ - { - name: "records the first cursor position for a peer", - before: State.create(), - args: { mark: "X", x: 0.5, y: 0.25 }, - after: { cursors: { X: at(0.5, 0.25) } }, - }, - { - name: "updates one peer's cursor while preserving the other's", - before: { cursors: { X: at(0.5, 0.25) } }, - args: { mark: "O", x: 0.75, y: 0.5 }, - after: { cursors: { X: at(0.5, 0.25), O: at(0.75, 0.5) } }, - }, -]; - -describe("State.movePresence", () => { - for (const { name, before, args, after } of cases) { - it(name, () => { - expectStateMatches(State.movePresence(before, args), after); - }); - } -}); diff --git a/packages/data-p2p-tictactoe/src/features/presence/data/state/move-presence.ts b/packages/data-p2p-tictactoe/src/features/presence/data/state/move-presence.ts index e56307c4..36173d8e 100644 --- a/packages/data-p2p-tictactoe/src/features/presence/data/state/move-presence.ts +++ b/packages/data-p2p-tictactoe/src/features/presence/data/state/move-presence.ts @@ -1,7 +1,9 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { PlayerMark } from "data-lit-tictactoe"; +import type { Vec2 } from "@adobe/data/math"; import { Cursors } from "../cursors/cursors.js"; import type { State } from "./state.js"; +import type { Conformance } from "./conformance-case.js"; /** * Update the given peer's cursor position. `mark` identifies the peer — in the @@ -15,3 +17,21 @@ export const movePresence = ( ...state, cursors: Cursors.set(state.cursors, mark, x, y), }); + +const at = (x: number, y: number): Vec2 => [x, y] as Vec2; + +// Spec-owned cases, shared with the ecs `movePresence` transaction and action. +export const cases: Conformance = [ + { + name: "records the first cursor position for a peer", + before: { cursors: {} }, + args: { mark: "X", x: 0.5, y: 0.25 }, + after: { cursors: { X: at(0.5, 0.25) } }, + }, + { + name: "updates one peer's cursor while preserving the other's", + before: { cursors: { X: at(0.5, 0.25) } }, + args: { mark: "O", x: 0.75, y: 0.5 }, + after: { cursors: { X: at(0.5, 0.25), O: at(0.75, 0.5) } }, + }, +]; diff --git a/packages/data-p2p-tictactoe/src/features/presence/data/state/public.ts b/packages/data-p2p-tictactoe/src/features/presence/data/state/public.ts index 63376956..3963f8ef 100644 --- a/packages/data-p2p-tictactoe/src/features/presence/data/state/public.ts +++ b/packages/data-p2p-tictactoe/src/features/presence/data/state/public.ts @@ -1,3 +1,3 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -export * from "./create.js"; -export * from "./move-presence.js"; +export { create } from "./create.js"; +export { movePresence } from "./move-presence.js"; diff --git a/packages/data-p2p-tictactoe/src/features/presence/data/state/record-effects.ts b/packages/data-p2p-tictactoe/src/features/presence/data/state/record-effects.ts new file mode 100644 index 00000000..5989feea --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/presence/data/state/record-effects.ts @@ -0,0 +1,105 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { expect } from "vitest"; +import { equalsUnordered } from "@adobe/data"; +import type { Effects } from "./conformance-case.js"; + +export type RecordedCall = readonly [string, ...unknown[]]; + +// Wrap a plain-object service so each method call is recorded, then delegates. +// No Proxy (services are plain objects with own enumerable methods — see +// `service.md`), so we enumerate and closure-wrap each function. +export const recordCalls = (service: S): { service: S; calls: RecordedCall[] } => { + const calls: RecordedCall[] = []; + const wrapped = Object.fromEntries( + Object.entries(service).map(([key, value]) => [ + key, + typeof value === "function" + ? (...args: unknown[]): unknown => { + calls.push([key, ...args]); + return (value as (...a: unknown[]) => unknown)(...args); + } + : value, + ]), + ) as S; + return { service: wrapped, calls }; +}; + +// Assert the calls recorded against one service match the case's expectation for +// it: an Array expects exactly these calls in order; a Set expects the same calls +// in any order (multiset). Absent expectation ⇒ no calls expected. +export const expectServiceCalls = ( + recorded: readonly RecordedCall[], + expected: readonly RecordedCall[] | ReadonlySet | undefined, +): void => { + if (expected instanceof Set) { + expect(equalsUnordered(recorded, [...expected])).toBe(true); + } else { + expect(recorded).toEqual(expected ?? []); + } +}; + +// Split a case's `args` into the injected services (objects with methods) and the +// remaining plain data. Services are wrapped for recording; the returned `calls` +// map is keyed by the same arg key so it can be matched against `effects`. +export const recordArgServices = ( + args: Args, +): { args: Args; calls: Record } => { + const calls: Record = {}; + const next = { ...args } as Record; + for (const [key, value] of Object.entries(args)) { + if (isServiceValue(value)) { + const rec = recordCalls(value); + next[key] = rec.service; + calls[key] = rec.calls; + } + } + return { args: next as Args, calls }; +}; + +// Assert each service **declared** in `effects` saw exactly its expected calls. +// Services not listed are ignored, so `effects` captures the fire-and-forget side +// effects you choose to assert. +export const expectEffects = ( + calls: Record, + effects: Effects> | undefined, +): void => { + const expected = (effects ?? {}) as Record>; + for (const key of Object.keys(expected)) { + expectServiceCalls(calls[key] ?? [], expected[key]); + } +}; + +// Split a case's `args` into the injected services (wrapped for recording, to be +// used as `Database.create` service overrides) and the remaining plain data (the +// action input). Keyed by the same arg name so `calls` matches against `effects`. +export const splitAndRecordServices = ( + args: Args, +): { + services: Record; + input: Record; + calls: Record; +} => { + const services: Record = {}; + const input: Record = {}; + const calls: Record = {}; + if (args !== null && typeof args === "object") { + for (const [key, value] of Object.entries(args)) { + if (isServiceValue(value)) { + const recorded = recordCalls(value); + services[key] = recorded.service; + calls[key] = recorded.calls; + } else { + input[key] = value; + } + } + } + return { services, input, calls }; +}; + +// A runtime service value: a non-array object with at least one method (mirrors +// the compile-time `IsService`). +const isServiceValue = (value: unknown): value is object => + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.values(value).some((member) => typeof member === "function"); diff --git a/packages/data-p2p-tictactoe/src/features/presence/data/state/spec.test.ts b/packages/data-p2p-tictactoe/src/features/presence/data/state/spec.test.ts new file mode 100644 index 00000000..f9cd53c1 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/presence/data/state/spec.test.ts @@ -0,0 +1,55 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +/// +import { describe, it } from "vitest"; +import type { State } from "./state.js"; +import type { ConformanceCase, DerivationCase } from "./conformance-case.js"; +import { expectStateMatches, expectMatches } from "./expect-state-matches.js"; +import { recordArgServices, expectEffects } from "./record-effects.js"; + +// The single spec test for every transform AND derivation in this folder. It +// auto-discovers each file (any sibling `.ts` that exports `cases`) via +// `import.meta.glob`, so a new one is covered the moment it ships. Each +// participating file must export exactly its function plus `cases` (enforced +// below). A case's shape selects the check: `after` → a transition; `value` → a +// derivation. +const modules = import.meta.glob>( + ["./*.ts", "!./*.test.ts", "!./*.type-test.ts"], + { eager: true }, +); + +const isDerivationCase = (c: unknown): c is DerivationCase => + typeof c === "object" && c !== null && "value" in c; + +for (const [path, module] of Object.entries(modules)) { + const exportNames = Object.keys(module); + if (!exportNames.includes("cases")) continue; + const functionNames = exportNames.filter((key) => typeof module[key] === "function"); + const label = functionNames.length === 1 ? functionNames[0] : path; + + describe(`State.${label}`, () => { + if (exportNames.length !== 2 || functionNames.length !== 1) { + it("exports exactly its function and `cases`", () => { + throw new Error(`${path} exports [${exportNames.join(", ")}] — expected one function + cases`); + }); + return; + } + // Runtime invariant: a participating file exports a function and its cases. + const fn = module[functionNames[0]] as (...args: unknown[]) => unknown; + const cases = module["cases"] as readonly unknown[]; + for (const testCase of cases) { + if (isDerivationCase(testCase)) { + it(testCase.name, () => expectMatches(fn(testCase.input), testCase.value)); + continue; + } + const transitionCase = testCase as ConformanceCase>; + it(transitionCase.name, async () => { + const raw = transitionCase.args; + const { args, calls } = + raw && typeof raw === "object" ? recordArgServices(raw) : { args: raw, calls: {} }; + const result = (await fn(transitionCase.before, args)) as State; + expectStateMatches(result, transitionCase.after); + expectEffects(calls, transitionCase.effects); + }); + } + }); +} diff --git a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/action-database/actions/index.ts b/packages/data-p2p-tictactoe/src/features/presence/services/main-service/action-database/actions/index.ts index d6092a3b..1737d13a 100644 --- a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/action-database/actions/index.ts +++ b/packages/data-p2p-tictactoe/src/features/presence/services/main-service/action-database/actions/index.ts @@ -1,2 +1,10 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +// +// The `actions` plugin facet. Only `trackPresence` (the UI-facing streaming pump) +// is registered. The same-named per-transition action `move-presence.ts` (the +// discrete realization of `State.movePresence`) is deliberately NOT re-exported +// into the facet: it is conformance-only and the UI never dispatches it (it uses +// the streaming `trackPresence`). It lives beside this barrel as a plain function +// exercised directly by `conformance/actions.test.ts` — mirroring how the +// reference runner imports and calls action functions, never through `db.actions`. export * from "./track-presence.js"; diff --git a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/action-database/actions/move-presence.ts b/packages/data-p2p-tictactoe/src/features/presence/services/main-service/action-database/actions/move-presence.ts new file mode 100644 index 00000000..419f518e --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/presence/services/main-service/action-database/actions/move-presence.ts @@ -0,0 +1,12 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { TransactionDatabase } from "../../transaction-database/transaction-database.js"; + +// App-facing realization of `State.movePresence`: commit one cursor move for the +// calling peer through a single (non-streaming) transaction dispatch. The peer +// identity (`mark`) is read by the transaction from the store's `userId`, so this +// takes only the plain `{ x, y }` payload. The live UI drives cursor updates as a +// stream via the `trackPresence` action; this discrete action is the same-named +// transition realization the conformance runner exercises. +export const movePresence = (db: TransactionDatabase, { x, y }: { x: number; y: number }) => { + db.transactions.movePresence({ x, y }); +}; diff --git a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/actions.test.ts b/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/actions.test.ts new file mode 100644 index 00000000..7b19e4dc --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/actions.test.ts @@ -0,0 +1,74 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +/// +import { describe, it, expect } from "vitest"; +import { Database, createRebaseReplayConcurrency } from "@adobe/data/ecs"; +import type { ConformanceCase } from "../../../data/state/conformance-case.js"; +import { splitAndRecordServices, expectEffects } from "../../../data/state/record-effects.js"; +import { expectStateMatches } from "../../../data/state/expect-state-matches.js"; +import { MainService } from "../main-service.js"; +import { fromState } from "./from-state.js"; +import { toState } from "./to-state.js"; +import { movePresence } from "../action-database/actions/move-presence.js"; +import { cases as movePresenceCases } from "../../../data/state/move-presence.js"; + +// Each transition's cases run against its same-named ecs **action**, asserting the +// resulting state and any declared side effects. `movePresence`'s peer identity is +// the transaction `userId`, so the db is created with a rebase-replay concurrency +// stamped with the case's `mark` — exactly how the live game database assigns each +// peer its id — then the action commits the plain `{ x, y }` payload. +const makeDb = (userId: string) => + Database.toSystemDatabase(Database.create(MainService.plugin, { concurrency: createRebaseReplayConcurrency(userId) })); +type Db = ReturnType; + +const covered = new Set(); +const conformsAction = ( + action: string, + config: { + readonly cases: readonly ConformanceCase[]; + readonly run: (db: Db, input: Partial) => Promise | void; + }, +): void => { + covered.add(action); + describe(`${action} action conforms`, () => { + for (const testCase of config.cases) { + it(testCase.name, async () => { + const { input, calls } = splitAndRecordServices(testCase.args); + const db = makeDb(testCase.args.mark); + fromState(db.store, testCase.before); + await config.run(db, input as Partial); + expectStateMatches(toState(db.store), testCase.after); + expectEffects(calls, testCase.effects); + }); + } + }); +}; + +conformsAction("movePresence", { + cases: movePresenceCases, + run: (db, input) => movePresence(db, { x: input.x ?? 0, y: input.y ?? 0 }), +}); + +// None-missed guard: every data/state **transition** (a file whose `cases` are +// `{ before, args, after }`) must have a same-named action wired above. Iterating +// transitions — not action files — is deliberate: the UI-facing streaming +// `trackPresence` action has no pure-transition analogue and is not conformed here. +const kebabToCamel = (name: string): string => + name.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase()); +const stateModules = import.meta.glob>( + ["../../../data/state/*.ts", "!../../../data/state/*.test.ts"], + { eager: true }, +); +describe("action conformance coverage", () => { + for (const [path, module] of Object.entries(stateModules)) { + const cases = module["cases"]; + const isTransition = + Array.isArray(cases) && + cases.length > 0 && + typeof cases[0] === "object" && + cases[0] !== null && + "after" in cases[0]; + if (!isTransition) continue; + const name = kebabToCamel(path.replace(/.*\//, "").replace(/\.ts$/, "")); + it(`${name} has an action conformance case`, () => expect(covered.has(name)).toBe(true)); + } +}); diff --git a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/expect-conforms.ts b/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/expect-conforms.ts index 0878f8d6..6690f14d 100644 --- a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/expect-conforms.ts +++ b/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/expect-conforms.ts @@ -9,15 +9,20 @@ import { fromState } from "./from-state.js"; import { toState } from "./to-state.js"; // The conformance runner, bound to the presence projection. For each case it -// proves `toState(apply(fromState(before), args)) ≡ spec(before, args)`. +// proves `toState(apply(fromState(before), args)) ≡ spec(before, args)`. The pure +// half (`spec(before, args) ≡ after`) is asserted once, centrally, by +// `data/state/spec.test.ts`, so this runner asserts only the ecs half; pass `spec` +// to re-check it in place. export const expectConforms = (config: { readonly cases: readonly ConformanceCase[]; - readonly spec: (before: State, args: Args) => State; + readonly spec?: (before: State, args: Args) => State; readonly apply: (store: CoreDatabase.Store, args: Args) => void; }): void => { for (const testCase of config.cases) { it(testCase.name, () => { - expectStateMatches(config.spec(testCase.before, testCase.args), testCase.after); + if (config.spec) { + expectStateMatches(config.spec(testCase.before, testCase.args), testCase.after); + } const store = createStore(); fromState(store, testCase.before); diff --git a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/transactions.test.ts b/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/transactions.test.ts new file mode 100644 index 00000000..65ef2372 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/transactions.test.ts @@ -0,0 +1,41 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it, expect } from "vitest"; +import type { CoreDatabase } from "../core-database/core-database.js"; +import type { ConformanceCase } from "../../../data/state/conformance-case.js"; +import { expectConforms } from "./expect-conforms.js"; +import { seedUserId } from "./seed-user-id.js"; +import * as registeredTransactions from "../transaction-database/transactions/index.js"; +import { movePresence } from "../transaction-database/transactions/move-presence.js"; +import { cases as movePresenceCases } from "../../../data/state/move-presence.js"; + +// The single conformance test for every ecs transaction. `movePresence` reads the +// peer identity from the transaction `userId` (the peer's assigned mark), so the +// `apply` closure seeds that identity from the case's `mark`, then dispatches the +// raw transaction with the plain `{ x, y }` payload. The guard asserts every +// REGISTERED transaction (the barrel) is wired below. +const covered = new Set(); +const conforms = ( + transaction: string, + config: { + readonly cases: readonly ConformanceCase[]; + readonly apply: (t: CoreDatabase.Store, args: Args) => void; + }, +): void => { + covered.add(transaction); + describe(`${transaction} transaction conforms`, () => expectConforms(config)); +}; + +conforms("movePresence", { + cases: movePresenceCases, + apply: (store, { mark, x, y }) => { + seedUserId(store, mark); + movePresence(store, { x, y }); + }, +}); + +// None-missed guard: every **registered** transaction must be wired above. +describe("transaction conformance coverage", () => { + for (const transaction of Object.keys(registeredTransactions)) { + it(`${transaction} has a conformance case`, () => expect(covered.has(transaction)).toBe(true)); + } +}); diff --git a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/transaction-database/transactions/move-presence.test.ts b/packages/data-p2p-tictactoe/src/features/presence/services/main-service/transaction-database/transactions/move-presence.test.ts deleted file mode 100644 index 0e0067b9..00000000 --- a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/transaction-database/transactions/move-presence.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe } from "vitest"; -import { State } from "../../../../data/state/state.js"; -import { cases } from "../../../../data/state/move-presence.test.js"; -import { expectConforms } from "../../conformance/expect-conforms.js"; -import { seedUserId } from "../../conformance/seed-user-id.js"; -import { movePresence } from "./move-presence.js"; - -// `movePresence` reads the peer identity from the transaction `userId` (the peer's -// assigned mark). The `apply` closure seeds that identity from the case's `mark`, -// then dispatches the raw transaction with the plain `{ x, y }` payload. -describe("movePresence transaction conforms to State.movePresence", () => { - expectConforms({ - cases, - spec: State.movePresence, - apply: (store, { mark, x, y }) => { - seedUserId(store, mark); - movePresence(store, { x, y }); - }, - }); -}); From 95c560450543bcac0e1d7fecc88307113f646c41 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Thu, 6 Aug 2026 20:29:16 -0700 Subject: [PATCH 11/37] feat(space-rock): convert to co-located conformance pattern Rollout conversion (systems + injected random service, multi-archetype): co-located cases, spec.test.ts, matcher-aware compare (ordered tuples + multiset entity bags), transaction + action + system(tick-loop) conformance, per-entity to-data probing three archetypes, random-service double. Real-time step transitions are realized and conformed by the systems tick loop, not actions. All gates green Node 24 (156). Co-Authored-By: Claude Opus 4.8 --- .../main/data/state/conformance-case.ts | 63 +++++- .../main/data/state/create-initial.cases.ts | 62 ------ .../main/data/state/create-initial.test.ts | 13 -- .../main/data/state/create-initial.ts | 72 ++++++- .../features/main/data/state/create.test.ts | 20 -- .../main/data/state/expect-state-matches.ts | 101 ++++++--- .../main/data/state/fire-bullet.cases.ts | 64 ------ .../main/data/state/fire-bullet.test.ts | 13 -- .../features/main/data/state/fire-bullet.ts | 62 ++++++ .../main/data/state/is-game-over.test.ts | 13 -- .../main/data/state/record-effects.ts | 107 ++++++++++ .../data/state/resolve-bullet-hits.cases.ts | 195 ------------------ .../data/state/resolve-bullet-hits.test.ts | 13 -- .../main/data/state/resolve-bullet-hits.ts | 188 +++++++++++++++++ .../data/state/resolve-ship-hits.cases.ts | 97 --------- .../main/data/state/resolve-ship-hits.test.ts | 13 -- .../main/data/state/resolve-ship-hits.ts | 94 +++++++++ .../data/state/spawn-random-wave.cases.ts | 53 ----- .../main/data/state/spawn-random-wave.test.ts | 38 ---- .../main/data/state/spawn-random-wave.ts | 47 +++++ .../main/data/state/spawn-wave.cases.ts | 46 ----- .../main/data/state/spawn-wave.test.ts | 21 -- .../features/main/data/state/spawn-wave.ts | 42 ++++ .../src/features/main/data/state/spec.test.ts | 61 ++++++ .../main/data/state/step-asteroids.cases.ts | 55 ----- .../main/data/state/step-asteroids.test.ts | 13 -- .../main/data/state/step-asteroids.ts | 53 +++++ .../main/data/state/step-bullets.cases.ts | 56 ----- .../main/data/state/step-bullets.test.ts | 13 -- .../features/main/data/state/step-bullets.ts | 53 +++++ .../main/data/state/step-ship.cases.ts | 60 ------ .../main/data/state/step-ship.test.ts | 16 -- .../src/features/main/data/state/step-ship.ts | 64 +++++- .../features/main/data/state/step.cases.ts | 148 ------------- .../src/features/main/data/state/step.test.ts | 57 ----- .../src/features/main/data/state/step.ts | 152 ++++++++++++-- .../action-database/action-database.ts | 20 ++ .../action-database/actions/fire-bullet.ts | 10 + .../action-database/actions/index.ts | 3 + .../actions/spawn-random-wave.ts | 12 ++ .../main-service/conformance/actions.test.ts | 72 +++++++ .../conformance/expect-conforms.ts | 24 +-- .../main-service/conformance/to-data.ts | 22 ++ .../main-service/conformance/to-state.ts | 67 +++--- .../conformance/transactions.test.ts | 153 ++++++++++++++ .../service-database/service-database.ts | 24 +++ .../system-database/system-database.ts | 23 ++- .../system-database/tick-loop.test.ts | 10 +- .../transactions/fire-bullet.test.ts | 18 -- .../transactions/hit-asteroid.test.ts | 61 ------ .../transactions/lose-life.test.ts | 47 ----- .../transactions/new-game.test.ts | 22 -- .../transactions/new-game.ts | 2 +- .../transactions/set-bounds.test.ts | 22 -- .../transactions/set-input.test.ts | 23 --- .../transactions/spawn-random-wave.test.ts | 22 -- 56 files changed, 1480 insertions(+), 1415 deletions(-) delete mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/create-initial.cases.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/create-initial.test.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/create.test.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/fire-bullet.cases.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/fire-bullet.test.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/is-game-over.test.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/record-effects.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/resolve-bullet-hits.cases.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/resolve-bullet-hits.test.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/resolve-ship-hits.cases.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/resolve-ship-hits.test.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/spawn-random-wave.cases.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/spawn-random-wave.test.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/spawn-wave.cases.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/spawn-wave.test.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/spec.test.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/step-asteroids.cases.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/step-asteroids.test.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/step-bullets.cases.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/step-bullets.test.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/step-ship.cases.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/step-ship.test.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/step.cases.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/step.test.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/services/main-service/action-database/action-database.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/services/main-service/action-database/actions/fire-bullet.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/services/main-service/action-database/actions/index.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/services/main-service/action-database/actions/spawn-random-wave.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/actions.test.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/to-data.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/transactions.test.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/services/main-service/service-database/service-database.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/fire-bullet.test.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/hit-asteroid.test.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/lose-life.test.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/new-game.test.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/set-bounds.test.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/set-input.test.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/spawn-random-wave.test.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/conformance-case.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/conformance-case.ts index 44e5a8a5..8c448828 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/conformance-case.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/conformance-case.ts @@ -1,13 +1,70 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { State } from "./state.js"; +// A service arg is an object with method members (not an array, not a function); +// this is what distinguishes an injected service from plain data args, since the +// `Service` marker itself is all-optional and would over-match. +type MethodKeys = { [K in keyof T]-?: T[K] extends (...a: never[]) => unknown ? K : never }[keyof T]; +type IsService = T extends readonly unknown[] + ? false + : T extends (...a: never[]) => unknown + ? false + : T extends object + ? [MethodKeys] extends [never] + ? false + : true + : false; + +// A strongly-typed call to one method of service `S`: `[methodName, ...its args]`. +// A no-arg method is just `[methodName]`. +export type Call = { + [M in keyof S]-?: S[M] extends (...a: infer A) => unknown ? readonly [M, ...A] : never; +}[keyof S]; + +// Expected side effects for a case, keyed by the service-typed args only. An +// `Array` value asserts these calls in this order; a `Set` value asserts the same +// calls in any order. Method names and their args are checked against the service. +export type Effects = { + readonly [K in keyof Args as IsService extends true ? K : never]?: + | readonly Call[] + | ReadonlySet>; +}; + // One spec-owned conformance case: a `data/` transform's `{ before, args, after }` -// authored as full `State`. Lives in `.cases.ts` and is shared, -// unchanged, by the data transform test and the ecs conformance runner -// (see `ecs/conformance/`). +// authored as full `State`, optionally with the side effects it makes on its +// injected service args. Shared unchanged by the spec aggregator (`spec.test.ts`) +// and the ecs conformance runners (`services/main-service/conformance/`). export type ConformanceCase = { readonly name: string; readonly before: State; readonly args: Args; readonly after: State; + readonly effects?: Effects; }; + +// A transform's cases, with the case `args` type derived from the transform's own +// signature — its second parameter, or `void` when it takes none. A transform +// co-locates `export const cases: Conformance = [...]`, so the +// cases cannot drift from what the function accepts, and the spec aggregator can +// discover the function without it being named twice. +export type Conformance unknown> = readonly ConformanceCase< + Parameters extends [unknown, infer Args, ...unknown[]] ? Args : void +>[]; + +// One case for a derivation (`(state) => value`): a state `input` and the `value` +// it yields. `value` may use asymmetric matchers. Both the pure derivation and +// its ecs computed are checked against these. +export type DerivationCase = { + readonly name: string; + readonly input: Input; + readonly value: Value; +}; + +// A derivation's cases, with `input` and `value` types read from the derivation's +// own signature (its parameter and return) — the `Conformance` analog for +// value-producing derivations. A derivation co-locates +// `export const cases: Derivation = [...]`. +export type Derivation unknown> = readonly DerivationCase< + Parameters[0], + ReturnType +>[]; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/create-initial.cases.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/create-initial.cases.ts deleted file mode 100644 index ee84fd41..00000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/create-initial.cases.ts +++ /dev/null @@ -1,62 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { State } from "./state.js"; -import type { ConformanceCase } from "./conformance-case.js"; -import type { Vec2 } from "@adobe/data/math"; - -// Spec-owned `{ before, args, after }` cases for `State.createInitial` -// (args = the play-field bounds), shared with the ecs `newGame` transaction. -// `createInitial` ignores `before` entirely — it produces a fresh game from the -// bounds alone — so `before` here is a deliberately dirty state, which also -// proves `newGame` clears whatever was there. A fresh game centres the ship, -// resets score/lives/wave, and spawns wave 1 (asteroidsFor(1)=4 large in a -// clean quadrant ring at radius min(bounds)·0.4), so every `after` is exact. -const dirty: State = { - bounds: [1, 1], - ship: { position: [10, 10], velocity: [5, 5], rotation: 1 }, - bullets: [{ position: [1, 1], velocity: [0, 0], age: 0.5 }], - asteroids: [{ position: [9, 9], velocity: [0, 0], size: "small" }], - score: 99, - lives: 1, - wave: 7, -}; - -export const cases: readonly ConformanceCase[] = [ - { - name: "starts a fresh 200×200 game: centred ship, first wave, reset counters", - before: dirty, - args: [200, 200], - after: { - bounds: [200, 200], - ship: { position: [100, 100], velocity: [0, 0], rotation: -Math.PI / 2 }, - bullets: [], - asteroids: [ - { position: [180, 100], velocity: [0, 60], size: "large" }, - { position: [100, 180], velocity: [-60, 0], size: "large" }, - { position: [20, 100], velocity: [0, -60], size: "large" }, - { position: [100, 20], velocity: [60, 0], size: "large" }, - ], - score: 0, - lives: 3, - wave: 1, - }, - }, - { - name: "starts a fresh 400×400 game with the ring scaled to the field", - before: dirty, - args: [400, 400], - after: { - bounds: [400, 400], - ship: { position: [200, 200], velocity: [0, 0], rotation: -Math.PI / 2 }, - bullets: [], - asteroids: [ - { position: [360, 200], velocity: [0, 60], size: "large" }, - { position: [200, 360], velocity: [-60, 0], size: "large" }, - { position: [40, 200], velocity: [0, -60], size: "large" }, - { position: [200, 40], velocity: [60, 0], size: "large" }, - ], - score: 0, - lives: 3, - wave: 1, - }, - }, -]; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/create-initial.test.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/create-initial.test.ts deleted file mode 100644 index ceb77eba..00000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/create-initial.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it } from "vitest"; -import { State } from "./state.js"; -import { cases } from "./create-initial.cases.js"; -import { expectStateMatches } from "./expect-state-matches.js"; - -describe("State.createInitial", () => { - for (const { name, args, after } of cases) { - it(name, () => { - expectStateMatches(State.createInitial(args), after); - }); - } -}); diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/create-initial.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/create-initial.ts index 6d27f0cb..3a8c48ea 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/create-initial.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/create-initial.ts @@ -1,12 +1,21 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Vec2 } from "@adobe/data/math"; import type { State } from "./state.js"; +import type { Conformance } from "./conformance-case.js"; import { Ship } from "../ship/ship.js"; import { spawnWave } from "./spawn-wave.js"; -// A fresh game for a `bounds`-sized field: ship centred, no bullets, three -// lives, zero score, and the first wave of asteroids spawned in. -export const createInitial = (bounds: Vec2): State => +// A fresh game for a `bounds`-sized field: ship centred, no bullets, three lives, +// zero score, and the first wave of asteroids spawned in. This is a "new game" +// TRANSITION — it produces a fresh game from the `bounds` alone and deliberately +// **ignores** the prior `state` (a reset), which is exactly what the ecs `newGame` +// transaction it maps to does (it clears whatever was there). The prior state is +// still the transition's first parameter so it fits the `(state, args) => state` +// shape the co-located conformance cases derive their `args` type from. +export const createInitial = ( + _state: State, + { bounds }: { readonly bounds: Vec2 }, +): State => spawnWave({ bounds, ship: Ship.spawn(Vec2.scale(bounds, 0.5)), @@ -16,3 +25,60 @@ export const createInitial = (bounds: Vec2): State => lives: 3, wave: 0, }); + +// Spec-owned cases, shared with the ecs `newGame` transaction. `createInitial` +// ignores `before` entirely — it produces a fresh game from the bounds alone — so +// `before` here is a deliberately dirty state, which also proves `newGame` clears +// whatever was there. A fresh game centres the ship, resets score/lives/wave, and +// spawns wave 1 (asteroidsFor(1)=4 large in a clean quadrant ring at radius +// min(bounds)·0.4), so every `after` is exact. +const dirty: State = { + bounds: [1, 1], + ship: { position: [10, 10], velocity: [5, 5], rotation: 1 }, + bullets: [{ position: [1, 1], velocity: [0, 0], age: 0.5 }], + asteroids: [{ position: [9, 9], velocity: [0, 0], size: "small" }], + score: 99, + lives: 1, + wave: 7, +}; + +export const cases: Conformance = [ + { + name: "starts a fresh 200×200 game: centred ship, first wave, reset counters", + before: dirty, + args: { bounds: [200, 200] }, + after: { + bounds: [200, 200], + ship: { position: [100, 100], velocity: [0, 0], rotation: -Math.PI / 2 }, + bullets: [], + asteroids: [ + { position: [180, 100], velocity: [0, 60], size: "large" }, + { position: [100, 180], velocity: [-60, 0], size: "large" }, + { position: [20, 100], velocity: [0, -60], size: "large" }, + { position: [100, 20], velocity: [60, 0], size: "large" }, + ], + score: 0, + lives: 3, + wave: 1, + }, + }, + { + name: "starts a fresh 400×400 game with the ring scaled to the field", + before: dirty, + args: { bounds: [400, 400] }, + after: { + bounds: [400, 400], + ship: { position: [200, 200], velocity: [0, 0], rotation: -Math.PI / 2 }, + bullets: [], + asteroids: [ + { position: [360, 200], velocity: [0, 60], size: "large" }, + { position: [200, 360], velocity: [-60, 0], size: "large" }, + { position: [40, 200], velocity: [0, -60], size: "large" }, + { position: [200, 40], velocity: [60, 0], size: "large" }, + ], + score: 0, + lives: 3, + wave: 1, + }, + }, +]; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/create.test.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/create.test.ts deleted file mode 100644 index 07673897..00000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/create.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it, expect } from "vitest"; -import { State } from "./state.js"; - -describe("State.create", () => { - it("is a blank neutral state: no field, idle ship, empty, full lives, wave 0", () => { - const state = State.create(); - expect(state.bounds).toEqual([0, 0]); - expect(state.ship.velocity).toEqual([0, 0]); - expect(state.bullets).toEqual([]); - expect(state.asteroids).toEqual([]); - expect(state.score).toBe(0); - expect(state.lives).toBe(3); - expect(state.wave).toBe(0); - }); - - it("is not game over", () => { - expect(State.isGameOver(State.create())).toBe(false); - }); -}); diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/expect-state-matches.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/expect-state-matches.ts index 3838f595..36a4849a 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/expect-state-matches.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/expect-state-matches.ts @@ -1,38 +1,89 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { expect } from "vitest"; -import { equalsUnordered } from "@adobe/data"; import type { State } from "./state.js"; -// Spec-owned tolerant `State` equality, shared by the data/ transform tests -// (f64, trig epsilon) and the ecs conformance runner (F32 columns, one storage -// rounding off the f64 oracle). Two orthogonal concerns, kept separate: -// -// precision — normalise every number on both sides onto a shared grid, so a -// value that differs only by F32↔f64 storage rounding OR by trig epsilon -// (a quadrant `cos`/`sin` yields ~3e-15 where a case authors 0) compares -// equal. `Math.fround` collapses the F32 rounding; rounding to 1e-2 -// (the tolerance the old `toBeCloseTo(_, 2)` used — far under any real -// off-by-a-unit bug at this game's magnitudes) collapses the trig epsilon. -// `+ 0` normalises `-0` to `0`, since the compare below distinguishes them. -// ordering — `equalsUnordered` compares arrays as MULTISETS (archetype -// hole-fills and broad-phase order make row order nondeterministic) and is -// object key-order independent. +// A vitest asymmetric matcher (`expect.any(...)`): honored on the EXPECTED side so +// a case can assert "any number" for a value it does not pin. This game exposes no +// ecs-minted ids in its `State` (bullets/asteroids are pure value types), so no +// case actually needs one — but the matcher path is kept so the comparison is the +// single matcher-aware oracle the rules describe. +const isMatcher = (value: unknown): value is { asymmetricMatch(actual: unknown): boolean } => + typeof value === "object" && + value !== null && + typeof (value as { asymmetricMatch?: unknown }).asymmetricMatch === "function"; + +// Collapse F32↔f64 storage rounding (ecs columns are F32, the spec authors f64) +// AND trig epsilon (a quadrant `cos`/`sin` yields ~3e-15 where a case authors 0) +// onto a small grid so float noise compares equal. `Math.fround` collapses the +// F32 rounding; rounding to 1e-2 (well under any real off-by-a-unit bug at this +// game's magnitudes) collapses the trig epsilon. `+ 0` normalises `-0` to `0`. const quantize = (n: number): number => Math.round(Math.fround(n) * 100) / 100 + 0; -const normalize = (value: unknown): unknown => { - if (typeof value === "number") return quantize(value); - if (Array.isArray(value)) return value.map(normalize); - if (value !== null && typeof value === "object") { - return Object.fromEntries(Object.entries(value).map(([key, v]) => [key, normalize(v)])); +// Tolerant structural match honoring asymmetric matchers and float precision, with +// arrays compared IN ORDER — correct for the ordered pairs this game is built from +// (`Vec2` position/velocity, whose two components are positional, not a bag). Bags +// of entities compare with `matchesUnordered` below, not here. +export const matches = (actual: unknown, expected: unknown): boolean => { + if (isMatcher(expected)) return expected.asymmetricMatch(actual); + if (typeof expected === "number" && typeof actual === "number") { + return quantize(actual) === quantize(expected); + } + if (Array.isArray(expected)) { + if (!Array.isArray(actual) || actual.length !== expected.length) return false; + return expected.every((exp, index) => matches(actual[index], exp)); } - return value; + if (expected !== null && typeof expected === "object") { + if (actual === null || typeof actual !== "object" || Array.isArray(actual)) return false; + const expectedKeys = Object.keys(expected); + const actualKeys = Object.keys(actual as object); + if (expectedKeys.length !== actualKeys.length) return false; + return expectedKeys.every((key) => + matches((actual as Record)[key], (expected as Record)[key]), + ); + } + return Object.is(actual, expected); }; +// Multiset (order-independent) match for the entity COLLECTIONS (`bullets`, +// `asteroids`). The ecs materialises these in nondeterministic row order — +// archetype hole-fills, the broad-phase scan, and split-child spawn order all vary +// — and, unlike todo's display-ordered list, they carry no display order and no +// stable key exposed in `State`, so they are genuine bags. Each element is still +// compared with the ordered, matcher-aware `matches` (so its `Vec2`s stay +// positional). Greedy pairing is sufficient for concrete values. +const matchesUnordered = (actual: readonly unknown[], expected: readonly unknown[]): boolean => { + if (actual.length !== expected.length) return false; + const used = new Array(actual.length).fill(false); + return expected.every((exp) => { + const index = actual.findIndex((act, i) => !used[i] && matches(act, exp)); + if (index < 0) return false; + used[index] = true; + return true; + }); +}; + +// Spec-owned tolerant `State` equality, shared by the data/ transform spec test +// and the ecs conformance runners. Scalars and the ordered `Vec2`/`ship` fields +// compare in order; the entity bags (`bullets`, `asteroids`) compare as multisets. +// No separate id-ignoring variant — this one comparison serves both the pure spec +// and every ecs surface. export const expectStateMatches = (actual: State, expected: State): void => { - const a = normalize(actual); - const b = normalize(expected); + const ok = + matches(actual.bounds, expected.bounds) && + matches(actual.ship, expected.ship) && + matches(actual.score, expected.score) && + matches(actual.lives, expected.lives) && + matches(actual.wave, expected.wave) && + matchesUnordered(actual.bullets, expected.bullets) && + matchesUnordered(actual.asteroids, expected.asteroids); expect( - equalsUnordered(a, b), - `State mismatch:\n actual ${JSON.stringify(a)}\n expected ${JSON.stringify(b)}`, + ok, + `State mismatch:\n actual ${JSON.stringify(actual)}\n expected ${JSON.stringify(expected)}`, ).toBe(true); }; + +// The same tolerant, matcher-aware comparison for any single value — the analog of +// todo's `expectMatches`, used where a compared value is not a whole `State`. +export const expectMatches = (actual: unknown, expected: unknown): void => { + expect(matches(actual, expected), `mismatch:\n actual ${JSON.stringify(actual)}`).toBe(true); +}; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/fire-bullet.cases.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/fire-bullet.cases.ts deleted file mode 100644 index 57b03e8a..00000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/fire-bullet.cases.ts +++ /dev/null @@ -1,64 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { State } from "./state.js"; -import type { ConformanceCase } from "./conformance-case.js"; - -// Spec-owned `{ before, args, after }` cases for `State.fireBullet` (no args), -// shared with the ecs `fireBullet` transaction. A bullet leaves the ship's nose -// (position + facing·Ship.radius=12) inheriting the ship's momentum plus -// Bullet.speed=400 along the facing. Rotation 0 → facing [1,0]; rotation −π/2 → -// facing [0,−1] (up). The existing bullets pass through untouched. -const field = { ...State.create(), bounds: [800, 600] as [number, number] }; - -export const cases: readonly ConformanceCase[] = [ - { - name: "fires from a ship facing +x at rest", - before: { ...field, ship: { position: [100, 100], velocity: [0, 0], rotation: 0 }, bullets: [] }, - args: undefined, - after: { - ...field, - ship: { position: [100, 100], velocity: [0, 0], rotation: 0 }, - bullets: [{ position: [112, 100], velocity: [400, 0], age: 0 }], - }, - }, - { - name: "inherits the ship's momentum", - before: { ...field, ship: { position: [100, 100], velocity: [10, 20], rotation: 0 }, bullets: [] }, - args: undefined, - after: { - ...field, - ship: { position: [100, 100], velocity: [10, 20], rotation: 0 }, - bullets: [{ position: [112, 100], velocity: [410, 20], age: 0 }], - }, - }, - { - name: "appends without disturbing bullets already in flight", - before: { - ...field, - ship: { position: [100, 100], velocity: [0, 0], rotation: 0 }, - bullets: [{ position: [0, 0], velocity: [1, 0], age: 0.2 }], - }, - args: undefined, - after: { - ...field, - ship: { position: [100, 100], velocity: [0, 0], rotation: 0 }, - bullets: [ - { position: [0, 0], velocity: [1, 0], age: 0.2 }, - { position: [112, 100], velocity: [400, 0], age: 0 }, - ], - }, - }, - { - name: "fires along the ship's facing (rotation −π/2 points up)", - before: { - ...field, - ship: { position: [100, 100], velocity: [0, 0], rotation: -Math.PI / 2 }, - bullets: [], - }, - args: undefined, - after: { - ...field, - ship: { position: [100, 100], velocity: [0, 0], rotation: -Math.PI / 2 }, - bullets: [{ position: [100, 88], velocity: [0, -400], age: 0 }], - }, - }, -]; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/fire-bullet.test.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/fire-bullet.test.ts deleted file mode 100644 index a159d27e..00000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/fire-bullet.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it } from "vitest"; -import { State } from "./state.js"; -import { cases } from "./fire-bullet.cases.js"; -import { expectStateMatches } from "./expect-state-matches.js"; - -describe("State.fireBullet", () => { - for (const { name, before, after } of cases) { - it(name, () => { - expectStateMatches(State.fireBullet(before), after); - }); - } -}); diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/fire-bullet.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/fire-bullet.ts index 302c8f75..1aaedc1b 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/fire-bullet.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/fire-bullet.ts @@ -1,5 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { State } from "./state.js"; +import type { Conformance } from "./conformance-case.js"; +import { create } from "./create.js"; import { Bullet } from "../bullet/bullet.js"; import { Ship } from "../ship/ship.js"; @@ -10,3 +12,63 @@ export const fireBullet = >(state: T): const bullet: Bullet = { position, velocity, age: 0 }; return { ...state, bullets: [...state.bullets, bullet] }; }; + +// Spec-owned cases, shared with the ecs `fireBullet` transaction. A bullet leaves +// the ship's nose (position + facing·Ship.radius=12) inheriting the ship's +// momentum plus Bullet.speed=400 along the facing. Rotation 0 → facing [1,0]; +// rotation −π/2 → facing [0,−1] (up). The existing bullets pass through untouched. +const field = { ...create(), bounds: [800, 600] as [number, number] }; + +export const cases: Conformance = [ + { + name: "fires from a ship facing +x at rest", + before: { ...field, ship: { position: [100, 100], velocity: [0, 0], rotation: 0 }, bullets: [] }, + args: undefined, + after: { + ...field, + ship: { position: [100, 100], velocity: [0, 0], rotation: 0 }, + bullets: [{ position: [112, 100], velocity: [400, 0], age: 0 }], + }, + }, + { + name: "inherits the ship's momentum", + before: { ...field, ship: { position: [100, 100], velocity: [10, 20], rotation: 0 }, bullets: [] }, + args: undefined, + after: { + ...field, + ship: { position: [100, 100], velocity: [10, 20], rotation: 0 }, + bullets: [{ position: [112, 100], velocity: [410, 20], age: 0 }], + }, + }, + { + name: "appends without disturbing bullets already in flight", + before: { + ...field, + ship: { position: [100, 100], velocity: [0, 0], rotation: 0 }, + bullets: [{ position: [0, 0], velocity: [1, 0], age: 0.2 }], + }, + args: undefined, + after: { + ...field, + ship: { position: [100, 100], velocity: [0, 0], rotation: 0 }, + bullets: [ + { position: [0, 0], velocity: [1, 0], age: 0.2 }, + { position: [112, 100], velocity: [400, 0], age: 0 }, + ], + }, + }, + { + name: "fires along the ship's facing (rotation −π/2 points up)", + before: { + ...field, + ship: { position: [100, 100], velocity: [0, 0], rotation: -Math.PI / 2 }, + bullets: [], + }, + args: undefined, + after: { + ...field, + ship: { position: [100, 100], velocity: [0, 0], rotation: -Math.PI / 2 }, + bullets: [{ position: [100, 88], velocity: [0, -400], age: 0 }], + }, + }, +]; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/is-game-over.test.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/is-game-over.test.ts deleted file mode 100644 index 23e82766..00000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/is-game-over.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it, expect } from "vitest"; -import { State } from "./state.js"; - -describe("State.isGameOver", () => { - it("is over once lives reach zero", () => { - expect(State.isGameOver({ lives: 0 })).toBe(true); - }); - - it("is not over while a life remains", () => { - expect(State.isGameOver({ lives: 1 })).toBe(false); - }); -}); diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/record-effects.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/record-effects.ts new file mode 100644 index 00000000..f3cf16bc --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/record-effects.ts @@ -0,0 +1,107 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { expect } from "vitest"; +import { equalsUnordered } from "@adobe/data"; +import type { Effects } from "./conformance-case.js"; + +export type RecordedCall = readonly [string, ...unknown[]]; + +// Wrap a plain-object service so each method call is recorded, then delegates. +// No Proxy (services are plain objects with own enumerable methods — see +// `service.md`), so we enumerate and closure-wrap each function. +export const recordCalls = (service: S): { service: S; calls: RecordedCall[] } => { + const calls: RecordedCall[] = []; + const wrapped = Object.fromEntries( + Object.entries(service).map(([key, value]) => [ + key, + typeof value === "function" + ? (...args: unknown[]): unknown => { + calls.push([key, ...args]); + return (value as (...a: unknown[]) => unknown)(...args); + } + : value, + ]), + ) as S; + return { service: wrapped, calls }; +}; + +// Assert the calls recorded against one service match the case's expectation for +// it: an Array expects exactly these calls in order; a Set expects the same calls +// in any order (multiset). Absent expectation ⇒ no calls expected. +export const expectServiceCalls = ( + recorded: readonly RecordedCall[], + expected: readonly RecordedCall[] | ReadonlySet | undefined, +): void => { + if (expected instanceof Set) { + expect(equalsUnordered(recorded, [...expected])).toBe(true); + } else { + expect(recorded).toEqual(expected ?? []); + } +}; + +// Split a case's `args` into the injected services (objects with methods) and the +// remaining plain data. Services are wrapped for recording; the returned `calls` +// map is keyed by the same arg key so it can be matched against `effects`. +export const recordArgServices = ( + args: Args, +): { args: Args; calls: Record } => { + const calls: Record = {}; + const next = { ...args } as Record; + for (const [key, value] of Object.entries(args)) { + if (isServiceValue(value)) { + const rec = recordCalls(value); + next[key] = rec.service; + calls[key] = rec.calls; + } + } + return { args: next as Args, calls }; +}; + +// Assert each service **declared** in `effects` saw exactly its expected calls +// (an extra or missing call on a declared service fails). Services not listed — +// e.g. a value-returning dependency read like `next` — are ignored, so `effects` +// captures the fire-and-forget side effects you choose to assert. +export const expectEffects = ( + calls: Record, + effects: Effects> | undefined, +): void => { + const expected = (effects ?? {}) as Record>; + for (const key of Object.keys(expected)) { + expectServiceCalls(calls[key] ?? [], expected[key]); + } +}; + +// Split a case's `args` into the injected services (wrapped for recording, to be +// used as `Database.create` service overrides) and the remaining plain data (the +// action input). Keyed by the same arg name so `calls` matches against `effects`. +export const splitAndRecordServices = ( + args: Args, +): { + services: Record; + input: Record; + calls: Record; +} => { + const services: Record = {}; + const input: Record = {}; + const calls: Record = {}; + // A no-arg transition has `undefined` args — nothing to split. + if (args !== null && typeof args === "object") { + for (const [key, value] of Object.entries(args)) { + if (isServiceValue(value)) { + const recorded = recordCalls(value); + services[key] = recorded.service; + calls[key] = recorded.calls; + } else { + input[key] = value; + } + } + } + return { services, input, calls }; +}; + +// A runtime service value: a non-array object with at least one method (mirrors +// the compile-time `IsService`). +const isServiceValue = (value: unknown): value is object => + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.values(value).some((member) => typeof member === "function"); diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/resolve-bullet-hits.cases.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/resolve-bullet-hits.cases.ts deleted file mode 100644 index 06ed70ac..00000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/resolve-bullet-hits.cases.ts +++ /dev/null @@ -1,195 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { State } from "./state.js"; -import type { ConformanceCase } from "./conformance-case.js"; - -// Spec-owned `{ before, args, after }` cases for `State.resolveBulletHits` -// (args = the frame `dt`), shared with the ecs `hitAsteroid` transaction -// (dispatched once per overlapping bullet by the collision system). Detection is -// SWEPT: each bullet's path this frame is the segment [position - velocity*dt, -// position], and it destroys the first asteroid that segment passes through, -// scores it (large 20 / medium 50 / small 100), and replaces it with its split -// children (large→2 medium, medium→2 small, small→none). Stationary parents -// (velocity [0,0]) give children velocity [0,0], so every `after` is exact. -// Bullet.radius 2, asteroid radii 40/20/10. Every case keeps each bullet -// overlapping at most one asteroid, so the outcome is order-independent (the ecs -// broad phase need not match the spec's order). Cases with velocity [0,0] -// collapse the segment to a point (prev == position), so their expectations are -// exactly the old point-vs-circle ones; the final case exercises tunnelling. -const field = { ...State.create(), bounds: [800, 600] as [number, number] }; - -export const cases: readonly ConformanceCase[] = [ - { - name: "destroys bullet + asteroid, scores, and spawns split children (large → 2 medium)", - before: { - ...field, - bullets: [{ position: [50, 50], velocity: [0, 0], age: 0 }], - asteroids: [{ position: [50, 50], velocity: [0, 0], size: "large" }], - score: 0, - }, - args: 1 / 60, - after: { - ...field, - bullets: [], - asteroids: [ - { position: [50, 50], velocity: [0, 0], size: "medium" }, - { position: [50, 50], velocity: [0, 0], size: "medium" }, - ], - score: 20, - }, - }, - { - name: "medium splits into two small", - before: { - ...field, - bullets: [{ position: [50, 50], velocity: [0, 0], age: 0 }], - asteroids: [{ position: [50, 50], velocity: [0, 0], size: "medium" }], - score: 5, - }, - args: 1 / 60, - after: { - ...field, - bullets: [], - asteroids: [ - { position: [50, 50], velocity: [0, 0], size: "small" }, - { position: [50, 50], velocity: [0, 0], size: "small" }, - ], - score: 55, - }, - }, - { - name: "the smallest tier is destroyed outright — no children", - before: { - ...field, - bullets: [{ position: [50, 50], velocity: [0, 0], age: 0 }], - asteroids: [{ position: [50, 50], velocity: [0, 0], size: "small" }], - score: 0, - }, - args: 1 / 60, - after: { ...field, bullets: [], asteroids: [], score: 100 }, - }, - { - name: "a bullet that hits nothing is left untouched", - before: { - ...field, - bullets: [{ position: [10, 10], velocity: [0, 0], age: 0 }], - asteroids: [{ position: [500, 500], velocity: [0, 0], size: "large" }], - score: 7, - }, - args: 1 / 60, - after: { - ...field, - bullets: [{ position: [10, 10], velocity: [0, 0], age: 0 }], - asteroids: [{ position: [500, 500], velocity: [0, 0], size: "large" }], - score: 7, - }, - }, - { - name: "only the overlapping asteroid is hit; distant ones remain", - before: { - ...field, - bullets: [{ position: [50, 50], velocity: [0, 0], age: 0 }], - asteroids: [ - { position: [50, 50], velocity: [0, 0], size: "large" }, - { position: [500, 500], velocity: [0, 0], size: "small" }, - ], - score: 0, - }, - args: 1 / 60, - after: { - ...field, - bullets: [], - asteroids: [ - { position: [50, 50], velocity: [0, 0], size: "medium" }, - { position: [50, 50], velocity: [0, 0], size: "medium" }, - { position: [500, 500], velocity: [0, 0], size: "small" }, - ], - score: 20, - }, - }, - { - name: "two bullets each destroy their own asteroid", - before: { - ...field, - bullets: [ - { position: [50, 50], velocity: [0, 0], age: 0 }, - { position: [500, 500], velocity: [0, 0], age: 0 }, - ], - asteroids: [ - { position: [50, 50], velocity: [0, 0], size: "small" }, - { position: [500, 500], velocity: [0, 0], size: "small" }, - ], - score: 0, - }, - args: 1 / 60, - after: { ...field, bullets: [], asteroids: [], score: 200 }, - }, - { - name: "split children are not hittable by another bullet in the same pass", - before: { - ...field, - bullets: [ - { position: [50, 50], velocity: [0, 0], age: 0 }, - { position: [50, 50], velocity: [0, 0], age: 0 }, - ], - asteroids: [{ position: [50, 50], velocity: [0, 0], size: "large" }], - score: 0, - }, - args: 1 / 60, - after: { - ...field, - // One bullet destroys the large (→ 2 medium). The second finds no - // original target — the large is gone and its children, spawned this - // same pass, are not yet hittable — so it survives. (Buggy behaviour let - // it hit a fresh medium, chain-annihilating: [medium, small, small], 70.) - bullets: [{ position: [50, 50], velocity: [0, 0], age: 0 }], - asteroids: [ - { position: [50, 50], velocity: [0, 0], size: "medium" }, - { position: [50, 50], velocity: [0, 0], size: "medium" }, - ], - score: 20, - }, - }, - { - name: "boundary: distance exactly equal to the radius sum still overlaps", - before: { - ...field, - bullets: [{ position: [0, 0], velocity: [0, 0], age: 0 }], - asteroids: [{ position: [42, 0], velocity: [0, 0], size: "large" }], - score: 0, - }, - args: 1 / 60, - after: { - ...field, - bullets: [], - asteroids: [ - { position: [42, 0], velocity: [0, 0], size: "medium" }, - { position: [42, 0], velocity: [0, 0], size: "medium" }, - ], - score: 20, - }, - }, - { - name: "a fast bullet whose path sweeps through a medium destroys it (no tunnelling)", - before: { - ...field, - // Over dt=1/60 the bullet travels 50px: prev = [0,0] - [-3000,0]/60 = [50,0]. - // Both endpoints ([50,0] end position, [0,0] current) are 25px from the - // medium at [25,0] — outside the 22px (2+20) radius sum, so a point test at - // the current position misses. The travelled segment crosses [25,0], so a - // swept test hits. - bullets: [{ position: [0, 0], velocity: [-3000, 0], age: 0 }], - asteroids: [{ position: [25, 0], velocity: [0, 0], size: "medium" }], - score: 0, - }, - args: 1 / 60, - after: { - ...field, - bullets: [], - asteroids: [ - { position: [25, 0], velocity: [0, 0], size: "small" }, - { position: [25, 0], velocity: [0, 0], size: "small" }, - ], - score: 50, - }, - }, -]; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/resolve-bullet-hits.test.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/resolve-bullet-hits.test.ts deleted file mode 100644 index 56ea84b6..00000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/resolve-bullet-hits.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it } from "vitest"; -import { State } from "./state.js"; -import { cases } from "./resolve-bullet-hits.cases.js"; -import { expectStateMatches } from "./expect-state-matches.js"; - -describe("State.resolveBulletHits", () => { - for (const { name, before, args, after } of cases) { - it(name, () => { - expectStateMatches(State.resolveBulletHits(before, args), after); - }); - } -}); diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/resolve-bullet-hits.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/resolve-bullet-hits.ts index 4152d9ff..643563dd 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/resolve-bullet-hits.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/resolve-bullet-hits.ts @@ -1,6 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Vec2 } from "@adobe/data/math"; import type { State } from "./state.js"; +import type { Conformance } from "./conformance-case.js"; +import { create } from "./create.js"; import { Bullet } from "../bullet/bullet.js"; import { Asteroid } from "../asteroid/asteroid.js"; import { Collision } from "../collision/collision.js"; @@ -43,3 +45,189 @@ export const resolveBulletHits = < } return { ...state, bullets: survivors, asteroids: [...asteroids, ...spawned], score }; }; + +// Spec-owned cases, shared with the ecs `hitAsteroid` transaction (dispatched +// once per overlapping bullet by the collision system). Detection is SWEPT: each +// bullet's path this frame is the segment [position - velocity*dt, position], and +// it destroys the first asteroid that segment passes through, scores it (large 20 +// / medium 50 / small 100), and replaces it with its split children (large→2 +// medium, medium→2 small, small→none). Stationary parents give children velocity +// [0,0]. Bullet.radius 2, asteroid radii 40/20/10. Every case keeps each bullet +// overlapping at most one asteroid, so the outcome is order-independent (the ecs +// broad phase need not match the spec's order — collections compare as multisets). +const field = { ...create(), bounds: [800, 600] as [number, number] }; + +export const cases: Conformance = [ + { + name: "destroys bullet + asteroid, scores, and spawns split children (large → 2 medium)", + before: { + ...field, + bullets: [{ position: [50, 50], velocity: [0, 0], age: 0 }], + asteroids: [{ position: [50, 50], velocity: [0, 0], size: "large" }], + score: 0, + }, + args: 1 / 60, + after: { + ...field, + bullets: [], + asteroids: [ + { position: [50, 50], velocity: [0, 0], size: "medium" }, + { position: [50, 50], velocity: [0, 0], size: "medium" }, + ], + score: 20, + }, + }, + { + name: "medium splits into two small", + before: { + ...field, + bullets: [{ position: [50, 50], velocity: [0, 0], age: 0 }], + asteroids: [{ position: [50, 50], velocity: [0, 0], size: "medium" }], + score: 5, + }, + args: 1 / 60, + after: { + ...field, + bullets: [], + asteroids: [ + { position: [50, 50], velocity: [0, 0], size: "small" }, + { position: [50, 50], velocity: [0, 0], size: "small" }, + ], + score: 55, + }, + }, + { + name: "the smallest tier is destroyed outright — no children", + before: { + ...field, + bullets: [{ position: [50, 50], velocity: [0, 0], age: 0 }], + asteroids: [{ position: [50, 50], velocity: [0, 0], size: "small" }], + score: 0, + }, + args: 1 / 60, + after: { ...field, bullets: [], asteroids: [], score: 100 }, + }, + { + name: "a bullet that hits nothing is left untouched", + before: { + ...field, + bullets: [{ position: [10, 10], velocity: [0, 0], age: 0 }], + asteroids: [{ position: [500, 500], velocity: [0, 0], size: "large" }], + score: 7, + }, + args: 1 / 60, + after: { + ...field, + bullets: [{ position: [10, 10], velocity: [0, 0], age: 0 }], + asteroids: [{ position: [500, 500], velocity: [0, 0], size: "large" }], + score: 7, + }, + }, + { + name: "only the overlapping asteroid is hit; distant ones remain", + before: { + ...field, + bullets: [{ position: [50, 50], velocity: [0, 0], age: 0 }], + asteroids: [ + { position: [50, 50], velocity: [0, 0], size: "large" }, + { position: [500, 500], velocity: [0, 0], size: "small" }, + ], + score: 0, + }, + args: 1 / 60, + after: { + ...field, + bullets: [], + asteroids: [ + { position: [50, 50], velocity: [0, 0], size: "medium" }, + { position: [50, 50], velocity: [0, 0], size: "medium" }, + { position: [500, 500], velocity: [0, 0], size: "small" }, + ], + score: 20, + }, + }, + { + name: "two bullets each destroy their own asteroid", + before: { + ...field, + bullets: [ + { position: [50, 50], velocity: [0, 0], age: 0 }, + { position: [500, 500], velocity: [0, 0], age: 0 }, + ], + asteroids: [ + { position: [50, 50], velocity: [0, 0], size: "small" }, + { position: [500, 500], velocity: [0, 0], size: "small" }, + ], + score: 0, + }, + args: 1 / 60, + after: { ...field, bullets: [], asteroids: [], score: 200 }, + }, + { + name: "split children are not hittable by another bullet in the same pass", + before: { + ...field, + bullets: [ + { position: [50, 50], velocity: [0, 0], age: 0 }, + { position: [50, 50], velocity: [0, 0], age: 0 }, + ], + asteroids: [{ position: [50, 50], velocity: [0, 0], size: "large" }], + score: 0, + }, + args: 1 / 60, + after: { + ...field, + // One bullet destroys the large (→ 2 medium). The second finds no original + // target — the large is gone and its children, spawned this same pass, are + // not yet hittable — so it survives. + bullets: [{ position: [50, 50], velocity: [0, 0], age: 0 }], + asteroids: [ + { position: [50, 50], velocity: [0, 0], size: "medium" }, + { position: [50, 50], velocity: [0, 0], size: "medium" }, + ], + score: 20, + }, + }, + { + name: "boundary: distance exactly equal to the radius sum still overlaps", + before: { + ...field, + bullets: [{ position: [0, 0], velocity: [0, 0], age: 0 }], + asteroids: [{ position: [42, 0], velocity: [0, 0], size: "large" }], + score: 0, + }, + args: 1 / 60, + after: { + ...field, + bullets: [], + asteroids: [ + { position: [42, 0], velocity: [0, 0], size: "medium" }, + { position: [42, 0], velocity: [0, 0], size: "medium" }, + ], + score: 20, + }, + }, + { + name: "a fast bullet whose path sweeps through a medium destroys it (no tunnelling)", + before: { + ...field, + // Over dt=1/60 the bullet travels 50px: prev = [0,0] - [-3000,0]/60 = [50,0]. + // Both endpoints are 25px from the medium at [25,0] — outside the 22px radius + // sum, so a point test misses. The travelled segment crosses [25,0], so a + // swept test hits. + bullets: [{ position: [0, 0], velocity: [-3000, 0], age: 0 }], + asteroids: [{ position: [25, 0], velocity: [0, 0], size: "medium" }], + score: 0, + }, + args: 1 / 60, + after: { + ...field, + bullets: [], + asteroids: [ + { position: [25, 0], velocity: [0, 0], size: "small" }, + { position: [25, 0], velocity: [0, 0], size: "small" }, + ], + score: 50, + }, + }, +]; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/resolve-ship-hits.cases.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/resolve-ship-hits.cases.ts deleted file mode 100644 index 8938c25e..00000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/resolve-ship-hits.cases.ts +++ /dev/null @@ -1,97 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { State } from "./state.js"; -import type { ConformanceCase } from "./conformance-case.js"; -import { Ship } from "../ship/ship.js"; - -// Spec-owned `{ before, args, after }` cases for `State.resolveShipHits` (no -// args), shared with the ecs `loseLife` transaction (dispatched by the -// collision system only when the ship is actually struck). A touching asteroid -// costs one life (floored at 0) and respawns the ship at the field centre; -// otherwise the state is untouched. Field 200×200 → centre [100,100]; -// Ship.radius 12, large asteroid radius 40. Respawn = Ship.spawn(centre) = -// { [100,100], [0,0], −π/2 }; the asteroids are left in place. -const field = { ...State.create(), bounds: [200, 200] as [number, number] }; -const respawned = Ship.spawn([100, 100]); - -export const cases: readonly ConformanceCase[] = [ - { - name: "an asteroid on the ship costs a life and respawns it at centre", - before: { - ...field, - ship: { position: [10, 10], velocity: [5, 5], rotation: 1 }, - asteroids: [{ position: [10, 10], velocity: [0, 0], size: "large" }], - lives: 3, - }, - args: undefined, - after: { - ...field, - ship: respawned, - asteroids: [{ position: [10, 10], velocity: [0, 0], size: "large" }], - lives: 2, - }, - }, - { - name: "no asteroid touching the ship is a no-op", - before: { - ...field, - ship: { position: [10, 10], velocity: [0, 0], rotation: 0 }, - asteroids: [{ position: [500, 500], velocity: [0, 0], size: "large" }], - lives: 3, - }, - args: undefined, - after: { - ...field, - ship: { position: [10, 10], velocity: [0, 0], rotation: 0 }, - asteroids: [{ position: [500, 500], velocity: [0, 0], size: "large" }], - lives: 3, - }, - }, - { - name: "lives never drop below zero, and the ship still respawns", - before: { - ...field, - ship: { position: [10, 10], velocity: [0, 0], rotation: 0 }, - asteroids: [{ position: [10, 10], velocity: [0, 0], size: "large" }], - lives: 0, - }, - args: undefined, - after: { - ...field, - ship: respawned, - asteroids: [{ position: [10, 10], velocity: [0, 0], size: "large" }], - lives: 0, - }, - }, - { - name: "boundary: distance exactly equal to the radius sum still counts as a hit", - before: { - ...field, - ship: { position: [0, 0], velocity: [0, 0], rotation: 0 }, - asteroids: [{ position: [52, 0], velocity: [0, 0], size: "large" }], - lives: 3, - }, - args: undefined, - after: { - ...field, - ship: respawned, - asteroids: [{ position: [52, 0], velocity: [0, 0], size: "large" }], - lives: 2, - }, - }, - { - name: "an empty field is a no-op", - before: { - ...field, - ship: { position: [10, 10], velocity: [0, 0], rotation: 0 }, - asteroids: [], - lives: 3, - }, - args: undefined, - after: { - ...field, - ship: { position: [10, 10], velocity: [0, 0], rotation: 0 }, - asteroids: [], - lives: 3, - }, - }, -]; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/resolve-ship-hits.test.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/resolve-ship-hits.test.ts deleted file mode 100644 index 8000872a..00000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/resolve-ship-hits.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it } from "vitest"; -import { State } from "./state.js"; -import { cases } from "./resolve-ship-hits.cases.js"; -import { expectStateMatches } from "./expect-state-matches.js"; - -describe("State.resolveShipHits", () => { - for (const { name, before, after } of cases) { - it(name, () => { - expectStateMatches(State.resolveShipHits(before), after); - }); - } -}); diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/resolve-ship-hits.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/resolve-ship-hits.ts index dd67ecf2..435eacbe 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/resolve-ship-hits.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/resolve-ship-hits.ts @@ -1,6 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Vec2 } from "@adobe/data/math"; import type { State } from "./state.js"; +import type { Conformance } from "./conformance-case.js"; +import { create } from "./create.js"; import { Ship } from "../ship/ship.js"; import { Asteroid } from "../asteroid/asteroid.js"; import { Collision } from "../collision/collision.js"; @@ -24,3 +26,95 @@ export const resolveShipHits = < ship: Ship.spawn(Vec2.scale(state.bounds, 0.5)), }; }; + +// Spec-owned cases, shared with the ecs `loseLife` transaction (dispatched by the +// collision system only when the ship is actually struck). A touching asteroid +// costs one life (floored at 0) and respawns the ship at the field centre; +// otherwise the state is untouched. Field 200×200 → centre [100,100]; Ship.radius +// 12, large asteroid radius 40. Respawn = Ship.spawn(centre) = { [100,100], [0,0], +// −π/2 }; the asteroids are left in place. +const field = { ...create(), bounds: [200, 200] as [number, number] }; +const respawned = Ship.spawn([100, 100]); + +export const cases: Conformance = [ + { + name: "an asteroid on the ship costs a life and respawns it at centre", + before: { + ...field, + ship: { position: [10, 10], velocity: [5, 5], rotation: 1 }, + asteroids: [{ position: [10, 10], velocity: [0, 0], size: "large" }], + lives: 3, + }, + args: undefined, + after: { + ...field, + ship: respawned, + asteroids: [{ position: [10, 10], velocity: [0, 0], size: "large" }], + lives: 2, + }, + }, + { + name: "no asteroid touching the ship is a no-op", + before: { + ...field, + ship: { position: [10, 10], velocity: [0, 0], rotation: 0 }, + asteroids: [{ position: [500, 500], velocity: [0, 0], size: "large" }], + lives: 3, + }, + args: undefined, + after: { + ...field, + ship: { position: [10, 10], velocity: [0, 0], rotation: 0 }, + asteroids: [{ position: [500, 500], velocity: [0, 0], size: "large" }], + lives: 3, + }, + }, + { + name: "lives never drop below zero, and the ship still respawns", + before: { + ...field, + ship: { position: [10, 10], velocity: [0, 0], rotation: 0 }, + asteroids: [{ position: [10, 10], velocity: [0, 0], size: "large" }], + lives: 0, + }, + args: undefined, + after: { + ...field, + ship: respawned, + asteroids: [{ position: [10, 10], velocity: [0, 0], size: "large" }], + lives: 0, + }, + }, + { + name: "boundary: distance exactly equal to the radius sum still counts as a hit", + before: { + ...field, + ship: { position: [0, 0], velocity: [0, 0], rotation: 0 }, + asteroids: [{ position: [52, 0], velocity: [0, 0], size: "large" }], + lives: 3, + }, + args: undefined, + after: { + ...field, + ship: respawned, + asteroids: [{ position: [52, 0], velocity: [0, 0], size: "large" }], + lives: 2, + }, + }, + { + name: "an empty field is a no-op", + before: { + ...field, + ship: { position: [10, 10], velocity: [0, 0], rotation: 0 }, + asteroids: [], + lives: 3, + }, + args: undefined, + after: { + ...field, + ship: { position: [10, 10], velocity: [0, 0], rotation: 0 }, + asteroids: [], + lives: 3, + }, + }, +]; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-random-wave.cases.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-random-wave.cases.ts deleted file mode 100644 index 27ae11f1..00000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-random-wave.cases.ts +++ /dev/null @@ -1,53 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { State } from "./state.js"; -import type { ConformanceCase } from "./conformance-case.js"; -import { RandomService } from "../../services/random-service/random-service.js"; - -// Spec-owned `{ before, args, after }` cases for `State.spawnRandomWave`, shared -// with the ecs `spawnRandomWave` transaction. The args carry the SAME injected -// double on both sides — `RandomService.createFake()` replays -// `RandomService.fakeRandoms` — so the randomized velocities are exact and the -// two sides agree: conformance stays honest even though the transition draws -// randomness. -// -// The published sequence has length 4 and a spawn draws exactly 4 values (one -// per asteroid at `asteroidsFor(1) = 4`), so the conformance runner re-consuming -// the same double for `spec` and then `apply` cycles cleanly back to the same -// four values (index % length) — the assertion is identical on both passes. -// -// Field 200×200 → centre [100,100], ring radius 80; positions match `spawnWave`, -// only drift SPEED is jittered: `speed(i) = 60·(0.5 + fakeRandoms[i])` → -// [30, 60, 45, 75] in ring order. -const field = { ...State.create(), bounds: [200, 200] as [number, number] }; - -export const cases: readonly ConformanceCase<{ random: RandomService }>[] = [ - { - name: "spawns a randomized wave (jittered drift speeds) when the field is clear", - before: { ...field, asteroids: [], wave: 0 }, - args: { random: RandomService.createFake() }, - after: { - ...field, - wave: 1, - asteroids: [ - { position: [180, 100], velocity: [0, 30], size: "large" }, - { position: [100, 180], velocity: [-60, 0], size: "large" }, - { position: [20, 100], velocity: [0, -45], size: "large" }, - { position: [100, 20], velocity: [75, 0], size: "large" }, - ], - }, - }, - { - name: "does nothing while asteroids still remain", - before: { - ...field, - wave: 1, - asteroids: [{ position: [10, 10], velocity: [0, 0], size: "large" }], - }, - args: { random: RandomService.createFake() }, - after: { - ...field, - wave: 1, - asteroids: [{ position: [10, 10], velocity: [0, 0], size: "large" }], - }, - }, -]; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-random-wave.test.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-random-wave.test.ts deleted file mode 100644 index f70fe9ff..00000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-random-wave.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it } from "vitest"; -import { State } from "./state.js"; -import { RandomService } from "../../services/random-service/random-service.js"; -import { cases } from "./spawn-random-wave.cases.js"; -import { expectStateMatches } from "./expect-state-matches.js"; - -// The transition takes an injected service, so its assertions lean on the -// double's PUBLISHED response schedule (`RandomService.fakeRandoms`, replayed in -// order), never on any hidden behaviour — mirroring todo's `createRandomTodo`. -describe("State.spawnRandomWave", () => { - for (const { name, before, args, after } of cases) { - it(name, () => { - expectStateMatches(State.spawnRandomWave(before, args), after); - }); - } - - it("draws one value per asteroid from the published schedule, in ring order", () => { - // Explicit schedule → exact per-asteroid speeds. speed(i) = 60·(0.5 + seq[i]), - // so seq 1 → 90 and seq 0 → 30; positions are the same deterministic ring. - const random = RandomService.createFake([1, 0, 1, 0]); - const after = State.spawnRandomWave( - { ...State.create(), bounds: [200, 200], asteroids: [], wave: 0 }, - { random }, - ); - expectStateMatches(after, { - ...State.create(), - bounds: [200, 200], - wave: 1, - asteroids: [ - { position: [180, 100], velocity: [0, 90], size: "large" }, - { position: [100, 180], velocity: [-30, 0], size: "large" }, - { position: [20, 100], velocity: [0, -90], size: "large" }, - { position: [100, 20], velocity: [30, 0], size: "large" }, - ], - }); - }); -}); diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-random-wave.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-random-wave.ts index 4f89aa9f..46ffbbbc 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-random-wave.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-random-wave.ts @@ -1,6 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Vec2 } from "@adobe/data/math"; import type { State } from "./state.js"; +import type { Conformance } from "./conformance-case.js"; +import { create } from "./create.js"; import type { Asteroid } from "../asteroid/asteroid.js"; import { Size } from "../size/size.js"; import { Motion } from "../motion/motion.js"; @@ -49,3 +51,48 @@ export const spawnRandomWave = = [ + { + name: "spawns a randomized wave (jittered drift speeds) when the field is clear", + before: { ...field, asteroids: [], wave: 0 }, + args: { random: RandomService.createFake() }, + after: { + ...field, + wave: 1, + asteroids: [ + { position: [180, 100], velocity: [0, 30], size: "large" }, + { position: [100, 180], velocity: [-60, 0], size: "large" }, + { position: [20, 100], velocity: [0, -45], size: "large" }, + { position: [100, 20], velocity: [75, 0], size: "large" }, + ], + }, + }, + { + name: "does nothing while asteroids still remain", + before: { + ...field, + wave: 1, + asteroids: [{ position: [10, 10], velocity: [0, 0], size: "large" }], + }, + args: { random: RandomService.createFake() }, + after: { + ...field, + wave: 1, + asteroids: [{ position: [10, 10], velocity: [0, 0], size: "large" }], + }, + }, +]; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-wave.cases.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-wave.cases.ts deleted file mode 100644 index cc5d6e11..00000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-wave.cases.ts +++ /dev/null @@ -1,46 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { State } from "./state.js"; -import type { ConformanceCase } from "./conformance-case.js"; - -// Spec-owned `{ before, args, after }` cases for the deterministic -// `State.spawnWave` (no args) — the fixed FIRST wave `createInitial` seeds (its -// randomized refill sibling is `spawn-random-wave.cases.ts`). When the field is -// clear it bumps the wave and spawns a ring of large asteroids around the -// centre, each drifting tangentially at 60px/s; while asteroids remain it is a -// no-op. Field -// 200×200 → centre [100,100], ring radius 80. From wave 0 the count is -// asteroidsFor(1)=4, so the ring lands on the four clean quadrant angles and -// every `after` position/velocity is exact. -const field = { ...State.create(), bounds: [200, 200] as [number, number] }; - -export const cases: readonly ConformanceCase[] = [ - { - name: "spawns the next wave of large asteroids when the field is clear", - before: { ...field, asteroids: [], wave: 0 }, - args: undefined, - after: { - ...field, - wave: 1, - asteroids: [ - { position: [180, 100], velocity: [0, 60], size: "large" }, - { position: [100, 180], velocity: [-60, 0], size: "large" }, - { position: [20, 100], velocity: [0, -60], size: "large" }, - { position: [100, 20], velocity: [60, 0], size: "large" }, - ], - }, - }, - { - name: "does nothing while asteroids still remain", - before: { - ...field, - wave: 1, - asteroids: [{ position: [10, 10], velocity: [0, 0], size: "large" }], - }, - args: undefined, - after: { - ...field, - wave: 1, - asteroids: [{ position: [10, 10], velocity: [0, 0], size: "large" }], - }, - }, -]; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-wave.test.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-wave.test.ts deleted file mode 100644 index da50d795..00000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-wave.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it, expect } from "vitest"; -import { Vec2 } from "@adobe/data/math"; -import { State } from "./state.js"; -import { cases } from "./spawn-wave.cases.js"; -import { expectStateMatches } from "./expect-state-matches.js"; - -describe("State.spawnWave", () => { - for (const { name, before, after } of cases) { - it(name, () => { - expectStateMatches(State.spawnWave(before), after); - }); - } - - it("grows the wave each time the field is cleared", () => { - const bounds: Vec2 = [200, 200]; - const first = State.spawnWave({ ...State.create(), bounds, wave: 0 }); - const second = State.spawnWave({ ...State.create(), bounds, wave: 1 }); - expect(second.asteroids.length).toBeGreaterThan(first.asteroids.length); - }); -}); diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-wave.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-wave.ts index 21813998..4cfdaf13 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-wave.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-wave.ts @@ -1,6 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Vec2 } from "@adobe/data/math"; import type { State } from "./state.js"; +import type { Conformance } from "./conformance-case.js"; +import { create } from "./create.js"; import type { Asteroid } from "../asteroid/asteroid.js"; import { Size } from "../size/size.js"; import { Motion } from "../motion/motion.js"; @@ -39,3 +41,43 @@ export const spawnWave = } return { ...state, wave, asteroids }; }; + +// Spec-owned cases for the deterministic `spawnWave` (no args) — the fixed FIRST +// wave `createInitial` seeds (its randomized refill sibling is `spawnRandomWave`). +// When the field is clear it bumps the wave and spawns a ring of large asteroids +// around the centre, each drifting tangentially at 60px/s; while asteroids remain +// it is a no-op. Field 200×200 → centre [100,100], ring radius 80. From wave 0 the +// count is asteroidsFor(1)=4, so the ring lands on the four clean quadrant angles. +const field = { ...create(), bounds: [200, 200] as [number, number] }; + +export const cases: Conformance = [ + { + name: "spawns the next wave of large asteroids when the field is clear", + before: { ...field, asteroids: [], wave: 0 }, + args: undefined, + after: { + ...field, + wave: 1, + asteroids: [ + { position: [180, 100], velocity: [0, 60], size: "large" }, + { position: [100, 180], velocity: [-60, 0], size: "large" }, + { position: [20, 100], velocity: [0, -60], size: "large" }, + { position: [100, 20], velocity: [60, 0], size: "large" }, + ], + }, + }, + { + name: "does nothing while asteroids still remain", + before: { + ...field, + wave: 1, + asteroids: [{ position: [10, 10], velocity: [0, 0], size: "large" }], + }, + args: undefined, + after: { + ...field, + wave: 1, + asteroids: [{ position: [10, 10], velocity: [0, 0], size: "large" }], + }, + }, +]; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/spec.test.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/spec.test.ts new file mode 100644 index 00000000..1d862422 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/spec.test.ts @@ -0,0 +1,61 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +/// +import { describe, it } from "vitest"; +import type { State } from "./state.js"; +import type { ConformanceCase, DerivationCase } from "./conformance-case.js"; +import { expectStateMatches, expectMatches } from "./expect-state-matches.js"; +import { recordArgServices, expectEffects } from "./record-effects.js"; + +// The single spec test for every transform AND derivation in this folder. It +// auto-discovers each file (any sibling `.ts` that exports `cases`) via +// `import.meta.glob`, so a new one is covered the moment it ships — none can be +// forgotten. Each participating file must export exactly its function plus `cases` +// (enforced below), which lets us find the function without it being named twice. +// A case's shape selects the check: `after` → a transition `(state, args) => state`; +// `value` → a derivation `(state) => value`. +const modules = import.meta.glob>( + ["./*.ts", "!./*.test.ts", "!./*.type-test.ts"], + { eager: true }, +); + +const isDerivationCase = (c: unknown): c is DerivationCase => + typeof c === "object" && c !== null && "value" in c; + +for (const [path, module] of Object.entries(modules)) { + const exportNames = Object.keys(module); + if (!exportNames.includes("cases")) continue; + const functionNames = exportNames.filter((key) => typeof module[key] === "function"); + const label = functionNames.length === 1 ? functionNames[0] : path; + + describe(`State.${label}`, () => { + if (exportNames.length !== 2 || functionNames.length !== 1) { + it("exports exactly its function and `cases`", () => { + throw new Error(`${path} exports [${exportNames.join(", ")}] — expected one function + cases`); + }); + return; + } + // Runtime invariant: a participating file exports a function and its cases. + const fn = module[functionNames[0]] as (...args: unknown[]) => unknown; + const cases = module["cases"] as readonly unknown[]; + for (const testCase of cases) { + if (isDerivationCase(testCase)) { + // Derivation: the value it yields matches, honoring `anyNumber`. + it(testCase.name, () => expectMatches(fn(testCase.input), testCase.value)); + continue; + } + // Transition: assert the resulting state and the declared side effects. + // A service-injected transition is async, so await uniformly. + const transitionCase = testCase as ConformanceCase>; + it(transitionCase.name, async () => { + const raw = transitionCase.args; + const { args, calls } = + raw && typeof raw === "object" && !Array.isArray(raw) + ? recordArgServices(raw) + : { args: raw, calls: {} }; + const result = (await fn(transitionCase.before, args)) as State; + expectStateMatches(result, transitionCase.after); + expectEffects(calls, transitionCase.effects); + }); + } + }); +} diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/step-asteroids.cases.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/step-asteroids.cases.ts deleted file mode 100644 index 004ee1e8..00000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/step-asteroids.cases.ts +++ /dev/null @@ -1,55 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { State } from "./state.js"; -import type { ConformanceCase } from "./conformance-case.js"; -import { Size } from "../size/size.js"; - -// Spec-owned `{ before, args, after }` cases for `State.stepAsteroids` -// (args = dt). Shared with the ecs system conformance (the asteroid half of -// `movement` reproduces this). The 100×100 field forces wrap. Asteroids drift -// by constant velocity, so `after` is exact. -const field = { ...State.create(), bounds: [100, 100] as [number, number] }; - -export const cases: readonly ConformanceCase[] = [ - { - name: "drifts an asteroid by its velocity", - before: { ...field, asteroids: [{ position: [10, 10], velocity: [30, 0], size: Size.largest }] }, - args: 1, - after: { ...field, asteroids: [{ position: [40, 10], velocity: [30, 0], size: Size.largest }] }, - }, - { - name: "wraps an asteroid around the toroidal field", - before: { ...field, asteroids: [{ position: [80, 80], velocity: [50, 50], size: Size.largest }] }, - args: 1, - after: { ...field, asteroids: [{ position: [30, 30], velocity: [50, 50], size: Size.largest }] }, - }, - { - name: "wraps negatively across the left edge", - before: { ...field, asteroids: [{ position: [10, 10], velocity: [-50, 0], size: "medium" }] }, - args: 1, - after: { ...field, asteroids: [{ position: [60, 10], velocity: [-50, 0], size: "medium" }] }, - }, - { - name: "advances several asteroids of different sizes independently", - before: { - ...field, - asteroids: [ - { position: [10, 10], velocity: [10, 0], size: "large" }, - { position: [20, 20], velocity: [0, 10], size: "small" }, - ], - }, - args: 1, - after: { - ...field, - asteroids: [ - { position: [20, 10], velocity: [10, 0], size: "large" }, - { position: [20, 30], velocity: [0, 10], size: "small" }, - ], - }, - }, - { - name: "an empty field stays empty", - before: { ...field, asteroids: [] }, - args: 1, - after: { ...field, asteroids: [] }, - }, -]; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/step-asteroids.test.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/step-asteroids.test.ts deleted file mode 100644 index e09d845e..00000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/step-asteroids.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it } from "vitest"; -import { State } from "./state.js"; -import { cases } from "./step-asteroids.cases.js"; -import { expectStateMatches } from "./expect-state-matches.js"; - -describe("State.stepAsteroids", () => { - for (const { name, before, args, after } of cases) { - it(name, () => { - expectStateMatches(State.stepAsteroids(before, args), after); - }); - } -}); diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/step-asteroids.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/step-asteroids.ts index e98de906..dccf56a7 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/step-asteroids.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/step-asteroids.ts @@ -1,6 +1,9 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { State } from "./state.js"; +import type { Conformance } from "./conformance-case.js"; +import { create } from "./create.js"; import { Motion } from "../motion/motion.js"; +import { Size } from "../size/size.js"; // Drift every asteroid one tick by its constant velocity, wrapping at edges. export const stepAsteroids = >( @@ -13,3 +16,53 @@ export const stepAsteroids = >( })); return { ...state, asteroids }; }; + +// Spec-owned cases, shared with the ecs system conformance (the asteroid half of +// `movement` reproduces this). The 100×100 field forces wrap. Asteroids drift by +// constant velocity, so `after` is exact. +const field = { ...create(), bounds: [100, 100] as [number, number] }; + +export const cases: Conformance = [ + { + name: "drifts an asteroid by its velocity", + before: { ...field, asteroids: [{ position: [10, 10], velocity: [30, 0], size: Size.largest }] }, + args: 1, + after: { ...field, asteroids: [{ position: [40, 10], velocity: [30, 0], size: Size.largest }] }, + }, + { + name: "wraps an asteroid around the toroidal field", + before: { ...field, asteroids: [{ position: [80, 80], velocity: [50, 50], size: Size.largest }] }, + args: 1, + after: { ...field, asteroids: [{ position: [30, 30], velocity: [50, 50], size: Size.largest }] }, + }, + { + name: "wraps negatively across the left edge", + before: { ...field, asteroids: [{ position: [10, 10], velocity: [-50, 0], size: "medium" }] }, + args: 1, + after: { ...field, asteroids: [{ position: [60, 10], velocity: [-50, 0], size: "medium" }] }, + }, + { + name: "advances several asteroids of different sizes independently", + before: { + ...field, + asteroids: [ + { position: [10, 10], velocity: [10, 0], size: "large" }, + { position: [20, 20], velocity: [0, 10], size: "small" }, + ], + }, + args: 1, + after: { + ...field, + asteroids: [ + { position: [20, 10], velocity: [10, 0], size: "large" }, + { position: [20, 30], velocity: [0, 10], size: "small" }, + ], + }, + }, + { + name: "an empty field stays empty", + before: { ...field, asteroids: [] }, + args: 1, + after: { ...field, asteroids: [] }, + }, +]; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/step-bullets.cases.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/step-bullets.cases.ts deleted file mode 100644 index a9931679..00000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/step-bullets.cases.ts +++ /dev/null @@ -1,56 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { State } from "./state.js"; -import type { ConformanceCase } from "./conformance-case.js"; -import { Bullet } from "../bullet/bullet.js"; - -// Spec-owned `{ before, args, after }` cases for `State.stepBullets` (args = dt). -// Shared with the ecs system conformance (the `lifetime` advance/age/expire -// path reproduces this). `Bullet.lifetime` is 1.2; the 100×100 field forces -// wrap. Covers move+age, wrap (both directions), expiry on the boundary, -// survival just under it, mixed drop, and the empty list. -const field = { ...State.create(), bounds: [100, 100] as [number, number] }; - -export const cases: readonly ConformanceCase[] = [ - { - name: "moves and ages a live bullet", - before: { ...field, bullets: [{ position: [10, 50], velocity: [100, 0], age: 0 }] }, - args: 0.1, - after: { ...field, bullets: [{ position: [20, 50], velocity: [100, 0], age: 0.1 }] }, - }, - { - name: "wraps a bullet across the right edge", - before: { ...field, bullets: [{ position: [95, 50], velocity: [100, 0], age: 0 }] }, - args: 0.1, - after: { ...field, bullets: [{ position: [5, 50], velocity: [100, 0], age: 0.1 }] }, - }, - { - name: "drops a bullet that expires this tick (age + dt ≥ lifetime)", - before: { ...field, bullets: [{ position: [10, 50], velocity: [100, 0], age: Bullet.lifetime }] }, - args: 0.1, - after: { ...field, bullets: [] }, - }, - { - name: "keeps and ages a bullet still under its lifetime", - before: { ...field, bullets: [{ position: [10, 50], velocity: [0, 0], age: 1.0 }] }, - args: 0.1, - after: { ...field, bullets: [{ position: [10, 50], velocity: [0, 0], age: 1.1 }] }, - }, - { - name: "advances survivors and drops only the expired bullet", - before: { - ...field, - bullets: [ - { position: [10, 50], velocity: [100, 0], age: 0 }, - { position: [10, 60], velocity: [100, 0], age: Bullet.lifetime }, - ], - }, - args: 0.1, - after: { ...field, bullets: [{ position: [20, 50], velocity: [100, 0], age: 0.1 }] }, - }, - { - name: "an empty list stays empty", - before: { ...field, bullets: [] }, - args: 0.1, - after: { ...field, bullets: [] }, - }, -]; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/step-bullets.test.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/step-bullets.test.ts deleted file mode 100644 index c812fc4b..00000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/step-bullets.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it } from "vitest"; -import { State } from "./state.js"; -import { cases } from "./step-bullets.cases.js"; -import { expectStateMatches } from "./expect-state-matches.js"; - -describe("State.stepBullets", () => { - for (const { name, before, args, after } of cases) { - it(name, () => { - expectStateMatches(State.stepBullets(before, args), after); - }); - } -}); diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/step-bullets.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/step-bullets.ts index 203cb08b..1ea1ed28 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/step-bullets.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/step-bullets.ts @@ -1,5 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { State } from "./state.js"; +import type { Conformance } from "./conformance-case.js"; +import { create } from "./create.js"; import { Bullet } from "../bullet/bullet.js"; import { Motion } from "../motion/motion.js"; @@ -18,3 +20,54 @@ export const stepBullets = >( })); return { ...state, bullets }; }; + +// Spec-owned cases, shared with the ecs system conformance (the `lifetime` +// advance/age/expire path reproduces this). `Bullet.lifetime` is 1.2; the 100×100 +// field forces wrap. Covers move+age, wrap, expiry on the boundary, survival just +// under it, mixed drop, and the empty list. +const field = { ...create(), bounds: [100, 100] as [number, number] }; + +export const cases: Conformance = [ + { + name: "moves and ages a live bullet", + before: { ...field, bullets: [{ position: [10, 50], velocity: [100, 0], age: 0 }] }, + args: 0.1, + after: { ...field, bullets: [{ position: [20, 50], velocity: [100, 0], age: 0.1 }] }, + }, + { + name: "wraps a bullet across the right edge", + before: { ...field, bullets: [{ position: [95, 50], velocity: [100, 0], age: 0 }] }, + args: 0.1, + after: { ...field, bullets: [{ position: [5, 50], velocity: [100, 0], age: 0.1 }] }, + }, + { + name: "drops a bullet that expires this tick (age + dt ≥ lifetime)", + before: { ...field, bullets: [{ position: [10, 50], velocity: [100, 0], age: Bullet.lifetime }] }, + args: 0.1, + after: { ...field, bullets: [] }, + }, + { + name: "keeps and ages a bullet still under its lifetime", + before: { ...field, bullets: [{ position: [10, 50], velocity: [0, 0], age: 1.0 }] }, + args: 0.1, + after: { ...field, bullets: [{ position: [10, 50], velocity: [0, 0], age: 1.1 }] }, + }, + { + name: "advances survivors and drops only the expired bullet", + before: { + ...field, + bullets: [ + { position: [10, 50], velocity: [100, 0], age: 0 }, + { position: [10, 60], velocity: [100, 0], age: Bullet.lifetime }, + ], + }, + args: 0.1, + after: { ...field, bullets: [{ position: [20, 50], velocity: [100, 0], age: 0.1 }] }, + }, + { + name: "an empty list stays empty", + before: { ...field, bullets: [] }, + args: 0.1, + after: { ...field, bullets: [] }, + }, +]; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/step-ship.cases.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/step-ship.cases.ts deleted file mode 100644 index cea40be8..00000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/step-ship.cases.ts +++ /dev/null @@ -1,60 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { State } from "./state.js"; -import type { ConformanceCase } from "./conformance-case.js"; -import type { Input } from "../input/input.js"; - -// Spec-owned `{ before, args, after }` cases for `State.stepShip`, shared with -// the ecs system conformance (the `control` + ship half of `movement` reproduce -// this). Full-`State` before/after; the generic-slice signature lets them flow -// through. Geometry is chosen so every `after` is exact (turnRate 3, -// thrustAccel 200, field 100×100 to force wrap). -type Args = { readonly dt: number; readonly input: Input }; -const field = { ...State.create(), bounds: [100, 100] as [number, number] }; -const idle: Input = { turn: 0, thrust: false, fire: false }; - -export const cases: readonly ConformanceCase[] = [ - { - name: "turns right by a positive turn input", - before: { ...field, ship: { position: [50, 50], velocity: [0, 0], rotation: 0 } }, - args: { dt: 1, input: { turn: 1, thrust: false, fire: false } }, - after: { ...field, ship: { position: [50, 50], velocity: [0, 0], rotation: 3 } }, - }, - { - name: "turns left by a negative turn input", - before: { ...field, ship: { position: [50, 50], velocity: [0, 0], rotation: 0 } }, - args: { dt: 1, input: { turn: -1, thrust: false, fire: false } }, - after: { ...field, ship: { position: [50, 50], velocity: [0, 0], rotation: -3 } }, - }, - { - name: "no turn holds rotation and coasts by velocity", - before: { ...field, ship: { position: [50, 50], velocity: [10, 0], rotation: 0.7 } }, - args: { dt: 1, input: idle }, - after: { ...field, ship: { position: [60, 50], velocity: [10, 0], rotation: 0.7 } }, - }, - { - name: "thrusts along the facing, then coasts by the new velocity", - before: { ...field, ship: { position: [50, 50], velocity: [0, 0], rotation: 0 } }, - args: { dt: 0.1, input: { turn: 0, thrust: true, fire: false } }, - after: { ...field, ship: { position: [52, 50], velocity: [20, 0], rotation: 0 } }, - }, - { - name: "wraps across the right edge", - before: { ...field, ship: { position: [95, 50], velocity: [100, 0], rotation: 0 } }, - args: { dt: 0.1, input: idle }, - after: { ...field, ship: { position: [5, 50], velocity: [100, 0], rotation: 0 } }, - }, - { - name: "wraps across the top edge (negative wrap)", - before: { ...field, ship: { position: [5, 5], velocity: [0, -100], rotation: 0 } }, - args: { dt: 0.1, input: idle }, - after: { ...field, ship: { position: [5, 95], velocity: [0, -100], rotation: 0 } }, - }, - { - // Turn then thrust: −3 turns to 0, and thrust must use the NEW rotation 0 - // (facing +x → velocity [200,0]); using the old −3 would point elsewhere. - name: "turn composes before thrust — thrust uses the post-turn rotation", - before: { ...field, ship: { position: [50, 50], velocity: [0, 0], rotation: -3 } }, - args: { dt: 1, input: { turn: 1, thrust: true, fire: false } }, - after: { ...field, ship: { position: [50, 50], velocity: [200, 0], rotation: 0 } }, - }, -]; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/step-ship.test.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/step-ship.test.ts deleted file mode 100644 index e0882923..00000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/step-ship.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it } from "vitest"; -import { State } from "./state.js"; -import { cases } from "./step-ship.cases.js"; -import { expectStateMatches } from "./expect-state-matches.js"; - -// The spec test runs the pure transform over the SHARED `{ before, args, after }` -// cases (the same array the ecs system conformance imports). Keeping the -// expectations here means "substitute the implementation, reuse the truth". -describe("State.stepShip", () => { - for (const { name, before, args, after } of cases) { - it(name, () => { - expectStateMatches(State.stepShip(before, args.dt, args.input), after); - }); - } -}); diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/step-ship.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/step-ship.ts index 147ed58d..3d87c5e0 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/step-ship.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/step-ship.ts @@ -1,15 +1,18 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { State } from "./state.js"; +import type { Conformance } from "./conformance-case.js"; +import { create } from "./create.js"; import { Ship } from "../ship/ship.js"; import { Input } from "../input/input.js"; import { Motion } from "../motion/motion.js"; -// Advance the ship one tick: turn, optionally thrust, then coast by its -// velocity and wrap at the screen edges. +// Advance the ship one tick: turn, optionally thrust, then coast by its velocity +// and wrap at the screen edges. `dt` and `input` are bundled into one args object +// (second parameter) so the co-located conformance cases derive their `args` type +// straight from this signature (`Conformance`). export const stepShip = >( state: T, - dt: number, - input: Input, + { dt, input }: { readonly dt: number; readonly input: Input }, ): T => { const { ship } = state; const rotation = Ship.turn(ship.rotation, input.turn, dt); @@ -17,3 +20,56 @@ export const stepShip = >( const position = Motion.wrap(Motion.advance(ship.position, velocity, dt), state.bounds); return { ...state, ship: { position, velocity, rotation } }; }; + +// Spec-owned cases, shared with the ecs system conformance (the `control` + ship +// half of `movement` reproduce this). Geometry chosen so every `after` is exact +// (turnRate 3, thrustAccel 200, field 100×100 to force wrap). +const field = { ...create(), bounds: [100, 100] as [number, number] }; +const idle: Input = { turn: 0, thrust: false, fire: false }; + +export const cases: Conformance = [ + { + name: "turns right by a positive turn input", + before: { ...field, ship: { position: [50, 50], velocity: [0, 0], rotation: 0 } }, + args: { dt: 1, input: { turn: 1, thrust: false, fire: false } }, + after: { ...field, ship: { position: [50, 50], velocity: [0, 0], rotation: 3 } }, + }, + { + name: "turns left by a negative turn input", + before: { ...field, ship: { position: [50, 50], velocity: [0, 0], rotation: 0 } }, + args: { dt: 1, input: { turn: -1, thrust: false, fire: false } }, + after: { ...field, ship: { position: [50, 50], velocity: [0, 0], rotation: -3 } }, + }, + { + name: "no turn holds rotation and coasts by velocity", + before: { ...field, ship: { position: [50, 50], velocity: [10, 0], rotation: 0.7 } }, + args: { dt: 1, input: idle }, + after: { ...field, ship: { position: [60, 50], velocity: [10, 0], rotation: 0.7 } }, + }, + { + name: "thrusts along the facing, then coasts by the new velocity", + before: { ...field, ship: { position: [50, 50], velocity: [0, 0], rotation: 0 } }, + args: { dt: 0.1, input: { turn: 0, thrust: true, fire: false } }, + after: { ...field, ship: { position: [52, 50], velocity: [20, 0], rotation: 0 } }, + }, + { + name: "wraps across the right edge", + before: { ...field, ship: { position: [95, 50], velocity: [100, 0], rotation: 0 } }, + args: { dt: 0.1, input: idle }, + after: { ...field, ship: { position: [5, 50], velocity: [100, 0], rotation: 0 } }, + }, + { + name: "wraps across the top edge (negative wrap)", + before: { ...field, ship: { position: [5, 5], velocity: [0, -100], rotation: 0 } }, + args: { dt: 0.1, input: idle }, + after: { ...field, ship: { position: [5, 95], velocity: [0, -100], rotation: 0 } }, + }, + { + // Turn then thrust: −3 turns to 0, and thrust must use the NEW rotation 0 + // (facing +x → velocity [200,0]); using the old −3 would point elsewhere. + name: "turn composes before thrust — thrust uses the post-turn rotation", + before: { ...field, ship: { position: [50, 50], velocity: [0, 0], rotation: -3 } }, + args: { dt: 1, input: { turn: 1, thrust: true, fire: false } }, + after: { ...field, ship: { position: [50, 50], velocity: [200, 0], rotation: 0 } }, + }, +]; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/step.cases.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/step.cases.ts deleted file mode 100644 index 8affaf25..00000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/step.cases.ts +++ /dev/null @@ -1,148 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { State } from "./state.js"; -import type { ConformanceCase } from "./conformance-case.js"; -import { Ship } from "../ship/ship.js"; -import { Input } from "../input/input.js"; - -// Spec-owned `{ before, args, after }` cases for the whole-tick `State.step` -// (args = { dt, input }), shared with the ecs system tick-loop conformance -// (one frame = one step). Each case exercises one branch of the step pipeline — -// advance/wrap, fire, bullet↔asteroid resolution, ship↔asteroid resolution, and -// the game-over freeze — with geometry chosen so every `after` is exact -// (stationary bodies, F32-representable numbers). -// -// NONE of these cases clears the field. The wave refill now draws randomness -// (`spawnRandomWave`), and the ECS `waves` system's real `Math.random` source -// cannot be shared with the pure oracle frame-for-frame — so, per the sanctioned -// conformance tradeoff (`features/services/main-service/conformance.md`), the -// randomized refill is kept OUT of the shared tick-loop cases and exercised -// directly instead: `step.test.ts` (a `step` frame with the injected double), -// `spawn-random-wave.test.ts` (the transition), and the `spawnRandomWave` -// transaction conformance (the ECS mutation). With asteroids present in every -// case here, `spawnRandomWave` is a no-op, so `step` never draws randomness and -// both sides stay exact. -type Args = { readonly dt: number; readonly input: Input }; - -export const cases: readonly ConformanceCase[] = [ - { - // Ship and an asteroid both advance and wrap; neither reaches the other. - name: "advances and wraps every body (movement)", - before: { - bounds: [200, 200], - ship: { position: [190, 100], velocity: [30, 0], rotation: 0 }, - bullets: [], - asteroids: [{ position: [190, 180], velocity: [30, 30], size: "large" }], - score: 0, - lives: 3, - wave: 1, - }, - args: { dt: 1, input: Input.none }, - after: { - bounds: [200, 200], - ship: { position: [20, 100], velocity: [30, 0], rotation: 0 }, - bullets: [], - asteroids: [{ position: [20, 10], velocity: [30, 30], size: "large" }], - score: 0, - lives: 3, - wave: 1, - }, - }, - { - // fire fires from the POST-move ship (stepShip → fireBullet → stepBullets), - // then the new bullet is advanced + aged the same tick. - name: "fires from the post-move muzzle and advances the new bullet (lifetime)", - before: { - bounds: [400, 400], - ship: { position: [100, 100], velocity: [0, 0], rotation: 0 }, - bullets: [], - asteroids: [{ position: [350, 350], velocity: [0, 0], size: "large" }], - score: 0, - lives: 3, - wave: 1, - }, - args: { dt: 0.1, input: { turn: 0, thrust: false, fire: true } }, - after: { - bounds: [400, 400], - ship: { position: [100, 100], velocity: [0, 0], rotation: 0 }, - bullets: [{ position: [152, 100], velocity: [400, 0], age: 0.1 }], - asteroids: [{ position: [350, 350], velocity: [0, 0], size: "large" }], - score: 0, - lives: 3, - wave: 1, - }, - }, - { - // A stationary bullet overlapping a large asteroid: it scores 20 and the - // rock splits into two stationary mediums; the far ship is untouched. - name: "resolves a bullet↔asteroid hit (split + score)", - before: { - bounds: [800, 600], - ship: { position: [700, 500], velocity: [0, 0], rotation: 0 }, - bullets: [{ position: [100, 100], velocity: [0, 0], age: 0 }], - asteroids: [{ position: [100, 100], velocity: [0, 0], size: "large" }], - score: 0, - lives: 3, - wave: 1, - }, - args: { dt: 0.1, input: Input.none }, - after: { - bounds: [800, 600], - ship: { position: [700, 500], velocity: [0, 0], rotation: 0 }, - bullets: [], - asteroids: [ - { position: [100, 100], velocity: [0, 0], size: "medium" }, - { position: [100, 100], velocity: [0, 0], size: "medium" }, - ], - score: 20, - lives: 3, - wave: 1, - }, - }, - { - // An asteroid on the centred ship costs a life and respawns it at centre. - name: "resolves a ship↔asteroid hit (lose a life, respawn at centre)", - before: { - bounds: [200, 200], - ship: Ship.spawn([100, 100]), - bullets: [], - asteroids: [{ position: [100, 100], velocity: [0, 0], size: "large" }], - score: 0, - lives: 3, - wave: 1, - }, - args: { dt: 0.1, input: Input.none }, - after: { - bounds: [200, 200], - ship: Ship.spawn([100, 100]), - bullets: [], - asteroids: [{ position: [100, 100], velocity: [0, 0], size: "large" }], - score: 0, - lives: 2, - wave: 1, - }, - }, - { - // Lives spent: the whole tick is frozen (idempotent). Every system must - // honour the game-over guard, so nothing — ship, bullet, asteroid — moves. - name: "freezes the whole tick once the game is over", - before: { - bounds: [200, 200], - ship: { position: [50, 50], velocity: [10, 0], rotation: 0 }, - bullets: [{ position: [60, 60], velocity: [0, 0], age: 0.5 }], - asteroids: [{ position: [100, 100], velocity: [0, 0], size: "large" }], - score: 40, - lives: 0, - wave: 2, - }, - args: { dt: 0.1, input: { turn: 1, thrust: true, fire: true } }, - after: { - bounds: [200, 200], - ship: { position: [50, 50], velocity: [10, 0], rotation: 0 }, - bullets: [{ position: [60, 60], velocity: [0, 0], age: 0.5 }], - asteroids: [{ position: [100, 100], velocity: [0, 0], size: "large" }], - score: 40, - lives: 0, - wave: 2, - }, - }, -]; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/step.test.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/step.test.ts deleted file mode 100644 index 57023e43..00000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/step.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it, expect } from "vitest"; -import { State } from "./state.js"; -import { Ship } from "../ship/ship.js"; -import { Input } from "../input/input.js"; -import { RandomService } from "../../services/random-service/random-service.js"; -import { cases } from "./step.cases.js"; -import { expectStateMatches } from "./expect-state-matches.js"; - -describe("State.step", () => { - for (const { name, before, args, after } of cases) { - it(name, () => { - // None of the shared cases clears the field, so `random` is never drawn; - // a fresh double satisfies the signature without affecting the outcome. - expectStateMatches( - State.step(before, args.dt, args.input, { random: RandomService.createFake() }), - after, - ); - }); - } - - it("returns the same reference (not just an equal value) when the game is over", () => { - const state = { ...State.create(), lives: 0 }; - expect(State.step(state, 0.016, Input.none, { random: RandomService.createFake() })).toBe(state); - }); - - // The randomized refill branch, kept out of the shared tick-loop cases: a - // cleared field refills through `spawnRandomWave`, deterministic GIVEN the - // injected double. Positions are the fixed ring; only drift speeds vary, - // computed from the published `RandomService.fakeRandoms` schedule. - it("refills a cleared field with a randomized wave (deterministic given the injected double)", () => { - const before: State = { - bounds: [200, 200], - ship: Ship.spawn([100, 100]), - bullets: [], - asteroids: [], - score: 0, - lives: 3, - wave: 0, - }; - const after = State.step(before, 0.1, Input.none, { random: RandomService.createFake() }); - expectStateMatches(after, { - bounds: [200, 200], - ship: Ship.spawn([100, 100]), - bullets: [], - asteroids: [ - { position: [180, 100], velocity: [0, 30], size: "large" }, - { position: [100, 180], velocity: [-60, 0], size: "large" }, - { position: [20, 100], velocity: [0, -45], size: "large" }, - { position: [100, 20], velocity: [75, 0], size: "large" }, - ], - score: 0, - lives: 3, - wave: 1, - }); - }); -}); diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/step.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/step.ts index a2340497..c5295856 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/step.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/step.ts @@ -1,6 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { State } from "./state.js"; +import type { Conformance } from "./conformance-case.js"; import { Input } from "../input/input.js"; +import { Ship } from "../ship/ship.js"; import { stepShip } from "./step-ship.js"; import { fireBullet } from "./fire-bullet.js"; import { stepBullets } from "./step-bullets.js"; @@ -11,29 +13,28 @@ import { spawnRandomWave } from "./spawn-random-wave.js"; import { isGameOver } from "./is-game-over.js"; import { RandomService } from "../../services/random-service/random-service.js"; -// Advance the whole game one tick. This is the authoritative spec the ECS -// systems are verified against: move the ship, fire, advance bullets and -// asteroids, resolve collisions, then refill the wave if the field is clear. -// A game that is over is frozen (idempotent). +// Advance the whole game one tick. This is the authoritative spec the ECS systems +// are verified against: move the ship, fire, advance bullets and asteroids, +// resolve collisions, then refill the wave if the field is clear. A game that is +// over is frozen (idempotent). `dt`, `input`, and the injected `random` service +// are bundled into one args object (second parameter) so the co-located +// conformance cases derive their `args` type from this signature. // -// The refill draws randomness, so `step` threads an injected `random` service +// The refill draws randomness, so `step` threads the injected `random` service // down to `spawnRandomWave` — keeping the whole tick deterministic GIVEN the -// service (inject a fixed sequence and one frame is fixed). The runtime ECS -// `waves` system supplies the real `Math.random`-backed source; because that -// source cannot be shared with the pure oracle frame-for-frame, the shared -// tick-loop conformance cases never clear the field (the randomized refill is -// exercised directly in `spawn-random-wave.test.ts` / `step.test.ts` and +// service. The runtime ECS `waves` system supplies a real `Math.random`-backed +// source; because that source cannot be shared with the pure oracle +// frame-for-frame, the shared tick-loop conformance cases never clear the field +// (the randomized refill is exercised directly in `spawn-random-wave.ts` cases and // conformed via the `spawnRandomWave` transaction). export const step = ( state: State, - dt: number, - input: Input, - { random }: { random: RandomService }, + { dt, input, random }: { readonly dt: number; readonly input: Input; readonly random: RandomService }, ): State => { if (isGameOver(state)) { return state; } - let next = stepShip(state, dt, input); + let next = stepShip(state, { dt, input }); if (input.fire) { next = fireBullet(next); } @@ -44,3 +45,126 @@ export const step = ( next = spawnRandomWave(next, { random }); return next; }; + +// Spec-owned cases for the whole-tick `step`, shared with the ecs system tick-loop +// conformance (one frame = one step). Each case exercises one branch of the step +// pipeline — advance/wrap, fire, bullet↔asteroid resolution, ship↔asteroid +// resolution, and the game-over freeze — with geometry chosen so every `after` is +// exact. NONE clears the field, so `random` is never drawn (a fresh double per +// case satisfies the signature without affecting the outcome); the randomized +// refill is conformed out-of-band via the `spawnRandomWave` transaction. +export const cases: Conformance = [ + { + name: "advances and wraps every body (movement)", + before: { + bounds: [200, 200], + ship: { position: [190, 100], velocity: [30, 0], rotation: 0 }, + bullets: [], + asteroids: [{ position: [190, 180], velocity: [30, 30], size: "large" }], + score: 0, + lives: 3, + wave: 1, + }, + args: { dt: 1, input: Input.none, random: RandomService.createFake() }, + after: { + bounds: [200, 200], + ship: { position: [20, 100], velocity: [30, 0], rotation: 0 }, + bullets: [], + asteroids: [{ position: [20, 10], velocity: [30, 30], size: "large" }], + score: 0, + lives: 3, + wave: 1, + }, + }, + { + name: "fires from the post-move muzzle and advances the new bullet (lifetime)", + before: { + bounds: [400, 400], + ship: { position: [100, 100], velocity: [0, 0], rotation: 0 }, + bullets: [], + asteroids: [{ position: [350, 350], velocity: [0, 0], size: "large" }], + score: 0, + lives: 3, + wave: 1, + }, + args: { dt: 0.1, input: { turn: 0, thrust: false, fire: true }, random: RandomService.createFake() }, + after: { + bounds: [400, 400], + ship: { position: [100, 100], velocity: [0, 0], rotation: 0 }, + bullets: [{ position: [152, 100], velocity: [400, 0], age: 0.1 }], + asteroids: [{ position: [350, 350], velocity: [0, 0], size: "large" }], + score: 0, + lives: 3, + wave: 1, + }, + }, + { + name: "resolves a bullet↔asteroid hit (split + score)", + before: { + bounds: [800, 600], + ship: { position: [700, 500], velocity: [0, 0], rotation: 0 }, + bullets: [{ position: [100, 100], velocity: [0, 0], age: 0 }], + asteroids: [{ position: [100, 100], velocity: [0, 0], size: "large" }], + score: 0, + lives: 3, + wave: 1, + }, + args: { dt: 0.1, input: Input.none, random: RandomService.createFake() }, + after: { + bounds: [800, 600], + ship: { position: [700, 500], velocity: [0, 0], rotation: 0 }, + bullets: [], + asteroids: [ + { position: [100, 100], velocity: [0, 0], size: "medium" }, + { position: [100, 100], velocity: [0, 0], size: "medium" }, + ], + score: 20, + lives: 3, + wave: 1, + }, + }, + { + name: "resolves a ship↔asteroid hit (lose a life, respawn at centre)", + before: { + bounds: [200, 200], + ship: Ship.spawn([100, 100]), + bullets: [], + asteroids: [{ position: [100, 100], velocity: [0, 0], size: "large" }], + score: 0, + lives: 3, + wave: 1, + }, + args: { dt: 0.1, input: Input.none, random: RandomService.createFake() }, + after: { + bounds: [200, 200], + ship: Ship.spawn([100, 100]), + bullets: [], + asteroids: [{ position: [100, 100], velocity: [0, 0], size: "large" }], + score: 0, + lives: 2, + wave: 1, + }, + }, + { + name: "freezes the whole tick once the game is over", + before: { + bounds: [200, 200], + ship: { position: [50, 50], velocity: [10, 0], rotation: 0 }, + bullets: [{ position: [60, 60], velocity: [0, 0], age: 0.5 }], + asteroids: [{ position: [100, 100], velocity: [0, 0], size: "large" }], + score: 40, + lives: 0, + wave: 2, + }, + args: { dt: 0.1, input: { turn: 1, thrust: true, fire: true }, random: RandomService.createFake() }, + after: { + bounds: [200, 200], + ship: { position: [50, 50], velocity: [10, 0], rotation: 0 }, + bullets: [{ position: [60, 60], velocity: [0, 0], age: 0.5 }], + asteroids: [{ position: [100, 100], velocity: [0, 0], size: "large" }], + score: 40, + lives: 0, + wave: 2, + }, + }, +]; diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/action-database/action-database.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/action-database/action-database.ts new file mode 100644 index 00000000..f23aede5 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/services/main-service/action-database/action-database.ts @@ -0,0 +1,20 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import { ServiceDatabase } from "../service-database/service-database.js"; +import * as actions from "./actions/index.js"; + +// Extends the service database with the `actions` facet: the async, app-facing +// realizations that orchestrate a `services/` port and commit through a single +// transaction. Per-frame step transitions are realized by the `systems` layer +// (conformed via the tick-loop), not by actions. +const actionDatabasePlugin = Database.Plugin.create({ + extends: ServiceDatabase.plugin, + actions, +}); + +export type ActionDatabase = Database.Plugin.ToDatabase; + +export namespace ActionDatabase { + export const plugin = actionDatabasePlugin; + export type Store = Database.Plugin.ToStore; +} diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/action-database/actions/fire-bullet.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/action-database/actions/fire-bullet.ts new file mode 100644 index 00000000..540be73d --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/services/main-service/action-database/actions/fire-bullet.ts @@ -0,0 +1,10 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ServiceDatabase } from "../../service-database/service-database.js"; + +// The app-facing realization of `State.fireBullet`: a local shot needs no outside +// capability, so it commits through the single `fireBullet` transaction (which +// reads the current ship from the store and inserts the muzzle bullet). The UI +// never awaits this — state flows back through observables. +export const fireBullet = (db: ServiceDatabase) => { + db.transactions.fireBullet(); +}; diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/action-database/actions/index.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/action-database/actions/index.ts new file mode 100644 index 00000000..d40e650a --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/services/main-service/action-database/actions/index.ts @@ -0,0 +1,3 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +export * from "./fire-bullet.js"; +export * from "./spawn-random-wave.js"; diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/action-database/actions/spawn-random-wave.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/action-database/actions/spawn-random-wave.ts new file mode 100644 index 00000000..511e41d0 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/services/main-service/action-database/actions/spawn-random-wave.ts @@ -0,0 +1,12 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ServiceDatabase } from "../../service-database/service-database.js"; + +// The app-facing realization of `State.spawnRandomWave` (which injects the same +// `random` port): read the `random` service from `db.services` and commit the next +// wave through the single `spawnRandomWave` transaction (which reads the current +// field/wave/bounds from the store — never a cached computed — and is a no-op +// while asteroids remain). `random.next()` is a value-returning read, not a +// fire-and-forget effect, so it is not surfaced to the caller. +export const spawnRandomWave = (db: ServiceDatabase) => { + db.transactions.spawnRandomWave({ random: db.services.random }); +}; diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/actions.test.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/actions.test.ts new file mode 100644 index 00000000..1a13850f --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/actions.test.ts @@ -0,0 +1,72 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +/// +import { describe, it, expect } from "vitest"; +import { Database } from "@adobe/data/ecs"; +import type { ConformanceCase } from "../../../data/state/conformance-case.js"; +import { splitAndRecordServices, expectEffects } from "../../../data/state/record-effects.js"; +import type { RandomService } from "../../random-service/random-service.js"; +import { expectStateMatches } from "../../../data/state/expect-state-matches.js"; +import { MainService } from "../main-service.js"; +import { fromState } from "./from-state.js"; +import { toState } from "./to-state.js"; +import { fireBullet } from "../action-database/actions/fire-bullet.js"; +import { spawnRandomWave } from "../action-database/actions/spawn-random-wave.js"; +import { cases as fireBulletCases } from "../../../data/state/fire-bullet.js"; +import { cases as spawnRandomWaveCases } from "../../../data/state/spawn-random-wave.js"; + +// Each transition's cases run against its same-named ecs **action** (the async +// realization), asserting both the resulting state and the declared side effects. +// The case's service args become the db's service overrides — wrapped so their +// calls are recorded — and the plain args drive the action. +// `toSystemDatabase` exposes the writable `.store` the projection needs while +// keeping services/transactions/actions. Runtime invariant: the recording +// wrappers preserve each service's shape, so they are valid factory overrides. +// +// Only the app-facing, single-transaction transitions get an action: `fireBullet` +// (no service) and `spawnRandomWave` (injects the `random` service — a +// value-returning read, so nothing is declared in `effects`). The per-frame step +// transitions (`stepShip`, `step`, …) are realized by the `systems` layer and +// conformed by the tick-loop test, not here. +const makeDb = (services: { random?: RandomService }) => + Database.toSystemDatabase(Database.create(MainService.plugin, { services })); +type Db = ReturnType; +type Run = (db: Db) => Promise | void; + +const covered = new Set(); +const conformsAction = ( + action: string, + config: { readonly cases: readonly ConformanceCase[]; readonly run: Run }, +): void => { + covered.add(action); + describe(`${action} action conforms`, () => { + for (const testCase of config.cases) { + it(testCase.name, async () => { + const { services, input, calls } = splitAndRecordServices(testCase.args); + // No action here takes a plain-data arg; assert none crept in. + expect(Object.keys(input)).toEqual([]); + const db = makeDb(services as { random?: RandomService }); + fromState(db.store, testCase.before); + await config.run(db); + expectStateMatches(toState(db.store), testCase.after); + expectEffects(calls, testCase.effects); + }); + } + }); +}; + +conformsAction("fireBullet", { cases: fireBulletCases, run: (db) => fireBullet(db) }); +conformsAction("spawnRandomWave", { cases: spawnRandomWaveCases, run: (db) => spawnRandomWave(db) }); + +// None-missed guard: every action file must be wired above. +const kebabToCamel = (name: string): string => + name.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase()); +describe("action conformance coverage", () => { + const files = import.meta.glob([ + "../action-database/actions/*.ts", + "!../action-database/actions/index.ts", + ]); + for (const path of Object.keys(files)) { + const action = kebabToCamel(path.replace(/.*\//, "").replace(/\.ts$/, "")); + it(`${action} has a conformance case`, () => expect(covered.has(action)).toBe(true)); + } +}); diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/expect-conforms.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/expect-conforms.ts index baf3ca2c..bcbf3e81 100644 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/expect-conforms.ts +++ b/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/expect-conforms.ts @@ -13,26 +13,26 @@ import { toState } from "./to-state.js"; // // toState(apply(fromState(before), args)) ≡ spec(before, args) // -// in two asserted halves: -// 1. `spec(before, args) ≡ after` — keeps the shared case honest (a -// mis-authored `after` is caught here, independent of the ecs path). -// 2. seed `fromState(before)` → run the caller's `apply` → `toState ≡ after` -// — the ecs implementation reproduces the pure transform. +// seeding `fromState(before)`, running the caller's `apply`, and asserting +// `toState ≡ after`. The pure half (`spec(before, args) ≡ after`) is asserted for +// every case once, centrally, by `data/state/spec.test.ts`, so this runner omits +// it by default; pass `spec` to re-check it in place. Entity collections compare +// as multisets, scalars/`Vec2` exactly (see `expectStateMatches`). // // `apply` receives the seeded writable store and calls the raw transaction -// function directly (a transaction is `(store, args) => void`, so no `Database` -// is involved). A mutation addressed by entity id resolves its entities from the -// seeded store there (the shared cases stay spec-shaped). Entity collections -// compare as multisets; scalars and resources exactly (see `expectStateMatches`). +// function directly (a transaction is `(store, args) => void`, so no `Database` is +// involved). A mutation addressed by entity ids resolves them from the seeded +// store inside its own `apply` closure (the shared cases stay spec-shaped). export const expectConforms = (config: { readonly cases: readonly ConformanceCase[]; - readonly spec: (before: State, args: Args) => State; + readonly spec?: (before: State, args: Args) => State; readonly apply: (store: CoreDatabase.Store, args: Args) => void; }): void => { for (const testCase of config.cases) { it(testCase.name, () => { - expectStateMatches(config.spec(testCase.before, testCase.args), testCase.after); - + if (config.spec) { + expectStateMatches(config.spec(testCase.before, testCase.args), testCase.after); + } const store = createStore(); fromState(store, testCase.before); config.apply(store, testCase.args); diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/to-data.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/to-data.ts new file mode 100644 index 00000000..c34e3851 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/to-data.ts @@ -0,0 +1,22 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Entity } from "@adobe/data/ecs"; +import type { Ship } from "../../../data/ship/ship.js"; +import type { Bullet } from "../../../data/bullet/bullet.js"; +import type { Asteroid } from "../../../data/asteroid/asteroid.js"; +import type { CoreDatabase } from "../core-database/core-database.js"; + +// Read one entity back into its `data/` value — the per-entity projection +// `toState` is built on, and the single place the ecs↔data mapping for the three +// entity kinds lives. Unlike a single-archetype feature (todo), an entity here is +// one of Ship / Bullet / Asteroid, so `toData` probes each named archetype (their +// component sets are distinct — only Ship has `rotation`, only Bullet `age`, only +// Asteroid `size`) and projects the first that matches. Test-only. +export const toData = (store: CoreDatabase.Store, entity: Entity): Ship | Bullet | Asteroid => { + const ship = store.read(entity, store.archetypes.Ship); + if (ship !== null) return { position: ship.position, velocity: ship.velocity, rotation: ship.rotation }; + const bullet = store.read(entity, store.archetypes.Bullet); + if (bullet !== null) return { position: bullet.position, velocity: bullet.velocity, age: bullet.age }; + const asteroid = store.read(entity, store.archetypes.Asteroid); + if (asteroid !== null) return { position: asteroid.position, velocity: asteroid.velocity, size: asteroid.size }; + throw new Error("conformance projection: entity is not a ship, bullet, or asteroid"); +}; diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/to-state.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/to-state.ts index 6779998a..0b7acb1d 100644 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/to-state.ts +++ b/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/to-state.ts @@ -4,52 +4,35 @@ import type { Ship } from "../../../data/ship/ship.js"; import type { Bullet } from "../../../data/bullet/bullet.js"; import type { Asteroid } from "../../../data/asteroid/asteroid.js"; import type { CoreDatabase } from "../core-database/core-database.js"; +import { toData } from "./to-data.js"; -// Read a store back into a `data/` `State` — the inverse of `fromState`. Each -// kind is read through its named archetype's full component set (not an -// incidental single column), so the three entity shapes never alias. Test-only. -const readShip = (store: CoreDatabase.Store): Ship => { - const [entity] = store.select(store.archetypes.Ship.components); - if (entity === undefined) throw new Error("conformance projection: expected a ship entity"); - const row = store.read(entity, store.archetypes.Ship); - if (row === null) throw new Error("conformance projection: expected a ship entity"); - return { position: row.position, velocity: row.velocity, rotation: row.rotation }; -}; - -const readBullets = (store: CoreDatabase.Store): Bullet[] => { +// Read a store back into a `data/` `State` — the inverse of `fromState`, built on +// the per-entity `toData` projection. Every entity carries `position`, so one +// query covers all three archetypes; each entity is projected through `toData` and +// sorted into the ship, bullets, or asteroids slot by its distinguishing member +// (`rotation` → ship, `age` → bullet, `size` → asteroid). Row order across +// archetypes is arbitrary, but the entity collections compare as multisets +// (`expectStateMatches`), so it need not be stable. Test-only. +export const toState = (store: CoreDatabase.Store): State => { + let ship: Ship | undefined; const bullets: Bullet[] = []; - for (const arch of store.queryArchetypes(store.archetypes.Bullet.components)) { - for (let row = 0; row < arch.rowCount; row++) { - bullets.push({ - position: arch.columns.position.get(row), - velocity: arch.columns.velocity.get(row), - age: arch.columns.age.get(row), - }); - } - } - return bullets; -}; - -const readAsteroids = (store: CoreDatabase.Store): Asteroid[] => { const asteroids: Asteroid[] = []; - for (const arch of store.queryArchetypes(store.archetypes.Asteroid.components)) { + for (const arch of store.queryArchetypes(["position"])) { for (let row = 0; row < arch.rowCount; row++) { - asteroids.push({ - position: arch.columns.position.get(row), - velocity: arch.columns.velocity.get(row), - size: arch.columns.size.get(row), - }); + const value = toData(store, arch.columns.id.get(row)); + if ("rotation" in value) ship = value; + else if ("age" in value) bullets.push(value); + else asteroids.push(value); } } - return asteroids; + if (ship === undefined) throw new Error("conformance projection: expected a ship entity"); + return { + bounds: store.resources.bounds, + ship, + bullets, + asteroids, + score: store.resources.score, + lives: store.resources.lives, + wave: store.resources.wave, + }; }; - -export const toState = (store: CoreDatabase.Store): State => ({ - bounds: store.resources.bounds, - ship: readShip(store), - bullets: readBullets(store), - asteroids: readAsteroids(store), - score: store.resources.score, - lives: store.resources.lives, - wave: store.resources.wave, -}); diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/transactions.test.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/transactions.test.ts new file mode 100644 index 00000000..1cce32dc --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/transactions.test.ts @@ -0,0 +1,153 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it, expect } from "vitest"; +import type { Entity } from "@adobe/data/ecs"; +import { Vec2 } from "@adobe/data/math"; +import type { CoreDatabase } from "../core-database/core-database.js"; +import type { ConformanceCase } from "../../../data/state/conformance-case.js"; +import { Collision } from "../../../data/collision/collision.js"; +import { Bullet } from "../../../data/bullet/bullet.js"; +import { Asteroid } from "../../../data/asteroid/asteroid.js"; +import { Ship } from "../../../data/ship/ship.js"; +import { expectConforms } from "./expect-conforms.js"; +import { createStore } from "./create-store.js"; +import * as registeredTransactions from "../transaction-database/transactions/index.js"; +import { setInput } from "../transaction-database/transactions/set-input.js"; +import { setBounds } from "../transaction-database/transactions/set-bounds.js"; +import { newGame } from "../transaction-database/transactions/new-game.js"; +import { spawnRandomWave } from "../transaction-database/transactions/spawn-random-wave.js"; +import { fireBullet } from "../transaction-database/transactions/fire-bullet.js"; +import { hitAsteroid } from "../transaction-database/transactions/hit-asteroid.js"; +import { loseLife } from "../transaction-database/transactions/lose-life.js"; +import { cases as createInitialCases } from "../../../data/state/create-initial.js"; +import { cases as spawnRandomWaveCases } from "../../../data/state/spawn-random-wave.js"; +import { cases as fireBulletCases } from "../../../data/state/fire-bullet.js"; +import { cases as resolveBulletHitsCases } from "../../../data/state/resolve-bullet-hits.js"; +import { cases as resolveShipHitsCases } from "../../../data/state/resolve-ship-hits.js"; + +// The single conformance test for every ecs transaction. Each transition's shared +// `data/state` cases run through its raw `apply` (`fromState(before)` → apply → +// `matches(toState, after)`); the pure half is asserted once, centrally, by +// `data/state/spec.test.ts`, so this runner asserts only the ecs half. The guard +// at the bottom asserts every REGISTERED transaction (the barrel, not a file glob) +// is wired below, so the flat `readShip` / `readAsteroids` helpers — kept out of +// the barrel — are naturally excluded and none can be missed. +const covered = new Set(); +const conforms = ( + transaction: string, + config: { + readonly cases: readonly ConformanceCase[]; + readonly apply: (t: CoreDatabase.Store, args: Args) => void; + }, +): void => { + covered.add(transaction); + describe(`${transaction} transaction conforms`, () => expectConforms(config)); +}; + +// newGame ⇄ createInitial: seed the bounds the transform reads, then rebuild. +conforms("newGame", { + cases: createInitialCases, + apply: (t, { bounds }) => { + setBounds(t, bounds); + newGame(t); + }, +}); + +// spawnRandomWave ⇄ State.spawnRandomWave: the same injected double drives both +// sides (carried in each case's `args.random`), so the jittered velocities agree. +conforms("spawnRandomWave", { cases: spawnRandomWaveCases, apply: spawnRandomWave }); + +// fireBullet ⇄ State.fireBullet: reads the seeded ship, inserts the muzzle bullet. +conforms("fireBullet", { cases: fireBulletCases, apply: (t) => fireBullet(t) }); + +// hitAsteroid ⇄ State.resolveBulletHits. The transform resolves EVERY bullet's hit +// in one pass; the transaction resolves ONE (bullet, asteroid) pair — the collision +// system dispatches it once per overlapping bullet. This `apply` reproduces that +// dispatch loop: detect every pair FIRST against the untouched store (so no child a +// split spawns this pass can be a target), each asteroid claimed by at most one +// bullet, using the same SWEPT segment test, then apply. +conforms("hitAsteroid", { + cases: resolveBulletHitsCases, + apply: (t, dt: number) => { + const asteroids: readonly Entity[] = [...t.select(t.archetypes.Asteroid.components)]; + const claimed = new Set(); + const hits: { readonly bullet: Entity; readonly asteroid: Entity }[] = []; + for (const bullet of t.select(t.archetypes.Bullet.components)) { + const bulletRow = t.read(bullet, t.archetypes.Bullet); + if (bulletRow === null) continue; + const prev = Vec2.subtract(bulletRow.position, Vec2.scale(bulletRow.velocity, dt)); + for (const asteroid of asteroids) { + if (claimed.has(asteroid)) continue; + const asteroidRow = t.read(asteroid, t.archetypes.Asteroid); + if (asteroidRow === null) continue; + if ( + Collision.segmentCircleOverlap( + prev, + bulletRow.position, + asteroidRow.position, + Bullet.radius + Asteroid.radius(asteroidRow), + ) + ) { + claimed.add(asteroid); + hits.push({ bullet, asteroid }); + break; + } + } + } + for (const hit of hits) hitAsteroid(t, hit); + }, +}); + +// loseLife ⇄ State.resolveShipHits. The transform decides whether the ship is +// struck AND applies the consequence; the transaction is only the struck branch +// (spend a life, respawn). This `apply` reproduces that decision from the seeded +// store: dispatch `loseLife` iff the ship overlaps an asteroid. +conforms("loseLife", { + cases: resolveShipHitsCases, + apply: (t) => { + const [shipId] = t.select(t.archetypes.Ship.components); + if (shipId === undefined) return; + const shipRow = t.read(shipId, t.archetypes.Ship); + if (shipRow === null) return; + let struck = false; + for (const asteroid of t.select(t.archetypes.Asteroid.components)) { + const asteroidRow = t.read(asteroid, t.archetypes.Asteroid); + if (asteroidRow === null) continue; + if ( + Collision.circlesOverlap(shipRow.position, Ship.radius, asteroidRow.position, Asteroid.radius(asteroidRow)) + ) { + struck = true; + break; + } + } + if (struck) loseLife(t); + }, +}); + +// setInput / setBounds have no `data/` transform to conform to — they only record +// a resource — so they get a direct resource assertion (per transactions.md), still +// counted by the coverage guard. +describe("setInput transaction", () => { + covered.add("setInput"); + it("writes the dispatched input to the resource verbatim", () => { + const store = createStore(); + const input = { turn: 1, thrust: true, fire: false }; + setInput(store, input); + expect(store.resources.input).toEqual(input); + }); +}); + +describe("setBounds transaction", () => { + covered.add("setBounds"); + it("writes the dispatched bounds to the resource verbatim", () => { + const store = createStore(); + setBounds(store, [1024, 768]); + expect(store.resources.bounds).toEqual([1024, 768]); + }); +}); + +// None-missed guard: every **registered** transaction (the barrel) must be wired. +describe("transaction conformance coverage", () => { + for (const transaction of Object.keys(registeredTransactions)) { + it(`${transaction} has a conformance case`, () => expect(covered.has(transaction)).toBe(true)); + } +}); diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/service-database/service-database.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/service-database/service-database.ts new file mode 100644 index 00000000..85292398 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/services/main-service/service-database/service-database.ts @@ -0,0 +1,24 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import { ComputedDatabase } from "../computed-database/computed-database.js"; +import { RandomService } from "../../random-service/random-service.js"; + +// Extends the computed database with the `services` facet. `random` is a +// capability port with no ECS state to bind, so it is registered directly from +// its `services/` contract (like data-lit-todo's `nameGenerator`): production +// uses the real `Math.random`-backed source; tests inject +// `RandomService.createFake` through the `Database.create` service override. +// Consumers reach it as `db.services.random`. +const serviceDatabasePlugin = Database.Plugin.create({ + extends: ComputedDatabase.plugin, + services: { + random: RandomService.create, + }, +}); + +export type ServiceDatabase = Database.Plugin.ToDatabase; + +export namespace ServiceDatabase { + export const plugin = serviceDatabasePlugin; + export type Store = Database.Plugin.ToStore; +} diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/system-database/system-database.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/system-database/system-database.ts index 3e4bb9af..ea2acc36 100644 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/system-database/system-database.ts +++ b/packages/data-lit-space-rock-game/src/features/main/services/main-service/system-database/system-database.ts @@ -2,7 +2,7 @@ import { Database, scheduler } from "@adobe/data/ecs"; import type { Entity } from "@adobe/data/ecs"; import { Vec2 } from "@adobe/data/math"; -import { ComputedDatabase } from "../computed-database/computed-database.js"; +import { ActionDatabase } from "../action-database/action-database.js"; import { Motion } from "../../../data/motion/motion.js"; import { Spatial } from "../../../data/spatial/spatial.js"; import { Collision } from "../../../data/collision/collision.js"; @@ -10,10 +10,11 @@ import { Asteroid } from "../../../data/asteroid/asteroid.js"; import { Bullet } from "../../../data/bullet/bullet.js"; import { Ship } from "../../../data/ship/ship.js"; import { State } from "../../../data/state/state.js"; -import { RandomService } from "../../random-service/random-service.js"; -// The real-time tick loop. Extends the computed database (schema + indexes + -// transactions + computed) combined with the built-in `scheduler`, and declares +// The real-time tick loop. Extends the action database (schema + indexes + +// transactions + computed + services + actions) combined with the built-in +// `scheduler` — systems come last in the pipeline, so they sit atop the service +// and action layers the feature builds — and declares // the `systems` facet INLINE so each `create`'s `db` is strongly typed (the // assembled database with a *writable* store) and the scheduler can infer the // system-name union from the map's keys. @@ -37,7 +38,7 @@ import { RandomService } from "../../random-service/random-service.js"; // rAF and drives frames itself by invoking `db.system.functions[name]()` for each // name in `db.system.order`. const systemDatabasePlugin = Database.Plugin.create({ - extends: Database.Plugin.combine(ComputedDatabase.plugin, scheduler), + extends: Database.Plugin.combine(ActionDatabase.plugin, scheduler), systems: { // Apply the player's intent to the ship: turn, then thrust along the new // facing — the rotation/velocity half of State.stepShip (movement advances @@ -253,15 +254,15 @@ const systemDatabasePlugin = Database.Plugin.create({ // remain and there is nothing to do; otherwise dispatch spawnRandomWave, which // bumps `wave` and inserts the next ring through the data/-verified layout — // its per-rock drift speeds jittered by the injected `random` service, so live - // waves vary run to run. The REAL Math.random-backed source is created once - // here (in `create`, before the per-frame loop) and injected on every spawn; - // tests inject RandomService.createFake for reproducible layouts. Frozen once - // the game is over — step never reaches the refill after game over, so a dead - // game does not respawn a wave. + // waves vary run to run. The random source is the feature's `random` service + // (`db.services.random`, the real Math.random-backed source in production); + // it is read once here (in `create`, before the per-frame loop) and injected on + // every spawn. Frozen once the game is over — step never reaches the refill + // after game over, so a dead game does not respawn a wave. waves: { schedule: { after: ["collision"] }, create: (db) => { - const random = RandomService.create(); + const random = db.services.random; return () => { if (State.isGameOver({ lives: db.store.resources.lives })) return; for (const arch of db.store.queryArchetypes(["size"])) { diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/system-database/tick-loop.test.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/system-database/tick-loop.test.ts index 3f8cd30c..1a1e44d8 100644 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/system-database/tick-loop.test.ts +++ b/packages/data-lit-space-rock-game/src/features/main/services/main-service/system-database/tick-loop.test.ts @@ -25,9 +25,8 @@ import { describe, it, expect } from "vitest"; import { State } from "../../../data/state/state.js"; import { Ship } from "../../../data/ship/ship.js"; import { Input } from "../../../data/input/input.js"; -import { cases } from "../../../data/state/step.cases.js"; +import { cases } from "../../../data/state/step.js"; import { expectStateMatches } from "../../../data/state/expect-state-matches.js"; -import { RandomService } from "../../random-service/random-service.js"; import { createSystemDatabase } from "../conformance/create-system-database.js"; import { fromState } from "../conformance/from-state.js"; import { toState } from "../conformance/to-state.js"; @@ -37,10 +36,9 @@ describe("ECS system tick loop conforms to State.step (one frame = one step)", ( for (const testCase of cases) { it(testCase.name, () => { const { dt, input } = testCase.args; - expectStateMatches( - State.step(testCase.before, dt, input, { random: RandomService.createFake() }), - testCase.after, - ); + // The co-located case carries its own inert `random` double (no case clears + // the field, so it is never drawn), so drive the oracle with the case args. + expectStateMatches(State.step(testCase.before, testCase.args), testCase.after); const db = createSystemDatabase(); fromState(db.store, testCase.before); diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/fire-bullet.test.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/fire-bullet.test.ts deleted file mode 100644 index ecef0627..00000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/fire-bullet.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -// -// `fireBullet` conforms to `State.fireBullet`: it reads the seeded ship, defers -// the muzzle kinematics to the transform, and inserts the bullet — leaving any -// bullets already in flight untouched. -import { describe } from "vitest"; -import { State } from "../../../../data/state/state.js"; -import { cases } from "../../../../data/state/fire-bullet.cases.js"; -import { expectConforms } from "../../conformance/expect-conforms.js"; -import { fireBullet } from "./fire-bullet.js"; - -describe("fireBullet transaction conforms to State.fireBullet", () => { - expectConforms({ - cases, - spec: State.fireBullet, - apply: fireBullet, - }); -}); diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/hit-asteroid.test.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/hit-asteroid.test.ts deleted file mode 100644 index 38eaadf7..00000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/hit-asteroid.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -// -// `hitAsteroid` conforms to `State.resolveBulletHits`. The transform resolves -// EVERY bullet's hit in one pass; the transaction resolves ONE (bullet, -// asteroid) pair — the collision system dispatches it once per overlapping -// bullet. The `apply` closure reproduces that dispatch loop: it resolves each -// bullet's target from the seeded store (narrow-phase overlap, the same test -// `resolveBulletHits` uses) and dispatches the transaction. Every shared case -// keeps each bullet overlapping at most one asteroid, so the outcome is -// order-independent and the whole per-bullet pass equals the transform. -import { describe } from "vitest"; -import type { Entity } from "@adobe/data/ecs"; -import { Vec2 } from "@adobe/data/math"; -import { State } from "../../../../data/state/state.js"; -import { Collision } from "../../../../data/collision/collision.js"; -import { Bullet } from "../../../../data/bullet/bullet.js"; -import { Asteroid } from "../../../../data/asteroid/asteroid.js"; -import { cases } from "../../../../data/state/resolve-bullet-hits.cases.js"; -import { expectConforms } from "../../conformance/expect-conforms.js"; -import { hitAsteroid } from "./hit-asteroid.js"; - -describe("hitAsteroid transaction conforms to State.resolveBulletHits", () => { - expectConforms({ - cases, - spec: State.resolveBulletHits, - apply: (store, dt) => { - // Detect every (bullet, asteroid) pair FIRST, against the untouched store — - // so no child a split spawns this pass can be a target (and no reused entity - // id can alias one). Each asteroid is claimed by at most one bullet, matching - // resolveBulletHits (which splices the hit asteroid out). Detection is swept: - // reconstruct each bullet's path this frame (prev = position - velocity*dt) - // and test that segment, so a fast bullet cannot tunnel through. Then apply. - const asteroids: readonly Entity[] = [...store.select(store.archetypes.Asteroid.components)]; - const claimed = new Set(); - const hits: { readonly bullet: Entity; readonly asteroid: Entity }[] = []; - for (const bullet of store.select(store.archetypes.Bullet.components)) { - const bulletRow = store.read(bullet, store.archetypes.Bullet); - if (bulletRow === null) continue; - const prev = Vec2.subtract(bulletRow.position, Vec2.scale(bulletRow.velocity, dt)); - for (const asteroid of asteroids) { - if (claimed.has(asteroid)) continue; - const asteroidRow = store.read(asteroid, store.archetypes.Asteroid); - if (asteroidRow === null) continue; - if ( - Collision.segmentCircleOverlap( - prev, - bulletRow.position, - asteroidRow.position, - Bullet.radius + Asteroid.radius(asteroidRow), - ) - ) { - claimed.add(asteroid); - hits.push({ bullet, asteroid }); - break; - } - } - } - for (const hit of hits) hitAsteroid(store, hit); - }, - }); -}); diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/lose-life.test.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/lose-life.test.ts deleted file mode 100644 index 3d659f28..00000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/lose-life.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -// -// `loseLife` conforms to `State.resolveShipHits`. The transform decides whether -// the ship is struck AND applies the consequence; the transaction is only the -// struck branch (spend a life, respawn at centre) — the collision system -// dispatches it exactly when the ship overlaps an asteroid. The `apply` closure -// reproduces that decision from the seeded store: it dispatches `loseLife` iff -// the ship is struck, so the outcome equals the transform on every case -// (struck, untouched, lives already zero, and the empty field). -import { describe } from "vitest"; -import { State } from "../../../../data/state/state.js"; -import { Collision } from "../../../../data/collision/collision.js"; -import { Ship } from "../../../../data/ship/ship.js"; -import { Asteroid } from "../../../../data/asteroid/asteroid.js"; -import { cases } from "../../../../data/state/resolve-ship-hits.cases.js"; -import { expectConforms } from "../../conformance/expect-conforms.js"; -import { loseLife } from "./lose-life.js"; - -describe("loseLife transaction conforms to State.resolveShipHits", () => { - expectConforms({ - cases, - spec: State.resolveShipHits, - apply: (store) => { - const [shipId] = store.select(store.archetypes.Ship.components); - if (shipId === undefined) return; - const shipRow = store.read(shipId, store.archetypes.Ship); - if (shipRow === null) return; - let struck = false; - for (const asteroid of store.select(store.archetypes.Asteroid.components)) { - const asteroidRow = store.read(asteroid, store.archetypes.Asteroid); - if (asteroidRow === null) continue; - if ( - Collision.circlesOverlap( - shipRow.position, - Ship.radius, - asteroidRow.position, - Asteroid.radius(asteroidRow), - ) - ) { - struck = true; - break; - } - } - if (struck) loseLife(store); - }, - }); -}); diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/new-game.test.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/new-game.test.ts deleted file mode 100644 index 81904dd7..00000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/new-game.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -// -// `newGame` conforms to `State.createInitial`: dispatched over the shared cases, -// the seeded store (deliberately dirty) is cleared and rebuilt into the fresh -// game the transform computes from the bounds alone. -import { describe } from "vitest"; -import { State } from "../../../../data/state/state.js"; -import { cases } from "../../../../data/state/create-initial.cases.js"; -import { expectConforms } from "../../conformance/expect-conforms.js"; -import { setBounds } from "./set-bounds.js"; -import { newGame } from "./new-game.js"; - -describe("newGame conforms to State.createInitial", () => { - expectConforms({ - cases, - spec: (_before, bounds) => State.createInitial(bounds), - apply: (store, bounds) => { - setBounds(store, bounds); - newGame(store); - }, - }); -}); diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/new-game.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/new-game.ts index d806df22..c1be62db 100644 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/new-game.ts +++ b/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/new-game.ts @@ -8,7 +8,7 @@ import { clearEntities } from "./clear-entities.js"; // the ship, and spawn the first wave. `bounds` is preserved — it is set by the // UI on canvas resize, not part of the reset. export const newGame = (t: CoreDatabase.Store): void => { - const initial = State.createInitial(t.resources.bounds); + const initial = State.createInitial(State.create(), { bounds: t.resources.bounds }); clearEntities(t); t.resources.score = initial.score; t.resources.lives = initial.lives; diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/set-bounds.test.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/set-bounds.test.ts deleted file mode 100644 index c50038e6..00000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/set-bounds.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -// -// `setBounds` has no `data/` transform to conform to — it only records the -// play-field size resource — so it gets a direct resource assertion. -import { describe, it, expect } from "vitest"; -import { createStore } from "../../conformance/create-store.js"; -import { setBounds } from "./set-bounds.js"; - -describe("setBounds", () => { - it("writes the dispatched bounds to the resource verbatim", () => { - const store = createStore(); - setBounds(store, [800, 600]); - expect(store.resources.bounds).toEqual([800, 600]); - }); - - it("overwrites a previously set bounds (e.g. on canvas resize)", () => { - const store = createStore(); - setBounds(store, [800, 600]); - setBounds(store, [1024, 768]); - expect(store.resources.bounds).toEqual([1024, 768]); - }); -}); diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/set-input.test.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/set-input.test.ts deleted file mode 100644 index 77ff15af..00000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/set-input.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -// -// `setInput` has no `data/` transform to conform to — it only records the -// player's intent resource — so it gets a direct resource assertion. -import { describe, it, expect } from "vitest"; -import { createStore } from "../../conformance/create-store.js"; -import { setInput } from "./set-input.js"; - -describe("setInput", () => { - it("writes the dispatched input to the resource verbatim", () => { - const store = createStore(); - const input = { turn: 1, thrust: true, fire: false }; - setInput(store, input); - expect(store.resources.input).toEqual(input); - }); - - it("overwrites a previously set input", () => { - const store = createStore(); - setInput(store, { turn: 1, thrust: true, fire: true }); - setInput(store, { turn: -1, thrust: false, fire: false }); - expect(store.resources.input).toEqual({ turn: -1, thrust: false, fire: false }); - }); -}); diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/spawn-random-wave.test.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/spawn-random-wave.test.ts deleted file mode 100644 index e3d1561f..00000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/spawn-random-wave.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -// -// `spawnRandomWave` conforms to `State.spawnRandomWave`: it reads the -// field/wave/bounds from the seeded store, defers the count and jittered layout -// to the transform, and inserts the result — a no-op while asteroids remain. -// Both the spec and the ecs apply receive the SAME injected double (carried in -// each case's `args.random`), so the randomized velocities agree exactly. The -// double's four-value schedule matches the four rocks a wave spawns, so the -// runner re-consuming it for spec then apply cycles back to the same values. -import { describe } from "vitest"; -import { State } from "../../../../data/state/state.js"; -import { cases } from "../../../../data/state/spawn-random-wave.cases.js"; -import { expectConforms } from "../../conformance/expect-conforms.js"; -import { spawnRandomWave } from "./spawn-random-wave.js"; - -describe("spawnRandomWave transaction conforms to State.spawnRandomWave", () => { - expectConforms({ - cases, - spec: State.spawnRandomWave, - apply: spawnRandomWave, - }); -}); From ab46c47c7a172e9582f5d3dfcb1a891ce9c1a52c Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Thu, 6 Aug 2026 20:31:14 -0700 Subject: [PATCH 12/37] docs(rules): findings from the real-time/systems conversion (space-rock) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - conformance.md: array comparison is per-collection — ordered by default (display-ordered collections + Vec2 tuples), multiset for orderless entity bags - actions.md: "every transition has an action" scoped to app-facing, transaction-backed transitions; per-frame/system transitions are conformed by the tick loop, not actions - state.md: all non-state inputs go in the single args object (Conformance reads Parameters[1]); co-located cases must not touch the public.js barrel at module load (import cycle); keep genuine non-transition helper tests (create/predicate) Co-Authored-By: Claude Opus 4.8 --- .../.claude/rules/features/data/state.md | 18 +++++++++++++++--- .../features/services/main-service/actions.md | 19 +++++++++++++------ .../services/main-service/conformance.md | 13 ++++++++++--- 3 files changed, 38 insertions(+), 12 deletions(-) diff --git a/packages/data-ai/.claude/rules/features/data/state.md b/packages/data-ai/.claude/rules/features/data/state.md index df1eaab4..19d6dec7 100644 --- a/packages/data-ai/.claude/rules/features/data/state.md +++ b/packages/data-ai/.claude/rules/features/data/state.md @@ -43,8 +43,16 @@ export const cases: Conformance = [ ``` - **Signature** `(state, args) => state`. Narrow-in/same-shape-out — generic over - the smallest `Pick` slice so it lifts to full-state. Args may be - narrowed/omitted. **Guard no-ops by returning `state` unchanged**, never throw. + the smallest `Pick` slice so it lifts to full-state. **All non-state + inputs go in the single `args` object** (`Conformance` reads + `Parameters[1]`) — bundle a `dt`, an injected service, etc. into it, never as a + third positional. Args may be narrowed/omitted. **Guard no-ops by returning + `state` unchanged**, never throw. +- **Co-located `cases` must not touch the feature's `public.js` barrel at module + load** — that barrel re-exports this very file, so calling `State.create()` (or + any barrel member) in a top-level `cases` literal dead-locks the import cycle. + Import the concrete helper directly (`import { create } from "./create.js"`) or + inline full-`State` literals. - **`Conformance`** derives the case `args` type from the function's own signature — author it once, and cases can't drift from what the function accepts. `before`/`after` are full `State`. @@ -55,7 +63,11 @@ export const cases: Conformance = [ only when a case needs one — a feature whose `State` exposes no ECS-minted ids (values abstracted behind a scalar/string) never does. - No per-transform test. The single **`spec.test.ts`** auto-discovers every file - exporting `cases` and asserts the pure result (see `conformance.md`). + exporting `cases` and asserts the pure result (see `conformance.md`). Only the + redundant per-transform tests are removed. A genuine **non-transition helper** in + `state/` — a `create()` constructor, a single-field predicate — has no `cases` + and isn't a `(state,args)=>state` transform, so `spec.test.ts` skips it: **keep + its own sibling `*.test.ts`** rather than deleting it and losing coverage. ## Injected services and side effects diff --git a/packages/data-ai/.claude/rules/features/services/main-service/actions.md b/packages/data-ai/.claude/rules/features/services/main-service/actions.md index 046f2434..4f189f87 100644 --- a/packages/data-ai/.claude/rules/features/services/main-service/actions.md +++ b/packages/data-ai/.claude/rules/features/services/main-service/actions.md @@ -10,12 +10,19 @@ argument and pure `data/` args. Actions orchestrate anything *outside* a single transaction — awaiting a `services/` port, sequencing calls, deriving timing — and then commit the result through a transaction. -**Every state transition has a corresponding same-named action** — the async, -app-facing realization the UI calls. It reads the same services the transition -injects from `db.services`, so it reproduces both the transition's state change -(through a transaction) and its side effects. It may reuse another transition's -transaction (`createRandomTodo` reuses `createTodo`) — there need not be a -same-named transaction; transactions are the looser layer. +**Every *app-facing, transaction-backed* transition has a corresponding +same-named action** — the async realization the UI drives. It reads the same +services the transition injects from `db.services`, so it reproduces both the +transition's state change (through a transaction) and its side effects. It may +reuse another transition's transaction (`createRandomTodo` reuses `createTodo`) — +there need not be a same-named transaction; transactions are the looser layer. + +**Per-frame / system transitions are exempt.** In a real-time feature the `step*` +/ physics / collision transitions are realized by the **systems** tick loop, not +by an action, and are conformed by the tick-loop test (`systems.md`), not +`actions.test.ts`. Give an action only to transitions a user/UI invokes directly +(and skip it too when the realization needs more than one transaction — e.g. a +`newGame` that both sets bounds and resets is conformed via its transaction). ```ts import type { ServiceDatabase } from "../../service-database/service-database.js"; diff --git a/packages/data-ai/.claude/rules/features/services/main-service/conformance.md b/packages/data-ai/.claude/rules/features/services/main-service/conformance.md index 2e132db7..bc255129 100644 --- a/packages/data-ai/.claude/rules/features/services/main-service/conformance.md +++ b/packages/data-ai/.claude/rules/features/services/main-service/conformance.md @@ -23,9 +23,16 @@ cases are the shared truth; these runners replay them against the ECS. Reference One matcher-aware `matches(actual, expected)` (exported; also backs derivations): honors vitest **asymmetric matchers** on the expected side (so `after`/`value` use `anyNumber` for ECS-assigned ids), quantizes numbers to absorb F32↔f64 noise, -and compares arrays **in order** (`toState` reads in display order — this is what -verifies a reorder). No separate id-ignoring variant. `expectStateMatches` / -`expectMatches` wrap it. +and compares arrays **in order** by default (`toState` reads a display-ordered +collection in order — this is what verifies a reorder; and ordered tuples like a +`Vec2` must stay in order). No separate id-ignoring variant. + +**Ordering is per-collection.** A collection the ECS materialises with **no +display order** (an entity *bag* — bullets, asteroids — whose row order is +nondeterministic) must compare as a **multiset**: expose a `matchesUnordered` and +apply it to just those fields in that feature's `expectStateMatches`. Ordered +default + multiset for orderless bags — never blanket-unordered (it would conflate +`[100,180]` with `[180,100]`). `expectStateMatches` / `expectMatches` wrap it. ## The runners — one aggregator per surface, each with a coverage guard From 492f923d9bbdf6a7f873ed0851070c11714b5dea Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Thu, 6 Aug 2026 20:31:48 -0700 Subject: [PATCH 13/37] test(space-rock): keep genuine non-transition helper tests (create, isGameOver) Per the refined rule: only redundant per-transform tests are removed; a genuine helper (constructor / single-field predicate) keeps its own unit test. Restores create.test.ts and is-game-over.test.ts deleted during conversion. (160 tests.) Co-Authored-By: Claude Opus 4.8 --- .../features/main/data/state/create.test.ts | 20 +++++++++++++++++++ .../main/data/state/is-game-over.test.ts | 13 ++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/create.test.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/is-game-over.test.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/create.test.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/create.test.ts new file mode 100644 index 00000000..07673897 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/create.test.ts @@ -0,0 +1,20 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it, expect } from "vitest"; +import { State } from "./state.js"; + +describe("State.create", () => { + it("is a blank neutral state: no field, idle ship, empty, full lives, wave 0", () => { + const state = State.create(); + expect(state.bounds).toEqual([0, 0]); + expect(state.ship.velocity).toEqual([0, 0]); + expect(state.bullets).toEqual([]); + expect(state.asteroids).toEqual([]); + expect(state.score).toBe(0); + expect(state.lives).toBe(3); + expect(state.wave).toBe(0); + }); + + it("is not game over", () => { + expect(State.isGameOver(State.create())).toBe(false); + }); +}); diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/is-game-over.test.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/is-game-over.test.ts new file mode 100644 index 00000000..23e82766 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/is-game-over.test.ts @@ -0,0 +1,13 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it, expect } from "vitest"; +import { State } from "./state.js"; + +describe("State.isGameOver", () => { + it("is over once lives reach zero", () => { + expect(State.isGameOver({ lives: 0 })).toBe(true); + }); + + it("is not over while a life remains", () => { + expect(State.isGameOver({ lives: 1 })).toBe(false); + }); +}); From 2d1d1a16df14f4a61aed57410f4d537689a09f1f Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Thu, 6 Aug 2026 20:42:08 -0700 Subject: [PATCH 14/37] refactor: feature-qualified public exports use MainService Align the packages' public assembled-db exports to the cross-feature naming rule: TodoDatabase -> TodoMainService, TictactoeDatabase -> TictactoeMainService, SpaceRockGameDatabase -> SpaceRockGameMainService. Co-Authored-By: Claude Opus 4.8 --- packages/data-lit-space-rock-game/src/index.ts | 2 +- packages/data-lit-tictactoe/src/index.ts | 4 ++-- packages/data-lit-todo/src/index.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/data-lit-space-rock-game/src/index.ts b/packages/data-lit-space-rock-game/src/index.ts index 1ae54a05..7cb9ef87 100644 --- a/packages/data-lit-space-rock-game/src/index.ts +++ b/packages/data-lit-space-rock-game/src/index.ts @@ -7,7 +7,7 @@ // consumers (per the cross-feature naming rule). Carries `.plugin` (schema + // indexes + transactions + computed, combined with the built-in rAF scheduler) // and `.Store` for consumers that build their own database from it. -export { MainService as SpaceRockGameDatabase } from "./features/main/services/main-service/main-service.js"; +export { MainService as SpaceRockGameMainService } from "./features/main/services/main-service/main-service.js"; export { SpaceRockGame } from "./features/main/ui/space-rock-game/space-rock-game.js"; export { SpaceRockGameElement } from "./features/main/ui/space-rock-game/space-rock-game-element.js"; diff --git a/packages/data-lit-tictactoe/src/index.ts b/packages/data-lit-tictactoe/src/index.ts index f2a636bd..781a4f3b 100644 --- a/packages/data-lit-tictactoe/src/index.ts +++ b/packages/data-lit-tictactoe/src/index.ts @@ -7,11 +7,11 @@ // services. Feature-qualified per the cross-feature naming rule so a peer or // downstream package never collides with another feature's database. Carries // `.plugin` / `.Store` for consumers that build their own database. -export { MainService as TictactoeDatabase } from "./features/main/services/main-service/main-service.js"; +export { MainService as TictactoeMainService } from "./features/main/services/main-service/main-service.js"; // The base game database — all game logic (resources, transactions, computed), // no AI. Combine its `.plugin` with P2P-specific plugins, or reach for -// `TictactoeDatabase` to get the agent-extended assembly. +// `TictactoeMainService` to get the agent-extended assembly. export { ComputedDatabase as TictactoeGameDatabase } from "./features/main/services/main-service/computed-database/computed-database.js"; export { Tictactoe } from "./features/main/ui/tictactoe-app/tictactoe-app.js"; diff --git a/packages/data-lit-todo/src/index.ts b/packages/data-lit-todo/src/index.ts index 5eee63dd..d11b4236 100644 --- a/packages/data-lit-todo/src/index.ts +++ b/packages/data-lit-todo/src/index.ts @@ -2,7 +2,7 @@ // // Library entry point for data-lit-todo. -export { MainService as TodoDatabase } from "./features/main/services/main-service/main-service.js"; +export { MainService as TodoMainService } from "./features/main/services/main-service/main-service.js"; export { TodoElement } from "./features/main/ui/todo-element.js"; export { TodoApp } from "./features/main/ui/todo-app/todo-app.js"; export { Todo } from "./features/main/data/todo/todo.js"; From c1649a8d9de33343cd16a4362162a2b959830636 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Thu, 6 Aug 2026 20:45:37 -0700 Subject: [PATCH 15/37] =?UTF-8?q?docs(rules):=20computed=20conformance=20s?= =?UTF-8?q?cope=20=E2=80=94=20wiring=20logic,=20not=20field=20count?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clarify why the computed-conformance covers state/ derivations and not trivial single-helper computeds: a trivial observe.field -> data/.helper computed is already covered by its parts (field via toState, math via the helper's unit test), so conforming it would be tautological. Escape hatches: promote to a thin state/ derivation to pin non-obvious wiring; promote an entity-folded value to a State field so toState conforms the projection. Co-Authored-By: Claude Opus 4.8 --- .../services/main-service/computed.md | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/data-ai/.claude/rules/features/services/main-service/computed.md b/packages/data-ai/.claude/rules/features/services/main-service/computed.md index 38daf7c0..1ef6948f 100644 --- a/packages/data-ai/.claude/rules/features/services/main-service/computed.md +++ b/packages/data-ai/.claude/rules/features/services/main-service/computed.md @@ -32,9 +32,22 @@ Type the parameter on the lowest database layer that exposes what it reads. An `index.ts` barrel re-exports every computed; `computed-database.ts` registers it under the `computed` facet. -**Conform a computed to its `data/` derivation** whenever one exists. The +**Conform a computed to its `data/state` derivation** whenever one exists. The derivation co-locates `{ input, value }` cases (`Derivation`), and `conformance/computeds.test.ts` seeds the store from `input`, reads the computed's value, and `matches(value)` (see `conformance.md`). A list-computed returning -entity ids needs no adapter — the runner hydrates through `toData`. A computed -with no pure `data/` derivation (an index-only helper like `allTodos`) is exempt. +entity ids needs no adapter — the runner hydrates through `toData`. + +**What needs conformance is proportional to wiring logic.** A computed that +composes/branches over the aggregate *is* a `state/` derivation (composes ≥2 +fields) and is conformed here. A computed that **trivially applies one +`data/` helper to one field** (`winner` ← `BoardState.getWinner(board)`) is +already covered by its parts — the field by `toState` conformance, the math by +that helper's unit test — so an added conformance would just re-invoke the same +helper on both sides (tautological); the helper's unit test is sufficient. Two +exceptions: (1) to pin a particular non-obvious wiring, promote it to a thin +`state/` derivation `(state) => Type.helper(state.field)` and the runner conforms +it — no new mechanism; (2) if a computed folds **entities into a value with no +intermediate `State` field**, that projection *is* unconformed — promote the +intermediate to a `State` field so `toState` covers it, making it a normal +`state/` derivation. From 3003c1dd5f4494bdb371405fcea8d4670f32b0ab Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Thu, 6 Aug 2026 21:04:47 -0700 Subject: [PATCH 16/37] feat(data-ai): `init` command for managed, auto-updating installs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `data-ai init`: pins @adobe/data-ai in the consumer's devDependencies (exact version), adds `data-ai install` to the consumer's OWN postinstall (chaining if present — a dependency's postinstall does not run under pnpm), and gitignores the managed bundle folders. Bumping the pinned version then refreshes the rules on install with no manual step and no committed diff. README rewritten: a "Recommended — managed, auto-updating" recipe (init + install) with the pnpm caveat spelled out for the installing agent, the Claude Code skills-plugin one-time step, and a one-shot no-dependency alternative. Co-Authored-By: Claude Opus 4.8 --- packages/data-ai/README.md | 76 +++++++++++++++++++++++----------- packages/data-ai/bin/cli.mjs | 79 +++++++++++++++++++++++++++++++++++- 2 files changed, 129 insertions(+), 26 deletions(-) diff --git a/packages/data-ai/README.md b/packages/data-ai/README.md index a7a66128..0ad1455e 100644 --- a/packages/data-ai/README.md +++ b/packages/data-ai/README.md @@ -11,42 +11,67 @@ It installs two things: - **skills** — `build-*` commands that scaffold features and applications. - **rules** — the path-scoped conventions those skills follow while building. -> **For the installing agent:** find the block below that matches your host -> (Claude Code or Cursor/other), run its commands, and stop. Each install owns a -> namespaced folder and is a clean wipe-and-recopy, so re-running is also how you -> **update** — it never touches files you authored. +The **rules** are the part that evolves often, so the recommended setup pins the +package and re-copies them automatically on every install: bump the version and +they refresh — no manual step, no committed diff, no hand-editing. -## Install — Claude Code +> **For the installing agent:** run the "Recommended" setup below from the project +> root (plus the Claude-Code plugin step if the host is Claude Code), then stop. +> Everything the package writes lives in namespaced folders it wipes-and-recopies, +> so it never touches files you authored. -Skills load as a marketplace plugin; rules install into the project. Run these -three from a shell (agent-runnable): +## Recommended — managed, auto-updating (any host) + +From the project root: + +``` +npx @adobe/data-ai@ init # = the latest published version (pin it, never @latest) +pnpm install # or npm/yarn — runs the postinstall init just added +``` + +`init` edits your `package.json` and `.gitignore` to make installs self-updating: + +1. pins `@adobe/data-ai` in `devDependencies` at the exact ``; +2. adds `data-ai install` to **your own** `postinstall` script (chaining if one + exists) — it must be *your* script: pnpm does not run a dependency's lifecycle + scripts, so a `postinstall` shipped inside the package would silently not fire; +3. gitignores the managed bundle folders. + +Every install then runs the copy: rules → `.claude/rules/adobe-data-ai/`, and (for +Cursor/Codex) skills → `.agents/skills/adobe-data-ai/`. They're regenerated +artifacts — never committed, never edited in place. + +**Update:** bump the `@adobe/data-ai` version in `package.json` (or re-run +`npx @adobe/data-ai@ init`) and install again. That's it. + +### Claude Code — add the skills plugin (one time) + +Claude Code loads skills from a marketplace plugin, not from `.claude/skills/`, so +its skills are a separate one-time install (the `init` step above already handles +the rules for Claude Code): ``` claude plugin marketplace add adobe/data --scope project claude plugin install adobe-data-ai@adobe-data-skills --scope project -npx @adobe/data-ai@latest install ``` -In an interactive Claude session you can instead use the slash-command form of -the first two — `/plugin marketplace add adobe/data` then -`/plugin install adobe-data-ai@adobe-data-skills` — but an agent driving a shell -should use the `claude plugin …` commands above (slash commands aren't -shell-runnable). - -- Skills → the `adobe-data-ai` plugin. **Update:** `claude plugin update adobe-data-ai`. -- Rules → `.claude/rules/adobe-data-ai/`. **Update:** re-run `npx @adobe/data-ai@latest install`. +(Interactive sessions can use `/plugin marketplace add adobe/data` then +`/plugin install adobe-data-ai@adobe-data-skills`.) **Update the skills:** +`claude plugin update adobe-data-ai`. Cursor/Codex need no plugin — `init` copies +their skills too. -## Install — Cursor (and Codex / other `.agents` agents) +## One-shot alternative — no dependency -One command installs both: +If you don't want a managed dev-dependency, copy the bundle once (re-run to +update): ``` -npx @adobe/data-ai@latest install +npx @adobe/data-ai@ install ``` -- Skills → `.agents/skills/adobe-data-ai/`. -- Rules → `.claude/rules/adobe-data-ai/`. -- **Update:** re-run the same command. +Rules → `.claude/rules/adobe-data-ai/`; skills → `.agents/skills/adobe-data-ai/` +(Claude Code: add the plugin as above). Re-running is a clean wipe-and-recopy — +it never touches files you authored. ## Use @@ -75,6 +100,9 @@ your prompt. This presumes you are already running the agent within the directory you want to use as your project root. ``` -Find the @adobe/data-ai package on npmjs and then install it in the current directory. -Next use it's /build-application skill to build +Find the latest @adobe/data-ai version on npmjs, then set it up in the current +directory: run `npx @adobe/data-ai@ init` and install (this pins it and +makes rules auto-update on every install). On Claude Code also add the skills +plugin per the package README. Then use its /build-application skill to build + ``` diff --git a/packages/data-ai/bin/cli.mjs b/packages/data-ai/bin/cli.mjs index c6dce388..99271e87 100755 --- a/packages/data-ai/bin/cli.mjs +++ b/packages/data-ai/bin/cli.mjs @@ -157,6 +157,71 @@ version on every install. Package: return file; } +// `init` — wire a consumer repo for auto-updating installs. Idempotently: +// 1. pins `@adobe/data-ai` in devDependencies (exact version — never a range, +// so an install can't silently pull an unreviewed release), +// 2. adds `data-ai install` to the consumer's OWN `postinstall` (a dependency's +// lifecycle script does NOT run under pnpm, so it must live here), and +// 3. gitignores the managed, regenerated bundle folders. +// After this, bumping the pinned version and re-installing recopies the bundle — +// no manual step, no committed diff. +const MANAGED_GITIGNORE = [ + `# ${PKG_NAME} — managed, regenerated on install; do not edit or commit`, + ".claude/rules/adobe-data-ai/", + ".claude/rules/adobe-data-ai-bootstrap.md", + ".agents/skills/adobe-data-ai/", +]; + +function initConsumer(base) { + const pkgPath = join(base, "package.json"); + if (!existsSync(pkgPath)) { + process.stderr.write(`No package.json in ${base} — run \`init\` from the project root.\n`); + process.exitCode = 1; + return; + } + const consumer = JSON.parse(readFileSync(pkgPath, "utf8")); + const changes = []; + + // 1. pinned devDependency + consumer.devDependencies ??= {}; + if (consumer.devDependencies[PKG_NAME] !== VERSION) { + consumer.devDependencies[PKG_NAME] = VERSION; + changes.push(`devDependencies["${PKG_NAME}"] = "${VERSION}" (exact)`); + } + + // 2. own postinstall runs the installer (chain if one already exists) + consumer.scripts ??= {}; + const INSTALL = "data-ai install"; + const post = consumer.scripts.postinstall; + if (!post) { + consumer.scripts.postinstall = INSTALL; + changes.push(`scripts.postinstall = "${INSTALL}"`); + } else if (!post.includes(INSTALL)) { + consumer.scripts.postinstall = `${post} && ${INSTALL}`; + changes.push(`scripts.postinstall += " && ${INSTALL}"`); + } + if (changes.length) writeFileSync(pkgPath, JSON.stringify(consumer, null, 2) + "\n"); + + // 3. gitignore the managed bundle folders + const giPath = join(base, ".gitignore"); + const gi = existsSync(giPath) ? readFileSync(giPath, "utf8") : ""; + if (!gi.includes(MANAGED_GITIGNORE[0])) { + const sep = gi === "" ? "" : gi.endsWith("\n") ? "\n" : "\n\n"; + writeFileSync(giPath, gi + sep + MANAGED_GITIGNORE.join("\n") + "\n"); + changes.push(".gitignore += managed bundle paths"); + } + + process.stdout.write(`${PKG_NAME} init (v${VERSION}) in ${base}\n`); + for (const c of changes) process.stdout.write(` ✓ ${c}\n`); + if (!changes.length) process.stdout.write(" already configured — nothing to change\n"); + process.stdout.write( + "\nNext: install (runs the postinstall, which copies the bundle):\n" + + " pnpm install # or npm install / yarn\n" + + "To update later: bump the pinned version above (or re-run this `init` from a newer\n" + + `\`npx ${PKG_NAME}@ init\`) and install again.\n`, + ); +} + function parseArgs(argv) { const positional = []; const flags = new Set(); @@ -177,10 +242,14 @@ Install the architecture skills + rules for Cursor, Codex, and other agents. same way for every agent — see README.) Usage: - npx ${PKG_NAME}@latest install [options] - npx ${PKG_NAME}@latest list + npx ${PKG_NAME}@ init # wire this repo for auto-updating installs + npx ${PKG_NAME}@ install # copy the bundle now (run by postinstall) + npx ${PKG_NAME}@ list Commands: + init Pin ${PKG_NAME} in devDependencies (exact), add \`data-ai install\` to the + repo's own postinstall, and gitignore the managed bundle folders. Then a + plain install copies the bundle; bumping the pinned version updates it. install Skills → .agents/skills/${BUNDLE}/, rules → .claude/rules/${BUNDLE}/ (default). list Print the skills bundled in this package. @@ -207,6 +276,12 @@ function main() { return; } + if (cmd === "init") { + const base = dir ? resolve(dir) : process.cwd(); + initConsumer(base); + return; + } + if (cmd !== "install") { process.stderr.write(`Unknown command: ${cmd}\n\n${HELP}`); process.exitCode = 1; From be57cff2b005973298fd65d08b5978c842d0a21c Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Thu, 6 Aug 2026 21:17:09 -0700 Subject: [PATCH 17/37] =?UTF-8?q?refactor(data-ai):=20fold=20`init`=20into?= =?UTF-8?q?=20`install`=20=E2=80=94=20one=20command=20copies=20+=20wires?= =?UTF-8?q?=20auto-updates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single `install` now both copies the bundle and (idempotently) pins the exact dev-dependency, adds the consumer's own postinstall, and gitignores the managed folders. Skips the wiring gracefully with --global or when there's no package.json. Removes the init-vs-install choice; updates HELP and README to the one-command flow. Co-Authored-By: Claude Opus 4.8 --- packages/data-ai/README.md | 67 ++++++++++++++++++------------------ packages/data-ai/bin/cli.mjs | 49 +++++++++++++------------- 2 files changed, 57 insertions(+), 59 deletions(-) diff --git a/packages/data-ai/README.md b/packages/data-ai/README.md index 0ad1455e..bd00a86e 100644 --- a/packages/data-ai/README.md +++ b/packages/data-ai/README.md @@ -11,38 +11,44 @@ It installs two things: - **skills** — `build-*` commands that scaffold features and applications. - **rules** — the path-scoped conventions those skills follow while building. -The **rules** are the part that evolves often, so the recommended setup pins the -package and re-copies them automatically on every install: bump the version and -they refresh — no manual step, no committed diff, no hand-editing. +The **rules** are the part that evolves often, so `install` pins the package and +re-copies them automatically on every install: bump the version and they refresh — +no manual step, no committed diff, no hand-editing. -> **For the installing agent:** run the "Recommended" setup below from the project -> root (plus the Claude-Code plugin step if the host is Claude Code), then stop. +> **For the installing agent:** run the one command below from the project root +> (plus the Claude-Code plugin step if the host is Claude Code), then stop. > Everything the package writes lives in namespaced folders it wipes-and-recopies, > so it never touches files you authored. -## Recommended — managed, auto-updating (any host) +## Install (any host) -From the project root: +From the project root, one command: ``` -npx @adobe/data-ai@ init # = the latest published version (pin it, never @latest) -pnpm install # or npm/yarn — runs the postinstall init just added +npx @adobe/data-ai@ install # = the latest published version (pin it, never @latest) ``` -`init` edits your `package.json` and `.gitignore` to make installs self-updating: +`install` does two things: it **copies the bundle now** (rules → +`.claude/rules/adobe-data-ai/`, and for Cursor/Codex skills → +`.agents/skills/adobe-data-ai/`), and it **wires the repo so future installs +self-update**, editing your `package.json` and `.gitignore` to: -1. pins `@adobe/data-ai` in `devDependencies` at the exact ``; -2. adds `data-ai install` to **your own** `postinstall` script (chaining if one +1. pin `@adobe/data-ai` in `devDependencies` at the exact ``; +2. add `data-ai install` to **your own** `postinstall` script (chaining if one exists) — it must be *your* script: pnpm does not run a dependency's lifecycle scripts, so a `postinstall` shipped inside the package would silently not fire; -3. gitignores the managed bundle folders. +3. gitignore the managed bundle folders. -Every install then runs the copy: rules → `.claude/rules/adobe-data-ai/`, and (for -Cursor/Codex) skills → `.agents/skills/adobe-data-ai/`. They're regenerated -artifacts — never committed, never edited in place. +The copied files are regenerated artifacts — never committed, never edited in +place. The wiring is idempotent, so re-running is harmless; with `--global` or in a +directory with no `package.json`, `install` just copies (no wiring). -**Update:** bump the `@adobe/data-ai` version in `package.json` (or re-run -`npx @adobe/data-ai@ init`) and install again. That's it. +After that first run, sync your lockfile with a normal install (`pnpm install`) so +the pinned dev-dependency is recorded; from then on every install refreshes the +bundle via the postinstall. + +**Update:** bump the `@adobe/data-ai` version in `package.json` and install again. +That's it. ### Claude Code — add the skills plugin (one time) @@ -57,21 +63,16 @@ claude plugin install adobe-data-ai@adobe-data-skills --scope project (Interactive sessions can use `/plugin marketplace add adobe/data` then `/plugin install adobe-data-ai@adobe-data-skills`.) **Update the skills:** -`claude plugin update adobe-data-ai`. Cursor/Codex need no plugin — `init` copies -their skills too. - -## One-shot alternative — no dependency - -If you don't want a managed dev-dependency, copy the bundle once (re-run to -update): +`claude plugin update adobe-data-ai`. Cursor/Codex need no plugin — `install` +copies their skills too. -``` -npx @adobe/data-ai@ install -``` +### Just the files, no managed dependency -Rules → `.claude/rules/adobe-data-ai/`; skills → `.agents/skills/adobe-data-ai/` -(Claude Code: add the plugin as above). Re-running is a clean wipe-and-recopy — -it never touches files you authored. +If you don't want the auto-update wiring — a throwaway scaffold, a non-Node repo, +or you'd rather commit the bundle and update it on your own schedule — run +`install` where it can't wire (no `package.json`, or pass `--global`), or simply +delete the `postinstall`/dev-dependency lines it added. Re-running `install` is +always a clean wipe-and-recopy; you then update by re-running it yourself. ## Use @@ -101,8 +102,8 @@ This presumes you are already running the agent within the directory you want to ``` Find the latest @adobe/data-ai version on npmjs, then set it up in the current -directory: run `npx @adobe/data-ai@ init` and install (this pins it and -makes rules auto-update on every install). On Claude Code also add the skills +directory: run `npx @adobe/data-ai@ install` (this copies the bundle and +pins it so rules auto-update on every install). On Claude Code also add the skills plugin per the package README. Then use its /build-application skill to build ``` diff --git a/packages/data-ai/bin/cli.mjs b/packages/data-ai/bin/cli.mjs index 99271e87..30a6eb88 100755 --- a/packages/data-ai/bin/cli.mjs +++ b/packages/data-ai/bin/cli.mjs @@ -157,14 +157,16 @@ version on every install. Package: return file; } -// `init` — wire a consumer repo for auto-updating installs. Idempotently: +// Wire the consumer repo for auto-updating installs (part of `install`, not a +// separate step). Idempotently: // 1. pins `@adobe/data-ai` in devDependencies (exact version — never a range, // so an install can't silently pull an unreviewed release), // 2. adds `data-ai install` to the consumer's OWN `postinstall` (a dependency's // lifecycle script does NOT run under pnpm, so it must live here), and // 3. gitignores the managed, regenerated bundle folders. // After this, bumping the pinned version and re-installing recopies the bundle — -// no manual step, no committed diff. +// no manual step, no committed diff. Skips gracefully with a note when there is +// no package.json (the bundle is still copied; the repo just isn't auto-wired). const MANAGED_GITIGNORE = [ `# ${PKG_NAME} — managed, regenerated on install; do not edit or commit`, ".claude/rules/adobe-data-ai/", @@ -172,11 +174,13 @@ const MANAGED_GITIGNORE = [ ".agents/skills/adobe-data-ai/", ]; -function initConsumer(base) { +function wireManagedUpdates(base) { const pkgPath = join(base, "package.json"); if (!existsSync(pkgPath)) { - process.stderr.write(`No package.json in ${base} — run \`init\` from the project root.\n`); - process.exitCode = 1; + process.stdout.write( + ` (no package.json in ${base} — auto-update not wired; the bundle was still copied.\n` + + ` Add one, re-run install to wire it, or re-run manually to update.)\n`, + ); return; } const consumer = JSON.parse(readFileSync(pkgPath, "utf8")); @@ -211,15 +215,8 @@ function initConsumer(base) { changes.push(".gitignore += managed bundle paths"); } - process.stdout.write(`${PKG_NAME} init (v${VERSION}) in ${base}\n`); - for (const c of changes) process.stdout.write(` ✓ ${c}\n`); - if (!changes.length) process.stdout.write(" already configured — nothing to change\n"); - process.stdout.write( - "\nNext: install (runs the postinstall, which copies the bundle):\n" + - " pnpm install # or npm install / yarn\n" + - "To update later: bump the pinned version above (or re-run this `init` from a newer\n" + - `\`npx ${PKG_NAME}@ init\`) and install again.\n`, - ); + for (const c of changes) process.stdout.write(` wired: ${c}\n`); + if (!changes.length) process.stdout.write(" auto-update already wired\n"); } function parseArgs(argv) { @@ -242,15 +239,17 @@ Install the architecture skills + rules for Cursor, Codex, and other agents. same way for every agent — see README.) Usage: - npx ${PKG_NAME}@ init # wire this repo for auto-updating installs - npx ${PKG_NAME}@ install # copy the bundle now (run by postinstall) + npx ${PKG_NAME}@ install # copy the bundle AND wire auto-updates (default) npx ${PKG_NAME}@ list Commands: - init Pin ${PKG_NAME} in devDependencies (exact), add \`data-ai install\` to the - repo's own postinstall, and gitignore the managed bundle folders. Then a - plain install copies the bundle; bumping the pinned version updates it. - install Skills → .agents/skills/${BUNDLE}/, rules → .claude/rules/${BUNDLE}/ (default). + install Copy the bundle (skills → .agents/skills/${BUNDLE}/, rules → + .claude/rules/${BUNDLE}/) AND wire this repo for auto-updating installs: + pin ${PKG_NAME} in devDependencies (exact), add \`data-ai install\` to the + repo's own postinstall, and gitignore the managed bundle folders. Run once; + thereafter a plain install refreshes the bundle and bumping the pinned + version updates it. Skips the wiring (copies only) with \`--global\` or when + there's no package.json. list Print the skills bundled in this package. Options: @@ -276,12 +275,6 @@ function main() { return; } - if (cmd === "init") { - const base = dir ? resolve(dir) : process.cwd(); - initConsumer(base); - return; - } - if (cmd !== "install") { process.stderr.write(`Unknown command: ${cmd}\n\n${HELP}`); process.exitCode = 1; @@ -304,6 +297,10 @@ function main() { process.stdout.write(` ${skills.length} skills → ${skillsDir}\n`); process.stdout.write(` ${ruleCount} rules → ${rulesDir}\n`); process.stdout.write(` bootstrap → ${bootstrapFile}\n`); + + // A project install also wires the repo for auto-updating installs (idempotent). + // A global install has no consumer package.json to wire, so it only copies. + if (!flags.has("global")) wireManagedUpdates(base); } main(); From 3e04f3cc564b0695c6446b27c1b0f8797226fb66 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Thu, 6 Aug 2026 21:18:58 -0700 Subject: [PATCH 18/37] feat(data-ai): install prints lockfile-sync reminder after pinning the dev-dep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When install adds/repins the exact @adobe/data-ai dev-dependency the lockfile is out of sync until a normal install records it, so it now prints a "run pnpm install" next-step — only when the pin actually changed, silent on idempotent re-runs. Co-Authored-By: Claude Opus 4.8 --- packages/data-ai/bin/cli.mjs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/data-ai/bin/cli.mjs b/packages/data-ai/bin/cli.mjs index 30a6eb88..7b01bc40 100755 --- a/packages/data-ai/bin/cli.mjs +++ b/packages/data-ai/bin/cli.mjs @@ -188,9 +188,11 @@ function wireManagedUpdates(base) { // 1. pinned devDependency consumer.devDependencies ??= {}; + let pinnedDep = false; if (consumer.devDependencies[PKG_NAME] !== VERSION) { consumer.devDependencies[PKG_NAME] = VERSION; changes.push(`devDependencies["${PKG_NAME}"] = "${VERSION}" (exact)`); + pinnedDep = true; } // 2. own postinstall runs the installer (chain if one already exists) @@ -217,6 +219,14 @@ function wireManagedUpdates(base) { for (const c of changes) process.stdout.write(` wired: ${c}\n`); if (!changes.length) process.stdout.write(" auto-update already wired\n"); + // Adding/repinning the dev-dependency leaves the lockfile out of sync until a + // normal install records it (and CI with a frozen lockfile would fail until then). + if (pinnedDep) { + process.stdout.write( + "\nNext: run your package manager's install to sync the lockfile\n" + + " pnpm install # or npm install / yarn\n", + ); + } } function parseArgs(argv) { From ae4a5b8249c6de2c23c6dbe936e7e952d5f63844 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Thu, 6 Aug 2026 21:20:57 -0700 Subject: [PATCH 19/37] chore: bump to v0.9.93 Co-Authored-By: Claude Sonnet 4.6 --- package.json | 2 +- packages/data-ai/.claude-plugin/plugin.json | 2 +- packages/data-ai/package.json | 2 +- packages/data-gpu-hopper/package.json | 2 +- packages/data-gpu-samples/package.json | 2 +- packages/data-gpu/package.json | 2 +- packages/data-lit-space-rock-game/package.json | 2 +- packages/data-lit-tictactoe/package.json | 2 +- packages/data-lit-todo/package.json | 2 +- packages/data-lit/package.json | 2 +- packages/data-p2p-tictactoe/package.json | 2 +- packages/data-persistence/package.json | 2 +- packages/data-react-hello/package.json | 2 +- packages/data-react-pixie/package.json | 2 +- packages/data-react/package.json | 2 +- packages/data-solid-dashboard/package.json | 2 +- packages/data-solid/package.json | 2 +- packages/data-sync/package.json | 2 +- packages/data/package.json | 2 +- 19 files changed, 19 insertions(+), 19 deletions(-) diff --git a/package.json b/package.json index 731ebbc1..2e671f75 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "data-monorepo", - "version": "0.9.92", + "version": "0.9.93", "private": true, "engines": { "node": ">=24" diff --git a/packages/data-ai/.claude-plugin/plugin.json b/packages/data-ai/.claude-plugin/plugin.json index b20f1ba3..24b24366 100644 --- a/packages/data-ai/.claude-plugin/plugin.json +++ b/packages/data-ai/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "adobe-data-ai", - "version": "0.9.92", + "version": "0.9.93", "description": "Architecture skills for @adobe/data — data-oriented modelling, archetype iteration, hot-path performance, and related conventions.", "author": { "name": "Adobe" diff --git a/packages/data-ai/package.json b/packages/data-ai/package.json index 5a90d235..0e3074e4 100644 --- a/packages/data-ai/package.json +++ b/packages/data-ai/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-ai", - "version": "0.9.92", + "version": "0.9.93", "description": "Cross-agent architecture skills for @adobe/data — installable as a Claude Code plugin or copied into any Agent-Skills-compatible agent (Cursor, Codex).", "type": "module", "private": false, diff --git a/packages/data-gpu-hopper/package.json b/packages/data-gpu-hopper/package.json index 1db85b3b..9765465f 100644 --- a/packages/data-gpu-hopper/package.json +++ b/packages/data-gpu-hopper/package.json @@ -1,6 +1,6 @@ { "name": "data-gpu-hopper", - "version": "0.9.92", + "version": "0.9.93", "description": "Hopper sample - real-time ECS game rendered as colored cubes via @adobe/data-gpu", "type": "module", "private": true, diff --git a/packages/data-gpu-samples/package.json b/packages/data-gpu-samples/package.json index 00549167..7132f5fd 100644 --- a/packages/data-gpu-samples/package.json +++ b/packages/data-gpu-samples/package.json @@ -1,6 +1,6 @@ { "name": "data-gpu-samples", - "version": "0.9.92", + "version": "0.9.93", "description": "WebGPU samples built on @adobe/data-gpu", "type": "module", "private": true, diff --git a/packages/data-gpu/package.json b/packages/data-gpu/package.json index e10089f1..c5cd1109 100644 --- a/packages/data-gpu/package.json +++ b/packages/data-gpu/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-gpu", - "version": "0.9.92", + "version": "0.9.93", "description": "Adobe data WebGPU plugins and types for graphics and compute", "type": "module", "private": false, diff --git a/packages/data-lit-space-rock-game/package.json b/packages/data-lit-space-rock-game/package.json index 5cfcd558..249f61df 100644 --- a/packages/data-lit-space-rock-game/package.json +++ b/packages/data-lit-space-rock-game/package.json @@ -1,6 +1,6 @@ { "name": "data-lit-space-rock-game", - "version": "0.9.92", + "version": "0.9.93", "description": "Space Rock Game sample - real-time ECS game with Lit and @adobe/data", "type": "module", "private": true, diff --git a/packages/data-lit-tictactoe/package.json b/packages/data-lit-tictactoe/package.json index 8c168905..b17cb3d1 100644 --- a/packages/data-lit-tictactoe/package.json +++ b/packages/data-lit-tictactoe/package.json @@ -1,6 +1,6 @@ { "name": "data-lit-tictactoe", - "version": "0.9.92", + "version": "0.9.93", "description": "Tic-Tac-Toe sample - Lit web components with @adobe/data-lit and AgenticService", "type": "module", "private": true, diff --git a/packages/data-lit-todo/package.json b/packages/data-lit-todo/package.json index d4abc531..a1240ecf 100644 --- a/packages/data-lit-todo/package.json +++ b/packages/data-lit-todo/package.json @@ -1,6 +1,6 @@ { "name": "data-lit-todo", - "version": "0.9.92", + "version": "0.9.93", "description": "Todo application - Lit web components with @adobe/data ECS", "type": "module", "private": true, diff --git a/packages/data-lit/package.json b/packages/data-lit/package.json index 6a5f6edf..4df97351 100644 --- a/packages/data-lit/package.json +++ b/packages/data-lit/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-lit", - "version": "0.9.92", + "version": "0.9.93", "description": "Adobe data Lit bindings - hooks, elements, decorators", "type": "module", "private": false, diff --git a/packages/data-p2p-tictactoe/package.json b/packages/data-p2p-tictactoe/package.json index b5beebdc..897827d3 100644 --- a/packages/data-p2p-tictactoe/package.json +++ b/packages/data-p2p-tictactoe/package.json @@ -1,6 +1,6 @@ { "name": "data-p2p-tictactoe", - "version": "0.9.92", + "version": "0.9.93", "description": "Serverless P2P tic-tac-toe — WebRTC DataChannel + @adobe/data-sync", "type": "module", "private": true, diff --git a/packages/data-persistence/package.json b/packages/data-persistence/package.json index 112f8de4..013161ed 100644 --- a/packages/data-persistence/package.json +++ b/packages/data-persistence/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-persistence", - "version": "0.9.92", + "version": "0.9.93", "description": "Worker-based incremental persistence layer for @adobe/data ECS over OPFS (browser) and node:fs (server).", "type": "module", "sideEffects": false, diff --git a/packages/data-react-hello/package.json b/packages/data-react-hello/package.json index 867cae7f..c6ed3fdf 100644 --- a/packages/data-react-hello/package.json +++ b/packages/data-react-hello/package.json @@ -1,6 +1,6 @@ { "name": "data-react-hello", - "version": "0.9.92", + "version": "0.9.93", "description": "Hello World sample - click counter using @adobe/data-react", "type": "module", "private": true, diff --git a/packages/data-react-pixie/package.json b/packages/data-react-pixie/package.json index 5f9c7f0b..3859c042 100644 --- a/packages/data-react-pixie/package.json +++ b/packages/data-react-pixie/package.json @@ -1,6 +1,6 @@ { "name": "data-react-pixie", - "version": "0.9.92", + "version": "0.9.93", "description": "PixiJS React sample - ECS sprites (bunny, fox) with @adobe/data-react", "type": "module", "private": true, diff --git a/packages/data-react/package.json b/packages/data-react/package.json index 346a70f3..6db84df7 100644 --- a/packages/data-react/package.json +++ b/packages/data-react/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-react", - "version": "0.9.92", + "version": "0.9.93", "description": "Adobe data React bindings — hooks and context for ECS database", "type": "module", "private": false, diff --git a/packages/data-solid-dashboard/package.json b/packages/data-solid-dashboard/package.json index 8c02b8ab..4a7b505b 100644 --- a/packages/data-solid-dashboard/package.json +++ b/packages/data-solid-dashboard/package.json @@ -1,6 +1,6 @@ { "name": "data-solid-dashboard", - "version": "0.9.92", + "version": "0.9.93", "description": "Mini dashboard sample — multiple components sharing one @adobe/data ECS database with SolidJS", "type": "module", "private": true, diff --git a/packages/data-solid/package.json b/packages/data-solid/package.json index 35bda073..55cd34bf 100644 --- a/packages/data-solid/package.json +++ b/packages/data-solid/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-solid", - "version": "0.9.92", + "version": "0.9.93", "description": "Adobe data SolidJS bindings — context and provider for ECS database", "type": "module", "private": false, diff --git a/packages/data-sync/package.json b/packages/data-sync/package.json index 8481a413..31f2a680 100644 --- a/packages/data-sync/package.json +++ b/packages/data-sync/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-sync", - "version": "0.9.92", + "version": "0.9.93", "description": "Multi-user real-time synchronisation for @adobe/data ECS — server, client, and in-process loopback.", "type": "module", "sideEffects": false, diff --git a/packages/data/package.json b/packages/data/package.json index edb58236..0601e300 100644 --- a/packages/data/package.json +++ b/packages/data/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data", - "version": "0.9.92", + "version": "0.9.93", "description": "Adobe data oriented programming library", "type": "module", "sideEffects": false, From 602c42fd6837e2551baefcd7677b301bc04ffea8 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Thu, 6 Aug 2026 21:52:36 -0700 Subject: [PATCH 20/37] docs(rules): explain case co-location; show vitest matcher; allow reusing pure state derivations in computeds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - state.md: concise rationale for co-locating cases (spec-owned fixtures 4 runners reuse, drift-proof type, no double-exec, central coverage guard); show matchers.ts = expect.any(Number) so the vitest pattern is explicit. - computed.md: relax the blanket ban on importing a state/ derivation into a computed — derivations take no services, so their cases are inert data that tree-shakes out; performance (a derivation observes full State) is the real constraint, not module hygiene. Co-Authored-By: Claude Sonnet 4.6 --- .../.claude/rules/features/data/state.md | 35 +++++++++++++++---- .../services/main-service/computed.md | 25 +++++++++---- 2 files changed, 48 insertions(+), 12 deletions(-) diff --git a/packages/data-ai/.claude/rules/features/data/state.md b/packages/data-ai/.claude/rules/features/data/state.md index 19d6dec7..43f7ac1d 100644 --- a/packages/data-ai/.claude/rules/features/data/state.md +++ b/packages/data-ai/.claude/rules/features/data/state.md @@ -26,8 +26,20 @@ spec aggregator throws if it finds one). The cases are the spec-owned truth ever conformance runner reuses; co-locating them removes the per-transform `.cases.ts` and `.test.ts`. +**Why co-locate `cases` (not a sibling `*.test.ts`)?** They are spec-owned +fixtures **four runners reuse** (spec / transaction / action / computed) — the +transform's contract expressed as data, not a per-file test — so they belong +beside the thing they specify, and `Conformance` binds them to the +signature so they can't drift. Kept inert (no `describe`; one aggregator runs +them) they also sidestep the double execution vitest triggers when a single file +both exports cases and runs its own `describe`. Coverage is then enforced +centrally by the aggregator's barrel-driven guard rather than by eyeballing one +test file per transform — and genuine non-transition helpers still keep their own +`*.test.ts` (see below). + ```ts // create-todo.ts +import { anyNumber } from "./matchers.js"; // vitest expect.any(Number) — see below export const createTodo = >( state: T, { name, complete, analytics }: { name: string; complete?: boolean; analytics: AnalyticsService }, @@ -56,12 +68,23 @@ export const cases: Conformance = [ - **`Conformance`** derives the case `args` type from the function's own signature — author it once, and cases can't drift from what the function accepts. `before`/`after` are full `State`. -- **`after` leaves minted values open** with the `anyNumber`/`anyString` matchers - (`matchers.ts`, wrapping vitest `expect.any`): an id the ECS assigns from its - own id-space is `id: anyNumber`, so the pure spec and the ECS satisfy the same - case. Match by content, not by the value you don't control. Add `matchers.ts` - only when a case needs one — a feature whose `State` exposes no ECS-minted ids - (values abstracted behind a scalar/string) never does. +- **`after` leaves minted values open** with the `anyNumber`/`anyString` + matchers. These are just **vitest's asymmetric matchers**, centralised so the + `vitest` import lives in one place and tree-shakes out of the app build: + + ```ts + // matchers.ts + import { expect } from "vitest"; + export const anyNumber = expect.any(Number); + export const anyString = expect.any(String); + ``` + + An id the ECS assigns from its own id-space is `id: anyNumber` (i.e. + `expect.any(Number)`), so the pure spec and the ECS satisfy the same case — + match by content, not by the value you don't control. `matches()` honors any + vitest asymmetric matcher here (`expect.stringContaining`, …), not only these + two. Add `matchers.ts` only when a case needs one — a feature whose `State` + exposes no ECS-minted ids (values abstracted behind a scalar/string) never does. - No per-transform test. The single **`spec.test.ts`** auto-discovers every file exporting `cases` and asserts the pure result (see `conformance.md`). Only the redundant per-transform tests are removed. A genuine **non-transition helper** in diff --git a/packages/data-ai/.claude/rules/features/services/main-service/computed.md b/packages/data-ai/.claude/rules/features/services/main-service/computed.md index 1ef6948f..cf8d8353 100644 --- a/packages/data-ai/.claude/rules/features/services/main-service/computed.md +++ b/packages/data-ai/.claude/rules/features/services/main-service/computed.md @@ -10,12 +10,25 @@ returns an `Observe` of state projected through pure `data/` helpers. Derivation logic itself lives in `data/`; a computed only wires a service observable to it. -Wire the **pure `data/` helper** (or compose indexes) — do **not** import a -`data/state` derivation into a production computed. A `state/` derivation's module -co-locates conformance `cases` that construct service test-doubles at load; it is -the spec the computed is *conformed to*, not a production dependency. Both the -computed and the `state/` derivation call the same `data/` helper, so they -agree. +**Reuse tested pure logic; let performance pick the wiring.** Prefer wiring the +pure **`data/` helper** (or composing indexes) directly — the tictactoe +computeds delegate their observable straight to +`BoardState.deriveStatus`/`getWinner`/`currentPlayer` — because a helper reads +exactly the resource/entities it needs, and both the computed and the matching +`state/` derivation call it, so they agree by construction. + +You **may** also import a pure `state/` derivation `(state) => value` into a +computed where performance is adequate: a small-N or resource/scalar composition, +not a hot per-entity or large-N path. Reusing a tested derivation is good — the +only cost is that it takes the **whole `State`**, so the computed must observe the +full-state projection and re-runs on *any* field change (fine for a small feature, +wasteful on a large or hot one, where you hand-wire the minimal resource/index +reads instead). A derivation takes **no services**, so its co-located `cases` are +inert `{ input, value }` data (no test-doubles) that tree-shake out of the app +build like `matchers.ts` does — the one hazard is a `cases` literal touching the +`public.js` barrel at load, which `state.md` already forbids. **Performance is the +first-class constraint**: reuse freely where it doesn't matter, hand-wire minimal +reads where it does. ```ts import { cached } from "@adobe/data/cache"; From b150e7303ac70131c2a7d031c2f09db4ba8c98a6 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Thu, 6 Aug 2026 21:58:33 -0700 Subject: [PATCH 21/37] docs(rules): make the test-helper-runtime ban emphatic and unambiguous fromState/toState/toData(store,entity) are conformance-only and must never run in production. Disambiguate the conformance toData(store,entity) reader from the library's db.toData() serialization API (name collision only). Tie the remaining anyNumber mention to expect.any(Number). Co-Authored-By: Claude Sonnet 4.6 --- .../features/services/main-service/conformance.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/data-ai/.claude/rules/features/services/main-service/conformance.md b/packages/data-ai/.claude/rules/features/services/main-service/conformance.md index bc255129..cb07f568 100644 --- a/packages/data-ai/.claude/rules/features/services/main-service/conformance.md +++ b/packages/data-ai/.claude/rules/features/services/main-service/conformance.md @@ -7,8 +7,15 @@ paths: Test-only (imported only by `*.test.ts`, in no facet barrel). The `data/state` cases are the shared truth; these runners replay them against the ECS. Reference: -`data-lit-todo`'s `conformance/` + its `spec.test.ts`. Never call -`fromState`/`toState`/`toData` from runtime code — they are full-store rewrites. +`data-lit-todo`'s `conformance/` + its `spec.test.ts`. + +**These `conformance/` projection helpers — `fromState`, `toState`, and the +`toData(store, entity)` reader defined here — are strictly for conformance tests +and MUST NEVER run in production code, ever.** `fromState`/`toState` rewrite the +whole store out-of-band; runtime code reads through observables/indexes and writes +through transactions. (This conformance `toData(store, entity)` reader is unrelated +to the library's `db.toData()` store-serialization method, which *is* a normal +runtime API — the collision is only in the name.) ## Projection (store ⇄ State) @@ -22,7 +29,8 @@ cases are the shared truth; these runners replay them against the ECS. Reference One matcher-aware `matches(actual, expected)` (exported; also backs derivations): honors vitest **asymmetric matchers** on the expected side (so `after`/`value` -use `anyNumber` for ECS-assigned ids), quantizes numbers to absorb F32↔f64 noise, +use `anyNumber` — i.e. `expect.any(Number)`, see `state.md` — for ECS-assigned +ids), quantizes numbers to absorb F32↔f64 noise, and compares arrays **in order** by default (`toState` reads a display-ordered collection in order — this is what verifies a reorder; and ordered tuples like a `Vec2` must stay in order). No separate id-ignoring variant. From 7b8261a964308b904eda56a92128d9adbbc1d473 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Thu, 6 Aug 2026 22:55:12 -0700 Subject: [PATCH 22/37] =?UTF-8?q?feat(data):=20@adobe/data/testing=20?= =?UTF-8?q?=E2=80=94=20shared=20conformance=20toolkit;=20migrate=20todo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `@adobe/data/testing` subpath export, two namespaces: - Match: framework-agnostic tolerant compare (matches/assert) honoring asymmetric matchers (anyNumber/anyString) and a `ref` id-correspondence matcher, float quantization, and per-collection ordered/multiset arrays. - Conformance: the case types (Case/Cases/DerivationCase/DerivationCases/Effects/ ServiceCall) bound to a feature's State via a ~10-line alias; effect recording; generic id resolution (fromState returns Id→Entity, `resolver` builds resolve); and the runSpec/runTransactions/runActions/runComputeds driver harnesses. vitest is an optional peerDependency (runners register describe/it); Match stays framework-free. `args` is now omittable when a transform takes none. Migrate data-lit-todo to it: delete per-feature conformance-case types, expect-state-matches, matchers, record-effects, expect-conforms (~240 lines); runner test files keep only their bespoke apply/run adapters. 117 tests green. Co-Authored-By: Claude Sonnet 4.6 --- .../features/main/data/state/append-todo.ts | 8 +- .../main/data/state/conformance-case.ts | 78 ++-------- .../data/state/conformance-case.type-test.ts | 25 +++- .../main/data/state/create-bulk-todos.ts | 41 +++-- .../main/data/state/create-random-todo.ts | 39 ++++- .../features/main/data/state/create-todo.ts | 39 +++-- .../features/main/data/state/delete-todo.ts | 24 +-- .../main/data/state/expect-state-matches.ts | 56 ------- .../src/features/main/data/state/matchers.ts | 11 -- .../features/main/data/state/reorder-todo.ts | 29 ++-- .../src/features/main/data/state/spec.test.ts | 69 ++------- .../main/data/state/toggle-complete.ts | 28 ++-- .../data/state/toggle-display-completed.ts | 17 ++- .../features/main/data/state/visible-todos.ts | 13 +- .../main-service/conformance/actions.test.ts | 140 ++++++++---------- .../conformance/computeds.test.ts | 118 ++++----------- .../main-service/conformance/create-store.ts | 3 +- .../conformance/expect-conforms.ts | 57 ------- .../main-service/conformance/from-state.ts | 32 ++-- .../conformance/projection.test.ts | 7 +- .../main-service/conformance/to-data.ts | 3 +- .../main-service/conformance/to-state.ts | 8 +- .../conformance/transactions.test.ts | 98 ++++++------ packages/data/package.json | 15 ++ .../data/src/testing/conformance/public.ts | 8 + .../testing/conformance}/record-effects.ts | 127 ++++++++-------- .../data/src/testing/conformance/resolve.ts | 12 ++ .../src/testing/conformance/run-actions.ts | 72 +++++++++ .../src/testing/conformance/run-computeds.ts | 100 +++++++++++++ .../data/src/testing/conformance/run-spec.ts | 62 ++++++++ .../testing/conformance/run-transactions.ts | 68 +++++++++ .../data/src/testing/conformance/types.ts | 68 +++++++++ packages/data/src/testing/index.ts | 9 ++ packages/data/src/testing/match/assert.ts | 19 +++ packages/data/src/testing/match/match.test.ts | 51 +++++++ packages/data/src/testing/match/match.ts | 102 +++++++++++++ packages/data/src/testing/match/matchers.ts | 15 ++ packages/data/src/testing/match/public.ts | 4 + 38 files changed, 1035 insertions(+), 640 deletions(-) delete mode 100644 packages/data-lit-todo/src/features/main/data/state/expect-state-matches.ts delete mode 100644 packages/data-lit-todo/src/features/main/data/state/matchers.ts delete mode 100644 packages/data-lit-todo/src/features/main/services/main-service/conformance/expect-conforms.ts create mode 100644 packages/data/src/testing/conformance/public.ts rename packages/{data-lit-todo/src/features/main/data/state => data/src/testing/conformance}/record-effects.ts (53%) create mode 100644 packages/data/src/testing/conformance/resolve.ts create mode 100644 packages/data/src/testing/conformance/run-actions.ts create mode 100644 packages/data/src/testing/conformance/run-computeds.ts create mode 100644 packages/data/src/testing/conformance/run-spec.ts create mode 100644 packages/data/src/testing/conformance/run-transactions.ts create mode 100644 packages/data/src/testing/conformance/types.ts create mode 100644 packages/data/src/testing/index.ts create mode 100644 packages/data/src/testing/match/assert.ts create mode 100644 packages/data/src/testing/match/match.test.ts create mode 100644 packages/data/src/testing/match/match.ts create mode 100644 packages/data/src/testing/match/matchers.ts create mode 100644 packages/data/src/testing/match/public.ts diff --git a/packages/data-lit-todo/src/features/main/data/state/append-todo.ts b/packages/data-lit-todo/src/features/main/data/state/append-todo.ts index 69e05f3e..81325d2b 100644 --- a/packages/data-lit-todo/src/features/main/data/state/append-todo.ts +++ b/packages/data-lit-todo/src/features/main/data/state/append-todo.ts @@ -10,9 +10,13 @@ export const appendTodo = >( state: T, input: { readonly name: string; readonly complete?: boolean }, ): T => { - const nextId = state.todos.reduce((max, todo) => Math.max(max, todo.id), 0) + 1; + const nextId = + state.todos.reduce((max, todo) => Math.max(max, todo.id), 0) + 1; return { ...state, - todos: [...state.todos, { id: nextId, name: input.name, complete: input.complete ?? false }], + todos: [ + ...state.todos, + { id: nextId, name: input.name, complete: input.complete ?? false }, + ], }; }; diff --git a/packages/data-lit-todo/src/features/main/data/state/conformance-case.ts b/packages/data-lit-todo/src/features/main/data/state/conformance-case.ts index 095f3cb2..0b87028e 100644 --- a/packages/data-lit-todo/src/features/main/data/state/conformance-case.ts +++ b/packages/data-lit-todo/src/features/main/data/state/conformance-case.ts @@ -1,70 +1,14 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance as ConformanceApi } from "@adobe/data/testing"; import type { State } from "./state.js"; -// A service arg is an object with method members (not an array, not a function); -// this is what distinguishes an injected service from plain data args, since the -// `Service` marker itself is all-optional and would over-match. -type MethodKeys = { [K in keyof T]-?: T[K] extends (...a: never[]) => unknown ? K : never }[keyof T]; -type IsService = T extends readonly unknown[] - ? false - : T extends (...a: never[]) => unknown - ? false - : T extends object - ? [MethodKeys] extends [never] - ? false - : true - : false; - -// A strongly-typed call to one method of service `S`: `[methodName, ...its args]`. -// A no-arg method is just `[methodName]`. -export type Call = { - [M in keyof S]-?: S[M] extends (...a: infer A) => unknown ? readonly [M, ...A] : never; -}[keyof S]; - -// Expected side effects for a case, keyed by the service-typed args only. An -// `Array` value asserts these calls in this order; a `Set` value asserts the same -// calls in any order. Method names and their args are checked against the service. -export type Effects = { - readonly [K in keyof Args as IsService extends true ? K : never]?: - | readonly Call[] - | ReadonlySet>; -}; - -// One spec-owned conformance case: a `data/` transform's `{ before, args, after }` -// authored as full `State`, optionally with the side effects it makes on its -// injected service args. Shared unchanged by the spec aggregator (`spec.test.ts`) -// and the ecs conformance runners (`services/main-service/conformance/`). -export type ConformanceCase = { - readonly name: string; - readonly before: State; - readonly args: Args; - readonly after: State; - readonly effects?: Effects; -}; - -// A transform's cases, with the case `args` type derived from the transform's own -// signature — its second parameter, or `void` when it takes none. A transform -// co-locates `export const cases: Conformance = [...]`, so the -// cases cannot drift from what the function accepts, and the spec aggregator can -// discover the function without it being named twice. -export type Conformance unknown> = readonly ConformanceCase< - Parameters extends [unknown, infer Args, ...unknown[]] ? Args : void ->[]; - -// One case for a derivation (`(state) => value`): a state `input` and the `value` -// it yields. `value` may use asymmetric matchers (`anyNumber` for ids). Both the -// pure derivation and its ecs computed are checked against these. -export type DerivationCase = { - readonly name: string; - readonly input: Input; - readonly value: Value; -}; - -// A derivation's cases, with `input` and `value` types read from the derivation's -// own signature (its parameter and return) — the `Conformance` analog for -// value-producing derivations. A derivation co-locates -// `export const cases: Derivation = [...]`. -export type Derivation unknown> = readonly DerivationCase< - Parameters[0], - ReturnType ->[]; +// The conformance case types for this feature — the shared `@adobe/data/testing` +// machinery with `State` bound once, so a transform authors `Conformance` +// and a derivation `Derivation` (args/input/value read from the +// function's own signature). This tiny file is the only per-feature conformance +// type declaration; everything else lives in `@adobe/data/testing`. +export type Conformance unknown> = + ConformanceApi.Cases; +export type Derivation unknown> = + ConformanceApi.DerivationCases; +export type Effects = ConformanceApi.Effects; diff --git a/packages/data-lit-todo/src/features/main/data/state/conformance-case.type-test.ts b/packages/data-lit-todo/src/features/main/data/state/conformance-case.type-test.ts index 9c3b109e..bf27d248 100644 --- a/packages/data-lit-todo/src/features/main/data/state/conformance-case.type-test.ts +++ b/packages/data-lit-todo/src/features/main/data/state/conformance-case.type-test.ts @@ -9,14 +9,25 @@ import type { AnalyticsService } from "../../services/analytics-service/analytic import type { Effects } from "./conformance-case.js"; // A representative transition arg shape: plain data + one injected service. -type Args = { readonly name: string; readonly complete?: boolean; readonly analytics: AnalyticsService }; +type Args = { + readonly name: string; + readonly complete?: boolean; + readonly analytics: AnalyticsService; +}; // ===== POSITIVE — must compile ===== -const ordered: Effects = { analytics: [["todoCreated", { name: "a" }], ["todoToggled"]] }; -const anyOrder: Effects = { analytics: new Set([["todoToggled"] as const, ["allTodosCleared"] as const]) }; +const ordered: Effects = { + analytics: [["todoCreated", { name: "a" }], ["todoToggled"]], +}; +const anyOrder: Effects = { + analytics: new Set([["todoToggled"] as const, ["allTodosCleared"] as const]), +}; const noArgMethod: Effects = { analytics: [["displayCompletedToggled"]] }; const empty: Effects = {}; -void ordered; void anyOrder; void noArgMethod; void empty; +void ordered; +void anyOrder; +void noArgMethod; +void empty; // ===== NEGATIVE — each must error ===== const badMethod: Effects = { @@ -39,4 +50,8 @@ const dataKey: Effects = { // @ts-expect-error - "name" is a data arg, not a service name: [["todoCreated", { name: "a" }]], }; -void badMethod; void badArgs; void missingArgs; void extraArg; void dataKey; +void badMethod; +void badArgs; +void missingArgs; +void extraArg; +void dataKey; diff --git a/packages/data-lit-todo/src/features/main/data/state/create-bulk-todos.ts b/packages/data-lit-todo/src/features/main/data/state/create-bulk-todos.ts index 70ccba98..bfa0f738 100644 --- a/packages/data-lit-todo/src/features/main/data/state/create-bulk-todos.ts +++ b/packages/data-lit-todo/src/features/main/data/state/create-bulk-todos.ts @@ -3,12 +3,14 @@ import { AnalyticsService } from "../../services/analytics-service/analytics-ser import type { State } from "./state.js"; import type { Conformance } from "./conformance-case.js"; import { appendTodo } from "./append-todo.js"; -import { anyNumber } from "./matchers.js"; - +import { Match } from "@adobe/data/testing"; /** Adds numbered placeholder todos for demos and performance testing. */ export const createBulkTodos = >( state: T, - { count, analytics }: { readonly count: number; readonly analytics: AnalyticsService }, + { + count, + analytics, + }: { readonly count: number; readonly analytics: AnalyticsService }, ): T => { analytics.bulkTodosCreated({ count }); const total = Math.max(0, Math.floor(count)); @@ -22,7 +24,7 @@ export const createBulkTodos = >( // Spec-owned cases, shared with the ecs `createBulkTodos` transaction. `count` // (floored, clamped at 0) numbered todos are appended; the transition logs // `bulkTodosCreated` with the raw count (as the action does), even on a no-op. -// Minted ids are left open (`anyNumber`) — the ecs assigns its own. +// Minted ids are left open (`Match.anyNumber`) — the ecs assigns its own. export const cases: Conformance = [ { name: "appends count numbered todos to an empty list", @@ -30,9 +32,9 @@ export const cases: Conformance = [ args: { count: 3, analytics: AnalyticsService.createFake() }, after: { todos: [ - { id: anyNumber, name: "Todo 0", complete: false }, - { id: anyNumber, name: "Todo 1", complete: false }, - { id: anyNumber, name: "Todo 2", complete: false }, + { id: Match.anyNumber, name: "Todo 0", complete: false }, + { id: Match.anyNumber, name: "Todo 1", complete: false }, + { id: Match.anyNumber, name: "Todo 2", complete: false }, ], displayCompleted: false, }, @@ -40,13 +42,16 @@ export const cases: Conformance = [ }, { name: "continues names after existing todos", - before: { todos: [{ id: 1, name: "a", complete: false }], displayCompleted: false }, + before: { + todos: [{ id: 1, name: "a", complete: false }], + displayCompleted: false, + }, args: { count: 2, analytics: AnalyticsService.createFake() }, after: { todos: [ - { id: anyNumber, name: "a", complete: false }, - { id: anyNumber, name: "Todo 1", complete: false }, - { id: anyNumber, name: "Todo 2", complete: false }, + { id: Match.anyNumber, name: "a", complete: false }, + { id: Match.anyNumber, name: "Todo 1", complete: false }, + { id: Match.anyNumber, name: "Todo 2", complete: false }, ], displayCompleted: false, }, @@ -58,8 +63,8 @@ export const cases: Conformance = [ args: { count: 2.9, analytics: AnalyticsService.createFake() }, after: { todos: [ - { id: anyNumber, name: "Todo 0", complete: false }, - { id: anyNumber, name: "Todo 1", complete: false }, + { id: Match.anyNumber, name: "Todo 0", complete: false }, + { id: Match.anyNumber, name: "Todo 1", complete: false }, ], displayCompleted: false, }, @@ -67,9 +72,15 @@ export const cases: Conformance = [ }, { name: "is a no-op for count 0 but still logs the request", - before: { todos: [{ id: 1, name: "a", complete: false }], displayCompleted: true }, + before: { + todos: [{ id: 1, name: "a", complete: false }], + displayCompleted: true, + }, args: { count: 0, analytics: AnalyticsService.createFake() }, - after: { todos: [{ id: anyNumber, name: "a", complete: false }], displayCompleted: true }, + after: { + todos: [{ id: Match.anyNumber, name: "a", complete: false }], + displayCompleted: true, + }, effects: { analytics: [["bulkTodosCreated", { count: 0 }]] }, }, ]; diff --git a/packages/data-lit-todo/src/features/main/data/state/create-random-todo.ts b/packages/data-lit-todo/src/features/main/data/state/create-random-todo.ts index 52803963..bc96dbc1 100644 --- a/packages/data-lit-todo/src/features/main/data/state/create-random-todo.ts +++ b/packages/data-lit-todo/src/features/main/data/state/create-random-todo.ts @@ -4,8 +4,7 @@ import { AnalyticsService } from "../../services/analytics-service/analytics-ser import type { State } from "./state.js"; import type { Conformance } from "./conformance-case.js"; import { appendTodo } from "./append-todo.js"; -import { anyNumber } from "./matchers.js"; - +import { Match } from "@adobe/data/testing"; /** * Async, service-injected transition: brackets the slow name generation with * analytics timing, then appends the todo via the shared {@link appendTodo} (so @@ -15,7 +14,10 @@ import { anyNumber } from "./matchers.js"; */ export const createRandomTodo = async >( state: T, - { nameGenerator, analytics }: { + { + nameGenerator, + analytics, + }: { readonly nameGenerator: NameGeneratorService; readonly analytics: AnalyticsService; }, @@ -36,15 +38,30 @@ export const cases: Conformance = [ { name: "names the new todo from the generator and logs the timed add", before: { todos: [], displayCompleted: false }, - args: { nameGenerator: NameGeneratorService.createFake(), analytics: AnalyticsService.createFake() }, + args: { + nameGenerator: NameGeneratorService.createFake(), + analytics: AnalyticsService.createFake(), + }, after: { - todos: [{ id: anyNumber, name: NameGeneratorService.fakeNames[0], complete: false }], + todos: [ + { + id: Match.anyNumber, + name: NameGeneratorService.fakeNames[0], + complete: false, + }, + ], displayCompleted: false, }, effects: { analytics: [ ["randomTodoRequested"], - ["randomTodoAdded", { timing: AnalyticsService.fakeTiming, name: NameGeneratorService.fakeNames[0] }], + [ + "randomTodoAdded", + { + timing: AnalyticsService.fakeTiming, + name: NameGeneratorService.fakeNames[0], + }, + ], ], }, }, @@ -55,11 +72,17 @@ export const cases: Conformance = [ nameGenerator: NameGeneratorService.createFake(["only name"]), analytics: AnalyticsService.createFake(), }, - after: { todos: [{ id: anyNumber, name: "only name", complete: false }], displayCompleted: false }, + after: { + todos: [{ id: Match.anyNumber, name: "only name", complete: false }], + displayCompleted: false, + }, effects: { analytics: [ ["randomTodoRequested"], - ["randomTodoAdded", { timing: AnalyticsService.fakeTiming, name: "only name" }], + [ + "randomTodoAdded", + { timing: AnalyticsService.fakeTiming, name: "only name" }, + ], ], }, }, diff --git a/packages/data-lit-todo/src/features/main/data/state/create-todo.ts b/packages/data-lit-todo/src/features/main/data/state/create-todo.ts index 1036f559..8848652c 100644 --- a/packages/data-lit-todo/src/features/main/data/state/create-todo.ts +++ b/packages/data-lit-todo/src/features/main/data/state/create-todo.ts @@ -3,11 +3,14 @@ import { AnalyticsService } from "../../services/analytics-service/analytics-ser import type { State } from "./state.js"; import type { Conformance } from "./conformance-case.js"; import { appendTodo } from "./append-todo.js"; -import { anyNumber } from "./matchers.js"; - +import { Match } from "@adobe/data/testing"; export const createTodo = >( state: T, - { name, complete, analytics }: { + { + name, + complete, + analytics, + }: { readonly name: string; readonly complete?: boolean; readonly analytics: AnalyticsService; @@ -18,24 +21,34 @@ export const createTodo = >( }; // Spec-owned cases, shared with the ecs `createTodo` transaction. A todo is -// appended (minted id left open as `anyNumber` — the ecs assigns its own) with +// appended (minted id left open as `Match.anyNumber` — the ecs assigns its own) with // `complete` defaulting to false; the transition logs `todoCreated`. export const cases: Conformance = [ { name: "appends the first todo to an empty list", before: { todos: [], displayCompleted: false }, args: { name: "a", analytics: AnalyticsService.createFake() }, - after: { todos: [{ id: anyNumber, name: "a", complete: false }], displayCompleted: false }, + after: { + todos: [{ id: Match.anyNumber, name: "a", complete: false }], + displayCompleted: false, + }, effects: { analytics: [["todoCreated", { name: "a" }]] }, }, { name: "appends a complete todo", - before: { todos: [{ id: 1, name: "a", complete: false }], displayCompleted: false }, - args: { name: "b", complete: true, analytics: AnalyticsService.createFake() }, + before: { + todos: [{ id: 1, name: "a", complete: false }], + displayCompleted: false, + }, + args: { + name: "b", + complete: true, + analytics: AnalyticsService.createFake(), + }, after: { todos: [ - { id: anyNumber, name: "a", complete: false }, - { id: anyNumber, name: "b", complete: true }, + { id: Match.anyNumber, name: "a", complete: false }, + { id: Match.anyNumber, name: "b", complete: true }, ], displayCompleted: false, }, @@ -54,10 +67,10 @@ export const cases: Conformance = [ args: { name: "d", analytics: AnalyticsService.createFake() }, after: { todos: [ - { id: anyNumber, name: "a", complete: false }, - { id: anyNumber, name: "b", complete: true }, - { id: anyNumber, name: "c", complete: false }, - { id: anyNumber, name: "d", complete: false }, + { id: Match.anyNumber, name: "a", complete: false }, + { id: Match.anyNumber, name: "b", complete: true }, + { id: Match.anyNumber, name: "c", complete: false }, + { id: Match.anyNumber, name: "d", complete: false }, ], displayCompleted: true, }, diff --git a/packages/data-lit-todo/src/features/main/data/state/delete-todo.ts b/packages/data-lit-todo/src/features/main/data/state/delete-todo.ts index cb3d8ca4..2aff09e8 100644 --- a/packages/data-lit-todo/src/features/main/data/state/delete-todo.ts +++ b/packages/data-lit-todo/src/features/main/data/state/delete-todo.ts @@ -2,11 +2,13 @@ import { AnalyticsService } from "../../services/analytics-service/analytics-service.js"; import type { State } from "./state.js"; import type { Conformance } from "./conformance-case.js"; -import { anyNumber } from "./matchers.js"; - +import { Match } from "@adobe/data/testing"; export const deleteTodo = >( state: T, - { id, analytics }: { readonly id: number; readonly analytics: AnalyticsService }, + { + id, + analytics, + }: { readonly id: number; readonly analytics: AnalyticsService }, ): T => { analytics.todoDeleted(); return { ...state, todos: state.todos.filter((todo) => todo.id !== id) }; @@ -21,7 +23,7 @@ const three = [ // Spec-owned cases, shared with the ecs `deleteTodo` transaction. The addressed // todo is removed; an unknown id is a no-op. The transition logs `todoDeleted`. // `before` ids are concrete (they address the delete); surviving `after` ids are -// left open (`anyNumber`) — the ecs assigns its own. +// left open (`Match.anyNumber`) — the ecs assigns its own. export const cases: Conformance = [ { name: "removes a middle todo", @@ -29,8 +31,8 @@ export const cases: Conformance = [ args: { id: 2, analytics: AnalyticsService.createFake() }, after: { todos: [ - { id: anyNumber, name: "a", complete: false }, - { id: anyNumber, name: "c", complete: false }, + { id: Match.anyNumber, name: "a", complete: false }, + { id: Match.anyNumber, name: "c", complete: false }, ], displayCompleted: false, }, @@ -42,8 +44,8 @@ export const cases: Conformance = [ args: { id: 1, analytics: AnalyticsService.createFake() }, after: { todos: [ - { id: anyNumber, name: "b", complete: true }, - { id: anyNumber, name: "c", complete: false }, + { id: Match.anyNumber, name: "b", complete: true }, + { id: Match.anyNumber, name: "c", complete: false }, ], displayCompleted: true, }, @@ -55,9 +57,9 @@ export const cases: Conformance = [ args: { id: 99, analytics: AnalyticsService.createFake() }, after: { todos: [ - { id: anyNumber, name: "a", complete: false }, - { id: anyNumber, name: "b", complete: true }, - { id: anyNumber, name: "c", complete: false }, + { id: Match.anyNumber, name: "a", complete: false }, + { id: Match.anyNumber, name: "b", complete: true }, + { id: Match.anyNumber, name: "c", complete: false }, ], displayCompleted: false, }, diff --git a/packages/data-lit-todo/src/features/main/data/state/expect-state-matches.ts b/packages/data-lit-todo/src/features/main/data/state/expect-state-matches.ts deleted file mode 100644 index c410fd8e..00000000 --- a/packages/data-lit-todo/src/features/main/data/state/expect-state-matches.ts +++ /dev/null @@ -1,56 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { expect } from "vitest"; -import type { State } from "./state.js"; - -// A vitest asymmetric matcher (`expect.any(...)`, see `matchers.ts`): honored on -// the EXPECTED side so a case can assert "any number" for a value it does not pin. -const isMatcher = (value: unknown): value is { asymmetricMatch(actual: unknown): boolean } => - typeof value === "object" && - value !== null && - typeof (value as { asymmetricMatch?: unknown }).asymmetricMatch === "function"; - -// Collapse F32↔f64 storage rounding (the ecs `order` column is F32, the spec -// authors integers) onto a small grid so float noise compares equal. `+ 0` -// normalises `-0` to `0`. -const quantize = (n: number): number => Math.round(Math.fround(n) * 100) / 100 + 0; - -// Tolerant structural match honoring asymmetric matchers, float precision, and -// order-sensitive arrays (`toState` reads todos in display order, so position is -// significant — this is what actually verifies a reorder). Exported so it can -// back other conformance comparisons (e.g. computed values). -export const matches = (actual: unknown, expected: unknown): boolean => { - if (isMatcher(expected)) return expected.asymmetricMatch(actual); - if (typeof expected === "number" && typeof actual === "number") { - return quantize(actual) === quantize(expected); - } - if (Array.isArray(expected)) { - if (!Array.isArray(actual) || actual.length !== expected.length) return false; - return expected.every((exp, index) => matches(actual[index], exp)); - } - if (expected !== null && typeof expected === "object") { - if (actual === null || typeof actual !== "object" || Array.isArray(actual)) return false; - const expectedKeys = Object.keys(expected); - const actualKeys = Object.keys(actual as object); - if (expectedKeys.length !== actualKeys.length) return false; - return expectedKeys.every((key) => - matches((actual as Record)[key], (expected as Record)[key]), - ); - } - return Object.is(actual, expected); -}; - -// Spec-owned tolerant `State` equality, shared by the data/ transform tests and -// the ecs conformance runners. `after` may use asymmetric matchers -// (`anyNumber` for ids the ecs assigns from its own id-space), so this one -// comparison serves both the pure spec and the ecs projection — no separate -// id-ignoring variant is needed. -export const expectStateMatches = (actual: State, expected: State): void => { - expectMatches(actual, expected); -}; - -// The same tolerant, matcher-aware comparison for any value — used by derivation -// spec tests and computed conformance, where the compared value is a `Todo[]` or -// a scalar rather than a whole `State`. -export const expectMatches = (actual: unknown, expected: unknown): void => { - expect(matches(actual, expected), `mismatch:\n actual ${JSON.stringify(actual)}`).toBe(true); -}; diff --git a/packages/data-lit-todo/src/features/main/data/state/matchers.ts b/packages/data-lit-todo/src/features/main/data/state/matchers.ts deleted file mode 100644 index fe3aad89..00000000 --- a/packages/data-lit-todo/src/features/main/data/state/matchers.ts +++ /dev/null @@ -1,11 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { expect } from "vitest"; - -// Asymmetric matchers for conformance-case values a case does not pin — chiefly -// an entity `id`, which the ecs assigns from its own id-space, so the spec and -// the ecs projection satisfy the same case without agreeing on the value. Typed -// `any` (like vitest's `expect.any`), they slot straight into the value's slot -// (`id: number`). Centralised here so the `vitest` import lives in one place; -// they are test-only data and tree-shake out of the app build. -export const anyNumber = expect.any(Number); -export const anyString = expect.any(String); diff --git a/packages/data-lit-todo/src/features/main/data/state/reorder-todo.ts b/packages/data-lit-todo/src/features/main/data/state/reorder-todo.ts index 881f09b7..c026fde5 100644 --- a/packages/data-lit-todo/src/features/main/data/state/reorder-todo.ts +++ b/packages/data-lit-todo/src/features/main/data/state/reorder-todo.ts @@ -1,8 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { State } from "./state.js"; import type { Conformance } from "./conformance-case.js"; -import { anyNumber } from "./matchers.js"; - +import { Match } from "@adobe/data/testing"; /** * Moves the todo with the given id to `toIndex` within the list, preserving the * relative order of every other todo. Out-of-range indices are clamped and an @@ -35,7 +34,7 @@ const three = [ // the same move — `finalIndex` is `toIndex`). Every case keeps all todos // incomplete with `displayCompleted` true, so the visible list `dragTodo` indexes // equals the full list. `before` ids address the move; `after` ids are open -// (`anyNumber`) but their *order* is verified. The unknown-id no-op is exercised +// (`Match.anyNumber`) but their *order* is verified. The unknown-id no-op is exercised // only by the pure transform — `dragTodo` has no such guard. export const cases: Conformance = [ { @@ -44,9 +43,9 @@ export const cases: Conformance = [ args: { id: 1, toIndex: 2 }, after: { todos: [ - { id: anyNumber, name: "b", complete: false }, - { id: anyNumber, name: "c", complete: false }, - { id: anyNumber, name: "a", complete: false }, + { id: Match.anyNumber, name: "b", complete: false }, + { id: Match.anyNumber, name: "c", complete: false }, + { id: Match.anyNumber, name: "a", complete: false }, ], displayCompleted: true, }, @@ -57,9 +56,9 @@ export const cases: Conformance = [ args: { id: 3, toIndex: 0 }, after: { todos: [ - { id: anyNumber, name: "c", complete: false }, - { id: anyNumber, name: "a", complete: false }, - { id: anyNumber, name: "b", complete: false }, + { id: Match.anyNumber, name: "c", complete: false }, + { id: Match.anyNumber, name: "a", complete: false }, + { id: Match.anyNumber, name: "b", complete: false }, ], displayCompleted: true, }, @@ -70,9 +69,9 @@ export const cases: Conformance = [ args: { id: 1, toIndex: 99 }, after: { todos: [ - { id: anyNumber, name: "b", complete: false }, - { id: anyNumber, name: "c", complete: false }, - { id: anyNumber, name: "a", complete: false }, + { id: Match.anyNumber, name: "b", complete: false }, + { id: Match.anyNumber, name: "c", complete: false }, + { id: Match.anyNumber, name: "a", complete: false }, ], displayCompleted: true, }, @@ -83,9 +82,9 @@ export const cases: Conformance = [ args: { id: 2, toIndex: 1 }, after: { todos: [ - { id: anyNumber, name: "a", complete: false }, - { id: anyNumber, name: "b", complete: false }, - { id: anyNumber, name: "c", complete: false }, + { id: Match.anyNumber, name: "a", complete: false }, + { id: Match.anyNumber, name: "b", complete: false }, + { id: Match.anyNumber, name: "c", complete: false }, ], displayCompleted: true, }, diff --git a/packages/data-lit-todo/src/features/main/data/state/spec.test.ts b/packages/data-lit-todo/src/features/main/data/state/spec.test.ts index d398242c..e46f21ac 100644 --- a/packages/data-lit-todo/src/features/main/data/state/spec.test.ts +++ b/packages/data-lit-todo/src/features/main/data/state/spec.test.ts @@ -1,59 +1,18 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. /// -import { describe, it } from "vitest"; -import type { State } from "./state.js"; -import type { ConformanceCase, DerivationCase } from "./conformance-case.js"; -import { expectStateMatches, expectMatches } from "./expect-state-matches.js"; -import { recordArgServices, expectEffects } from "./record-effects.js"; +import { Conformance } from "@adobe/data/testing"; -// The single spec test for every transform AND derivation in this folder. It -// auto-discovers each file (any sibling `.ts` that exports `cases`) via -// `import.meta.glob`, so a new one is covered the moment it ships — none can be -// forgotten. Each participating file must export exactly its function plus `cases` -// (enforced below), which lets us find the function without it being named twice. -// A case's shape selects the check: `after` → a transition `(state, args) => state`; -// `value` → a derivation `(state) => value`. -const modules = import.meta.glob>( - ["./*.ts", "!./*.test.ts", "!./*.type-test.ts"], - { eager: true }, +// The single pure-spec test for every transform AND derivation in this folder. +// `runSpec` auto-discovers each sibling that exports `cases`, requires it to +// export exactly its function plus `cases`, and dispatches on case shape (a +// `value` case is a derivation, otherwise a transition whose declared `effects` +// are also asserted). Todo's `State` lists are display-ordered, so the default +// (ordered, matcher-aware) comparison is correct — no options needed. +Conformance.runSpec( + import.meta.glob>( + ["./*.ts", "!./*.test.ts", "!./*.type-test.ts"], + { + eager: true, + }, + ), ); - -const isDerivationCase = (c: unknown): c is DerivationCase => - typeof c === "object" && c !== null && "value" in c; - -for (const [path, module] of Object.entries(modules)) { - const exportNames = Object.keys(module); - if (!exportNames.includes("cases")) continue; - const functionNames = exportNames.filter((key) => typeof module[key] === "function"); - const label = functionNames.length === 1 ? functionNames[0] : path; - - describe(`State.${label}`, () => { - if (exportNames.length !== 2 || functionNames.length !== 1) { - it("exports exactly its function and `cases`", () => { - throw new Error(`${path} exports [${exportNames.join(", ")}] — expected one function + cases`); - }); - return; - } - // Runtime invariant: a participating file exports a function and its cases. - const fn = module[functionNames[0]] as (...args: unknown[]) => unknown; - const cases = module["cases"] as readonly unknown[]; - for (const testCase of cases) { - if (isDerivationCase(testCase)) { - // Derivation: the value it yields matches, honoring `anyNumber`. - it(testCase.name, () => expectMatches(fn(testCase.input), testCase.value)); - continue; - } - // Transition: assert the resulting state and the declared side effects. - // A service-injected transition is async, so await uniformly. - const transitionCase = testCase as ConformanceCase>; - it(transitionCase.name, async () => { - const raw = transitionCase.args; - const { args, calls } = - raw && typeof raw === "object" ? recordArgServices(raw) : { args: raw, calls: {} }; - const result = (await fn(transitionCase.before, args)) as State; - expectStateMatches(result, transitionCase.after); - expectEffects(calls, transitionCase.effects); - }); - } - }); -} diff --git a/packages/data-lit-todo/src/features/main/data/state/toggle-complete.ts b/packages/data-lit-todo/src/features/main/data/state/toggle-complete.ts index 170ed03a..819bbddc 100644 --- a/packages/data-lit-todo/src/features/main/data/state/toggle-complete.ts +++ b/packages/data-lit-todo/src/features/main/data/state/toggle-complete.ts @@ -2,11 +2,13 @@ import { AnalyticsService } from "../../services/analytics-service/analytics-service.js"; import type { State } from "./state.js"; import type { Conformance } from "./conformance-case.js"; -import { anyNumber } from "./matchers.js"; - +import { Match } from "@adobe/data/testing"; export const toggleComplete = >( state: T, - { id, analytics }: { readonly id: number; readonly analytics: AnalyticsService }, + { + id, + analytics, + }: { readonly id: number; readonly analytics: AnalyticsService }, ): T => { analytics.todoToggled(); return { @@ -20,7 +22,7 @@ export const toggleComplete = >( // Spec-owned cases, shared with the ecs `toggleComplete` transaction. Only the // addressed todo's `complete` flips; an unknown id is a no-op. The transition // logs `todoToggled` unconditionally (as the action does). `before` ids address -// the toggle; `after` ids are left open (`anyNumber`). +// the toggle; `after` ids are left open (`Match.anyNumber`). export const cases: Conformance = [ { name: "marks an incomplete todo complete", @@ -34,8 +36,8 @@ export const cases: Conformance = [ args: { id: 1, analytics: AnalyticsService.createFake() }, after: { todos: [ - { id: anyNumber, name: "a", complete: true }, - { id: anyNumber, name: "b", complete: false }, + { id: Match.anyNumber, name: "a", complete: true }, + { id: Match.anyNumber, name: "b", complete: false }, ], displayCompleted: false, }, @@ -53,8 +55,8 @@ export const cases: Conformance = [ args: { id: 1, analytics: AnalyticsService.createFake() }, after: { todos: [ - { id: anyNumber, name: "a", complete: false }, - { id: anyNumber, name: "b", complete: false }, + { id: Match.anyNumber, name: "a", complete: false }, + { id: Match.anyNumber, name: "b", complete: false }, ], displayCompleted: true, }, @@ -62,9 +64,15 @@ export const cases: Conformance = [ }, { name: "is a no-op for an unknown id but still logs the toggle", - before: { todos: [{ id: 1, name: "a", complete: false }], displayCompleted: false }, + before: { + todos: [{ id: 1, name: "a", complete: false }], + displayCompleted: false, + }, args: { id: 99, analytics: AnalyticsService.createFake() }, - after: { todos: [{ id: anyNumber, name: "a", complete: false }], displayCompleted: false }, + after: { + todos: [{ id: Match.anyNumber, name: "a", complete: false }], + displayCompleted: false, + }, effects: { analytics: [["todoToggled"]] }, }, ]; diff --git a/packages/data-lit-todo/src/features/main/data/state/toggle-display-completed.ts b/packages/data-lit-todo/src/features/main/data/state/toggle-display-completed.ts index 792ed98d..6157bcad 100644 --- a/packages/data-lit-todo/src/features/main/data/state/toggle-display-completed.ts +++ b/packages/data-lit-todo/src/features/main/data/state/toggle-display-completed.ts @@ -2,9 +2,10 @@ import { AnalyticsService } from "../../services/analytics-service/analytics-service.js"; import type { State } from "./state.js"; import type { Conformance } from "./conformance-case.js"; -import { anyNumber } from "./matchers.js"; - -export const toggleDisplayCompleted = >( +import { Match } from "@adobe/data/testing"; +export const toggleDisplayCompleted = < + T extends Pick, +>( state: T, { analytics }: { readonly analytics: AnalyticsService }, ): T => { @@ -25,9 +26,15 @@ export const cases: Conformance = [ }, { name: "turns the completed view off, leaving todos intact", - before: { todos: [{ id: 1, name: "a", complete: true }], displayCompleted: true }, + before: { + todos: [{ id: 1, name: "a", complete: true }], + displayCompleted: true, + }, args: { analytics: AnalyticsService.createFake() }, - after: { todos: [{ id: anyNumber, name: "a", complete: true }], displayCompleted: false }, + after: { + todos: [{ id: Match.anyNumber, name: "a", complete: true }], + displayCompleted: false, + }, effects: { analytics: [["displayCompletedToggled"]] }, }, ]; diff --git a/packages/data-lit-todo/src/features/main/data/state/visible-todos.ts b/packages/data-lit-todo/src/features/main/data/state/visible-todos.ts index 79991088..028ba12f 100644 --- a/packages/data-lit-todo/src/features/main/data/state/visible-todos.ts +++ b/packages/data-lit-todo/src/features/main/data/state/visible-todos.ts @@ -2,8 +2,7 @@ import type { State } from "./state.js"; import type { Todo } from "../todo/todo.js"; import type { Derivation } from "./conformance-case.js"; -import { anyNumber } from "./matchers.js"; - +import { Match } from "@adobe/data/testing"; // The todos the user should see, in display order: all of them when // `displayCompleted`, otherwise only the incomplete ones. export const visibleTodos = ( @@ -14,7 +13,7 @@ export const visibleTodos = ( : state.todos.filter((todo) => !todo.complete); // Spec-owned cases, shared with the ecs `visibleTodos` computed. A derivation -// case is `{ input, value }`; `value` leaves ids open (`anyNumber`) and its order +// case is `{ input, value }`; `value` leaves ids open (`Match.anyNumber`) and its order // is significant (display order). export const cases: Derivation = [ { @@ -28,8 +27,8 @@ export const cases: Derivation = [ displayCompleted: false, }, value: [ - { id: anyNumber, name: "a", complete: false }, - { id: anyNumber, name: "c", complete: false }, + { id: Match.anyNumber, name: "a", complete: false }, + { id: Match.anyNumber, name: "c", complete: false }, ], }, { @@ -42,8 +41,8 @@ export const cases: Derivation = [ displayCompleted: true, }, value: [ - { id: anyNumber, name: "a", complete: false }, - { id: anyNumber, name: "b", complete: true }, + { id: Match.anyNumber, name: "a", complete: false }, + { id: Match.anyNumber, name: "b", complete: true }, ], }, ]; diff --git a/packages/data-lit-todo/src/features/main/services/main-service/conformance/actions.test.ts b/packages/data-lit-todo/src/features/main/services/main-service/conformance/actions.test.ts index 5fd5cf1b..bf692edd 100644 --- a/packages/data-lit-todo/src/features/main/services/main-service/conformance/actions.test.ts +++ b/packages/data-lit-todo/src/features/main/services/main-service/conformance/actions.test.ts @@ -1,15 +1,10 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -/// -import { describe, it, expect } from "vitest"; -import { Database, Entity } from "@adobe/data/ecs"; -import type { ConformanceCase } from "../../../data/state/conformance-case.js"; -import { splitAndRecordServices, expectEffects } from "../../../data/state/record-effects.js"; +import { Database } from "@adobe/data/ecs"; +import { Conformance } from "@adobe/data/testing"; import type { AnalyticsService } from "../../analytics-service/analytics-service.js"; import type { NameGeneratorService } from "../../name-generator-service/name-generator-service.js"; import { MainService } from "../main-service.js"; -import { fromState } from "./from-state.js"; -import { toState } from "./to-state.js"; -import { expectStateMatches } from "../../../data/state/expect-state-matches.js"; +import * as registeredActions from "../action-database/actions/index.js"; import { createTodo } from "../action-database/actions/create-todo.js"; import { createBulkTodos } from "../action-database/actions/create-bulk-todos.js"; import { createRandomTodo } from "../action-database/actions/create-random-todo.js"; @@ -24,79 +19,60 @@ import { cases as deleteTodoCases } from "../../../data/state/delete-todo.js"; import { cases as deleteAllTodosCases } from "../../../data/state/delete-all-todos.js"; import { cases as toggleCompleteCases } from "../../../data/state/toggle-complete.js"; import { cases as toggleDisplayCompletedCases } from "../../../data/state/toggle-display-completed.js"; +import { fromState } from "./from-state.js"; +import { toState } from "./to-state.js"; -// Each transition's cases run against its same-named ecs **action** (the async -// realization). The case's service args become the db's service overrides — -// wrapped so their calls are recorded — the plain args drive the action, and we -// assert both the resulting state (ignoring ids) and the declared side effects. -// A transition realized only by a transaction (e.g. `reorderTodo`) is covered by -// `transactions.test.ts`, not here. -// `toSystemDatabase` exposes the writable `.store` the projection needs while -// keeping services/transactions/actions. Runtime invariant: the recording -// wrappers preserve each service's shape, so they are valid factory overrides. -const makeDb = (services: { analytics?: AnalyticsService; nameGenerator?: NameGeneratorService }) => - Database.toSystemDatabase(Database.create(MainService.plugin, { services })); -type Db = ReturnType; -type Run = (db: Db, input: Args, resolve: (specId: number) => Entity) => Promise | void; - -const covered = new Set(); -const conformsAction = ( - action: string, - config: { readonly cases: readonly ConformanceCase[]; readonly run: Run> }, -): void => { - covered.add(action); - describe(`${action} action conforms`, () => { - for (const testCase of config.cases) { - it(testCase.name, async () => { - const { services, input, calls } = splitAndRecordServices(testCase.args); - const db = makeDb(services as { analytics?: AnalyticsService; nameGenerator?: NameGeneratorService }); - const entities = fromState(db.store, testCase.before); - const bySpecId = new Map(testCase.before.todos.map((todo, i) => [todo.id, entities[i]])); - const resolve = (specId: number): Entity => bySpecId.get(specId) ?? Entity.none; - await config.run(db, input as Partial, resolve); - expectStateMatches(toState(db.store), testCase.after); - expectEffects(calls, testCase.effects); - }); - } - }); -}; - -conformsAction("createTodo", { - cases: createTodoCases, - run: (db, input) => createTodo(db, { name: input.name ?? "", complete: input.complete }), -}); -conformsAction("createBulkTodos", { - cases: createBulkTodosCases, - run: (db, input) => createBulkTodos(db, { count: input.count ?? 0 }), -}); -conformsAction("createRandomTodo", { - cases: createRandomTodoCases, - run: (db) => createRandomTodo(db), -}); -conformsAction("deleteTodo", { - cases: deleteTodoCases, - run: (db, input, resolve) => deleteTodo(db, resolve(input.id ?? -1)), -}); -conformsAction("deleteAllTodos", { cases: deleteAllTodosCases, run: (db) => deleteAllTodos(db) }); -conformsAction("toggleComplete", { - cases: toggleCompleteCases, - run: (db, input, resolve) => toggleComplete(db, resolve(input.id ?? -1)), -}); -conformsAction("toggleDisplayCompleted", { - cases: toggleDisplayCompletedCases, - run: (db) => toggleDisplayCompleted(db), -}); - -// None-missed guard: every action file must be wired above. -const kebabToCamel = (name: string): string => - name.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase()); -describe("action conformance coverage", () => { - const files = import.meta.glob([ - "../action-database/actions/*.ts", - "!../action-database/actions/index.ts", - ]); - for (const path of Object.keys(files)) { - const action = kebabToCamel(path.replace(/.*\//, "").replace(/\.ts$/, "")); - it(`${action} has a conformance case`, () => expect(covered.has(action)).toBe(true)); - } +// Each transition's cases run against its same-named ecs action. `runActions` +// splits the case's injected services into recording overrides (via `makeDb`), +// runs the action, then asserts both the resulting state and the declared effects; +// the harness/coverage are shared. A transition realized only by a transaction +// (e.g. `reorderTodo`) is covered by `transactions.test.ts`, not here. +Conformance.runActions({ + // `toSystemDatabase` exposes the writable `.store` the projection needs. Runtime + // invariant: the recording wrappers preserve each service's shape, so they are + // valid factory overrides. + makeDb: (services) => + Database.toSystemDatabase( + Database.create(MainService.plugin, { + services: services as { + analytics?: AnalyticsService; + nameGenerator?: NameGeneratorService; + }, + }), + ), + store: (db) => db.store, + fromState, + toState, + registered: registeredActions, + define: (conforms) => { + conforms("createTodo", { + cases: createTodoCases, + run: (db, input) => + createTodo(db, { name: input.name ?? "", complete: input.complete }), + }); + conforms("createBulkTodos", { + cases: createBulkTodosCases, + run: (db, input) => createBulkTodos(db, { count: input.count ?? 0 }), + }); + conforms("createRandomTodo", { + cases: createRandomTodoCases, + run: (db) => createRandomTodo(db), + }); + conforms("deleteTodo", { + cases: deleteTodoCases, + run: (db, input, resolve) => deleteTodo(db, resolve(input.id ?? -1)), + }); + conforms("deleteAllTodos", { + cases: deleteAllTodosCases, + run: (db) => deleteAllTodos(db), + }); + conforms("toggleComplete", { + cases: toggleCompleteCases, + run: (db, input, resolve) => toggleComplete(db, resolve(input.id ?? -1)), + }); + conforms("toggleDisplayCompleted", { + cases: toggleDisplayCompletedCases, + run: (db) => toggleDisplayCompleted(db), + }); + }, }); diff --git a/packages/data-lit-todo/src/features/main/services/main-service/conformance/computeds.test.ts b/packages/data-lit-todo/src/features/main/services/main-service/conformance/computeds.test.ts index 4c3ddea5..4b9f607c 100644 --- a/packages/data-lit-todo/src/features/main/services/main-service/conformance/computeds.test.ts +++ b/packages/data-lit-todo/src/features/main/services/main-service/conformance/computeds.test.ts @@ -1,96 +1,38 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. /// -import { describe, it, expect } from "vitest"; -import { Database, Entity } from "@adobe/data/ecs"; -import type { Observe } from "@adobe/data/observe"; -import type { State } from "../../../data/state/state.js"; -import type { DerivationCase } from "../../../data/state/conformance-case.js"; -import { expectMatches } from "../../../data/state/expect-state-matches.js"; +import { Database } from "@adobe/data/ecs"; +import { Conformance } from "@adobe/data/testing"; import { ComputedDatabase } from "../computed-database/computed-database.js"; -import { fromState } from "./from-state.js"; -import { toData } from "./to-data.js"; import { visibleTodos } from "../computed-database/computed/visible-todos.js"; import { cases as visibleTodosCases } from "../../../data/state/visible-todos.js"; +import { fromState } from "./from-state.js"; +import { toData } from "./to-data.js"; -// Each derivation's cases run against its same-named ecs computed. A computed is -// an `Observe`, so after seeding the store we read its synchronous emission. ecs -// list-computeds are entity-id based, so by default we hydrate the output into -// `data/` values through the feature's per-entity `toData` (the same projection -// `toState` uses) — so an id-based computed like `visibleTodos` needs no adapter. -// This is the computed analog of the transaction/action runners. -// -// Built from the `ComputedDatabase` layer (which adds the computeds), not the -// assembled feature db: a behaviour layer above it that subscribes to a computed -// at construction would `withCache` the pre-seed value, and a direct `fromState` -// seed emits no transaction to invalidate it. -const makeDb = () => Database.toSystemDatabase(Database.create(ComputedDatabase.plugin)); -type Db = ReturnType; - -// The default projection: hydrate a computed's entity-id list into the value -// shape a derivation yields. Override only for a computed whose output is not a -// list of entities (a scalar, a single entity, a nested shape). -const hydrateEntities = (raw: unknown, db: Db): unknown => - (raw as readonly Entity[]).map((entity) => toData(db.store, entity)); - -const readComputed = (observe: Observe): T => { - let value!: T; - let read = false; - const unsubscribe = observe((next) => { - value = next; - read = true; - }); - unsubscribe(); - if (!read) throw new Error("computed did not emit synchronously on subscribe"); - return value; -}; - -const covered = new Set(); -const conformsComputed = ( - name: string, - config: { - readonly cases: readonly DerivationCase[]; - readonly computed: (db: Db) => Observe; - readonly project?: (raw: unknown, db: Db) => unknown; +// Each derivation's cases run against its same-named ecs computed. `runComputeds` +// seeds the store from the case `input`, reads the computed's synchronous +// emission, hydrates an entity-id list through `toData` (so an id-based computed +// like `visibleTodos` needs no adapter), and matches the derivation's `value`. +// Built from the `ComputedDatabase` layer (not the assembled db) so a `withCache` +// above it cannot serve a stale pre-seed value. Coverage is keyed off the +// `data/state/` derivation modules — every one must be wired. +Conformance.runComputeds({ + makeDb: () => + Database.toSystemDatabase(Database.create(ComputedDatabase.plugin)), + store: (db) => db.store, + fromState, + toData, + derivationModules: import.meta.glob>( + [ + "../../../data/state/*.ts", + "!../../../data/state/*.test.ts", + "!../../../data/state/*.type-test.ts", + ], + { eager: true }, + ), + define: (conforms) => { + conforms("visibleTodos", { + cases: visibleTodosCases, + computed: visibleTodos, + }); }, -): void => { - covered.add(name); - const project = config.project ?? hydrateEntities; - describe(`${name} computed conforms`, () => { - for (const testCase of config.cases) { - it(testCase.name, () => { - const db = makeDb(); - // Runtime invariant: a derivation's `input` is authored as a full State. - fromState(db.store, testCase.input as State); - const raw = readComputed(config.computed(db)); - expectMatches(project(raw, db), testCase.value); - }); - } - }); -}; - -// An id-based list computed needs no adapter — the default `hydrateEntities` -// projection reads each entity through `toData`. -conformsComputed("visibleTodos", { cases: visibleTodosCases, computed: visibleTodos }); - -// None-missed guard: every data/state derivation (a file whose `cases` are -// `{ input, value }`) must be wired above. -const kebabToCamel = (name: string): string => - name.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase()); -const derivationModules = import.meta.glob>( - ["../../../data/state/*.ts", "!../../../data/state/*.test.ts", "!../../../data/state/*.type-test.ts"], - { eager: true }, -); -describe("computed conformance coverage", () => { - for (const [path, module] of Object.entries(derivationModules)) { - const cases = module["cases"]; - const isDerivation = - Array.isArray(cases) && - cases.length > 0 && - typeof cases[0] === "object" && - cases[0] !== null && - "value" in cases[0]; - if (!isDerivation) continue; - const name = kebabToCamel(path.replace(/.*\//, "").replace(/\.ts$/, "")); - it(`${name} has a computed conformance case`, () => expect(covered.has(name)).toBe(true)); - } }); diff --git a/packages/data-lit-todo/src/features/main/services/main-service/conformance/create-store.ts b/packages/data-lit-todo/src/features/main/services/main-service/conformance/create-store.ts index c94f0d01..b864f388 100644 --- a/packages/data-lit-todo/src/features/main/services/main-service/conformance/create-store.ts +++ b/packages/data-lit-todo/src/features/main/services/main-service/conformance/create-store.ts @@ -9,4 +9,5 @@ import { IndexDatabase } from "../index-database/index-database.js"; // plugin's schema facets directly. Typed as `CoreDatabase.Store`: the surface the // projection (`fromState` / `toState`) and the raw transaction functions use. // Test-only. -export const createStore = (): CoreDatabase.Store => Store.create(IndexDatabase.plugin); +export const createStore = (): CoreDatabase.Store => + Store.create(IndexDatabase.plugin); diff --git a/packages/data-lit-todo/src/features/main/services/main-service/conformance/expect-conforms.ts b/packages/data-lit-todo/src/features/main/services/main-service/conformance/expect-conforms.ts deleted file mode 100644 index 2af8a055..00000000 --- a/packages/data-lit-todo/src/features/main/services/main-service/conformance/expect-conforms.ts +++ /dev/null @@ -1,57 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { it } from "vitest"; -import { Entity } from "@adobe/data/ecs"; -import type { State } from "../../../data/state/state.js"; -import type { ConformanceCase } from "../../../data/state/conformance-case.js"; -import { expectStateMatches } from "../../../data/state/expect-state-matches.js"; -import type { CoreDatabase } from "../core-database/core-database.js"; -import { createStore } from "./create-store.js"; -import { fromState } from "./from-state.js"; -import { toState } from "./to-state.js"; - -// Resolve a spec domain `id` to the ecs entity seeded for it. `fromState` -// returns the seeded entities in display order, so the i-th `before` todo maps -// to the i-th entity; an id no todo carries resolves to `Entity.none`, so an -// id-addressed transaction reads no such entity and is a no-op. -export type ResolveEntity = (specId: number) => Entity; - -// The conformance runner, bound to THIS feature's projection (`fromState` / -// `toState`). For each case it proves the one conformance property -// -// toState(apply(fromState(before), args)) ≡ spec(before, args) -// -// in two asserted halves: -// 1. `spec(before, args) ≡ after` — keeps the shared case honest (a -// mis-authored `after` is caught here, independent of the ecs path). -// 2. seed `fromState(before)` → run the caller's `apply` → `toState ≡ after` -// — the ecs implementation reproduces the pure transform. -// -// `apply` receives the seeded writable store, the case args, and a `resolve` -// that maps a spec `id` to the seeded entity, then calls the raw transaction -// function directly (a transaction is `(store, …) => void`, so no `Database` -// is involved). The `after` authors ids as `anyNumber`, so the same -// `expectStateMatches` compares both halves — the ecs owns its entity-id space -// and conforms only up to a renaming of ids, which the matcher expresses. -export const expectConforms = (config: { - readonly cases: readonly ConformanceCase[]; - // Optional: half 1 (spec(before,args) ≡ after) is already asserted for every - // case by `data/state/spec.test.ts`, so the conformance aggregator omits it and - // this runner asserts only the ecs half. Pass `spec` to re-check it in place. - readonly spec?: (before: State, args: Args) => State; - readonly apply: (store: CoreDatabase.Store, args: Args, resolve: ResolveEntity) => void; -}): void => { - for (const testCase of config.cases) { - it(testCase.name, () => { - if (config.spec) { - expectStateMatches(config.spec(testCase.before, testCase.args), testCase.after); - } - - const store = createStore(); - const entities = fromState(store, testCase.before); - const bySpecId = new Map(testCase.before.todos.map((todo, i) => [todo.id, entities[i]])); - const resolve: ResolveEntity = (specId) => bySpecId.get(specId) ?? Entity.none; - config.apply(store, testCase.args, resolve); - expectStateMatches(toState(store), testCase.after); - }); - } -}; diff --git a/packages/data-lit-todo/src/features/main/services/main-service/conformance/from-state.ts b/packages/data-lit-todo/src/features/main/services/main-service/conformance/from-state.ts index f7e15a3c..f2e4fc0b 100644 --- a/packages/data-lit-todo/src/features/main/services/main-service/conformance/from-state.ts +++ b/packages/data-lit-todo/src/features/main/services/main-service/conformance/from-state.ts @@ -13,24 +13,30 @@ import type { CoreDatabase } from "../core-database/core-database.js"; // the implementation-only slots (`dragPosition`, `assignees`) are seeded empty. // // The ecs assigns entity ids from its own quadrant-encoded id-space, unrelated -// to the spec's domain `id`. So the seeded entities are returned in display -// order and the caller maps spec `id` → entity positionally (see -// `expect-conforms.ts`); nothing here assumes the two id-spaces coincide. -export const fromState = (store: CoreDatabase.Store, state: State): readonly Entity[] => { +// to the spec's domain `id`. This returns the `spec id → seeded entity` map so the +// conformance runners resolve id-addressed operations generically +// (`Conformance.resolver`); nothing here assumes the two id-spaces coincide. +export const fromState = ( + store: CoreDatabase.Store, + state: State, +): ReadonlyMap => { for (const arch of store.queryArchetypes(store.archetypes.Todo.components)) { for (let row = arch.rowCount - 1; row >= 0; row--) { store.delete(arch.columns.id.get(row)); } } store.resources.displayCompleted = state.displayCompleted; - return state.todos.map((todo, index) => - store.archetypes.Todo.insert({ - todo: true, - name: todo.name, - complete: todo.complete, - order: index, - dragPosition: null, - assignees: [], - }), + return new Map( + state.todos.map((todo, index) => [ + todo.id, + store.archetypes.Todo.insert({ + todo: true, + name: todo.name, + complete: todo.complete, + order: index, + dragPosition: null, + assignees: [], + }), + ]), ); }; diff --git a/packages/data-lit-todo/src/features/main/services/main-service/conformance/projection.test.ts b/packages/data-lit-todo/src/features/main/services/main-service/conformance/projection.test.ts index 137b5b47..bb25701d 100644 --- a/packages/data-lit-todo/src/features/main/services/main-service/conformance/projection.test.ts +++ b/packages/data-lit-todo/src/features/main/services/main-service/conformance/projection.test.ts @@ -6,9 +6,8 @@ // identity test — `toState(fromState(s)) ≡ s` over representative states — // proves the projection round-trips faithfully on its own. import { describe, it } from "vitest"; +import { Match } from "@adobe/data/testing"; import type { State } from "../../../data/state/state.js"; -import { expectStateMatches } from "../../../data/state/expect-state-matches.js"; -import { anyNumber } from "../../../data/state/matchers.js"; import { createStore } from "./create-store.js"; import { fromState } from "./from-state.js"; import { toState } from "./to-state.js"; @@ -49,9 +48,9 @@ describe("ecs/conformance projection round-trips (toState ∘ fromState ≡ iden fromState(store, state); // The ecs reassigns ids from its own id-space, so compare against the same // state with ids left open. - expectStateMatches(toState(store), { + Match.assert(toState(store), { ...state, - todos: state.todos.map((todo) => ({ ...todo, id: anyNumber })), + todos: state.todos.map((todo) => ({ ...todo, id: Match.anyNumber })), }); }); } diff --git a/packages/data-lit-todo/src/features/main/services/main-service/conformance/to-data.ts b/packages/data-lit-todo/src/features/main/services/main-service/conformance/to-data.ts index c1dcaeee..392de3a2 100644 --- a/packages/data-lit-todo/src/features/main/services/main-service/conformance/to-data.ts +++ b/packages/data-lit-todo/src/features/main/services/main-service/conformance/to-data.ts @@ -10,6 +10,7 @@ import type { CoreDatabase } from "../core-database/core-database.js"; // yields, so those computeds need no bespoke projection. Test-only. export const toData = (store: CoreDatabase.Store, entity: Entity): Todo => { const row = store.read(entity, store.archetypes.Todo); - if (row === null) throw new Error("conformance projection: expected a todo entity"); + if (row === null) + throw new Error("conformance projection: expected a todo entity"); return { id: row.id, name: row.name, complete: row.complete }; }; diff --git a/packages/data-lit-todo/src/features/main/services/main-service/conformance/to-state.ts b/packages/data-lit-todo/src/features/main/services/main-service/conformance/to-state.ts index 69d6e307..952e80a8 100644 --- a/packages/data-lit-todo/src/features/main/services/main-service/conformance/to-state.ts +++ b/packages/data-lit-todo/src/features/main/services/main-service/conformance/to-state.ts @@ -13,9 +13,11 @@ import { toData } from "./to-data.js"; // cases author `after` ids as `anyNumber`, so the comparison leaves them open. // Test-only. const readTodos = (store: CoreDatabase.Store): Todo[] => - [...store.select(store.archetypes.Todo.components, { order: { order: true } })].map((entity) => - toData(store, entity), - ); + [ + ...store.select(store.archetypes.Todo.components, { + order: { order: true }, + }), + ].map((entity) => toData(store, entity)); export const toState = (store: CoreDatabase.Store): State => ({ todos: readTodos(store), diff --git a/packages/data-lit-todo/src/features/main/services/main-service/conformance/transactions.test.ts b/packages/data-lit-todo/src/features/main/services/main-service/conformance/transactions.test.ts index d0895996..a8ed0647 100644 --- a/packages/data-lit-todo/src/features/main/services/main-service/conformance/transactions.test.ts +++ b/packages/data-lit-todo/src/features/main/services/main-service/conformance/transactions.test.ts @@ -1,9 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -/// -import { describe, it, expect } from "vitest"; -import type { CoreDatabase } from "../core-database/core-database.js"; -import type { ConformanceCase } from "../../../data/state/conformance-case.js"; -import { expectConforms, type ResolveEntity } from "./expect-conforms.js"; +import { Conformance } from "@adobe/data/testing"; import * as registeredTransactions from "../transaction-database/transactions/index.js"; import { createTodo } from "../transaction-database/transactions/create-todo.js"; import { createBulkTodos } from "../transaction-database/transactions/create-bulk-todos.js"; @@ -19,54 +15,50 @@ import { cases as deleteAllTodosCases } from "../../../data/state/delete-all-tod import { cases as reorderTodoCases } from "../../../data/state/reorder-todo.js"; import { cases as toggleCompleteCases } from "../../../data/state/toggle-complete.js"; import { cases as toggleDisplayCompletedCases } from "../../../data/state/toggle-display-completed.js"; +import { createStore } from "./create-store.js"; +import { fromState } from "./from-state.js"; +import { toState } from "./to-state.js"; -// The single conformance test for every ecs transaction. Unlike the pure -// `data/state/spec.test.ts` (fully uniform, so glob-driven), each transaction's -// `apply` is bespoke — an id-addressed transaction resolves its entity, `dragTodo` -// remaps to a final-drop — and transaction files must stay single-export (the -// `transactions/` barrel is `export *`-ed straight into the plugin facet), so the -// wiring lives here rather than beside each transaction. The guard at the bottom -// asserts every transaction file is wired below, so none can be missed. -const covered = new Set(); -const conforms = ( - transaction: string, - config: { - readonly cases: readonly ConformanceCase[]; - readonly apply: (t: CoreDatabase.Store, args: Args, resolve: ResolveEntity) => void; +// The single conformance test for every ecs transaction. `runTransactions` owns +// the harness (fresh store, `fromState` seed, `resolve`, `toState` compare, +// coverage guard keyed off the registered barrel); only the bespoke `apply` +// adapters are per-transaction — an id-addressed transaction resolves its entity, +// `dragTodo` remaps to a final-drop reproducing `State.reorderTodo`. +Conformance.runTransactions({ + createStore, + fromState, + toState, + registered: registeredTransactions, + define: (conforms) => { + conforms("createTodo", { cases: createTodoCases, apply: createTodo }); + conforms("createBulkTodos", { + cases: createBulkTodosCases, + apply: createBulkTodos, + }); + conforms("deleteTodo", { + cases: deleteTodoCases, + apply: (t, args, resolve) => deleteTodo(t, resolve(args.id)), + }); + conforms("deleteAllTodos", { + cases: deleteAllTodosCases, + apply: deleteAllTodos, + }); + conforms("dragTodo", { + cases: reorderTodoCases, + apply: (t, args, resolve) => + dragTodo(t, { + entity: resolve(args.id), + dragPosition: 0, + finalIndex: args.toIndex, + }), + }); + conforms("toggleComplete", { + cases: toggleCompleteCases, + apply: (t, args, resolve) => toggleComplete(t, resolve(args.id)), + }); + conforms("toggleDisplayCompleted", { + cases: toggleDisplayCompletedCases, + apply: toggleDisplayCompleted, + }); }, -): void => { - covered.add(transaction); - describe(`${transaction} transaction conforms`, () => expectConforms(config)); -}; - -conforms("createTodo", { cases: createTodoCases, apply: createTodo }); -conforms("createBulkTodos", { cases: createBulkTodosCases, apply: createBulkTodos }); -conforms("deleteTodo", { - cases: deleteTodoCases, - apply: (t, args, resolve) => deleteTodo(t, resolve(args.id)), -}); -conforms("deleteAllTodos", { cases: deleteAllTodosCases, apply: deleteAllTodos }); -// dragTodo's final drop reproduces State.reorderTodo (its shared cases). -conforms("dragTodo", { - cases: reorderTodoCases, - apply: (t, args, resolve) => - dragTodo(t, { entity: resolve(args.id), dragPosition: 0, finalIndex: args.toIndex }), -}); -conforms("toggleComplete", { - cases: toggleCompleteCases, - apply: (t, args, resolve) => toggleComplete(t, resolve(args.id)), -}); -conforms("toggleDisplayCompleted", { - cases: toggleDisplayCompletedCases, - apply: toggleDisplayCompleted, -}); - -// None-missed guard: every **registered** transaction must be wired above. Keyed -// off the barrel (the transactions the plugin actually dispatches), not a file -// glob — so a shared read helper parked flat in `transactions/` (kept out of the -// barrel) is naturally excluded. -describe("transaction conformance coverage", () => { - for (const transaction of Object.keys(registeredTransactions)) { - it(`${transaction} has a conformance case`, () => expect(covered.has(transaction)).toBe(true)); - } }); diff --git a/packages/data/package.json b/packages/data/package.json index 0601e300..d04b8478 100644 --- a/packages/data/package.json +++ b/packages/data/package.json @@ -91,6 +91,14 @@ "hash-wasm": "^4.12.0", "jsonpath": "^1.1.1" }, + "peerDependencies": { + "vitest": "^1.6.0" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + }, "typesVersions": { "*": { "*": [ @@ -131,6 +139,9 @@ ], "math": [ "./dist/math/index.d.ts" + ], + "testing": [ + "./dist/testing/index.d.ts" ] } }, @@ -139,6 +150,10 @@ "import": "./dist/index.js", "types": "./dist/index.d.ts" }, + "./testing": { + "import": "./dist/testing/index.js", + "types": "./dist/testing/index.d.ts" + }, "./functions": { "import": "./dist/functions/index.js", "types": "./dist/functions/index.d.ts" diff --git a/packages/data/src/testing/conformance/public.ts b/packages/data/src/testing/conformance/public.ts new file mode 100644 index 00000000..9736964e --- /dev/null +++ b/packages/data/src/testing/conformance/public.ts @@ -0,0 +1,8 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +export type { Case, Cases, DerivationCase, DerivationCases, Effects, ServiceCall } from "./types.js"; +export { recordCalls, recordArgServices, splitAndRecordServices, expectEffects, type RecordedCall } from "./record-effects.js"; +export { resolver, type Resolve } from "./resolve.js"; +export { runSpec, type SpecOptions } from "./run-spec.js"; +export { runTransactions, type TransactionConforms, type TransactionRunConfig } from "./run-transactions.js"; +export { runActions, type ActionConforms, type ActionRunConfig } from "./run-actions.js"; +export { runComputeds, type ComputedConforms, type ComputedRunConfig } from "./run-computeds.js"; diff --git a/packages/data-lit-todo/src/features/main/data/state/record-effects.ts b/packages/data/src/testing/conformance/record-effects.ts similarity index 53% rename from packages/data-lit-todo/src/features/main/data/state/record-effects.ts rename to packages/data/src/testing/conformance/record-effects.ts index 8fb3d25f..cea368b0 100644 --- a/packages/data-lit-todo/src/features/main/data/state/record-effects.ts +++ b/packages/data/src/testing/conformance/record-effects.ts @@ -1,13 +1,21 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { expect } from "vitest"; -import { equalsUnordered } from "@adobe/data"; -import type { Effects } from "./conformance-case.js"; +import { equalsUnordered } from "../../equals-unordered.js"; +import { matches } from "../match/match.js"; +import type { Effects } from "./types.js"; export type RecordedCall = readonly [string, ...unknown[]]; +// A runtime service value: a non-array object with at least one method (mirrors +// the compile-time service detection in `types.ts`). +const isServiceValue = (value: unknown): value is object => + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.values(value).some((member) => typeof member === "function"); + // Wrap a plain-object service so each method call is recorded, then delegates. -// No Proxy (services are plain objects with own enumerable methods — see -// `service.md`), so we enumerate and closure-wrap each function. +// No Proxy (services are plain objects with own enumerable methods), so we +// enumerate and closure-wrap each function. export const recordCalls = (service: S): { service: S; calls: RecordedCall[] } => { const calls: RecordedCall[] = []; const wrapped = Object.fromEntries( @@ -24,66 +32,35 @@ export const recordCalls = (service: S): { service: S; calls: return { service: wrapped, calls }; }; -// Assert the calls recorded against one service match the case's expectation for -// it: an Array expects exactly these calls in order; a Set expects the same calls -// in any order (multiset). Absent expectation ⇒ no calls expected. -export const expectServiceCalls = ( - recorded: readonly RecordedCall[], - expected: readonly RecordedCall[] | ReadonlySet | undefined, -): void => { - if (expected instanceof Set) { - expect(equalsUnordered(recorded, [...expected])).toBe(true); - } else { - expect(recorded).toEqual(expected ?? []); - } -}; - -// Split a case's `args` into the injected services (objects with methods) and the -// remaining plain data. Services are wrapped for recording; the returned `calls` -// map is keyed by the same arg key so it can be matched against `effects`. -export const recordArgServices = ( +// Wrap the injected services IN PLACE within a case's `args` (leaving plain data +// untouched), returning the args ready to pass to a pure transform plus the +// per-service `calls` map. Used by the pure spec runner, which calls the transform +// with its full args; the ecs action runner uses `splitAndRecordServices` instead. +export const recordArgServices = ( args: Args, ): { args: Args; calls: Record } => { const calls: Record = {}; - const next = { ...args } as Record; - for (const [key, value] of Object.entries(args)) { + if (args === null || typeof args !== "object") return { args, calls }; + const next = { ...(args as object) } as Record; + for (const [key, value] of Object.entries(args as object)) { if (isServiceValue(value)) { - const rec = recordCalls(value); - next[key] = rec.service; - calls[key] = rec.calls; + const recorded = recordCalls(value); + next[key] = recorded.service; + calls[key] = recorded.calls; } } return { args: next as Args, calls }; }; -// Assert each service **declared** in `effects` saw exactly its expected calls -// (an extra or missing call on a declared service fails). Services not listed — -// e.g. a value-returning dependency read like `generateName` — are ignored, so -// `effects` captures the fire-and-forget side effects you choose to assert. -export const expectEffects = ( - calls: Record, - effects: Effects> | undefined, -): void => { - const expected = (effects ?? {}) as Record>; - for (const key of Object.keys(expected)) { - expectServiceCalls(calls[key] ?? [], expected[key]); - } -}; - -// Split a case's `args` into the injected services (wrapped for recording, to be -// used as `Database.create` service overrides) and the remaining plain data (the -// action input). Keyed by the same arg name so `calls` matches against `effects`. +// Split a case's `args` into the injected services (wrapped for recording) and +// the remaining plain data. Keyed by the same arg name so `calls` matches against +// `effects`. A no-arg case (`undefined` args) splits into nothing. export const splitAndRecordServices = ( args: Args, -): { - services: Record; - input: Record; - calls: Record; -} => { +): { services: Record; input: Record; calls: Record } => { const services: Record = {}; const input: Record = {}; const calls: Record = {}; - // A no-arg transition has `undefined` args — nothing to split. if (args !== null && typeof args === "object") { for (const [key, value] of Object.entries(args)) { if (isServiceValue(value)) { @@ -98,10 +75,44 @@ export const splitAndRecordServices = ( return { services, input, calls }; }; -// A runtime service value: a non-array object with at least one method (mirrors -// the compile-time `IsService`). -const isServiceValue = (value: unknown): value is object => - typeof value === "object" && - value !== null && - !Array.isArray(value) && - Object.values(value).some((member) => typeof member === "function"); +const showCalls = (calls: unknown): string => { + try { + return JSON.stringify(calls) ?? String(calls); + } catch { + return String(calls); + } +}; + +// Assert the calls recorded against one service match the case's expectation for +// it: an Array expects exactly these calls in order; a Set expects the same calls +// in any order (multiset). Absent expectation ⇒ no calls expected. Ordered calls +// compare matcher-aware, so a call arg may itself use `anyNumber`. +const expectServiceCalls = ( + key: string, + recorded: readonly RecordedCall[], + expected: readonly RecordedCall[] | ReadonlySet | undefined, +): void => { + const ok = + expected instanceof Set ? equalsUnordered(recorded, [...expected]) : matches(recorded, expected ?? []); + if (!ok) { + throw new Error( + `effects mismatch on "${key}":\n recorded: ${showCalls(recorded)}\n expected: ${showCalls( + expected instanceof Set ? [...expected] : (expected ?? []), + )}`, + ); + } +}; + +// Assert each service DECLARED in `effects` saw exactly its expected calls (an +// extra or missing call on a declared service fails). Services not listed — e.g. a +// value-returning read like `generateName` — are ignored, so `effects` captures the +// fire-and-forget side effects a case chooses to assert. +export const expectEffects = ( + calls: Record, + effects: Effects> | undefined, +): void => { + const expected = (effects ?? {}) as Record>; + for (const key of Object.keys(expected)) { + expectServiceCalls(key, calls[key] ?? [], expected[key]); + } +}; diff --git a/packages/data/src/testing/conformance/resolve.ts b/packages/data/src/testing/conformance/resolve.ts new file mode 100644 index 00000000..946744c5 --- /dev/null +++ b/packages/data/src/testing/conformance/resolve.ts @@ -0,0 +1,12 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Entity } from "../../ecs/entity/entity.js"; + +// Maps a spec-domain id to the ecs entity seeded for it. A feature's `fromState` +// returns the `Id → Entity` map (it already loops its collections to seed); the +// conformance runners turn that map into this resolver, so no feature writes id +// resolution by hand. An id no entity carries resolves to `Entity.none`, so an +// id-addressed transaction reads no such entity and is a no-op. +export type Resolve = (id: Id) => Entity; + +export const resolver = (seeded: ReadonlyMap): Resolve => (id) => + seeded.get(id) ?? Entity.none; diff --git a/packages/data/src/testing/conformance/run-actions.ts b/packages/data/src/testing/conformance/run-actions.ts new file mode 100644 index 00000000..94682042 --- /dev/null +++ b/packages/data/src/testing/conformance/run-actions.ts @@ -0,0 +1,72 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it } from "vitest"; +import type { Entity } from "../../ecs/entity/entity.js"; +import { assert } from "../match/assert.js"; +import type { MatchOptions } from "../match/match.js"; +import { splitAndRecordServices, expectEffects } from "./record-effects.js"; +import { resolver, type Resolve } from "./resolve.js"; +import type { Case } from "./types.js"; + +// Wire one action to a transition's shared cases. The case's service args become +// the db's recording service overrides (via `makeDb`), the plain args drive the +// action through `run`, and both the resulting state AND the declared effects are +// asserted. +export type ActionConforms = ( + action: string, + config: { + readonly cases: readonly Case[]; + readonly run: (db: Db, input: Partial, resolve: Resolve) => Promise | void; + }, +) => void; + +export interface ActionRunConfig { + // Build a db with the given (recording) service overrides — typically + // `Database.toSystemDatabase(Database.create(MainService.plugin, { services }))`. + readonly makeDb: (services: Record) => Db; + // The writable store exposed by that db (usually `(db) => db.store`). + readonly store: (db: Db) => Store; + readonly fromState: (store: Store, before: State) => ReadonlyMap; + readonly toState: (store: Store) => State; + // The registered-actions barrel — coverage requires every key wired. + readonly registered: Record; + readonly match?: MatchOptions; + readonly define: (conforms: ActionConforms) => void; +} + +// The single conformance test for every ecs action: each transition's cases run +// against its same-named async action, asserting state and effects. Transitions +// realized only by a transaction (not an action) are covered by `runTransactions`. +export const runActions = (config: ActionRunConfig): void => { + const covered = new Set(); + const conforms = ( + action: string, + aconfig: { + readonly cases: readonly Case[]; + readonly run: (db: Db, input: Partial, resolve: Resolve) => Promise | void; + }, + ): void => { + covered.add(action); + describe(`${action} action conforms`, () => { + for (const testCase of aconfig.cases) { + it(testCase.name, async () => { + // A void-arg case omits `args` (see `Case`); split yields empty maps. + const args = (testCase as { readonly args?: Args }).args as Args; + const { services, input, calls } = splitAndRecordServices(args); + const db = config.makeDb(services); + const resolve = resolver(config.fromState(config.store(db), testCase.before)); + await aconfig.run(db, input as Partial, resolve); + assert(config.toState(config.store(db)), testCase.after, config.match); + expectEffects(calls, (testCase as { readonly effects?: never }).effects); + }); + } + }); + }; + config.define(conforms); + describe("action conformance coverage", () => { + for (const action of Object.keys(config.registered)) { + it(`${action} has a conformance case`, () => { + if (!covered.has(action)) throw new Error(`${action} has no conformance case`); + }); + } + }); +}; diff --git a/packages/data/src/testing/conformance/run-computeds.ts b/packages/data/src/testing/conformance/run-computeds.ts new file mode 100644 index 00000000..c6742219 --- /dev/null +++ b/packages/data/src/testing/conformance/run-computeds.ts @@ -0,0 +1,100 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it } from "vitest"; +import type { Observe } from "../../observe/index.js"; +import type { Entity } from "../../ecs/entity/entity.js"; +import { assert } from "../match/assert.js"; +import type { MatchOptions } from "../match/match.js"; + +// Read a computed's synchronous emission: subscribe once, capture, unsubscribe. +const readComputed = (observe: Observe): T => { + let value!: T; + let read = false; + const unsubscribe = observe((next) => { + value = next; + read = true; + }); + unsubscribe(); + if (!read) throw new Error("computed did not emit synchronously on subscribe"); + return value; +}; + +const kebabToCamel = (name: string): string => + name.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase()); + +const isDerivationModule = (module: Record): boolean => { + const cases = module["cases"]; + return ( + Array.isArray(cases) && + cases.length > 0 && + typeof cases[0] === "object" && + cases[0] !== null && + "value" in (cases[0] as object) + ); +}; + +// Wire one computed to a derivation's shared `{ input, value }` cases. +export type ComputedConforms = ( + name: string, + config: { + readonly cases: readonly { readonly name: string; readonly input: unknown; readonly value: Value }[]; + readonly computed: (db: Db) => Observe; + // Project the raw emission into the value shape a derivation yields. Defaults + // to hydrating an entity-id list through `toData` (so an id-based list + // computed needs no adapter). Override for a scalar / single-entity output. + readonly project?: (raw: unknown, db: Db) => unknown; + }, +) => void; + +export interface ComputedRunConfig { + // Build a db from the COMPUTED layer (`Database.toSystemDatabase(Database.create( + // ComputedDatabase.plugin))`) — not the assembled feature db, whose higher + // layers may `withCache` a pre-seed value that a direct `fromState` seed cannot + // invalidate. + readonly makeDb: () => Db; + readonly store: (db: Db) => Store; + readonly fromState: (store: Store, input: State) => unknown; + // Per-entity projection used by the default `project` to hydrate id lists. + readonly toData?: (store: Store, entity: Entity) => unknown; + // The `data/state/` modules glob (eager) — coverage requires every derivation + // among them (a file whose cases are `{ input, value }`) to be wired. + readonly derivationModules: Record>; + readonly match?: MatchOptions; + readonly define: (conforms: ComputedConforms) => void; +} + +// The single conformance test for every ecs computed backing a `data/state` +// derivation: seed the store from the case `input`, read the computed's emission, +// hydrate it, and match the derivation's `value`. +export const runComputeds = (config: ComputedRunConfig): void => { + const hydrateEntities = (raw: unknown, db: Db): unknown => { + const toData = config.toData; + if (!toData) throw new Error("runComputeds: a list computed needs `toData` (or a `project`)"); + return (raw as readonly Entity[]).map((entity) => toData(config.store(db), entity)); + }; + const covered = new Set(); + const conforms: ComputedConforms = (name, cconfig) => { + covered.add(name); + const project = cconfig.project ?? hydrateEntities; + describe(`${name} computed conforms`, () => { + for (const testCase of cconfig.cases) { + it(testCase.name, () => { + const db = config.makeDb(); + // Runtime invariant: a derivation's `input` is authored as a full State. + config.fromState(config.store(db), testCase.input as State); + const raw = readComputed(cconfig.computed(db)); + assert(project(raw, db), testCase.value, config.match); + }); + } + }); + }; + config.define(conforms); + describe("computed conformance coverage", () => { + for (const [path, module] of Object.entries(config.derivationModules)) { + if (!isDerivationModule(module)) continue; + const name = kebabToCamel(path.replace(/.*\//, "").replace(/\.ts$/, "")); + it(`${name} has a computed conformance case`, () => { + if (!covered.has(name)) throw new Error(`${name} has no computed conformance case`); + }); + } + }); +}; diff --git a/packages/data/src/testing/conformance/run-spec.ts b/packages/data/src/testing/conformance/run-spec.ts new file mode 100644 index 00000000..65f7d6d7 --- /dev/null +++ b/packages/data/src/testing/conformance/run-spec.ts @@ -0,0 +1,62 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it } from "vitest"; +import { assert } from "../match/assert.js"; +import type { MatchOptions } from "../match/match.js"; +import { recordArgServices, expectEffects } from "./record-effects.js"; +import type { DerivationCase, Effects } from "./types.js"; + +const isDerivationCase = (c: unknown): c is DerivationCase => + typeof c === "object" && c !== null && "value" in c; + +export interface SpecOptions { + // Passed through to `matches` (float tolerance, unordered collections). + readonly match?: MatchOptions; + // Override the `describe` label per module (default `State.`). + readonly label?: (path: string, fnName: string | undefined) => string; +} + +// The single pure-spec test for every transform AND derivation in a `data/state/` +// folder. Pass `import.meta.glob(["./*.ts", "!./*.test.ts"], { eager: true })`; it +// auto-discovers each file that exports `cases`, requires that file to export +// exactly its function plus `cases`, and dispatches on case shape: a `value` case +// checks a derivation `(state) => value`; otherwise a transition `(state, args) => +// state`, whose declared `effects` on injected services are also asserted. A +// service-injected transition is async, so results are awaited uniformly. +export const runSpec = (modules: Record>, options: SpecOptions = {}): void => { + for (const [path, module] of Object.entries(modules)) { + const exportNames = Object.keys(module); + if (!exportNames.includes("cases")) continue; + const functionNames = exportNames.filter((key) => typeof module[key] === "function"); + const fnName = functionNames.length === 1 ? functionNames[0] : undefined; + const label = options.label ? options.label(path, fnName) : `State.${fnName ?? path}`; + describe(label, () => { + if (exportNames.length !== 2 || functionNames.length !== 1) { + it("exports exactly its function and `cases`", () => { + throw new Error(`${path} exports [${exportNames.join(", ")}] — expected one function + cases`); + }); + return; + } + // Runtime invariant: a participating file exports one function and its cases. + const fn = module[functionNames[0]] as (...args: unknown[]) => unknown; + const cases = module["cases"] as readonly unknown[]; + for (const testCase of cases) { + if (isDerivationCase(testCase)) { + it(testCase.name, () => assert(fn(testCase.input), testCase.value, options.match)); + continue; + } + const tc = testCase as { + readonly name: string; + readonly before: unknown; + readonly args?: unknown; + readonly after: unknown; + readonly effects?: Effects>; + }; + it(tc.name, async () => { + const { args, calls } = recordArgServices(tc.args); + assert(await fn(tc.before, args), tc.after, options.match); + expectEffects(calls, tc.effects); + }); + } + }); + } +}; diff --git a/packages/data/src/testing/conformance/run-transactions.ts b/packages/data/src/testing/conformance/run-transactions.ts new file mode 100644 index 00000000..a8ca8339 --- /dev/null +++ b/packages/data/src/testing/conformance/run-transactions.ts @@ -0,0 +1,68 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it } from "vitest"; +import type { Entity } from "../../ecs/entity/entity.js"; +import { assert } from "../match/assert.js"; +import type { MatchOptions } from "../match/match.js"; +import { resolver, type Resolve } from "./resolve.js"; +import type { Case } from "./types.js"; + +// Wire one transaction to a transform's shared cases. `apply` receives the seeded +// writable store, the case args, and a `resolve` mapping a spec id to the seeded +// entity — then calls the raw transaction directly. +export type TransactionConforms = ( + transaction: string, + config: { + readonly cases: readonly Case[]; + readonly apply: (store: Store, args: Args, resolve: Resolve) => void; + }, +) => void; + +export interface TransactionRunConfig { + readonly createStore: () => Store; + // Seed a fresh store to `before`, returning the `spec id → seeded entity` map. + readonly fromState: (store: Store, before: State) => ReadonlyMap; + readonly toState: (store: Store) => State; + // The registered-transactions barrel — the coverage guard requires every key + // here to be wired, so none can be missed. + readonly registered: Record; + readonly match?: MatchOptions; + readonly define: (conforms: TransactionConforms) => void; +} + +// The single conformance test for every ecs transaction, proving +// `toState(apply(fromState(before), args)) ≡ after` for each shared case (half 1, +// `spec(before,args) ≡ after`, is asserted by `runSpec`). Bespoke `apply` +// adapters stay per-feature (an id-addressed transaction resolves its entity); the +// seed, projection, matching, and coverage guard are all shared here. +export const runTransactions = (config: TransactionRunConfig): void => { + const covered = new Set(); + const conforms = ( + transaction: string, + tconfig: { + readonly cases: readonly Case[]; + readonly apply: (store: Store, args: Args, resolve: Resolve) => void; + }, + ): void => { + covered.add(transaction); + describe(`${transaction} transaction conforms`, () => { + for (const testCase of tconfig.cases) { + it(testCase.name, () => { + const store = config.createStore(); + const resolve = resolver(config.fromState(store, testCase.before)); + // A void-arg case omits `args` (see `Case`); reading yields the correct `undefined`. + const args = (testCase as { readonly args?: Args }).args as Args; + tconfig.apply(store, args, resolve); + assert(config.toState(store), testCase.after, config.match); + }); + } + }); + }; + config.define(conforms); + describe("transaction conformance coverage", () => { + for (const transaction of Object.keys(config.registered)) { + it(`${transaction} has a conformance case`, () => { + if (!covered.has(transaction)) throw new Error(`${transaction} has no conformance case`); + }); + } + }); +}; diff --git a/packages/data/src/testing/conformance/types.ts b/packages/data/src/testing/conformance/types.ts new file mode 100644 index 00000000..95484e31 --- /dev/null +++ b/packages/data/src/testing/conformance/types.ts @@ -0,0 +1,68 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +// A service arg is an object with method members (not an array, not a function); +// this is what distinguishes an injected service from plain data args. +type MethodKeys = { [K in keyof T]-?: T[K] extends (...a: never[]) => unknown ? K : never }[keyof T]; +type IsService = T extends readonly unknown[] + ? false + : T extends (...a: never[]) => unknown + ? false + : T extends object + ? [MethodKeys] extends [never] + ? false + : true + : false; + +// A strongly-typed call to one method of service `S`: `[methodName, ...its args]`. +// A no-arg method is just `[methodName]`. +export type ServiceCall = { + [M in keyof S]-?: S[M] extends (...a: infer A) => unknown ? readonly [M, ...A] : never; +}[keyof S]; + +// Expected side effects for a case, keyed by the service-typed args only. An +// `Array` value asserts these calls in this order; a `Set` value asserts the same +// calls in any order. Method names and their args are checked against the service. +export type Effects = { + readonly [K in keyof Args as IsService extends true ? K : never]?: + | readonly ServiceCall[] + | ReadonlySet>; +}; + +// The case `args` type read from a transform's own signature — its second +// parameter, or `void` when it takes none. +type ArgsOf unknown> = Parameters extends [unknown, infer Args, ...unknown[]] + ? Args + : void; + +// One spec-owned conformance case: a `data/` transform's `{ before, args, after }` +// authored as full `State`, optionally with the side effects it makes on its +// injected service args. `args` is OMITTABLE exactly when the transform takes +// none. Shared unchanged by the spec aggregator and the ecs conformance runners. +export type Case = { + readonly name: string; + readonly before: State; + readonly after: State; + readonly effects?: Effects; +} & ([Args] extends [void] ? { readonly args?: undefined } : { readonly args: Args }); + +// A transform's cases, with the case `args` derived from the transform's own +// signature — author `export const cases: Conformance.Cases` +// (features alias it to a one-arg `Cases` binding `State` once), so the cases +// cannot drift from what the function accepts. +export type Cases unknown> = readonly Case>[]; + +// One case for a derivation (`(state) => value`): a state `input` and the `value` +// it yields. `value` may use asymmetric matchers. Both the pure derivation and its +// ecs computed are checked against these. +export type DerivationCase = { + readonly name: string; + readonly input: Input; + readonly value: Value; +}; + +// A derivation's cases, with `input` and `value` read from the derivation's own +// signature — the `Cases` analog for value-producing derivations. +export type DerivationCases unknown> = readonly DerivationCase< + Parameters[0], + ReturnType +>[]; diff --git a/packages/data/src/testing/index.ts b/packages/data/src/testing/index.ts new file mode 100644 index 00000000..992015f4 --- /dev/null +++ b/packages/data/src/testing/index.ts @@ -0,0 +1,9 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +// Shared test-only utilities for the spec↔ecs conformance pattern. Two namespaces: +// Match — tolerant, matcher-aware value comparison (framework-agnostic). +// Conformance — the case types, effect recording, id resolution, and the +// spec/transaction/action/computed runner drivers. +// Import only from `*.test.ts`; `sideEffects: false` keeps it out of app builds. +export * as Match from "./match/public.js"; +export * as Conformance from "./conformance/public.js"; diff --git a/packages/data/src/testing/match/assert.ts b/packages/data/src/testing/match/assert.ts new file mode 100644 index 00000000..d29ef78c --- /dev/null +++ b/packages/data/src/testing/match/assert.ts @@ -0,0 +1,19 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { matches, type MatchOptions } from "./match.js"; + +const show = (value: unknown): string => { + try { + return JSON.stringify(value) ?? String(value); + } catch { + return String(value); + } +}; + +// Throwing assertion built on `matches` — a mismatch throws an `Error` the test +// runner reports. Framework-agnostic (no `expect` import), so it works under any +// runner; the message shows both sides. +export const assert = (actual: unknown, expected: unknown, options?: MatchOptions): void => { + if (!matches(actual, expected, options)) { + throw new Error(`match failed:\n actual: ${show(actual)}\n expected: ${show(expected)}`); + } +}; diff --git a/packages/data/src/testing/match/match.test.ts b/packages/data/src/testing/match/match.test.ts new file mode 100644 index 00000000..44688e83 --- /dev/null +++ b/packages/data/src/testing/match/match.test.ts @@ -0,0 +1,51 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it, expect } from "vitest"; +import { matches, ref, anyNumber, anyString } from "./public.js"; + +describe("Match.matches", () => { + it("compares plain structures deeply", () => { + expect(matches({ a: 1, b: [2, 3] }, { a: 1, b: [2, 3] })).toBe(true); + expect(matches({ a: 1 }, { a: 2 })).toBe(false); + expect(matches({ a: 1, b: 2 }, { a: 1 })).toBe(false); // extra key on actual + }); + + it("absorbs F32/f64 and trig float noise onto the tolerance grid", () => { + expect(matches(Math.fround(0.1), 0.1)).toBe(true); + expect(matches(3e-15, 0)).toBe(true); + expect(matches(0.5, 0.5001)).toBe(true); // within 0.01 + expect(matches(0.5, 0.52)).toBe(false); // outside 0.01 + expect(matches(-0, 0)).toBe(true); + }); + + it("honors anyNumber / anyString and vitest-style asymmetric matchers", () => { + expect(matches({ id: 42, name: "x" }, { id: anyNumber, name: anyString })).toBe(true); + expect(matches({ id: "no" }, { id: anyNumber })).toBe(false); + expect(matches(7, expect.any(Number))).toBe(true); + }); + + it("compares arrays in order by default, as multisets when named", () => { + expect(matches([1, 2, 3], [1, 2, 3])).toBe(true); + expect(matches([1, 2, 3], [3, 2, 1])).toBe(false); + const opts = { unordered: new Set(["bag"]) }; + expect(matches({ bag: [1, 2, 3] }, { bag: [3, 1, 2] }, opts)).toBe(true); + expect(matches({ bag: [1, 2] }, { bag: [1, 2, 3] }, opts)).toBe(false); + }); + + describe("ref — id correspondence up to renaming", () => { + it("binds a label to the first actual and requires later ones to match", () => { + // Same ecs id in two places must be the same actual value. + expect(matches({ sel: 100, items: [{ id: 100 }] }, { sel: ref("a"), items: [{ id: ref("a") }] })).toBe( + true, + ); + // A dangling reference (sel points at an id no item has) fails. + expect(matches({ sel: 999, items: [{ id: 100 }] }, { sel: ref("a"), items: [{ id: ref("a") }] })).toBe( + false, + ); + }); + + it("is injective — two labels cannot bind the same actual", () => { + expect(matches([5, 6], [ref("a"), ref("b")])).toBe(true); + expect(matches([5, 5], [ref("a"), ref("b")])).toBe(false); + }); + }); +}); diff --git a/packages/data/src/testing/match/match.ts b/packages/data/src/testing/match/match.ts new file mode 100644 index 00000000..2d410dbb --- /dev/null +++ b/packages/data/src/testing/match/match.ts @@ -0,0 +1,102 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +// Options controlling the tolerant structural comparison. +export interface MatchOptions { + // Object keys whose array values compare as multisets (order-independent) at + // any depth — for ecs entity collections materialised in nondeterministic row + // order. Every other array stays order-sensitive (positional tuples like a + // `Vec2`, or a display-ordered list whose order a case verifies). + readonly unordered?: ReadonlySet; + // Float grid that absorbs F32↔f64 storage rounding and trig epsilon. Numbers + // are snapped to this grid before comparing. Default `0.01`. + readonly tolerance?: number; +} + +// A `ref(label)` on the EXPECTED side asserts id CORRESPONDENCE without pinning +// the value: the first occurrence of a label binds to whatever actual value sits +// there; later occurrences of the same label must equal that binding, and two +// labels can never bind the same actual (a bijection). This checks that ecs ids +// line up structurally — e.g. a `selectedId` points at the entity a case means — +// even though the ecs assigns ids from its own space. For an id a case does not +// care about, use `anyNumber` instead. +const REF = Symbol.for("@adobe/data/testing:ref"); +export const ref = (label: string): { readonly [REF]: string } => ({ [REF]: label }); +const isRef = (value: unknown): value is { readonly [REF]: string } => + typeof value === "object" && value !== null && REF in value; + +// An asymmetric matcher (this module's `anyNumber`/`anyString`, or vitest's +// `expect.any(...)`): honored on the EXPECTED side so a case asserts a shape it +// does not pin. Recognised structurally, so no test framework is imported. +const isMatcher = (value: unknown): value is { asymmetricMatch(actual: unknown): boolean } => + typeof value === "object" && + value !== null && + typeof (value as { asymmetricMatch?: unknown }).asymmetricMatch === "function"; + +const quantize = (n: number, tolerance: number): number => { + const factor = 1 / tolerance; + return Math.round(Math.fround(n) * factor) / factor + 0; // `+ 0` normalises `-0` to `0` +}; + +// Multiset (order-independent) match: greedy pairing, sufficient for concrete +// values. Elements compare with the ordered, matcher-aware path; `ref` bindings +// do not cross element boundaries here (ids in bags are `anyNumber`, not refs). +const matchesUnordered = ( + actual: readonly unknown[], + expected: readonly unknown[], + options: MatchOptions, +): boolean => { + if (actual.length !== expected.length) return false; + const used = new Array(actual.length).fill(false); + return expected.every((exp) => { + const index = actual.findIndex((act, i) => !used[i] && matchesWith(act, exp, options, new Map())); + if (index < 0) return false; + used[index] = true; + return true; + }); +}; + +const matchesWith = ( + actual: unknown, + expected: unknown, + options: MatchOptions, + bindings: Map, +): boolean => { + if (isRef(expected)) { + const label = expected[REF]; + if (bindings.has(label)) return Object.is(bindings.get(label), actual); + for (const bound of bindings.values()) if (Object.is(bound, actual)) return false; // injective + bindings.set(label, actual); + return true; + } + if (isMatcher(expected)) return expected.asymmetricMatch(actual); + if (typeof expected === "number" && typeof actual === "number") { + const tolerance = options.tolerance ?? 0.01; + return quantize(actual, tolerance) === quantize(expected, tolerance); + } + if (Array.isArray(expected)) { + if (!Array.isArray(actual) || actual.length !== expected.length) return false; + return expected.every((exp, index) => matchesWith(actual[index], exp, options, bindings)); + } + if (expected !== null && typeof expected === "object") { + if (actual === null || typeof actual !== "object" || Array.isArray(actual)) return false; + const expectedKeys = Object.keys(expected as object); + const actualKeys = Object.keys(actual as object); + if (expectedKeys.length !== actualKeys.length) return false; + return expectedKeys.every((key) => { + const exp = (expected as Record)[key]; + const act = (actual as Record)[key]; + if (options.unordered?.has(key) && Array.isArray(exp) && Array.isArray(act)) { + return matchesUnordered(act, exp, options); + } + return matchesWith(act, exp, options, bindings); + }); + } + return Object.is(actual, expected); +}; + +// Tolerant structural comparison: honors asymmetric matchers and `ref` +// correspondence on the expected side, absorbs float noise, and compares arrays +// in order except where `options.unordered` names a multiset collection. Pure +// and framework-agnostic — `assert` wraps it for a throwing test assertion. +export const matches = (actual: unknown, expected: unknown, options: MatchOptions = {}): boolean => + matchesWith(actual, expected, options, new Map()); diff --git a/packages/data/src/testing/match/matchers.ts b/packages/data/src/testing/match/matchers.ts new file mode 100644 index 00000000..f25b7e4f --- /dev/null +++ b/packages/data/src/testing/match/matchers.ts @@ -0,0 +1,15 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +// Asymmetric matchers for a value a case does not pin — chiefly an entity `id` +// the ecs assigns from its own id-space, so the spec and the ecs projection +// satisfy the same case without agreeing on the value. Plain `{ asymmetricMatch }` +// objects (no test-framework dependency), recognised by `matches` and +// interchangeable with vitest's `expect.any(Number)` / `expect.any(String)`. +const numberMatcher = { asymmetricMatch: (actual: unknown): boolean => typeof actual === "number" }; +const stringMatcher = { asymmetricMatch: (actual: unknown): boolean => typeof actual === "string" }; + +// Typed as the value each stands in for (as vitest types `expect.any`), so it +// slots into a pinned `number` / `string` field of a case's expected value; +// `matches` recognises the object at runtime by its `asymmetricMatch` method. +export const anyNumber = numberMatcher as unknown as number; +export const anyString = stringMatcher as unknown as string; diff --git a/packages/data/src/testing/match/public.ts b/packages/data/src/testing/match/public.ts new file mode 100644 index 00000000..6d7c2d91 --- /dev/null +++ b/packages/data/src/testing/match/public.ts @@ -0,0 +1,4 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +export { matches, ref, type MatchOptions } from "./match.js"; +export { assert } from "./assert.js"; +export { anyNumber, anyString } from "./matchers.js"; From 7636d835fdc4dd806106b84025af0c73afa7b480 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Thu, 6 Aug 2026 23:10:49 -0700 Subject: [PATCH 23/37] feat(data): migrate tictactoe/space-rock/solid-dashboard/react-pixie to @adobe/data/testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each feature drops its hand-authored conformance-case types, expect-state-matches, matchers, record-effects, and expect-conforms in favor of the shared toolkit; the runner test files keep only their bespoke apply/run adapters. Toolkit refinements from the harder samples: - fromState may return void (index/singleton-addressed features need no id map). - runTransactions `covers` lists transactions asserted outside the cases mechanism (space-rock setInput/setBounds — no data/ transform, direct resource assertion). - arg splitters guard arrays. - space-rock passes match:{unordered:{bullets,asteroids}} for its entity bags; its systems tick-loop test uses Match.assert. Also tidy tictactoe: the `state` computed now composes the `board` computed with the resource observables (Observe.fromProperties) instead of re-folding the placed-mark entities — the mark-fold lives only in `board`. Co-Authored-By: Claude Sonnet 4.6 --- .../main/data/state/conformance-case.ts | 77 +------ .../main/data/state/expect-state-matches.ts | 89 -------- .../features/main/data/state/fire-bullet.ts | 16 +- .../features/main/data/state/is-game-over.ts | 3 +- .../main/data/state/record-effects.ts | 107 --------- .../main/data/state/resolve-bullet-hits.ts | 19 +- .../main/data/state/resolve-ship-hits.ts | 7 +- .../main/data/state/spawn-random-wave.ts | 4 +- .../features/main/data/state/spawn-wave.ts | 4 +- .../src/features/main/data/state/spec.test.ts | 70 ++---- .../main/data/state/step-asteroids.ts | 43 +++- .../features/main/data/state/step-bullets.ts | 47 +++- .../src/features/main/data/state/step-ship.ts | 79 +++++-- .../src/features/main/data/state/step.ts | 22 +- .../main-service/conformance/actions.test.ts | 89 +++----- .../main-service/conformance/create-store.ts | 3 +- .../main-service/conformance/drive-frame.ts | 5 +- .../conformance/expect-conforms.ts | 42 ---- .../conformance/projection.test.ts | 13 +- .../main-service/conformance/to-data.ts | 30 ++- .../main-service/conformance/to-state.ts | 3 +- .../conformance/transactions.test.ts | 216 +++++++++--------- .../system-database/tick-loop.test.ts | 11 +- .../main/data/state/conformance-case.ts | 78 +------ .../main/data/state/current-player.ts | 32 ++- .../main/data/state/expect-state-matches.ts | 58 ----- .../src/features/main/data/state/play-move.ts | 106 +++++++-- .../main/data/state/play-opponent-move.ts | 52 ++++- .../main/data/state/record-effects.ts | 107 --------- .../features/main/data/state/restart-game.ts | 64 +++++- .../src/features/main/data/state/spec.test.ts | 68 ++---- .../computed-database/computed/state.ts | 34 ++- .../main-service/conformance/actions.test.ts | 96 +++----- .../conformance/computeds.test.ts | 120 +++------- .../main-service/conformance/create-store.ts | 3 +- .../conformance/expect-conforms.ts | 46 ---- .../main-service/conformance/from-state.ts | 4 +- .../conformance/projection.test.ts | 36 ++- .../main-service/conformance/to-data.ts | 8 +- .../main-service/conformance/to-state.ts | 4 +- .../conformance/transactions.test.ts | 49 ++-- .../main/data/state/conformance-case.ts | 77 +------ .../features/main/data/state/create-sprite.ts | 48 +++- .../main/data/state/expect-state-matches.ts | 56 ----- .../src/features/main/data/state/matchers.ts | 11 - .../main/data/state/record-effects.ts | 107 --------- .../main/data/state/set-sprite-active.ts | 28 ++- .../main/data/state/set-sprite-hovered.ts | 28 ++- .../src/features/main/data/state/spec.test.ts | 70 ++---- .../src/features/main/data/state/tick.ts | 24 +- .../main/data/state/toggle-sprite-active.ts | 32 ++- .../main-service/conformance/actions.test.ts | 144 +++++------- .../conformance/expect-conforms.ts | 48 ---- .../main-service/conformance/from-state.ts | 35 +-- .../conformance/projection.test.ts | 55 ++++- .../conformance/transactions.test.ts | 81 +++---- .../main/data/state/conformance-case.ts | 77 +------ .../main/data/state/expect-state-matches.ts | 49 ---- .../main/data/state/record-effects.ts | 107 --------- .../src/features/main/data/state/spec.test.ts | 69 ++---- .../main-service/conformance/actions.test.ts | 96 +++----- .../conformance/expect-conforms.ts | 41 ---- .../main-service/conformance/from-state.ts | 13 +- .../conformance/projection.test.ts | 10 +- .../conformance/transactions.test.ts | 56 ++--- .../src/testing/conformance/record-effects.ts | 4 +- .../data/src/testing/conformance/resolve.ts | 8 +- .../src/testing/conformance/run-actions.ts | 2 +- .../testing/conformance/run-transactions.ts | 11 +- 69 files changed, 1225 insertions(+), 2126 deletions(-) delete mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/expect-state-matches.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/record-effects.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/expect-conforms.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/data/state/expect-state-matches.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/data/state/record-effects.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/expect-conforms.ts delete mode 100644 packages/data-react-pixie/src/features/main/data/state/expect-state-matches.ts delete mode 100644 packages/data-react-pixie/src/features/main/data/state/matchers.ts delete mode 100644 packages/data-react-pixie/src/features/main/data/state/record-effects.ts delete mode 100644 packages/data-react-pixie/src/features/main/services/main-service/conformance/expect-conforms.ts delete mode 100644 packages/data-solid-dashboard/src/features/main/data/state/expect-state-matches.ts delete mode 100644 packages/data-solid-dashboard/src/features/main/data/state/record-effects.ts delete mode 100644 packages/data-solid-dashboard/src/features/main/services/main-service/conformance/expect-conforms.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/conformance-case.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/conformance-case.ts index 8c448828..4426eca4 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/conformance-case.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/conformance-case.ts @@ -1,70 +1,13 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance as ConformanceApi } from "@adobe/data/testing"; import type { State } from "./state.js"; -// A service arg is an object with method members (not an array, not a function); -// this is what distinguishes an injected service from plain data args, since the -// `Service` marker itself is all-optional and would over-match. -type MethodKeys = { [K in keyof T]-?: T[K] extends (...a: never[]) => unknown ? K : never }[keyof T]; -type IsService = T extends readonly unknown[] - ? false - : T extends (...a: never[]) => unknown - ? false - : T extends object - ? [MethodKeys] extends [never] - ? false - : true - : false; - -// A strongly-typed call to one method of service `S`: `[methodName, ...its args]`. -// A no-arg method is just `[methodName]`. -export type Call = { - [M in keyof S]-?: S[M] extends (...a: infer A) => unknown ? readonly [M, ...A] : never; -}[keyof S]; - -// Expected side effects for a case, keyed by the service-typed args only. An -// `Array` value asserts these calls in this order; a `Set` value asserts the same -// calls in any order. Method names and their args are checked against the service. -export type Effects = { - readonly [K in keyof Args as IsService extends true ? K : never]?: - | readonly Call[] - | ReadonlySet>; -}; - -// One spec-owned conformance case: a `data/` transform's `{ before, args, after }` -// authored as full `State`, optionally with the side effects it makes on its -// injected service args. Shared unchanged by the spec aggregator (`spec.test.ts`) -// and the ecs conformance runners (`services/main-service/conformance/`). -export type ConformanceCase = { - readonly name: string; - readonly before: State; - readonly args: Args; - readonly after: State; - readonly effects?: Effects; -}; - -// A transform's cases, with the case `args` type derived from the transform's own -// signature — its second parameter, or `void` when it takes none. A transform -// co-locates `export const cases: Conformance = [...]`, so the -// cases cannot drift from what the function accepts, and the spec aggregator can -// discover the function without it being named twice. -export type Conformance unknown> = readonly ConformanceCase< - Parameters extends [unknown, infer Args, ...unknown[]] ? Args : void ->[]; - -// One case for a derivation (`(state) => value`): a state `input` and the `value` -// it yields. `value` may use asymmetric matchers. Both the pure derivation and -// its ecs computed are checked against these. -export type DerivationCase = { - readonly name: string; - readonly input: Input; - readonly value: Value; -}; - -// A derivation's cases, with `input` and `value` types read from the derivation's -// own signature (its parameter and return) — the `Conformance` analog for -// value-producing derivations. A derivation co-locates -// `export const cases: Derivation = [...]`. -export type Derivation unknown> = readonly DerivationCase< - Parameters[0], - ReturnType ->[]; +// The conformance case types for this feature — the shared `@adobe/data/testing` +// machinery with `State` bound once, so a transform authors `Conformance` +// and a derivation `Derivation` (args/input/value read from the +// function's own signature). This tiny file is the only per-feature conformance +// type declaration; everything else lives in `@adobe/data/testing`. +export type Conformance unknown> = + ConformanceApi.Cases; +export type Derivation unknown> = + ConformanceApi.DerivationCases; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/expect-state-matches.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/expect-state-matches.ts deleted file mode 100644 index 36a4849a..00000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/expect-state-matches.ts +++ /dev/null @@ -1,89 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { expect } from "vitest"; -import type { State } from "./state.js"; - -// A vitest asymmetric matcher (`expect.any(...)`): honored on the EXPECTED side so -// a case can assert "any number" for a value it does not pin. This game exposes no -// ecs-minted ids in its `State` (bullets/asteroids are pure value types), so no -// case actually needs one — but the matcher path is kept so the comparison is the -// single matcher-aware oracle the rules describe. -const isMatcher = (value: unknown): value is { asymmetricMatch(actual: unknown): boolean } => - typeof value === "object" && - value !== null && - typeof (value as { asymmetricMatch?: unknown }).asymmetricMatch === "function"; - -// Collapse F32↔f64 storage rounding (ecs columns are F32, the spec authors f64) -// AND trig epsilon (a quadrant `cos`/`sin` yields ~3e-15 where a case authors 0) -// onto a small grid so float noise compares equal. `Math.fround` collapses the -// F32 rounding; rounding to 1e-2 (well under any real off-by-a-unit bug at this -// game's magnitudes) collapses the trig epsilon. `+ 0` normalises `-0` to `0`. -const quantize = (n: number): number => Math.round(Math.fround(n) * 100) / 100 + 0; - -// Tolerant structural match honoring asymmetric matchers and float precision, with -// arrays compared IN ORDER — correct for the ordered pairs this game is built from -// (`Vec2` position/velocity, whose two components are positional, not a bag). Bags -// of entities compare with `matchesUnordered` below, not here. -export const matches = (actual: unknown, expected: unknown): boolean => { - if (isMatcher(expected)) return expected.asymmetricMatch(actual); - if (typeof expected === "number" && typeof actual === "number") { - return quantize(actual) === quantize(expected); - } - if (Array.isArray(expected)) { - if (!Array.isArray(actual) || actual.length !== expected.length) return false; - return expected.every((exp, index) => matches(actual[index], exp)); - } - if (expected !== null && typeof expected === "object") { - if (actual === null || typeof actual !== "object" || Array.isArray(actual)) return false; - const expectedKeys = Object.keys(expected); - const actualKeys = Object.keys(actual as object); - if (expectedKeys.length !== actualKeys.length) return false; - return expectedKeys.every((key) => - matches((actual as Record)[key], (expected as Record)[key]), - ); - } - return Object.is(actual, expected); -}; - -// Multiset (order-independent) match for the entity COLLECTIONS (`bullets`, -// `asteroids`). The ecs materialises these in nondeterministic row order — -// archetype hole-fills, the broad-phase scan, and split-child spawn order all vary -// — and, unlike todo's display-ordered list, they carry no display order and no -// stable key exposed in `State`, so they are genuine bags. Each element is still -// compared with the ordered, matcher-aware `matches` (so its `Vec2`s stay -// positional). Greedy pairing is sufficient for concrete values. -const matchesUnordered = (actual: readonly unknown[], expected: readonly unknown[]): boolean => { - if (actual.length !== expected.length) return false; - const used = new Array(actual.length).fill(false); - return expected.every((exp) => { - const index = actual.findIndex((act, i) => !used[i] && matches(act, exp)); - if (index < 0) return false; - used[index] = true; - return true; - }); -}; - -// Spec-owned tolerant `State` equality, shared by the data/ transform spec test -// and the ecs conformance runners. Scalars and the ordered `Vec2`/`ship` fields -// compare in order; the entity bags (`bullets`, `asteroids`) compare as multisets. -// No separate id-ignoring variant — this one comparison serves both the pure spec -// and every ecs surface. -export const expectStateMatches = (actual: State, expected: State): void => { - const ok = - matches(actual.bounds, expected.bounds) && - matches(actual.ship, expected.ship) && - matches(actual.score, expected.score) && - matches(actual.lives, expected.lives) && - matches(actual.wave, expected.wave) && - matchesUnordered(actual.bullets, expected.bullets) && - matchesUnordered(actual.asteroids, expected.asteroids); - expect( - ok, - `State mismatch:\n actual ${JSON.stringify(actual)}\n expected ${JSON.stringify(expected)}`, - ).toBe(true); -}; - -// The same tolerant, matcher-aware comparison for any single value — the analog of -// todo's `expectMatches`, used where a compared value is not a whole `State`. -export const expectMatches = (actual: unknown, expected: unknown): void => { - expect(matches(actual, expected), `mismatch:\n actual ${JSON.stringify(actual)}`).toBe(true); -}; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/fire-bullet.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/fire-bullet.ts index 1aaedc1b..cf2264e4 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/fire-bullet.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/fire-bullet.ts @@ -7,7 +7,9 @@ import { Ship } from "../ship/ship.js"; // Fire one bullet from the ship's nose, inheriting its momentum. Composes the // ship's muzzle kinematics with the bullet's own speed constant. -export const fireBullet = >(state: T): T => { +export const fireBullet = >( + state: T, +): T => { const { position, velocity } = Ship.muzzle(state.ship, Bullet.speed); const bullet: Bullet = { position, velocity, age: 0 }; return { ...state, bullets: [...state.bullets, bullet] }; @@ -22,7 +24,11 @@ const field = { ...create(), bounds: [800, 600] as [number, number] }; export const cases: Conformance = [ { name: "fires from a ship facing +x at rest", - before: { ...field, ship: { position: [100, 100], velocity: [0, 0], rotation: 0 }, bullets: [] }, + before: { + ...field, + ship: { position: [100, 100], velocity: [0, 0], rotation: 0 }, + bullets: [], + }, args: undefined, after: { ...field, @@ -32,7 +38,11 @@ export const cases: Conformance = [ }, { name: "inherits the ship's momentum", - before: { ...field, ship: { position: [100, 100], velocity: [10, 20], rotation: 0 }, bullets: [] }, + before: { + ...field, + ship: { position: [100, 100], velocity: [10, 20], rotation: 0 }, + bullets: [], + }, args: undefined, after: { ...field, diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/is-game-over.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/is-game-over.ts index 18b3d6e8..84665f91 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/is-game-over.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/is-game-over.ts @@ -2,4 +2,5 @@ import type { State } from "./state.js"; // The game is over once every life is spent. -export const isGameOver = (state: Pick): boolean => state.lives <= 0; +export const isGameOver = (state: Pick): boolean => + state.lives <= 0; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/record-effects.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/record-effects.ts deleted file mode 100644 index f3cf16bc..00000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/record-effects.ts +++ /dev/null @@ -1,107 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { expect } from "vitest"; -import { equalsUnordered } from "@adobe/data"; -import type { Effects } from "./conformance-case.js"; - -export type RecordedCall = readonly [string, ...unknown[]]; - -// Wrap a plain-object service so each method call is recorded, then delegates. -// No Proxy (services are plain objects with own enumerable methods — see -// `service.md`), so we enumerate and closure-wrap each function. -export const recordCalls = (service: S): { service: S; calls: RecordedCall[] } => { - const calls: RecordedCall[] = []; - const wrapped = Object.fromEntries( - Object.entries(service).map(([key, value]) => [ - key, - typeof value === "function" - ? (...args: unknown[]): unknown => { - calls.push([key, ...args]); - return (value as (...a: unknown[]) => unknown)(...args); - } - : value, - ]), - ) as S; - return { service: wrapped, calls }; -}; - -// Assert the calls recorded against one service match the case's expectation for -// it: an Array expects exactly these calls in order; a Set expects the same calls -// in any order (multiset). Absent expectation ⇒ no calls expected. -export const expectServiceCalls = ( - recorded: readonly RecordedCall[], - expected: readonly RecordedCall[] | ReadonlySet | undefined, -): void => { - if (expected instanceof Set) { - expect(equalsUnordered(recorded, [...expected])).toBe(true); - } else { - expect(recorded).toEqual(expected ?? []); - } -}; - -// Split a case's `args` into the injected services (objects with methods) and the -// remaining plain data. Services are wrapped for recording; the returned `calls` -// map is keyed by the same arg key so it can be matched against `effects`. -export const recordArgServices = ( - args: Args, -): { args: Args; calls: Record } => { - const calls: Record = {}; - const next = { ...args } as Record; - for (const [key, value] of Object.entries(args)) { - if (isServiceValue(value)) { - const rec = recordCalls(value); - next[key] = rec.service; - calls[key] = rec.calls; - } - } - return { args: next as Args, calls }; -}; - -// Assert each service **declared** in `effects` saw exactly its expected calls -// (an extra or missing call on a declared service fails). Services not listed — -// e.g. a value-returning dependency read like `next` — are ignored, so `effects` -// captures the fire-and-forget side effects you choose to assert. -export const expectEffects = ( - calls: Record, - effects: Effects> | undefined, -): void => { - const expected = (effects ?? {}) as Record>; - for (const key of Object.keys(expected)) { - expectServiceCalls(calls[key] ?? [], expected[key]); - } -}; - -// Split a case's `args` into the injected services (wrapped for recording, to be -// used as `Database.create` service overrides) and the remaining plain data (the -// action input). Keyed by the same arg name so `calls` matches against `effects`. -export const splitAndRecordServices = ( - args: Args, -): { - services: Record; - input: Record; - calls: Record; -} => { - const services: Record = {}; - const input: Record = {}; - const calls: Record = {}; - // A no-arg transition has `undefined` args — nothing to split. - if (args !== null && typeof args === "object") { - for (const [key, value] of Object.entries(args)) { - if (isServiceValue(value)) { - const recorded = recordCalls(value); - services[key] = recorded.service; - calls[key] = recorded.calls; - } else { - input[key] = value; - } - } - } - return { services, input, calls }; -}; - -// A runtime service value: a non-array object with at least one method (mirrors -// the compile-time `IsService`). -const isServiceValue = (value: unknown): value is object => - typeof value === "object" && - value !== null && - !Array.isArray(value) && - Object.values(value).some((member) => typeof member === "function"); diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/resolve-bullet-hits.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/resolve-bullet-hits.ts index 643563dd..f0fcabed 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/resolve-bullet-hits.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/resolve-bullet-hits.ts @@ -31,9 +31,17 @@ export const resolveBulletHits = < const survivors: Bullet[] = []; let score = state.score; for (const bullet of state.bullets) { - const prev = Vec2.subtract(bullet.position, Vec2.scale(bullet.velocity, dt)); + const prev = Vec2.subtract( + bullet.position, + Vec2.scale(bullet.velocity, dt), + ); const hit = asteroids.findIndex((a) => - Collision.segmentCircleOverlap(prev, bullet.position, a.position, Bullet.radius + Asteroid.radius(a)), + Collision.segmentCircleOverlap( + prev, + bullet.position, + a.position, + Bullet.radius + Asteroid.radius(a), + ), ); if (hit < 0) { survivors.push(bullet); @@ -43,7 +51,12 @@ export const resolveBulletHits = < score += Asteroid.score(asteroid); spawned.push(...Asteroid.split(asteroid)); } - return { ...state, bullets: survivors, asteroids: [...asteroids, ...spawned], score }; + return { + ...state, + bullets: survivors, + asteroids: [...asteroids, ...spawned], + score, + }; }; // Spec-owned cases, shared with the ecs `hitAsteroid` transaction (dispatched diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/resolve-ship-hits.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/resolve-ship-hits.ts index 435eacbe..1ac4d83a 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/resolve-ship-hits.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/resolve-ship-hits.ts @@ -15,7 +15,12 @@ export const resolveShipHits = < state: T, ): T => { const struck = state.asteroids.some((a) => - Collision.circlesOverlap(state.ship.position, Ship.radius, a.position, Asteroid.radius(a)), + Collision.circlesOverlap( + state.ship.position, + Ship.radius, + a.position, + Asteroid.radius(a), + ), ); if (!struck) { return state; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-random-wave.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-random-wave.ts index 46ffbbbc..a00f1fdd 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-random-wave.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-random-wave.ts @@ -26,7 +26,9 @@ const asteroidsFor = (wave: number): number => 3 + wave; * `[0.5×, 1.5×)` — drawing one value per asteroid in ring order. A no-op while * asteroids remain (draws nothing, returns the same reference). */ -export const spawnRandomWave = >( +export const spawnRandomWave = < + T extends Pick, +>( state: T, { random }: { random: RandomService }, ): T => { diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-wave.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-wave.ts index 4cfdaf13..8a7f7c92 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-wave.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-wave.ts @@ -18,7 +18,9 @@ const asteroidsFor = (wave: number): number => 3 + wave; // (`createInitial`, so a fresh game always starts from the same fair layout). // The randomized sibling `spawnRandomWave` injects a `random` service for the // varied refill waves the tick loop spawns. -export const spawnWave = >( +export const spawnWave = < + T extends Pick, +>( state: T, ): T => { if (state.asteroids.length > 0) { diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/spec.test.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/spec.test.ts index 1d862422..9b061739 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/spec.test.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/spec.test.ts @@ -1,61 +1,17 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. /// -import { describe, it } from "vitest"; -import type { State } from "./state.js"; -import type { ConformanceCase, DerivationCase } from "./conformance-case.js"; -import { expectStateMatches, expectMatches } from "./expect-state-matches.js"; -import { recordArgServices, expectEffects } from "./record-effects.js"; +import { Conformance } from "@adobe/data/testing"; -// The single spec test for every transform AND derivation in this folder. It -// auto-discovers each file (any sibling `.ts` that exports `cases`) via -// `import.meta.glob`, so a new one is covered the moment it ships — none can be -// forgotten. Each participating file must export exactly its function plus `cases` -// (enforced below), which lets us find the function without it being named twice. -// A case's shape selects the check: `after` → a transition `(state, args) => state`; -// `value` → a derivation `(state) => value`. -const modules = import.meta.glob>( - ["./*.ts", "!./*.test.ts", "!./*.type-test.ts"], - { eager: true }, +// The single pure-spec test for every transform AND derivation in this folder. +// `runSpec` auto-discovers each sibling exporting `cases` and dispatches on shape. +// The entity bags (`bullets`, `asteroids`) the ecs materialises in nondeterministic +// row order compare as multisets; ordered `Vec2`s and scalars compare in order. +Conformance.runSpec( + import.meta.glob>( + ["./*.ts", "!./*.test.ts", "!./*.type-test.ts"], + { + eager: true, + }, + ), + { match: { unordered: new Set(["bullets", "asteroids"]) } }, ); - -const isDerivationCase = (c: unknown): c is DerivationCase => - typeof c === "object" && c !== null && "value" in c; - -for (const [path, module] of Object.entries(modules)) { - const exportNames = Object.keys(module); - if (!exportNames.includes("cases")) continue; - const functionNames = exportNames.filter((key) => typeof module[key] === "function"); - const label = functionNames.length === 1 ? functionNames[0] : path; - - describe(`State.${label}`, () => { - if (exportNames.length !== 2 || functionNames.length !== 1) { - it("exports exactly its function and `cases`", () => { - throw new Error(`${path} exports [${exportNames.join(", ")}] — expected one function + cases`); - }); - return; - } - // Runtime invariant: a participating file exports a function and its cases. - const fn = module[functionNames[0]] as (...args: unknown[]) => unknown; - const cases = module["cases"] as readonly unknown[]; - for (const testCase of cases) { - if (isDerivationCase(testCase)) { - // Derivation: the value it yields matches, honoring `anyNumber`. - it(testCase.name, () => expectMatches(fn(testCase.input), testCase.value)); - continue; - } - // Transition: assert the resulting state and the declared side effects. - // A service-injected transition is async, so await uniformly. - const transitionCase = testCase as ConformanceCase>; - it(transitionCase.name, async () => { - const raw = transitionCase.args; - const { args, calls } = - raw && typeof raw === "object" && !Array.isArray(raw) - ? recordArgServices(raw) - : { args: raw, calls: {} }; - const result = (await fn(transitionCase.before, args)) as State; - expectStateMatches(result, transitionCase.after); - expectEffects(calls, transitionCase.effects); - }); - } - }); -} diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/step-asteroids.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/step-asteroids.ts index dccf56a7..e14fe5ec 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/step-asteroids.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/step-asteroids.ts @@ -12,7 +12,10 @@ export const stepAsteroids = >( ): T => { const asteroids = state.asteroids.map((a) => ({ ...a, - position: Motion.wrap(Motion.advance(a.position, a.velocity, dt), state.bounds), + position: Motion.wrap( + Motion.advance(a.position, a.velocity, dt), + state.bounds, + ), })); return { ...state, asteroids }; }; @@ -25,21 +28,47 @@ const field = { ...create(), bounds: [100, 100] as [number, number] }; export const cases: Conformance = [ { name: "drifts an asteroid by its velocity", - before: { ...field, asteroids: [{ position: [10, 10], velocity: [30, 0], size: Size.largest }] }, + before: { + ...field, + asteroids: [ + { position: [10, 10], velocity: [30, 0], size: Size.largest }, + ], + }, args: 1, - after: { ...field, asteroids: [{ position: [40, 10], velocity: [30, 0], size: Size.largest }] }, + after: { + ...field, + asteroids: [ + { position: [40, 10], velocity: [30, 0], size: Size.largest }, + ], + }, }, { name: "wraps an asteroid around the toroidal field", - before: { ...field, asteroids: [{ position: [80, 80], velocity: [50, 50], size: Size.largest }] }, + before: { + ...field, + asteroids: [ + { position: [80, 80], velocity: [50, 50], size: Size.largest }, + ], + }, args: 1, - after: { ...field, asteroids: [{ position: [30, 30], velocity: [50, 50], size: Size.largest }] }, + after: { + ...field, + asteroids: [ + { position: [30, 30], velocity: [50, 50], size: Size.largest }, + ], + }, }, { name: "wraps negatively across the left edge", - before: { ...field, asteroids: [{ position: [10, 10], velocity: [-50, 0], size: "medium" }] }, + before: { + ...field, + asteroids: [{ position: [10, 10], velocity: [-50, 0], size: "medium" }], + }, args: 1, - after: { ...field, asteroids: [{ position: [60, 10], velocity: [-50, 0], size: "medium" }] }, + after: { + ...field, + asteroids: [{ position: [60, 10], velocity: [-50, 0], size: "medium" }], + }, }, { name: "advances several asteroids of different sizes independently", diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/step-bullets.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/step-bullets.ts index 1ea1ed28..2c4a81f6 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/step-bullets.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/step-bullets.ts @@ -15,7 +15,10 @@ export const stepBullets = >( .filter((b) => !Bullet.isExpired(b.age, dt)) .map((b) => ({ ...b, - position: Motion.wrap(Motion.advance(b.position, b.velocity, dt), state.bounds), + position: Motion.wrap( + Motion.advance(b.position, b.velocity, dt), + state.bounds, + ), age: b.age + dt, })); return { ...state, bullets }; @@ -30,27 +33,50 @@ const field = { ...create(), bounds: [100, 100] as [number, number] }; export const cases: Conformance = [ { name: "moves and ages a live bullet", - before: { ...field, bullets: [{ position: [10, 50], velocity: [100, 0], age: 0 }] }, + before: { + ...field, + bullets: [{ position: [10, 50], velocity: [100, 0], age: 0 }], + }, args: 0.1, - after: { ...field, bullets: [{ position: [20, 50], velocity: [100, 0], age: 0.1 }] }, + after: { + ...field, + bullets: [{ position: [20, 50], velocity: [100, 0], age: 0.1 }], + }, }, { name: "wraps a bullet across the right edge", - before: { ...field, bullets: [{ position: [95, 50], velocity: [100, 0], age: 0 }] }, + before: { + ...field, + bullets: [{ position: [95, 50], velocity: [100, 0], age: 0 }], + }, args: 0.1, - after: { ...field, bullets: [{ position: [5, 50], velocity: [100, 0], age: 0.1 }] }, + after: { + ...field, + bullets: [{ position: [5, 50], velocity: [100, 0], age: 0.1 }], + }, }, { name: "drops a bullet that expires this tick (age + dt ≥ lifetime)", - before: { ...field, bullets: [{ position: [10, 50], velocity: [100, 0], age: Bullet.lifetime }] }, + before: { + ...field, + bullets: [ + { position: [10, 50], velocity: [100, 0], age: Bullet.lifetime }, + ], + }, args: 0.1, after: { ...field, bullets: [] }, }, { name: "keeps and ages a bullet still under its lifetime", - before: { ...field, bullets: [{ position: [10, 50], velocity: [0, 0], age: 1.0 }] }, + before: { + ...field, + bullets: [{ position: [10, 50], velocity: [0, 0], age: 1.0 }], + }, args: 0.1, - after: { ...field, bullets: [{ position: [10, 50], velocity: [0, 0], age: 1.1 }] }, + after: { + ...field, + bullets: [{ position: [10, 50], velocity: [0, 0], age: 1.1 }], + }, }, { name: "advances survivors and drops only the expired bullet", @@ -62,7 +88,10 @@ export const cases: Conformance = [ ], }, args: 0.1, - after: { ...field, bullets: [{ position: [20, 50], velocity: [100, 0], age: 0.1 }] }, + after: { + ...field, + bullets: [{ position: [20, 50], velocity: [100, 0], age: 0.1 }], + }, }, { name: "an empty list stays empty", diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/step-ship.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/step-ship.ts index 3d87c5e0..c2edd28e 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/step-ship.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/step-ship.ts @@ -16,8 +16,13 @@ export const stepShip = >( ): T => { const { ship } = state; const rotation = Ship.turn(ship.rotation, input.turn, dt); - const velocity = input.thrust ? Ship.thrust(ship.velocity, rotation, dt) : ship.velocity; - const position = Motion.wrap(Motion.advance(ship.position, velocity, dt), state.bounds); + const velocity = input.thrust + ? Ship.thrust(ship.velocity, rotation, dt) + : ship.velocity; + const position = Motion.wrap( + Motion.advance(ship.position, velocity, dt), + state.bounds, + ); return { ...state, ship: { position, velocity, rotation } }; }; @@ -30,46 +35,88 @@ const idle: Input = { turn: 0, thrust: false, fire: false }; export const cases: Conformance = [ { name: "turns right by a positive turn input", - before: { ...field, ship: { position: [50, 50], velocity: [0, 0], rotation: 0 } }, + before: { + ...field, + ship: { position: [50, 50], velocity: [0, 0], rotation: 0 }, + }, args: { dt: 1, input: { turn: 1, thrust: false, fire: false } }, - after: { ...field, ship: { position: [50, 50], velocity: [0, 0], rotation: 3 } }, + after: { + ...field, + ship: { position: [50, 50], velocity: [0, 0], rotation: 3 }, + }, }, { name: "turns left by a negative turn input", - before: { ...field, ship: { position: [50, 50], velocity: [0, 0], rotation: 0 } }, + before: { + ...field, + ship: { position: [50, 50], velocity: [0, 0], rotation: 0 }, + }, args: { dt: 1, input: { turn: -1, thrust: false, fire: false } }, - after: { ...field, ship: { position: [50, 50], velocity: [0, 0], rotation: -3 } }, + after: { + ...field, + ship: { position: [50, 50], velocity: [0, 0], rotation: -3 }, + }, }, { name: "no turn holds rotation and coasts by velocity", - before: { ...field, ship: { position: [50, 50], velocity: [10, 0], rotation: 0.7 } }, + before: { + ...field, + ship: { position: [50, 50], velocity: [10, 0], rotation: 0.7 }, + }, args: { dt: 1, input: idle }, - after: { ...field, ship: { position: [60, 50], velocity: [10, 0], rotation: 0.7 } }, + after: { + ...field, + ship: { position: [60, 50], velocity: [10, 0], rotation: 0.7 }, + }, }, { name: "thrusts along the facing, then coasts by the new velocity", - before: { ...field, ship: { position: [50, 50], velocity: [0, 0], rotation: 0 } }, + before: { + ...field, + ship: { position: [50, 50], velocity: [0, 0], rotation: 0 }, + }, args: { dt: 0.1, input: { turn: 0, thrust: true, fire: false } }, - after: { ...field, ship: { position: [52, 50], velocity: [20, 0], rotation: 0 } }, + after: { + ...field, + ship: { position: [52, 50], velocity: [20, 0], rotation: 0 }, + }, }, { name: "wraps across the right edge", - before: { ...field, ship: { position: [95, 50], velocity: [100, 0], rotation: 0 } }, + before: { + ...field, + ship: { position: [95, 50], velocity: [100, 0], rotation: 0 }, + }, args: { dt: 0.1, input: idle }, - after: { ...field, ship: { position: [5, 50], velocity: [100, 0], rotation: 0 } }, + after: { + ...field, + ship: { position: [5, 50], velocity: [100, 0], rotation: 0 }, + }, }, { name: "wraps across the top edge (negative wrap)", - before: { ...field, ship: { position: [5, 5], velocity: [0, -100], rotation: 0 } }, + before: { + ...field, + ship: { position: [5, 5], velocity: [0, -100], rotation: 0 }, + }, args: { dt: 0.1, input: idle }, - after: { ...field, ship: { position: [5, 95], velocity: [0, -100], rotation: 0 } }, + after: { + ...field, + ship: { position: [5, 95], velocity: [0, -100], rotation: 0 }, + }, }, { // Turn then thrust: −3 turns to 0, and thrust must use the NEW rotation 0 // (facing +x → velocity [200,0]); using the old −3 would point elsewhere. name: "turn composes before thrust — thrust uses the post-turn rotation", - before: { ...field, ship: { position: [50, 50], velocity: [0, 0], rotation: -3 } }, + before: { + ...field, + ship: { position: [50, 50], velocity: [0, 0], rotation: -3 }, + }, args: { dt: 1, input: { turn: 1, thrust: true, fire: false } }, - after: { ...field, ship: { position: [50, 50], velocity: [200, 0], rotation: 0 } }, + after: { + ...field, + ship: { position: [50, 50], velocity: [200, 0], rotation: 0 }, + }, }, ]; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/step.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/step.ts index c5295856..28763ae8 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/step.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/step.ts @@ -29,7 +29,15 @@ import { RandomService } from "../../services/random-service/random-service.js"; // conformed via the `spawnRandomWave` transaction). export const step = ( state: State, - { dt, input, random }: { readonly dt: number; readonly input: Input; readonly random: RandomService }, + { + dt, + input, + random, + }: { + readonly dt: number; + readonly input: Input; + readonly random: RandomService; + }, ): State => { if (isGameOver(state)) { return state; @@ -87,7 +95,11 @@ export const cases: Conformance = [ lives: 3, wave: 1, }, - args: { dt: 0.1, input: { turn: 0, thrust: false, fire: true }, random: RandomService.createFake() }, + args: { + dt: 0.1, + input: { turn: 0, thrust: false, fire: true }, + random: RandomService.createFake(), + }, after: { bounds: [400, 400], ship: { position: [100, 100], velocity: [0, 0], rotation: 0 }, @@ -156,7 +168,11 @@ export const cases: Conformance = [ lives: 0, wave: 2, }, - args: { dt: 0.1, input: { turn: 1, thrust: true, fire: true }, random: RandomService.createFake() }, + args: { + dt: 0.1, + input: { turn: 1, thrust: true, fire: true }, + random: RandomService.createFake(), + }, after: { bounds: [200, 200], ship: { position: [50, 50], velocity: [10, 0], rotation: 0 }, diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/actions.test.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/actions.test.ts index 1a13850f..c76557fc 100644 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/actions.test.ts +++ b/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/actions.test.ts @@ -1,72 +1,43 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -/// -import { describe, it, expect } from "vitest"; import { Database } from "@adobe/data/ecs"; -import type { ConformanceCase } from "../../../data/state/conformance-case.js"; -import { splitAndRecordServices, expectEffects } from "../../../data/state/record-effects.js"; +import { Conformance } from "@adobe/data/testing"; import type { RandomService } from "../../random-service/random-service.js"; -import { expectStateMatches } from "../../../data/state/expect-state-matches.js"; import { MainService } from "../main-service.js"; -import { fromState } from "./from-state.js"; -import { toState } from "./to-state.js"; +import * as registeredActions from "../action-database/actions/index.js"; import { fireBullet } from "../action-database/actions/fire-bullet.js"; import { spawnRandomWave } from "../action-database/actions/spawn-random-wave.js"; import { cases as fireBulletCases } from "../../../data/state/fire-bullet.js"; import { cases as spawnRandomWaveCases } from "../../../data/state/spawn-random-wave.js"; +import { fromState } from "./from-state.js"; +import { toState } from "./to-state.js"; -// Each transition's cases run against its same-named ecs **action** (the async -// realization), asserting both the resulting state and the declared side effects. -// The case's service args become the db's service overrides — wrapped so their -// calls are recorded — and the plain args drive the action. -// `toSystemDatabase` exposes the writable `.store` the projection needs while -// keeping services/transactions/actions. Runtime invariant: the recording -// wrappers preserve each service's shape, so they are valid factory overrides. -// // Only the app-facing, single-transaction transitions get an action: `fireBullet` // (no service) and `spawnRandomWave` (injects the `random` service — a // value-returning read, so nothing is declared in `effects`). The per-frame step -// transitions (`stepShip`, `step`, …) are realized by the `systems` layer and -// conformed by the tick-loop test, not here. -const makeDb = (services: { random?: RandomService }) => - Database.toSystemDatabase(Database.create(MainService.plugin, { services })); -type Db = ReturnType; -type Run = (db: Db) => Promise | void; - -const covered = new Set(); -const conformsAction = ( - action: string, - config: { readonly cases: readonly ConformanceCase[]; readonly run: Run }, -): void => { - covered.add(action); - describe(`${action} action conforms`, () => { - for (const testCase of config.cases) { - it(testCase.name, async () => { - const { services, input, calls } = splitAndRecordServices(testCase.args); - // No action here takes a plain-data arg; assert none crept in. - expect(Object.keys(input)).toEqual([]); - const db = makeDb(services as { random?: RandomService }); - fromState(db.store, testCase.before); - await config.run(db); - expectStateMatches(toState(db.store), testCase.after); - expectEffects(calls, testCase.effects); - }); - } - }); -}; - -conformsAction("fireBullet", { cases: fireBulletCases, run: (db) => fireBullet(db) }); -conformsAction("spawnRandomWave", { cases: spawnRandomWaveCases, run: (db) => spawnRandomWave(db) }); - -// None-missed guard: every action file must be wired above. -const kebabToCamel = (name: string): string => - name.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase()); -describe("action conformance coverage", () => { - const files = import.meta.glob([ - "../action-database/actions/*.ts", - "!../action-database/actions/index.ts", - ]); - for (const path of Object.keys(files)) { - const action = kebabToCamel(path.replace(/.*\//, "").replace(/\.ts$/, "")); - it(`${action} has a conformance case`, () => expect(covered.has(action)).toBe(true)); - } +// transitions are realized by the systems layer and conformed by the tick-loop +// test, not here. Entity bags compare as multisets via the `match` option. +Conformance.runActions({ + // Runtime invariant: the recording wrappers preserve the service's shape, so + // they are a valid factory override. + makeDb: (services) => + Database.toSystemDatabase( + Database.create(MainService.plugin, { + services: services as { random?: RandomService }, + }), + ), + store: (db) => db.store, + fromState, + toState, + registered: registeredActions, + match: { unordered: new Set(["bullets", "asteroids"]) }, + define: (conforms) => { + conforms("fireBullet", { + cases: fireBulletCases, + run: (db) => fireBullet(db), + }); + conforms("spawnRandomWave", { + cases: spawnRandomWaveCases, + run: (db) => spawnRandomWave(db), + }); + }, }); diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/create-store.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/create-store.ts index 4fa8cc16..2b2ba403 100644 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/create-store.ts +++ b/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/create-store.ts @@ -9,4 +9,5 @@ import { IndexDatabase } from "../index-database/index-database.js"; // none — and `Store.create` reads a plugin's schema facets directly. Typed as // `CoreDatabase.Store`: the surface the projection (`fromState` / `toState`) and // the raw transaction functions use. Test-only. -export const createStore = (): CoreDatabase.Store => Store.create(IndexDatabase.plugin); +export const createStore = (): CoreDatabase.Store => + Store.create(IndexDatabase.plugin); diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/drive-frame.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/drive-frame.ts index b044ee3e..1d03634b 100644 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/drive-frame.ts +++ b/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/drive-frame.ts @@ -13,7 +13,10 @@ export const driveFrame = (db: SystemDatabase): void => { // `name` comes from `db.system.order`, so it is always one of this // database's declared system keys — an invariant the `string` element // type of `order` cannot carry. - const fn = db.system.functions[name as keyof SystemDatabase["system"]["functions"]]; + const fn = + db.system.functions[ + name as keyof SystemDatabase["system"]["functions"] + ]; if (typeof fn === "function") fn(); } } diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/expect-conforms.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/expect-conforms.ts deleted file mode 100644 index bcbf3e81..00000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/expect-conforms.ts +++ /dev/null @@ -1,42 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { it } from "vitest"; -import type { State } from "../../../data/state/state.js"; -import type { ConformanceCase } from "../../../data/state/conformance-case.js"; -import { expectStateMatches } from "../../../data/state/expect-state-matches.js"; -import type { CoreDatabase } from "../core-database/core-database.js"; -import { createStore } from "./create-store.js"; -import { fromState } from "./from-state.js"; -import { toState } from "./to-state.js"; - -// The conformance runner, bound to THIS feature's projection (`fromState` / -// `toState`). For each case it proves the one conformance property -// -// toState(apply(fromState(before), args)) ≡ spec(before, args) -// -// seeding `fromState(before)`, running the caller's `apply`, and asserting -// `toState ≡ after`. The pure half (`spec(before, args) ≡ after`) is asserted for -// every case once, centrally, by `data/state/spec.test.ts`, so this runner omits -// it by default; pass `spec` to re-check it in place. Entity collections compare -// as multisets, scalars/`Vec2` exactly (see `expectStateMatches`). -// -// `apply` receives the seeded writable store and calls the raw transaction -// function directly (a transaction is `(store, args) => void`, so no `Database` is -// involved). A mutation addressed by entity ids resolves them from the seeded -// store inside its own `apply` closure (the shared cases stay spec-shaped). -export const expectConforms = (config: { - readonly cases: readonly ConformanceCase[]; - readonly spec?: (before: State, args: Args) => State; - readonly apply: (store: CoreDatabase.Store, args: Args) => void; -}): void => { - for (const testCase of config.cases) { - it(testCase.name, () => { - if (config.spec) { - expectStateMatches(config.spec(testCase.before, testCase.args), testCase.after); - } - const store = createStore(); - fromState(store, testCase.before); - config.apply(store, testCase.args); - expectStateMatches(toState(store), testCase.after); - }); - } -}; diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/projection.test.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/projection.test.ts index 9384ad8d..3724dc44 100644 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/projection.test.ts +++ b/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/projection.test.ts @@ -1,17 +1,18 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. // // Guards the projection itself. `fromState` and `toState` are the bridge every -// transaction / system conformance test trusts; a symmetric bug in the pair -// (e.g. both dropping the same field) would cancel out and mask a real ecs -// defect. This identity test — `toState(fromState(s)) ≡ s` over representative -// states — proves the projection round-trips faithfully on its own. +// transaction / system conformance test trusts; a symmetric bug in the pair would +// cancel out and mask a real ecs defect. This identity test — `toState(fromState(s)) +// ≡ s` over representative states — proves the projection round-trips on its own. import { describe, it } from "vitest"; +import { Match } from "@adobe/data/testing"; import type { State } from "../../../data/state/state.js"; -import { expectStateMatches } from "../../../data/state/expect-state-matches.js"; import { createStore } from "./create-store.js"; import { fromState } from "./from-state.js"; import { toState } from "./to-state.js"; +const unordered = { unordered: new Set(["bullets", "asteroids"]) }; + const states: readonly { readonly name: string; readonly state: State }[] = [ { name: "a full game: ship + bullets + asteroids of every size, non-zero counters", @@ -67,7 +68,7 @@ describe("ecs/conformance projection round-trips (toState ∘ fromState ≡ iden it(name, () => { const store = createStore(); fromState(store, state); - expectStateMatches(toState(store), state); + Match.assert(toState(store), state, unordered); }); } }); diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/to-data.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/to-data.ts index c34e3851..57b1c40e 100644 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/to-data.ts +++ b/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/to-data.ts @@ -11,12 +11,32 @@ import type { CoreDatabase } from "../core-database/core-database.js"; // one of Ship / Bullet / Asteroid, so `toData` probes each named archetype (their // component sets are distinct — only Ship has `rotation`, only Bullet `age`, only // Asteroid `size`) and projects the first that matches. Test-only. -export const toData = (store: CoreDatabase.Store, entity: Entity): Ship | Bullet | Asteroid => { +export const toData = ( + store: CoreDatabase.Store, + entity: Entity, +): Ship | Bullet | Asteroid => { const ship = store.read(entity, store.archetypes.Ship); - if (ship !== null) return { position: ship.position, velocity: ship.velocity, rotation: ship.rotation }; + if (ship !== null) + return { + position: ship.position, + velocity: ship.velocity, + rotation: ship.rotation, + }; const bullet = store.read(entity, store.archetypes.Bullet); - if (bullet !== null) return { position: bullet.position, velocity: bullet.velocity, age: bullet.age }; + if (bullet !== null) + return { + position: bullet.position, + velocity: bullet.velocity, + age: bullet.age, + }; const asteroid = store.read(entity, store.archetypes.Asteroid); - if (asteroid !== null) return { position: asteroid.position, velocity: asteroid.velocity, size: asteroid.size }; - throw new Error("conformance projection: entity is not a ship, bullet, or asteroid"); + if (asteroid !== null) + return { + position: asteroid.position, + velocity: asteroid.velocity, + size: asteroid.size, + }; + throw new Error( + "conformance projection: entity is not a ship, bullet, or asteroid", + ); }; diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/to-state.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/to-state.ts index 0b7acb1d..bb763e41 100644 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/to-state.ts +++ b/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/to-state.ts @@ -25,7 +25,8 @@ export const toState = (store: CoreDatabase.Store): State => { else asteroids.push(value); } } - if (ship === undefined) throw new Error("conformance projection: expected a ship entity"); + if (ship === undefined) + throw new Error("conformance projection: expected a ship entity"); return { bounds: store.resources.bounds, ship, diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/transactions.test.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/transactions.test.ts index 1cce32dc..059d1842 100644 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/transactions.test.ts +++ b/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/transactions.test.ts @@ -2,14 +2,14 @@ import { describe, it, expect } from "vitest"; import type { Entity } from "@adobe/data/ecs"; import { Vec2 } from "@adobe/data/math"; -import type { CoreDatabase } from "../core-database/core-database.js"; -import type { ConformanceCase } from "../../../data/state/conformance-case.js"; +import { Conformance } from "@adobe/data/testing"; import { Collision } from "../../../data/collision/collision.js"; import { Bullet } from "../../../data/bullet/bullet.js"; import { Asteroid } from "../../../data/asteroid/asteroid.js"; import { Ship } from "../../../data/ship/ship.js"; -import { expectConforms } from "./expect-conforms.js"; import { createStore } from "./create-store.js"; +import { fromState } from "./from-state.js"; +import { toState } from "./to-state.js"; import * as registeredTransactions from "../transaction-database/transactions/index.js"; import { setInput } from "../transaction-database/transactions/set-input.js"; import { setBounds } from "../transaction-database/transactions/set-bounds.js"; @@ -24,110 +24,124 @@ import { cases as fireBulletCases } from "../../../data/state/fire-bullet.js"; import { cases as resolveBulletHitsCases } from "../../../data/state/resolve-bullet-hits.js"; import { cases as resolveShipHitsCases } from "../../../data/state/resolve-ship-hits.js"; -// The single conformance test for every ecs transaction. Each transition's shared -// `data/state` cases run through its raw `apply` (`fromState(before)` → apply → -// `matches(toState, after)`); the pure half is asserted once, centrally, by -// `data/state/spec.test.ts`, so this runner asserts only the ecs half. The guard -// at the bottom asserts every REGISTERED transaction (the barrel, not a file glob) -// is wired below, so the flat `readShip` / `readAsteroids` helpers — kept out of -// the barrel — are naturally excluded and none can be missed. -const covered = new Set(); -const conforms = ( - transaction: string, - config: { - readonly cases: readonly ConformanceCase[]; - readonly apply: (t: CoreDatabase.Store, args: Args) => void; - }, -): void => { - covered.add(transaction); - describe(`${transaction} transaction conforms`, () => expectConforms(config)); -}; +// The single conformance test for every ecs transaction. `runTransactions` owns the +// harness (fresh store, `fromState` seed, `toState` compare, coverage guard keyed +// off the registered barrel); the bespoke `apply` adapters stay here — several +// reproduce a collision system's per-pair dispatch against the seeded store. Entity +// bags compare as multisets via the `match` option. `setInput` / `setBounds` have no +// `data/` transform (they only record a resource), so they are asserted directly +// below and named in `covers` so the guard still counts them. +Conformance.runTransactions({ + createStore, + fromState, + toState, + registered: registeredTransactions, + covers: ["setInput", "setBounds"], + match: { unordered: new Set(["bullets", "asteroids"]) }, + define: (conforms) => { + // newGame ⇄ createInitial: seed the bounds the transform reads, then rebuild. + conforms("newGame", { + cases: createInitialCases, + apply: (t, { bounds }) => { + setBounds(t, bounds); + newGame(t); + }, + }); -// newGame ⇄ createInitial: seed the bounds the transform reads, then rebuild. -conforms("newGame", { - cases: createInitialCases, - apply: (t, { bounds }) => { - setBounds(t, bounds); - newGame(t); - }, -}); + // spawnRandomWave ⇄ State.spawnRandomWave: the same injected double drives both + // sides (carried in each case's `args.random`), so the jittered velocities agree. + conforms("spawnRandomWave", { + cases: spawnRandomWaveCases, + apply: spawnRandomWave, + }); -// spawnRandomWave ⇄ State.spawnRandomWave: the same injected double drives both -// sides (carried in each case's `args.random`), so the jittered velocities agree. -conforms("spawnRandomWave", { cases: spawnRandomWaveCases, apply: spawnRandomWave }); + // fireBullet ⇄ State.fireBullet: reads the seeded ship, inserts the muzzle bullet. + conforms("fireBullet", { + cases: fireBulletCases, + apply: (t) => fireBullet(t), + }); -// fireBullet ⇄ State.fireBullet: reads the seeded ship, inserts the muzzle bullet. -conforms("fireBullet", { cases: fireBulletCases, apply: (t) => fireBullet(t) }); - -// hitAsteroid ⇄ State.resolveBulletHits. The transform resolves EVERY bullet's hit -// in one pass; the transaction resolves ONE (bullet, asteroid) pair — the collision -// system dispatches it once per overlapping bullet. This `apply` reproduces that -// dispatch loop: detect every pair FIRST against the untouched store (so no child a -// split spawns this pass can be a target), each asteroid claimed by at most one -// bullet, using the same SWEPT segment test, then apply. -conforms("hitAsteroid", { - cases: resolveBulletHitsCases, - apply: (t, dt: number) => { - const asteroids: readonly Entity[] = [...t.select(t.archetypes.Asteroid.components)]; - const claimed = new Set(); - const hits: { readonly bullet: Entity; readonly asteroid: Entity }[] = []; - for (const bullet of t.select(t.archetypes.Bullet.components)) { - const bulletRow = t.read(bullet, t.archetypes.Bullet); - if (bulletRow === null) continue; - const prev = Vec2.subtract(bulletRow.position, Vec2.scale(bulletRow.velocity, dt)); - for (const asteroid of asteroids) { - if (claimed.has(asteroid)) continue; - const asteroidRow = t.read(asteroid, t.archetypes.Asteroid); - if (asteroidRow === null) continue; - if ( - Collision.segmentCircleOverlap( - prev, + // hitAsteroid ⇄ State.resolveBulletHits. The transform resolves EVERY bullet's hit + // in one pass; the transaction resolves ONE (bullet, asteroid) pair — the collision + // system dispatches it once per overlapping bullet. This `apply` reproduces that + // dispatch loop: detect every pair FIRST against the untouched store (so no child a + // split spawns this pass can be a target), each asteroid claimed by at most one + // bullet, using the same SWEPT segment test, then apply. + conforms("hitAsteroid", { + cases: resolveBulletHitsCases, + apply: (t, dt: number) => { + const asteroids: readonly Entity[] = [ + ...t.select(t.archetypes.Asteroid.components), + ]; + const claimed = new Set(); + const hits: { readonly bullet: Entity; readonly asteroid: Entity }[] = + []; + for (const bullet of t.select(t.archetypes.Bullet.components)) { + const bulletRow = t.read(bullet, t.archetypes.Bullet); + if (bulletRow === null) continue; + const prev = Vec2.subtract( bulletRow.position, - asteroidRow.position, - Bullet.radius + Asteroid.radius(asteroidRow), - ) - ) { - claimed.add(asteroid); - hits.push({ bullet, asteroid }); - break; + Vec2.scale(bulletRow.velocity, dt), + ); + for (const asteroid of asteroids) { + if (claimed.has(asteroid)) continue; + const asteroidRow = t.read(asteroid, t.archetypes.Asteroid); + if (asteroidRow === null) continue; + if ( + Collision.segmentCircleOverlap( + prev, + bulletRow.position, + asteroidRow.position, + Bullet.radius + Asteroid.radius(asteroidRow), + ) + ) { + claimed.add(asteroid); + hits.push({ bullet, asteroid }); + break; + } + } } - } - } - for (const hit of hits) hitAsteroid(t, hit); - }, -}); + for (const hit of hits) hitAsteroid(t, hit); + }, + }); -// loseLife ⇄ State.resolveShipHits. The transform decides whether the ship is -// struck AND applies the consequence; the transaction is only the struck branch -// (spend a life, respawn). This `apply` reproduces that decision from the seeded -// store: dispatch `loseLife` iff the ship overlaps an asteroid. -conforms("loseLife", { - cases: resolveShipHitsCases, - apply: (t) => { - const [shipId] = t.select(t.archetypes.Ship.components); - if (shipId === undefined) return; - const shipRow = t.read(shipId, t.archetypes.Ship); - if (shipRow === null) return; - let struck = false; - for (const asteroid of t.select(t.archetypes.Asteroid.components)) { - const asteroidRow = t.read(asteroid, t.archetypes.Asteroid); - if (asteroidRow === null) continue; - if ( - Collision.circlesOverlap(shipRow.position, Ship.radius, asteroidRow.position, Asteroid.radius(asteroidRow)) - ) { - struck = true; - break; - } - } - if (struck) loseLife(t); + // loseLife ⇄ State.resolveShipHits. The transform decides whether the ship is + // struck AND applies the consequence; the transaction is only the struck branch + // (spend a life, respawn). This `apply` reproduces that decision from the seeded + // store: dispatch `loseLife` iff the ship overlaps an asteroid. + conforms("loseLife", { + cases: resolveShipHitsCases, + apply: (t) => { + const [shipId] = t.select(t.archetypes.Ship.components); + if (shipId === undefined) return; + const shipRow = t.read(shipId, t.archetypes.Ship); + if (shipRow === null) return; + let struck = false; + for (const asteroid of t.select(t.archetypes.Asteroid.components)) { + const asteroidRow = t.read(asteroid, t.archetypes.Asteroid); + if (asteroidRow === null) continue; + if ( + Collision.circlesOverlap( + shipRow.position, + Ship.radius, + asteroidRow.position, + Asteroid.radius(asteroidRow), + ) + ) { + struck = true; + break; + } + } + if (struck) loseLife(t); + }, + }); }, }); -// setInput / setBounds have no `data/` transform to conform to — they only record -// a resource — so they get a direct resource assertion (per transactions.md), still -// counted by the coverage guard. +// setInput / setBounds have no `data/` transform to conform to — they only record a +// resource — so they get a direct resource assertion (per transactions.md); they are +// named in `covers` above so the coverage guard still counts them. describe("setInput transaction", () => { - covered.add("setInput"); it("writes the dispatched input to the resource verbatim", () => { const store = createStore(); const input = { turn: 1, thrust: true, fire: false }; @@ -137,17 +151,9 @@ describe("setInput transaction", () => { }); describe("setBounds transaction", () => { - covered.add("setBounds"); it("writes the dispatched bounds to the resource verbatim", () => { const store = createStore(); setBounds(store, [1024, 768]); expect(store.resources.bounds).toEqual([1024, 768]); }); }); - -// None-missed guard: every **registered** transaction (the barrel) must be wired. -describe("transaction conformance coverage", () => { - for (const transaction of Object.keys(registeredTransactions)) { - it(`${transaction} has a conformance case`, () => expect(covered.has(transaction)).toBe(true)); - } -}); diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/system-database/tick-loop.test.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/system-database/tick-loop.test.ts index 1a1e44d8..4669bae7 100644 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/system-database/tick-loop.test.ts +++ b/packages/data-lit-space-rock-game/src/features/main/services/main-service/system-database/tick-loop.test.ts @@ -26,7 +26,7 @@ import { State } from "../../../data/state/state.js"; import { Ship } from "../../../data/ship/ship.js"; import { Input } from "../../../data/input/input.js"; import { cases } from "../../../data/state/step.js"; -import { expectStateMatches } from "../../../data/state/expect-state-matches.js"; +import { Match } from "@adobe/data/testing"; import { createSystemDatabase } from "../conformance/create-system-database.js"; import { fromState } from "../conformance/from-state.js"; import { toState } from "../conformance/to-state.js"; @@ -36,16 +36,21 @@ describe("ECS system tick loop conforms to State.step (one frame = one step)", ( for (const testCase of cases) { it(testCase.name, () => { const { dt, input } = testCase.args; + const unordered = { unordered: new Set(["bullets", "asteroids"]) }; // The co-located case carries its own inert `random` double (no case clears // the field, so it is never drawn), so drive the oracle with the case args. - expectStateMatches(State.step(testCase.before, testCase.args), testCase.after); + Match.assert( + State.step(testCase.before, testCase.args), + testCase.after, + unordered, + ); const db = createSystemDatabase(); fromState(db.store, testCase.before); db.store.resources.frameDelta = dt; db.transactions.setInput(input); driveFrame(db); - expectStateMatches(toState(db.store), testCase.after); + Match.assert(toState(db.store), testCase.after, unordered); }); } diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/conformance-case.ts b/packages/data-lit-tictactoe/src/features/main/data/state/conformance-case.ts index 095f3cb2..0b87028e 100644 --- a/packages/data-lit-tictactoe/src/features/main/data/state/conformance-case.ts +++ b/packages/data-lit-tictactoe/src/features/main/data/state/conformance-case.ts @@ -1,70 +1,14 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance as ConformanceApi } from "@adobe/data/testing"; import type { State } from "./state.js"; -// A service arg is an object with method members (not an array, not a function); -// this is what distinguishes an injected service from plain data args, since the -// `Service` marker itself is all-optional and would over-match. -type MethodKeys = { [K in keyof T]-?: T[K] extends (...a: never[]) => unknown ? K : never }[keyof T]; -type IsService = T extends readonly unknown[] - ? false - : T extends (...a: never[]) => unknown - ? false - : T extends object - ? [MethodKeys] extends [never] - ? false - : true - : false; - -// A strongly-typed call to one method of service `S`: `[methodName, ...its args]`. -// A no-arg method is just `[methodName]`. -export type Call = { - [M in keyof S]-?: S[M] extends (...a: infer A) => unknown ? readonly [M, ...A] : never; -}[keyof S]; - -// Expected side effects for a case, keyed by the service-typed args only. An -// `Array` value asserts these calls in this order; a `Set` value asserts the same -// calls in any order. Method names and their args are checked against the service. -export type Effects = { - readonly [K in keyof Args as IsService extends true ? K : never]?: - | readonly Call[] - | ReadonlySet>; -}; - -// One spec-owned conformance case: a `data/` transform's `{ before, args, after }` -// authored as full `State`, optionally with the side effects it makes on its -// injected service args. Shared unchanged by the spec aggregator (`spec.test.ts`) -// and the ecs conformance runners (`services/main-service/conformance/`). -export type ConformanceCase = { - readonly name: string; - readonly before: State; - readonly args: Args; - readonly after: State; - readonly effects?: Effects; -}; - -// A transform's cases, with the case `args` type derived from the transform's own -// signature — its second parameter, or `void` when it takes none. A transform -// co-locates `export const cases: Conformance = [...]`, so the -// cases cannot drift from what the function accepts, and the spec aggregator can -// discover the function without it being named twice. -export type Conformance unknown> = readonly ConformanceCase< - Parameters extends [unknown, infer Args, ...unknown[]] ? Args : void ->[]; - -// One case for a derivation (`(state) => value`): a state `input` and the `value` -// it yields. `value` may use asymmetric matchers (`anyNumber` for ids). Both the -// pure derivation and its ecs computed are checked against these. -export type DerivationCase = { - readonly name: string; - readonly input: Input; - readonly value: Value; -}; - -// A derivation's cases, with `input` and `value` types read from the derivation's -// own signature (its parameter and return) — the `Conformance` analog for -// value-producing derivations. A derivation co-locates -// `export const cases: Derivation = [...]`. -export type Derivation unknown> = readonly DerivationCase< - Parameters[0], - ReturnType ->[]; +// The conformance case types for this feature — the shared `@adobe/data/testing` +// machinery with `State` bound once, so a transform authors `Conformance` +// and a derivation `Derivation` (args/input/value read from the +// function's own signature). This tiny file is the only per-feature conformance +// type declaration; everything else lives in `@adobe/data/testing`. +export type Conformance unknown> = + ConformanceApi.Cases; +export type Derivation unknown> = + ConformanceApi.DerivationCases; +export type Effects = ConformanceApi.Effects; diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/current-player.ts b/packages/data-lit-tictactoe/src/features/main/data/state/current-player.ts index 912083c7..c984a1e6 100644 --- a/packages/data-lit-tictactoe/src/features/main/data/state/current-player.ts +++ b/packages/data-lit-tictactoe/src/features/main/data/state/current-player.ts @@ -17,22 +17,46 @@ export const currentPlayer = (state: State): PlayerMark => export const cases: Derivation = [ { name: "the first player moves on an empty board", - input: { board: " ", firstPlayer: "X", xWins: 0, oWins: 0, draws: 0 }, + input: { + board: " ", + firstPlayer: "X", + xWins: 0, + oWins: 0, + draws: 0, + }, value: "X", }, { name: "honors a first player of O on an empty board", - input: { board: " ", firstPlayer: "O", xWins: 0, oWins: 0, draws: 0 }, + input: { + board: " ", + firstPlayer: "O", + xWins: 0, + oWins: 0, + draws: 0, + }, value: "O", }, { name: "alternates to the opponent after the first move", - input: { board: " X ", firstPlayer: "X", xWins: 0, oWins: 0, draws: 0 }, + input: { + board: " X ", + firstPlayer: "X", + xWins: 0, + oWins: 0, + draws: 0, + }, value: "O", }, { name: "returns to the first player after both have moved", - input: { board: "XO ", firstPlayer: "X", xWins: 1, oWins: 2, draws: 0 }, + input: { + board: "XO ", + firstPlayer: "X", + xWins: 1, + oWins: 2, + draws: 0, + }, value: "X", }, ]; diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/expect-state-matches.ts b/packages/data-lit-tictactoe/src/features/main/data/state/expect-state-matches.ts deleted file mode 100644 index 8ff8578c..00000000 --- a/packages/data-lit-tictactoe/src/features/main/data/state/expect-state-matches.ts +++ /dev/null @@ -1,58 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { expect } from "vitest"; -import type { State } from "./state.js"; - -// A vitest asymmetric matcher (`expect.any(...)`): honored on the EXPECTED side -// so a case can assert "any number" for a value it does not pin. Tic-tac-toe's -// `State` exposes no ecs-minted ids (marks fold into the board string, scores are -// plain counters), so no case needs one today — but the comparison stays -// matcher-aware to match the shared pattern and stay robust if one is ever added. -const isMatcher = (value: unknown): value is { asymmetricMatch(actual: unknown): boolean } => - typeof value === "object" && - value !== null && - typeof (value as { asymmetricMatch?: unknown }).asymmetricMatch === "function"; - -// Collapse F32↔f64 storage rounding onto a small grid so float noise compares -// equal. `+ 0` normalises `-0` to `0`. (Tic-tac-toe's scalars are integer -// counters and the board is a string, so this is a no-op here — kept to mirror -// the shared pattern and stay robust if a float field is added.) -const quantize = (n: number): number => Math.round(Math.fround(n) * 100) / 100 + 0; - -// Tolerant structural match honoring asymmetric matchers, float precision, and -// order-sensitive arrays. Exported so it can back other conformance comparisons -// (e.g. computed values). -export const matches = (actual: unknown, expected: unknown): boolean => { - if (isMatcher(expected)) return expected.asymmetricMatch(actual); - if (typeof expected === "number" && typeof actual === "number") { - return quantize(actual) === quantize(expected); - } - if (Array.isArray(expected)) { - if (!Array.isArray(actual) || actual.length !== expected.length) return false; - return expected.every((exp, index) => matches(actual[index], exp)); - } - if (expected !== null && typeof expected === "object") { - if (actual === null || typeof actual !== "object" || Array.isArray(actual)) return false; - const expectedKeys = Object.keys(expected); - const actualKeys = Object.keys(actual as object); - if (expectedKeys.length !== actualKeys.length) return false; - return expectedKeys.every((key) => - matches((actual as Record)[key], (expected as Record)[key]), - ); - } - return Object.is(actual, expected); -}; - -// Spec-owned tolerant `State` equality, shared by the data/ transform tests and -// the ecs conformance runners. `after` may use asymmetric matchers, so this one -// comparison serves both the pure spec and the ecs projection — no separate -// id-ignoring variant is needed. -export const expectStateMatches = (actual: State, expected: State): void => { - expectMatches(actual, expected); -}; - -// The same tolerant, matcher-aware comparison for any value — used by derivation -// spec tests and computed conformance, where the compared value is a scalar -// (`PlayerMark`) rather than a whole `State`. -export const expectMatches = (actual: unknown, expected: unknown): void => { - expect(matches(actual, expected), `mismatch:\n actual ${JSON.stringify(actual)}`).toBe(true); -}; diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/play-move.ts b/packages/data-lit-tictactoe/src/features/main/data/state/play-move.ts index 04cffff8..f9c56b20 100644 --- a/packages/data-lit-tictactoe/src/features/main/data/state/play-move.ts +++ b/packages/data-lit-tictactoe/src/features/main/data/state/play-move.ts @@ -10,13 +10,19 @@ export const playMove = >( state: T, input: PlayMoveArgs, ): T => { - if (!PlayMoveArgs.canPlayMove({ board: state.board, index: input.index }).ok) { + if ( + !PlayMoveArgs.canPlayMove({ board: state.board, index: input.index }).ok + ) { return state; } const mark = BoardState.currentPlayer(state.board, state.firstPlayer); return { ...state, - board: BoardState.setBoardCell({ board: state.board, index: input.index, mark }), + board: BoardState.setBoardCell({ + board: state.board, + index: input.index, + mark, + }), }; }; @@ -27,38 +33,110 @@ export const playMove = >( export const cases: Conformance = [ { name: "places the first player's mark into an empty cell", - before: { board: " ", firstPlayer: "X", xWins: 0, oWins: 0, draws: 0 }, + before: { + board: " ", + firstPlayer: "X", + xWins: 0, + oWins: 0, + draws: 0, + }, args: { index: 4 }, - after: { board: " X ", firstPlayer: "X", xWins: 0, oWins: 0, draws: 0 }, + after: { + board: " X ", + firstPlayer: "X", + xWins: 0, + oWins: 0, + draws: 0, + }, }, { name: "alternates to the opponent by move count", - before: { board: " X ", firstPlayer: "X", xWins: 0, oWins: 0, draws: 0 }, + before: { + board: " X ", + firstPlayer: "X", + xWins: 0, + oWins: 0, + draws: 0, + }, args: { index: 0 }, - after: { board: "O X ", firstPlayer: "X", xWins: 0, oWins: 0, draws: 0 }, + after: { + board: "O X ", + firstPlayer: "X", + xWins: 0, + oWins: 0, + draws: 0, + }, }, { name: "completes a three-in-a-row (winning placement is still just a placement)", - before: { board: "XX OO ", firstPlayer: "X", xWins: 1, oWins: 2, draws: 0 }, + before: { + board: "XX OO ", + firstPlayer: "X", + xWins: 1, + oWins: 2, + draws: 0, + }, args: { index: 2 }, - after: { board: "XXX OO ", firstPlayer: "X", xWins: 1, oWins: 2, draws: 0 }, + after: { + board: "XXX OO ", + firstPlayer: "X", + xWins: 1, + oWins: 2, + draws: 0, + }, }, { name: "ignores an occupied cell (no-op)", - before: { board: " X ", firstPlayer: "X", xWins: 0, oWins: 0, draws: 0 }, + before: { + board: " X ", + firstPlayer: "X", + xWins: 0, + oWins: 0, + draws: 0, + }, args: { index: 4 }, - after: { board: " X ", firstPlayer: "X", xWins: 0, oWins: 0, draws: 0 }, + after: { + board: " X ", + firstPlayer: "X", + xWins: 0, + oWins: 0, + draws: 0, + }, }, { name: "ignores an out-of-bounds index (no-op)", - before: { board: " ", firstPlayer: "O", xWins: 0, oWins: 0, draws: 0 }, + before: { + board: " ", + firstPlayer: "O", + xWins: 0, + oWins: 0, + draws: 0, + }, args: { index: 9 }, - after: { board: " ", firstPlayer: "O", xWins: 0, oWins: 0, draws: 0 }, + after: { + board: " ", + firstPlayer: "O", + xWins: 0, + oWins: 0, + draws: 0, + }, }, { name: "ignores a move once the game is already won (no-op)", - before: { board: "XXX ", firstPlayer: "X", xWins: 0, oWins: 0, draws: 0 }, + before: { + board: "XXX ", + firstPlayer: "X", + xWins: 0, + oWins: 0, + draws: 0, + }, args: { index: 4 }, - after: { board: "XXX ", firstPlayer: "X", xWins: 0, oWins: 0, draws: 0 }, + after: { + board: "XXX ", + firstPlayer: "X", + xWins: 0, + oWins: 0, + draws: 0, + }, }, ]; diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/play-opponent-move.ts b/packages/data-lit-tictactoe/src/features/main/data/state/play-opponent-move.ts index 10977806..4006a5cc 100644 --- a/packages/data-lit-tictactoe/src/features/main/data/state/play-opponent-move.ts +++ b/packages/data-lit-tictactoe/src/features/main/data/state/play-opponent-move.ts @@ -16,7 +16,9 @@ import type { Conformance } from "./conformance-case.js"; * is still **deterministic given its dependency**: inject a fixed opponent and * the result is fixed — which is exactly how it is unit-tested. */ -export const playOpponentMove = async >( +export const playOpponentMove = async < + T extends Pick, +>( state: T, { opponent }: { opponent: OpponentService }, ): Promise => { @@ -32,25 +34,61 @@ export const playOpponentMove = async = [ { name: "plays the opponent's first selected move for the current player", - before: { board: " ", firstPlayer: "X", xWins: 0, oWins: 0, draws: 0 }, + before: { + board: " ", + firstPlayer: "X", + xWins: 0, + oWins: 0, + draws: 0, + }, args: { opponent: OpponentService.createFake() }, // fakeMoves[0] === 4; the current player on an empty board is the first // player (X), so an X lands in the centre cell. - after: { board: " X ", firstPlayer: "X", xWins: 0, oWins: 0, draws: 0 }, + after: { + board: " X ", + firstPlayer: "X", + xWins: 0, + oWins: 0, + draws: 0, + }, }, { name: "plays the next mark onto a running board", - before: { board: " X ", firstPlayer: "X", xWins: 0, oWins: 0, draws: 0 }, + before: { + board: " X ", + firstPlayer: "X", + xWins: 0, + oWins: 0, + draws: 0, + }, args: { opponent: OpponentService.createFake([0]) }, // The published move is cell 0; the current player alternates to O by move // count, so an O lands in the top-left cell. - after: { board: "O X ", firstPlayer: "X", xWins: 0, oWins: 0, draws: 0 }, + after: { + board: "O X ", + firstPlayer: "X", + xWins: 0, + oWins: 0, + draws: 0, + }, }, { name: "ignores an illegal selected move, leaving the state unchanged", - before: { board: " X ", firstPlayer: "X", xWins: 0, oWins: 0, draws: 0 }, + before: { + board: " X ", + firstPlayer: "X", + xWins: 0, + oWins: 0, + draws: 0, + }, args: { opponent: OpponentService.createFake([4]) }, // Cell 4 is occupied — `playMove` rejects it, so the transition is a no-op. - after: { board: " X ", firstPlayer: "X", xWins: 0, oWins: 0, draws: 0 }, + after: { + board: " X ", + firstPlayer: "X", + xWins: 0, + oWins: 0, + draws: 0, + }, }, ]; diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/record-effects.ts b/packages/data-lit-tictactoe/src/features/main/data/state/record-effects.ts deleted file mode 100644 index b51fc084..00000000 --- a/packages/data-lit-tictactoe/src/features/main/data/state/record-effects.ts +++ /dev/null @@ -1,107 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { expect } from "vitest"; -import { equalsUnordered } from "@adobe/data"; -import type { Effects } from "./conformance-case.js"; - -export type RecordedCall = readonly [string, ...unknown[]]; - -// Wrap a plain-object service so each method call is recorded, then delegates. -// No Proxy (services are plain objects with own enumerable methods — see -// `service.md`), so we enumerate and closure-wrap each function. -export const recordCalls = (service: S): { service: S; calls: RecordedCall[] } => { - const calls: RecordedCall[] = []; - const wrapped = Object.fromEntries( - Object.entries(service).map(([key, value]) => [ - key, - typeof value === "function" - ? (...args: unknown[]): unknown => { - calls.push([key, ...args]); - return (value as (...a: unknown[]) => unknown)(...args); - } - : value, - ]), - ) as S; - return { service: wrapped, calls }; -}; - -// Assert the calls recorded against one service match the case's expectation for -// it: an Array expects exactly these calls in order; a Set expects the same calls -// in any order (multiset). Absent expectation ⇒ no calls expected. -export const expectServiceCalls = ( - recorded: readonly RecordedCall[], - expected: readonly RecordedCall[] | ReadonlySet | undefined, -): void => { - if (expected instanceof Set) { - expect(equalsUnordered(recorded, [...expected])).toBe(true); - } else { - expect(recorded).toEqual(expected ?? []); - } -}; - -// Split a case's `args` into the injected services (objects with methods) and the -// remaining plain data. Services are wrapped for recording; the returned `calls` -// map is keyed by the same arg key so it can be matched against `effects`. -export const recordArgServices = ( - args: Args, -): { args: Args; calls: Record } => { - const calls: Record = {}; - const next = { ...args } as Record; - for (const [key, value] of Object.entries(args)) { - if (isServiceValue(value)) { - const rec = recordCalls(value); - next[key] = rec.service; - calls[key] = rec.calls; - } - } - return { args: next as Args, calls }; -}; - -// Assert each service **declared** in `effects` saw exactly its expected calls -// (an extra or missing call on a declared service fails). Services not listed — -// e.g. a value-returning dependency read like `selectMove` — are ignored, so -// `effects` captures the fire-and-forget side effects you choose to assert. -export const expectEffects = ( - calls: Record, - effects: Effects> | undefined, -): void => { - const expected = (effects ?? {}) as Record>; - for (const key of Object.keys(expected)) { - expectServiceCalls(calls[key] ?? [], expected[key]); - } -}; - -// Split a case's `args` into the injected services (wrapped for recording, to be -// used as `Database.create` service overrides) and the remaining plain data (the -// action input). Keyed by the same arg name so `calls` matches against `effects`. -export const splitAndRecordServices = ( - args: Args, -): { - services: Record; - input: Record; - calls: Record; -} => { - const services: Record = {}; - const input: Record = {}; - const calls: Record = {}; - // A no-arg transition has `undefined` args — nothing to split. - if (args !== null && typeof args === "object") { - for (const [key, value] of Object.entries(args)) { - if (isServiceValue(value)) { - const recorded = recordCalls(value); - services[key] = recorded.service; - calls[key] = recorded.calls; - } else { - input[key] = value; - } - } - } - return { services, input, calls }; -}; - -// A runtime service value: a non-array object with at least one method (mirrors -// the compile-time `IsService`). -const isServiceValue = (value: unknown): value is object => - typeof value === "object" && - value !== null && - !Array.isArray(value) && - Object.values(value).some((member) => typeof member === "function"); diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/restart-game.ts b/packages/data-lit-tictactoe/src/features/main/data/state/restart-game.ts index 0a13ff85..41f2d815 100644 --- a/packages/data-lit-tictactoe/src/features/main/data/state/restart-game.ts +++ b/packages/data-lit-tictactoe/src/features/main/data/state/restart-game.ts @@ -25,26 +25,74 @@ export const restartGame = (state: State): State => { export const cases: Conformance = [ { name: "tallies an X win, alternates first player, clears the board", - before: { board: "XXX ", firstPlayer: "X", xWins: 0, oWins: 0, draws: 0 }, + before: { + board: "XXX ", + firstPlayer: "X", + xWins: 0, + oWins: 0, + draws: 0, + }, args: undefined, - after: { board: " ", firstPlayer: "O", xWins: 1, oWins: 0, draws: 0 }, + after: { + board: " ", + firstPlayer: "O", + xWins: 1, + oWins: 0, + draws: 0, + }, }, { name: "tallies an O win", - before: { board: "OOOXX ", firstPlayer: "O", xWins: 1, oWins: 2, draws: 0 }, + before: { + board: "OOOXX ", + firstPlayer: "O", + xWins: 1, + oWins: 2, + draws: 0, + }, args: undefined, - after: { board: " ", firstPlayer: "X", xWins: 1, oWins: 3, draws: 0 }, + after: { + board: " ", + firstPlayer: "X", + xWins: 1, + oWins: 3, + draws: 0, + }, }, { name: "tallies a draw (full board, no line)", - before: { board: "XOXXOOOXX", firstPlayer: "O", xWins: 2, oWins: 1, draws: 0 }, + before: { + board: "XOXXOOOXX", + firstPlayer: "O", + xWins: 2, + oWins: 1, + draws: 0, + }, args: undefined, - after: { board: " ", firstPlayer: "X", xWins: 2, oWins: 1, draws: 1 }, + after: { + board: " ", + firstPlayer: "X", + xWins: 2, + oWins: 1, + draws: 1, + }, }, { name: "restarts an unfinished game without touching any counter", - before: { board: "X O ", firstPlayer: "X", xWins: 1, oWins: 1, draws: 1 }, + before: { + board: "X O ", + firstPlayer: "X", + xWins: 1, + oWins: 1, + draws: 1, + }, args: undefined, - after: { board: " ", firstPlayer: "O", xWins: 1, oWins: 1, draws: 1 }, + after: { + board: " ", + firstPlayer: "O", + xWins: 1, + oWins: 1, + draws: 1, + }, }, ]; diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/spec.test.ts b/packages/data-lit-tictactoe/src/features/main/data/state/spec.test.ts index d398242c..3d1fc60a 100644 --- a/packages/data-lit-tictactoe/src/features/main/data/state/spec.test.ts +++ b/packages/data-lit-tictactoe/src/features/main/data/state/spec.test.ts @@ -1,59 +1,17 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. /// -import { describe, it } from "vitest"; -import type { State } from "./state.js"; -import type { ConformanceCase, DerivationCase } from "./conformance-case.js"; -import { expectStateMatches, expectMatches } from "./expect-state-matches.js"; -import { recordArgServices, expectEffects } from "./record-effects.js"; +import { Conformance } from "@adobe/data/testing"; -// The single spec test for every transform AND derivation in this folder. It -// auto-discovers each file (any sibling `.ts` that exports `cases`) via -// `import.meta.glob`, so a new one is covered the moment it ships — none can be -// forgotten. Each participating file must export exactly its function plus `cases` -// (enforced below), which lets us find the function without it being named twice. -// A case's shape selects the check: `after` → a transition `(state, args) => state`; -// `value` → a derivation `(state) => value`. -const modules = import.meta.glob>( - ["./*.ts", "!./*.test.ts", "!./*.type-test.ts"], - { eager: true }, +// The single pure-spec test for every transform AND derivation in this folder. +// `runSpec` auto-discovers each sibling that exports `cases`, requires exactly its +// function plus `cases`, and dispatches on case shape (a `value` case is a +// derivation, otherwise a transition). Tic-tac-toe's `State` is scalar (a board +// string + counters, no arrays or minted ids), so the default comparison applies. +Conformance.runSpec( + import.meta.glob>( + ["./*.ts", "!./*.test.ts", "!./*.type-test.ts"], + { + eager: true, + }, + ), ); - -const isDerivationCase = (c: unknown): c is DerivationCase => - typeof c === "object" && c !== null && "value" in c; - -for (const [path, module] of Object.entries(modules)) { - const exportNames = Object.keys(module); - if (!exportNames.includes("cases")) continue; - const functionNames = exportNames.filter((key) => typeof module[key] === "function"); - const label = functionNames.length === 1 ? functionNames[0] : path; - - describe(`State.${label}`, () => { - if (exportNames.length !== 2 || functionNames.length !== 1) { - it("exports exactly its function and `cases`", () => { - throw new Error(`${path} exports [${exportNames.join(", ")}] — expected one function + cases`); - }); - return; - } - // Runtime invariant: a participating file exports a function and its cases. - const fn = module[functionNames[0]] as (...args: unknown[]) => unknown; - const cases = module["cases"] as readonly unknown[]; - for (const testCase of cases) { - if (isDerivationCase(testCase)) { - // Derivation: the value it yields matches, honoring `anyNumber`. - it(testCase.name, () => expectMatches(fn(testCase.input), testCase.value)); - continue; - } - // Transition: assert the resulting state and the declared side effects. - // A service-injected transition is async, so await uniformly. - const transitionCase = testCase as ConformanceCase>; - it(transitionCase.name, async () => { - const raw = transitionCase.args; - const { args, calls } = - raw && typeof raw === "object" ? recordArgServices(raw) : { args: raw, calls: {} }; - const result = (await fn(transitionCase.before, args)) as State; - expectStateMatches(result, transitionCase.after); - expectEffects(calls, transitionCase.effects); - }); - } - }); -} diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/computed-database/computed/state.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/computed-database/computed/state.ts index dfcae65a..f197d418 100644 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/computed-database/computed/state.ts +++ b/packages/data-lit-tictactoe/src/features/main/services/main-service/computed-database/computed/state.ts @@ -1,29 +1,21 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { cached } from "@adobe/data/cache"; import { Observe } from "@adobe/data/observe"; -import { BoardState } from "../../../../data/board-state/board-state.js"; -import type { PlacedMark } from "../../../../data/placed-mark/placed-mark.js"; import type { State } from "../../../../data/state/state.js"; import type { IndexDatabase } from "../../index-database/index-database.js"; +import { board } from "./board.js"; // The full logical `State` projected from the ECS — the conformance anchor -// between the data-layer spec and this implementation. Mark entities are folded -// back into the board string; scalars come straight from resources. -export const state = cached((db: IndexDatabase) => - Observe.withCache(db.derive((read): State => { - const marks: PlacedMark[] = []; - for (const id of read.select(db.archetypes.PlacedMark.components)) { - const m = read.read(id); - if (m && m.mark !== undefined && m.index !== undefined) { - marks.push({ mark: m.mark, index: m.index }); - } - } - return { - board: BoardState.fromMarks(marks), - firstPlayer: read.resources.firstPlayer, - xWins: read.resources.xWins, - oWins: read.resources.oWins, - draws: read.resources.draws, - }; - })), +// between the data-layer spec and this implementation. Reuses the `board` +// computed (which folds the placed-mark entities into the board string) and joins +// it with the scalar resource observables, so the mark-fold lives in exactly one +// place. Re-emits whenever the board or any counter changes. +export const state = cached((db: IndexDatabase): Observe => + Observe.fromProperties({ + board: board(db), + firstPlayer: db.observe.resources.firstPlayer, + xWins: db.observe.resources.xWins, + oWins: db.observe.resources.oWins, + draws: db.observe.resources.draws, + }), ); diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/actions.test.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/actions.test.ts index af37b71f..7d73855e 100644 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/actions.test.ts +++ b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/actions.test.ts @@ -1,73 +1,47 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -/// -import { describe, it, expect } from "vitest"; import { Database } from "@adobe/data/ecs"; -import type { ConformanceCase } from "../../../data/state/conformance-case.js"; -import { splitAndRecordServices, expectEffects } from "../../../data/state/record-effects.js"; +import { Conformance } from "@adobe/data/testing"; import type { OpponentService } from "../../opponent-service/opponent-service.js"; -import { expectStateMatches } from "../../../data/state/expect-state-matches.js"; import { MainService } from "../main-service.js"; -import { fromState } from "./from-state.js"; -import { toState } from "./to-state.js"; +import * as registeredActions from "../action-database/actions/index.js"; import { playMove } from "../action-database/actions/play-move.js"; import { playOpponentMove } from "../action-database/actions/play-opponent-move.js"; import { restartGame } from "../action-database/actions/restart-game.js"; import { cases as playMoveCases } from "../../../data/state/play-move.js"; import { cases as playOpponentMoveCases } from "../../../data/state/play-opponent-move.js"; import { cases as restartGameCases } from "../../../data/state/restart-game.js"; +import { fromState } from "./from-state.js"; +import { toState } from "./to-state.js"; -// Each transition's cases run against its same-named ecs **action** (the async -// realization), asserting both the resulting state and the declared side effects. -// The case's service args become the db's service overrides — wrapped so their -// calls are recorded — and the plain args drive the action. -// `toSystemDatabase` exposes the writable `.store` the projection needs while -// keeping services/transactions/actions. Runtime invariant: the recording -// wrappers preserve each service's shape, so they are valid factory overrides. -const makeDb = (services: { opponent?: OpponentService }) => - Database.toSystemDatabase(Database.create(MainService.plugin, { services })); -type Db = ReturnType; -type Run = (db: Db, input: Args) => Promise | void; - -const covered = new Set(); -const conformsAction = ( - action: string, - config: { readonly cases: readonly ConformanceCase[]; readonly run: Run> }, -): void => { - covered.add(action); - describe(`${action} action conforms`, () => { - for (const testCase of config.cases) { - it(testCase.name, async () => { - const { services, input, calls } = splitAndRecordServices(testCase.args); - const db = makeDb(services as { opponent?: OpponentService }); - fromState(db.store, testCase.before); - await config.run(db, input as Partial); - expectStateMatches(toState(db.store), testCase.after); - expectEffects(calls, testCase.effects); - }); - } - }); -}; - -conformsAction("playMove", { - cases: playMoveCases, - run: (db, input) => playMove(db, { index: input.index ?? -1 }), -}); -conformsAction("playOpponentMove", { - cases: playOpponentMoveCases, - run: (db) => playOpponentMove(db), -}); -conformsAction("restartGame", { cases: restartGameCases, run: (db) => restartGame(db) }); - -// None-missed guard: every action file must be wired above. -const kebabToCamel = (name: string): string => - name.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase()); -describe("action conformance coverage", () => { - const files = import.meta.glob([ - "../action-database/actions/*.ts", - "!../action-database/actions/index.ts", - ]); - for (const path of Object.keys(files)) { - const action = kebabToCamel(path.replace(/.*\//, "").replace(/\.ts$/, "")); - it(`${action} has a conformance case`, () => expect(covered.has(action)).toBe(true)); - } +// Each transition's cases run against its same-named ecs action, asserting state +// and declared effects. `runActions` splits the case's injected services into +// recording overrides via `makeDb`; the harness/coverage are shared. +Conformance.runActions({ + // `toSystemDatabase` exposes the writable `.store` the projection needs. Runtime + // invariant: the recording wrappers preserve the service's shape, so they are a + // valid factory override. + makeDb: (services) => + Database.toSystemDatabase( + Database.create(MainService.plugin, { + services: services as { opponent?: OpponentService }, + }), + ), + store: (db) => db.store, + fromState, + toState, + registered: registeredActions, + define: (conforms) => { + conforms("playMove", { + cases: playMoveCases, + run: (db, input) => playMove(db, { index: input.index ?? -1 }), + }); + conforms("playOpponentMove", { + cases: playOpponentMoveCases, + run: (db) => playOpponentMove(db), + }); + conforms("restartGame", { + cases: restartGameCases, + run: (db) => restartGame(db), + }); + }, }); diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/computeds.test.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/computeds.test.ts index 37b8be79..483a8f8c 100644 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/computeds.test.ts +++ b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/computeds.test.ts @@ -1,98 +1,38 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. /// -import { describe, it, expect } from "vitest"; -import { Database, Entity } from "@adobe/data/ecs"; -import type { Observe } from "@adobe/data/observe"; -import type { State } from "../../../data/state/state.js"; -import type { DerivationCase } from "../../../data/state/conformance-case.js"; -import { expectMatches } from "../../../data/state/expect-state-matches.js"; +import { Database } from "@adobe/data/ecs"; +import { Conformance } from "@adobe/data/testing"; import { ComputedDatabase } from "../computed-database/computed-database.js"; -import { fromState } from "./from-state.js"; -import { toData } from "./to-data.js"; import { currentPlayer } from "../computed-database/computed/current-player.js"; import { cases as currentPlayerCases } from "../../../data/state/current-player.js"; +import { fromState } from "./from-state.js"; +import { toData } from "./to-data.js"; -// Each `data/state` derivation's cases run against its same-named ecs computed. A -// computed is an `Observe`, so after seeding the store we read its synchronous -// emission and `matches(value)`. This is the computed analog of the -// transaction/action runners. -// -// Built from the `ComputedDatabase` layer (which adds the computeds), not the -// assembled feature db: a behaviour layer above it that subscribes to a computed -// at construction (the agent services do) would `withCache` the pre-seed value, -// and a direct `fromState` seed emits no transaction to invalidate it. -const makeDb = () => Database.toSystemDatabase(Database.create(ComputedDatabase.plugin)); -type Db = ReturnType; - -// The default projection: hydrate a computed's entity-id list into the value -// shape a derivation yields. Override for a computed whose output is not a list of -// entities (a scalar, a single entity, a nested shape). -const hydrateEntities = (raw: unknown, db: Db): unknown => - (raw as readonly Entity[]).map((entity) => toData(db.store, entity)); - -const readComputed = (observe: Observe): T => { - let value!: T; - let read = false; - const unsubscribe = observe((next) => { - value = next; - read = true; - }); - unsubscribe(); - if (!read) throw new Error("computed did not emit synchronously on subscribe"); - return value; -}; - -const covered = new Set(); -const conformsComputed = ( - name: string, - config: { - readonly cases: readonly DerivationCase[]; - readonly computed: (db: Db) => Observe; - readonly project?: (raw: unknown, db: Db) => unknown; +// Each `data/state` derivation's cases run against its same-named ecs computed. +// Built from the `ComputedDatabase` layer so a `withCache` above it cannot serve a +// stale pre-seed value. Only `currentPlayer` is a `state/` derivation (it composes +// board + firstPlayer); the single-`data/board-state` computeds (winner/status/…) +// are covered by their helper's unit test, per the rules. +Conformance.runComputeds({ + makeDb: () => + Database.toSystemDatabase(Database.create(ComputedDatabase.plugin)), + store: (db) => db.store, + fromState, + toData, + derivationModules: import.meta.glob>( + [ + "../../../data/state/*.ts", + "!../../../data/state/*.test.ts", + "!../../../data/state/*.type-test.ts", + ], + { eager: true }, + ), + define: (conforms) => { + // `currentPlayer` emits a scalar `PlayerMark`, so the projection is identity. + conforms("currentPlayer", { + cases: currentPlayerCases, + computed: currentPlayer, + project: (raw) => raw, + }); }, -): void => { - covered.add(name); - const project = config.project ?? hydrateEntities; - describe(`${name} computed conforms`, () => { - for (const testCase of config.cases) { - it(testCase.name, () => { - const db = makeDb(); - // Runtime invariant: a derivation's `input` is authored as a full State. - fromState(db.store, testCase.input as State); - const raw = readComputed(config.computed(db)); - expectMatches(project(raw, db), testCase.value); - }); - } - }); -}; - -// `currentPlayer` emits a scalar `PlayerMark`, so the projection is the identity — -// no entity hydration. -conformsComputed("currentPlayer", { - cases: currentPlayerCases, - computed: currentPlayer, - project: (raw) => raw, -}); - -// None-missed guard: every data/state derivation (a file whose `cases` are -// `{ input, value }`) must be wired above. -const kebabToCamel = (name: string): string => - name.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase()); -const derivationModules = import.meta.glob>( - ["../../../data/state/*.ts", "!../../../data/state/*.test.ts", "!../../../data/state/*.type-test.ts"], - { eager: true }, -); -describe("computed conformance coverage", () => { - for (const [path, module] of Object.entries(derivationModules)) { - const cases = module["cases"]; - const isDerivation = - Array.isArray(cases) && - cases.length > 0 && - typeof cases[0] === "object" && - cases[0] !== null && - "value" in cases[0]; - if (!isDerivation) continue; - const name = kebabToCamel(path.replace(/.*\//, "").replace(/\.ts$/, "")); - it(`${name} has a computed conformance case`, () => expect(covered.has(name)).toBe(true)); - } }); diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/create-store.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/create-store.ts index c94f0d01..b864f388 100644 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/create-store.ts +++ b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/create-store.ts @@ -9,4 +9,5 @@ import { IndexDatabase } from "../index-database/index-database.js"; // plugin's schema facets directly. Typed as `CoreDatabase.Store`: the surface the // projection (`fromState` / `toState`) and the raw transaction functions use. // Test-only. -export const createStore = (): CoreDatabase.Store => Store.create(IndexDatabase.plugin); +export const createStore = (): CoreDatabase.Store => + Store.create(IndexDatabase.plugin); diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/expect-conforms.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/expect-conforms.ts deleted file mode 100644 index c22ee49e..00000000 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/expect-conforms.ts +++ /dev/null @@ -1,46 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { it } from "vitest"; -import type { State } from "../../../data/state/state.js"; -import type { ConformanceCase } from "../../../data/state/conformance-case.js"; -import { expectStateMatches } from "../../../data/state/expect-state-matches.js"; -import type { CoreDatabase } from "../core-database/core-database.js"; -import { createStore } from "./create-store.js"; -import { fromState } from "./from-state.js"; -import { toState } from "./to-state.js"; - -// The conformance runner, bound to THIS feature's projection (`fromState` / -// `toState`). For each case it proves the one conformance property -// -// toState(apply(fromState(before), args)) ≡ spec(before, args) -// -// in two asserted halves: -// 1. `spec(before, args) ≡ after` — keeps the shared case honest (a -// mis-authored `after` is caught here, independent of the ecs path). -// 2. seed `fromState(before)` → run the caller's `apply` → `toState ≡ after` -// — the ecs implementation reproduces the pure transform. -// -// `apply` receives the seeded writable store and calls the raw transaction -// function directly (a transaction is `(store, args) => void`, so no `Database` -// is involved). tictactoe's transactions take plain data args (a board index, -// or nothing), so no entity resolution is needed in `apply`. -export const expectConforms = (config: { - readonly cases: readonly ConformanceCase[]; - // Optional: half 1 (spec(before,args) ≡ after) is already asserted for every - // case by `data/state/spec.test.ts`, so the conformance aggregator omits it and - // this runner asserts only the ecs half. Pass `spec` to re-check it in place. - readonly spec?: (before: State, args: Args) => State; - readonly apply: (store: CoreDatabase.Store, args: Args) => void; -}): void => { - for (const testCase of config.cases) { - it(testCase.name, () => { - if (config.spec) { - expectStateMatches(config.spec(testCase.before, testCase.args), testCase.after); - } - - const store = createStore(); - fromState(store, testCase.before); - config.apply(store, testCase.args); - expectStateMatches(toState(store), testCase.after); - }); - } -}; diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/from-state.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/from-state.ts index 2e5e097c..43461520 100644 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/from-state.ts +++ b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/from-state.ts @@ -13,7 +13,9 @@ import type { CoreDatabase } from "../core-database/core-database.js"; // shift). The board string carries the marks (tictactoe stores each mark as an // entity), so `PlayerMark.is` narrows each cell and skips the blanks. export const fromState = (store: CoreDatabase.Store, state: State): void => { - for (const arch of store.queryArchetypes(store.archetypes.PlacedMark.components)) { + for (const arch of store.queryArchetypes( + store.archetypes.PlacedMark.components, + )) { for (let row = arch.rowCount - 1; row >= 0; row--) { store.delete(arch.columns.id.get(row)); } diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/projection.test.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/projection.test.ts index 2de27bce..dbd7de0a 100644 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/projection.test.ts +++ b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/projection.test.ts @@ -1,13 +1,12 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. // // Guards the projection itself. `fromState` and `toState` are the bridge every -// transaction conformance test trusts; a symmetric bug in the pair (e.g. both -// dropping the same field) would cancel out and mask a real ecs defect. This -// identity test — `toState(fromState(s)) ≡ s` over representative states — -// proves the projection round-trips faithfully on its own. +// transaction conformance test trusts; a symmetric bug in the pair would cancel +// out and mask a real ecs defect. This identity test — `toState(fromState(s)) ≡ s` +// over representative states — proves the projection round-trips on its own. import { describe, it } from "vitest"; +import { Match } from "@adobe/data/testing"; import type { State } from "../../../data/state/state.js"; -import { expectStateMatches } from "../../../data/state/expect-state-matches.js"; import { createStore } from "./create-store.js"; import { fromState } from "./from-state.js"; import { toState } from "./to-state.js"; @@ -15,15 +14,33 @@ import { toState } from "./to-state.js"; const states: readonly { readonly name: string; readonly state: State }[] = [ { name: "a full board with non-zero counters", - state: { board: "XOXXOOOXX", firstPlayer: "O", xWins: 3, oWins: 2, draws: 1 }, + state: { + board: "XOXXOOOXX", + firstPlayer: "O", + xWins: 3, + oWins: 2, + draws: 1, + }, }, { name: "an empty board (just the resources)", - state: { board: " ", firstPlayer: "X", xWins: 0, oWins: 0, draws: 0 }, + state: { + board: " ", + firstPlayer: "X", + xWins: 0, + oWins: 0, + draws: 0, + }, }, { name: "a game in progress", - state: { board: "X O X ", firstPlayer: "O", xWins: 1, oWins: 0, draws: 0 }, + state: { + board: "X O X ", + firstPlayer: "O", + xWins: 1, + oWins: 0, + draws: 0, + }, }, ]; @@ -32,7 +49,8 @@ describe("ecs/conformance projection round-trips (toState ∘ fromState ≡ iden it(name, () => { const store = createStore(); fromState(store, state); - expectStateMatches(toState(store), state); + // Tic-tac-toe's board/counters carry no ecs-minted ids, so it compares equal. + Match.assert(toState(store), state); }); } }); diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/to-data.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/to-data.ts index cbdc895c..fe2cd0fd 100644 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/to-data.ts +++ b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/to-data.ts @@ -6,8 +6,12 @@ import type { CoreDatabase } from "../core-database/core-database.js"; // Read one entity back into its `data/` value — the per-entity projection // `toState` is built on, and the single place the ecs↔data mapping for a placed // mark lives. Test-only. -export const toData = (store: CoreDatabase.Store, entity: Entity): PlacedMark => { +export const toData = ( + store: CoreDatabase.Store, + entity: Entity, +): PlacedMark => { const row = store.read(entity, store.archetypes.PlacedMark); - if (row === null) throw new Error("conformance projection: expected a placed-mark entity"); + if (row === null) + throw new Error("conformance projection: expected a placed-mark entity"); return { mark: row.mark, index: row.index }; }; diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/to-state.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/to-state.ts index cfdac3b0..1c7f3f78 100644 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/to-state.ts +++ b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/to-state.ts @@ -10,7 +10,9 @@ import { toData } from "./to-data.js"; // Test-only. const readBoard = (store: CoreDatabase.Store): BoardState => BoardState.fromMarks( - [...store.select(store.archetypes.PlacedMark.components)].map((entity) => toData(store, entity)), + [...store.select(store.archetypes.PlacedMark.components)].map((entity) => + toData(store, entity), + ), ); export const toState = (store: CoreDatabase.Store): State => ({ diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/transactions.test.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/transactions.test.ts index 5e504810..131e6194 100644 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/transactions.test.ts +++ b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/transactions.test.ts @@ -1,39 +1,28 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it, expect } from "vitest"; -import type { CoreDatabase } from "../core-database/core-database.js"; -import type { ConformanceCase } from "../../../data/state/conformance-case.js"; -import { expectConforms } from "./expect-conforms.js"; +import { Conformance } from "@adobe/data/testing"; import * as registeredTransactions from "../transaction-database/transactions/index.js"; import { playMove } from "../transaction-database/transactions/play-move.js"; import { restartGame } from "../transaction-database/transactions/restart-game.js"; import { cases as playMoveCases } from "../../../data/state/play-move.js"; import { cases as restartGameCases } from "../../../data/state/restart-game.js"; +import { createStore } from "./create-store.js"; +import { fromState } from "./from-state.js"; +import { toState } from "./to-state.js"; -// The single conformance test for every ecs transaction. Each transaction's -// shared `data/state` cases run through its raw `apply` (`fromState(before)` → -// apply → `matches(toState, after)`); the pure half is asserted once, centrally, -// by `data/state/spec.test.ts`, so this runner asserts only the ecs half. The -// guard at the bottom asserts every REGISTERED transaction (the barrel, not a -// file glob) is wired below, so the flat `readBoard` helper — kept out of the -// barrel — is naturally excluded and none can be missed. -const covered = new Set(); -const conforms = ( - transaction: string, - config: { - readonly cases: readonly ConformanceCase[]; - readonly apply: (t: CoreDatabase.Store, args: Args) => void; +// The single conformance test for every ecs transaction. `runTransactions` owns +// the harness (fresh store, `fromState` seed, `toState` compare, coverage guard +// keyed off the registered barrel). Tic-tac-toe moves are addressed by board +// index, not entity id, so the `apply` adapters need no `resolve`. +Conformance.runTransactions({ + createStore, + fromState, + toState, + registered: registeredTransactions, + define: (conforms) => { + conforms("playMove", { cases: playMoveCases, apply: playMove }); + conforms("restartGame", { + cases: restartGameCases, + apply: (t) => restartGame(t), + }); }, -): void => { - covered.add(transaction); - describe(`${transaction} transaction conforms`, () => expectConforms(config)); -}; - -conforms("playMove", { cases: playMoveCases, apply: playMove }); -conforms("restartGame", { cases: restartGameCases, apply: (t) => restartGame(t) }); - -// None-missed guard: every **registered** transaction must be wired above. -describe("transaction conformance coverage", () => { - for (const transaction of Object.keys(registeredTransactions)) { - it(`${transaction} has a conformance case`, () => expect(covered.has(transaction)).toBe(true)); - } }); diff --git a/packages/data-react-pixie/src/features/main/data/state/conformance-case.ts b/packages/data-react-pixie/src/features/main/data/state/conformance-case.ts index 095f3cb2..4426eca4 100644 --- a/packages/data-react-pixie/src/features/main/data/state/conformance-case.ts +++ b/packages/data-react-pixie/src/features/main/data/state/conformance-case.ts @@ -1,70 +1,13 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance as ConformanceApi } from "@adobe/data/testing"; import type { State } from "./state.js"; -// A service arg is an object with method members (not an array, not a function); -// this is what distinguishes an injected service from plain data args, since the -// `Service` marker itself is all-optional and would over-match. -type MethodKeys = { [K in keyof T]-?: T[K] extends (...a: never[]) => unknown ? K : never }[keyof T]; -type IsService = T extends readonly unknown[] - ? false - : T extends (...a: never[]) => unknown - ? false - : T extends object - ? [MethodKeys] extends [never] - ? false - : true - : false; - -// A strongly-typed call to one method of service `S`: `[methodName, ...its args]`. -// A no-arg method is just `[methodName]`. -export type Call = { - [M in keyof S]-?: S[M] extends (...a: infer A) => unknown ? readonly [M, ...A] : never; -}[keyof S]; - -// Expected side effects for a case, keyed by the service-typed args only. An -// `Array` value asserts these calls in this order; a `Set` value asserts the same -// calls in any order. Method names and their args are checked against the service. -export type Effects = { - readonly [K in keyof Args as IsService extends true ? K : never]?: - | readonly Call[] - | ReadonlySet>; -}; - -// One spec-owned conformance case: a `data/` transform's `{ before, args, after }` -// authored as full `State`, optionally with the side effects it makes on its -// injected service args. Shared unchanged by the spec aggregator (`spec.test.ts`) -// and the ecs conformance runners (`services/main-service/conformance/`). -export type ConformanceCase = { - readonly name: string; - readonly before: State; - readonly args: Args; - readonly after: State; - readonly effects?: Effects; -}; - -// A transform's cases, with the case `args` type derived from the transform's own -// signature — its second parameter, or `void` when it takes none. A transform -// co-locates `export const cases: Conformance = [...]`, so the -// cases cannot drift from what the function accepts, and the spec aggregator can -// discover the function without it being named twice. -export type Conformance unknown> = readonly ConformanceCase< - Parameters extends [unknown, infer Args, ...unknown[]] ? Args : void ->[]; - -// One case for a derivation (`(state) => value`): a state `input` and the `value` -// it yields. `value` may use asymmetric matchers (`anyNumber` for ids). Both the -// pure derivation and its ecs computed are checked against these. -export type DerivationCase = { - readonly name: string; - readonly input: Input; - readonly value: Value; -}; - -// A derivation's cases, with `input` and `value` types read from the derivation's -// own signature (its parameter and return) — the `Conformance` analog for -// value-producing derivations. A derivation co-locates -// `export const cases: Derivation = [...]`. -export type Derivation unknown> = readonly DerivationCase< - Parameters[0], - ReturnType ->[]; +// The conformance case types for this feature — the shared `@adobe/data/testing` +// machinery with `State` bound once, so a transform authors `Conformance` +// and a derivation `Derivation` (args/input/value read from the +// function's own signature). This tiny file is the only per-feature conformance +// type declaration; everything else lives in `@adobe/data/testing`. +export type Conformance unknown> = + ConformanceApi.Cases; +export type Derivation unknown> = + ConformanceApi.DerivationCases; diff --git a/packages/data-react-pixie/src/features/main/data/state/create-sprite.ts b/packages/data-react-pixie/src/features/main/data/state/create-sprite.ts index 3b8729ef..deb30c7e 100644 --- a/packages/data-react-pixie/src/features/main/data/state/create-sprite.ts +++ b/packages/data-react-pixie/src/features/main/data/state/create-sprite.ts @@ -3,14 +3,18 @@ import type { Vec2 } from "@adobe/data/math"; import type { SpriteKind } from "../sprite-kind/sprite-kind.js"; import type { State } from "./state.js"; import type { Conformance } from "./conformance-case.js"; -import { anyNumber } from "./matchers.js"; +import { Match } from "@adobe/data/testing"; const nextSpriteId = (state: Pick): number => state.sprites.reduce((max, sprite) => Math.max(max, sprite.id), 0) + 1; export const createSprite = >( state: T, - input: { readonly position: Vec2; readonly rotation?: number; readonly kind: SpriteKind }, + input: { + readonly position: Vec2; + readonly rotation?: number; + readonly kind: SpriteKind; + }, ): T => ({ ...state, sprites: [ @@ -36,21 +40,53 @@ export const cases: Conformance = [ before: { sprites: [], filter: "none" }, args: { position: [100, 100], kind: "bunny" }, after: { - sprites: [{ id: anyNumber, position: [100, 100], rotation: 0, kind: "bunny", hovered: false, active: false }], + sprites: [ + { + id: Match.anyNumber, + position: [100, 100], + rotation: 0, + kind: "bunny", + hovered: false, + active: false, + }, + ], filter: "none", }, }, { name: "appends a fox with the next id and an explicit rotation", before: { - sprites: [{ id: 1, position: [100, 100], rotation: 0, kind: "bunny", hovered: false, active: false }], + sprites: [ + { + id: 1, + position: [100, 100], + rotation: 0, + kind: "bunny", + hovered: false, + active: false, + }, + ], filter: "sepia", }, args: { position: [300, 200], rotation: 1, kind: "fox" }, after: { sprites: [ - { id: anyNumber, position: [100, 100], rotation: 0, kind: "bunny", hovered: false, active: false }, - { id: anyNumber, position: [300, 200], rotation: 1, kind: "fox", hovered: false, active: false }, + { + id: Match.anyNumber, + position: [100, 100], + rotation: 0, + kind: "bunny", + hovered: false, + active: false, + }, + { + id: Match.anyNumber, + position: [300, 200], + rotation: 1, + kind: "fox", + hovered: false, + active: false, + }, ], filter: "sepia", }, diff --git a/packages/data-react-pixie/src/features/main/data/state/expect-state-matches.ts b/packages/data-react-pixie/src/features/main/data/state/expect-state-matches.ts deleted file mode 100644 index 687f2a16..00000000 --- a/packages/data-react-pixie/src/features/main/data/state/expect-state-matches.ts +++ /dev/null @@ -1,56 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { expect } from "vitest"; -import type { State } from "./state.js"; - -// A vitest asymmetric matcher (`expect.any(...)`, see `matchers.ts`): honored on -// the EXPECTED side so a case can assert "any number" for a value it does not pin. -const isMatcher = (value: unknown): value is { asymmetricMatch(actual: unknown): boolean } => - typeof value === "object" && - value !== null && - typeof (value as { asymmetricMatch?: unknown }).asymmetricMatch === "function"; - -// Collapse F32↔f64 storage rounding (the ecs `rotation` column is F32, the spec -// authors plain numbers) onto a small grid so float noise compares equal. `+ 0` -// normalises `-0` to `0`. -const quantize = (n: number): number => Math.round(Math.fround(n) * 100) / 100 + 0; - -// Tolerant structural match honoring asymmetric matchers, float precision, and -// order-sensitive arrays (`toState` reads sprites in insertion order, matching -// the case's authored order). Exported so it can back other conformance -// comparisons (e.g. computed values). -export const matches = (actual: unknown, expected: unknown): boolean => { - if (isMatcher(expected)) return expected.asymmetricMatch(actual); - if (typeof expected === "number" && typeof actual === "number") { - return quantize(actual) === quantize(expected); - } - if (Array.isArray(expected)) { - if (!Array.isArray(actual) || actual.length !== expected.length) return false; - return expected.every((exp, index) => matches(actual[index], exp)); - } - if (expected !== null && typeof expected === "object") { - if (actual === null || typeof actual !== "object" || Array.isArray(actual)) return false; - const expectedKeys = Object.keys(expected); - const actualKeys = Object.keys(actual as object); - if (expectedKeys.length !== actualKeys.length) return false; - return expectedKeys.every((key) => - matches((actual as Record)[key], (expected as Record)[key]), - ); - } - return Object.is(actual, expected); -}; - -// Spec-owned tolerant `State` equality, shared by the data/ transform tests and -// the ecs conformance runners. `after` may use asymmetric matchers -// (`anyNumber` for ids the ecs assigns from its own id-space), so this one -// comparison serves both the pure spec and the ecs projection — no separate -// id-ignoring variant is needed. -export const expectStateMatches = (actual: State, expected: State): void => { - expectMatches(actual, expected); -}; - -// The same tolerant, matcher-aware comparison for any value — used by derivation -// spec tests and computed conformance, where the compared value is a `Sprite[]` -// or a scalar rather than a whole `State`. -export const expectMatches = (actual: unknown, expected: unknown): void => { - expect(matches(actual, expected), `mismatch:\n actual ${JSON.stringify(actual)}`).toBe(true); -}; diff --git a/packages/data-react-pixie/src/features/main/data/state/matchers.ts b/packages/data-react-pixie/src/features/main/data/state/matchers.ts deleted file mode 100644 index 3dcdc5c8..00000000 --- a/packages/data-react-pixie/src/features/main/data/state/matchers.ts +++ /dev/null @@ -1,11 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { expect } from "vitest"; - -// Asymmetric matchers for conformance-case values a case does not pin — chiefly -// a sprite `id`, which the ecs assigns from its own id-space, so the spec and -// the ecs projection satisfy the same case without agreeing on the value. Typed -// `any` (like vitest's `expect.any`), they slot straight into the value's slot -// (`id: number`). Centralised here so the `vitest` import lives in one place; -// they are test-only data and tree-shake out of the app build. -export const anyNumber = expect.any(Number); -export const anyString = expect.any(String); diff --git a/packages/data-react-pixie/src/features/main/data/state/record-effects.ts b/packages/data-react-pixie/src/features/main/data/state/record-effects.ts deleted file mode 100644 index 8fb3d25f..00000000 --- a/packages/data-react-pixie/src/features/main/data/state/record-effects.ts +++ /dev/null @@ -1,107 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { expect } from "vitest"; -import { equalsUnordered } from "@adobe/data"; -import type { Effects } from "./conformance-case.js"; - -export type RecordedCall = readonly [string, ...unknown[]]; - -// Wrap a plain-object service so each method call is recorded, then delegates. -// No Proxy (services are plain objects with own enumerable methods — see -// `service.md`), so we enumerate and closure-wrap each function. -export const recordCalls = (service: S): { service: S; calls: RecordedCall[] } => { - const calls: RecordedCall[] = []; - const wrapped = Object.fromEntries( - Object.entries(service).map(([key, value]) => [ - key, - typeof value === "function" - ? (...args: unknown[]): unknown => { - calls.push([key, ...args]); - return (value as (...a: unknown[]) => unknown)(...args); - } - : value, - ]), - ) as S; - return { service: wrapped, calls }; -}; - -// Assert the calls recorded against one service match the case's expectation for -// it: an Array expects exactly these calls in order; a Set expects the same calls -// in any order (multiset). Absent expectation ⇒ no calls expected. -export const expectServiceCalls = ( - recorded: readonly RecordedCall[], - expected: readonly RecordedCall[] | ReadonlySet | undefined, -): void => { - if (expected instanceof Set) { - expect(equalsUnordered(recorded, [...expected])).toBe(true); - } else { - expect(recorded).toEqual(expected ?? []); - } -}; - -// Split a case's `args` into the injected services (objects with methods) and the -// remaining plain data. Services are wrapped for recording; the returned `calls` -// map is keyed by the same arg key so it can be matched against `effects`. -export const recordArgServices = ( - args: Args, -): { args: Args; calls: Record } => { - const calls: Record = {}; - const next = { ...args } as Record; - for (const [key, value] of Object.entries(args)) { - if (isServiceValue(value)) { - const rec = recordCalls(value); - next[key] = rec.service; - calls[key] = rec.calls; - } - } - return { args: next as Args, calls }; -}; - -// Assert each service **declared** in `effects` saw exactly its expected calls -// (an extra or missing call on a declared service fails). Services not listed — -// e.g. a value-returning dependency read like `generateName` — are ignored, so -// `effects` captures the fire-and-forget side effects you choose to assert. -export const expectEffects = ( - calls: Record, - effects: Effects> | undefined, -): void => { - const expected = (effects ?? {}) as Record>; - for (const key of Object.keys(expected)) { - expectServiceCalls(calls[key] ?? [], expected[key]); - } -}; - -// Split a case's `args` into the injected services (wrapped for recording, to be -// used as `Database.create` service overrides) and the remaining plain data (the -// action input). Keyed by the same arg name so `calls` matches against `effects`. -export const splitAndRecordServices = ( - args: Args, -): { - services: Record; - input: Record; - calls: Record; -} => { - const services: Record = {}; - const input: Record = {}; - const calls: Record = {}; - // A no-arg transition has `undefined` args — nothing to split. - if (args !== null && typeof args === "object") { - for (const [key, value] of Object.entries(args)) { - if (isServiceValue(value)) { - const recorded = recordCalls(value); - services[key] = recorded.service; - calls[key] = recorded.calls; - } else { - input[key] = value; - } - } - } - return { services, input, calls }; -}; - -// A runtime service value: a non-array object with at least one method (mirrors -// the compile-time `IsService`). -const isServiceValue = (value: unknown): value is object => - typeof value === "object" && - value !== null && - !Array.isArray(value) && - Object.values(value).some((member) => typeof member === "function"); diff --git a/packages/data-react-pixie/src/features/main/data/state/set-sprite-active.ts b/packages/data-react-pixie/src/features/main/data/state/set-sprite-active.ts index 359a8cd0..f42e6b18 100644 --- a/packages/data-react-pixie/src/features/main/data/state/set-sprite-active.ts +++ b/packages/data-react-pixie/src/features/main/data/state/set-sprite-active.ts @@ -2,7 +2,7 @@ import type { Sprite } from "../sprite/sprite.js"; import type { State } from "./state.js"; import type { Conformance } from "./conformance-case.js"; -import { anyNumber } from "./matchers.js"; +import { Match } from "@adobe/data/testing"; export const setSpriteActive = >( state: T, @@ -14,8 +14,22 @@ export const setSpriteActive = >( ), }); -const bunny: Sprite = { id: 1, position: [100, 100], rotation: 0, kind: "bunny", hovered: false, active: false }; -const fox: Sprite = { id: 2, position: [300, 200], rotation: 1, kind: "fox", hovered: false, active: false }; +const bunny: Sprite = { + id: 1, + position: [100, 100], + rotation: 0, + kind: "bunny", + hovered: false, + active: false, +}; +const fox: Sprite = { + id: 2, + position: [300, 200], + rotation: 1, + kind: "fox", + hovered: false, + active: false, +}; // Spec-owned cases, shared with the ecs `setSpriteActive` transaction. Sets the // addressed sprite's `active` flag; a no-op for an unknown id. `before` ids @@ -27,8 +41,8 @@ export const cases: Conformance = [ args: { id: 2, active: true }, after: { sprites: [ - { ...bunny, id: anyNumber }, - { ...fox, id: anyNumber, active: true }, + { ...bunny, id: Match.anyNumber }, + { ...fox, id: Match.anyNumber, active: true }, ], filter: "none", }, @@ -39,8 +53,8 @@ export const cases: Conformance = [ args: { id: 99, active: true }, after: { sprites: [ - { ...bunny, id: anyNumber }, - { ...fox, id: anyNumber }, + { ...bunny, id: Match.anyNumber }, + { ...fox, id: Match.anyNumber }, ], filter: "none", }, diff --git a/packages/data-react-pixie/src/features/main/data/state/set-sprite-hovered.ts b/packages/data-react-pixie/src/features/main/data/state/set-sprite-hovered.ts index 477ba675..69d9b639 100644 --- a/packages/data-react-pixie/src/features/main/data/state/set-sprite-hovered.ts +++ b/packages/data-react-pixie/src/features/main/data/state/set-sprite-hovered.ts @@ -2,7 +2,7 @@ import type { Sprite } from "../sprite/sprite.js"; import type { State } from "./state.js"; import type { Conformance } from "./conformance-case.js"; -import { anyNumber } from "./matchers.js"; +import { Match } from "@adobe/data/testing"; export const setSpriteHovered = >( state: T, @@ -14,8 +14,22 @@ export const setSpriteHovered = >( ), }); -const bunny: Sprite = { id: 1, position: [100, 100], rotation: 0, kind: "bunny", hovered: false, active: false }; -const fox: Sprite = { id: 2, position: [300, 200], rotation: 1, kind: "fox", hovered: false, active: false }; +const bunny: Sprite = { + id: 1, + position: [100, 100], + rotation: 0, + kind: "bunny", + hovered: false, + active: false, +}; +const fox: Sprite = { + id: 2, + position: [300, 200], + rotation: 1, + kind: "fox", + hovered: false, + active: false, +}; // Spec-owned cases, shared with the ecs `setSpriteHovered` transaction. Sets the // addressed sprite's `hovered` flag; a no-op for an unknown id. `before` ids @@ -27,8 +41,8 @@ export const cases: Conformance = [ args: { id: 1, hovered: true }, after: { sprites: [ - { ...bunny, id: anyNumber, hovered: true }, - { ...fox, id: anyNumber }, + { ...bunny, id: Match.anyNumber, hovered: true }, + { ...fox, id: Match.anyNumber }, ], filter: "none", }, @@ -39,8 +53,8 @@ export const cases: Conformance = [ args: { id: 99, hovered: true }, after: { sprites: [ - { ...bunny, id: anyNumber }, - { ...fox, id: anyNumber }, + { ...bunny, id: Match.anyNumber }, + { ...fox, id: Match.anyNumber }, ], filter: "none", }, diff --git a/packages/data-react-pixie/src/features/main/data/state/spec.test.ts b/packages/data-react-pixie/src/features/main/data/state/spec.test.ts index d398242c..ebdddf05 100644 --- a/packages/data-react-pixie/src/features/main/data/state/spec.test.ts +++ b/packages/data-react-pixie/src/features/main/data/state/spec.test.ts @@ -1,59 +1,19 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. /// -import { describe, it } from "vitest"; -import type { State } from "./state.js"; -import type { ConformanceCase, DerivationCase } from "./conformance-case.js"; -import { expectStateMatches, expectMatches } from "./expect-state-matches.js"; -import { recordArgServices, expectEffects } from "./record-effects.js"; +import { Conformance } from "@adobe/data/testing"; -// The single spec test for every transform AND derivation in this folder. It -// auto-discovers each file (any sibling `.ts` that exports `cases`) via -// `import.meta.glob`, so a new one is covered the moment it ships — none can be -// forgotten. Each participating file must export exactly its function plus `cases` -// (enforced below), which lets us find the function without it being named twice. -// A case's shape selects the check: `after` → a transition `(state, args) => state`; -// `value` → a derivation `(state) => value`. -const modules = import.meta.glob>( - ["./*.ts", "!./*.test.ts", "!./*.type-test.ts"], - { eager: true }, +// The single pure-spec test for every transform AND derivation in this folder. +// `runSpec` auto-discovers each sibling that exports `cases`, requires it to +// export exactly its function plus `cases`, and dispatches on case shape (a +// `value` case is a derivation, otherwise a transition whose declared `effects` +// are also asserted). `toState` reads sprites in insertion order matching each +// case's authored order, so the default (ordered, matcher-aware) comparison is +// correct — no options needed. +Conformance.runSpec( + import.meta.glob>( + ["./*.ts", "!./*.test.ts", "!./*.type-test.ts"], + { + eager: true, + }, + ), ); - -const isDerivationCase = (c: unknown): c is DerivationCase => - typeof c === "object" && c !== null && "value" in c; - -for (const [path, module] of Object.entries(modules)) { - const exportNames = Object.keys(module); - if (!exportNames.includes("cases")) continue; - const functionNames = exportNames.filter((key) => typeof module[key] === "function"); - const label = functionNames.length === 1 ? functionNames[0] : path; - - describe(`State.${label}`, () => { - if (exportNames.length !== 2 || functionNames.length !== 1) { - it("exports exactly its function and `cases`", () => { - throw new Error(`${path} exports [${exportNames.join(", ")}] — expected one function + cases`); - }); - return; - } - // Runtime invariant: a participating file exports a function and its cases. - const fn = module[functionNames[0]] as (...args: unknown[]) => unknown; - const cases = module["cases"] as readonly unknown[]; - for (const testCase of cases) { - if (isDerivationCase(testCase)) { - // Derivation: the value it yields matches, honoring `anyNumber`. - it(testCase.name, () => expectMatches(fn(testCase.input), testCase.value)); - continue; - } - // Transition: assert the resulting state and the declared side effects. - // A service-injected transition is async, so await uniformly. - const transitionCase = testCase as ConformanceCase>; - it(transitionCase.name, async () => { - const raw = transitionCase.args; - const { args, calls } = - raw && typeof raw === "object" ? recordArgServices(raw) : { args: raw, calls: {} }; - const result = (await fn(transitionCase.before, args)) as State; - expectStateMatches(result, transitionCase.after); - expectEffects(calls, transitionCase.effects); - }); - } - }); -} diff --git a/packages/data-react-pixie/src/features/main/data/state/tick.ts b/packages/data-react-pixie/src/features/main/data/state/tick.ts index b3bf3adf..52b23574 100644 --- a/packages/data-react-pixie/src/features/main/data/state/tick.ts +++ b/packages/data-react-pixie/src/features/main/data/state/tick.ts @@ -2,7 +2,7 @@ import type { Sprite } from "../sprite/sprite.js"; import type { State } from "./state.js"; import type { Conformance } from "./conformance-case.js"; -import { anyNumber } from "./matchers.js"; +import { Match } from "@adobe/data/testing"; // Advance one animation frame: every sprite rotates by `delta * 0.1` radians. // `delta` is the frame time step, supplied by the caller (the render loop). @@ -17,8 +17,22 @@ export const tick = >( })), }); -const bunny: Sprite = { id: 1, position: [100, 100], rotation: 0, kind: "bunny", hovered: false, active: false }; -const fox: Sprite = { id: 2, position: [300, 200], rotation: 1, kind: "fox", hovered: false, active: false }; +const bunny: Sprite = { + id: 1, + position: [100, 100], + rotation: 0, + kind: "bunny", + hovered: false, + active: false, +}; +const fox: Sprite = { + id: 2, + position: [300, 200], + rotation: 1, + kind: "fox", + hovered: false, + active: false, +}; // Spec-owned cases, shared with the ecs `tick` transaction. Every sprite's // rotation advances by delta * 0.1; ids are left open (`anyNumber`). @@ -29,8 +43,8 @@ export const cases: Conformance = [ args: { delta: 10 }, after: { sprites: [ - { ...bunny, id: anyNumber, rotation: 1 }, - { ...fox, id: anyNumber, rotation: 2 }, + { ...bunny, id: Match.anyNumber, rotation: 1 }, + { ...fox, id: Match.anyNumber, rotation: 2 }, ], filter: "none", }, diff --git a/packages/data-react-pixie/src/features/main/data/state/toggle-sprite-active.ts b/packages/data-react-pixie/src/features/main/data/state/toggle-sprite-active.ts index 1bd044b1..17135047 100644 --- a/packages/data-react-pixie/src/features/main/data/state/toggle-sprite-active.ts +++ b/packages/data-react-pixie/src/features/main/data/state/toggle-sprite-active.ts @@ -2,7 +2,7 @@ import type { Sprite } from "../sprite/sprite.js"; import type { State } from "./state.js"; import type { Conformance } from "./conformance-case.js"; -import { anyNumber } from "./matchers.js"; +import { Match } from "@adobe/data/testing"; export const toggleSpriteActive = >( state: T, @@ -14,8 +14,22 @@ export const toggleSpriteActive = >( ), }); -const bunny: Sprite = { id: 1, position: [100, 100], rotation: 0, kind: "bunny", hovered: false, active: false }; -const activeFox: Sprite = { id: 2, position: [300, 200], rotation: 1, kind: "fox", hovered: false, active: true }; +const bunny: Sprite = { + id: 1, + position: [100, 100], + rotation: 0, + kind: "bunny", + hovered: false, + active: false, +}; +const activeFox: Sprite = { + id: 2, + position: [300, 200], + rotation: 1, + kind: "fox", + hovered: false, + active: true, +}; // Spec-owned cases, shared with the ecs `toggleSpriteActive` transaction. Flips // the addressed sprite's `active` flag; a no-op for an unknown id. `before` ids @@ -27,8 +41,8 @@ export const cases: Conformance = [ args: { id: 1 }, after: { sprites: [ - { ...bunny, id: anyNumber, active: true }, - { ...activeFox, id: anyNumber }, + { ...bunny, id: Match.anyNumber, active: true }, + { ...activeFox, id: Match.anyNumber }, ], filter: "none", }, @@ -39,8 +53,8 @@ export const cases: Conformance = [ args: { id: 2 }, after: { sprites: [ - { ...bunny, id: anyNumber }, - { ...activeFox, id: anyNumber, active: false }, + { ...bunny, id: Match.anyNumber }, + { ...activeFox, id: Match.anyNumber, active: false }, ], filter: "none", }, @@ -51,8 +65,8 @@ export const cases: Conformance = [ args: { id: 99 }, after: { sprites: [ - { ...bunny, id: anyNumber }, - { ...activeFox, id: anyNumber }, + { ...bunny, id: Match.anyNumber }, + { ...activeFox, id: Match.anyNumber }, ], filter: "none", }, diff --git a/packages/data-react-pixie/src/features/main/services/main-service/conformance/actions.test.ts b/packages/data-react-pixie/src/features/main/services/main-service/conformance/actions.test.ts index f47cdf1b..554c7198 100644 --- a/packages/data-react-pixie/src/features/main/services/main-service/conformance/actions.test.ts +++ b/packages/data-react-pixie/src/features/main/services/main-service/conformance/actions.test.ts @@ -1,13 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -/// -import { describe, it, expect } from "vitest"; -import { Database, Entity } from "@adobe/data/ecs"; -import type { ConformanceCase } from "../../../data/state/conformance-case.js"; -import { splitAndRecordServices, expectEffects } from "../../../data/state/record-effects.js"; -import { expectStateMatches } from "../../../data/state/expect-state-matches.js"; +import { Database } from "@adobe/data/ecs"; +import { Conformance } from "@adobe/data/testing"; import { MainService } from "../main-service.js"; -import { fromState } from "./from-state.js"; -import { toState } from "./to-state.js"; +import * as registeredActions from "../action-database/actions/index.js"; import { createSprite } from "../action-database/actions/create-sprite.js"; import { setFilter } from "../action-database/actions/set-filter.js"; import { setSpriteActive } from "../action-database/actions/set-sprite-active.js"; @@ -20,80 +15,65 @@ import { cases as setSpriteActiveCases } from "../../../data/state/set-sprite-ac import { cases as setSpriteHoveredCases } from "../../../data/state/set-sprite-hovered.js"; import { cases as toggleSpriteActiveCases } from "../../../data/state/toggle-sprite-active.js"; import { cases as tickCases } from "../../../data/state/tick.js"; +import { fromState } from "./from-state.js"; +import { toState } from "./to-state.js"; -// Each transition's cases run against its same-named ecs **action** (the async -// realization). The case's service args (this feature injects none) become the -// db's service overrides — wrapped so their calls are recorded — the plain args -// drive the action, and we assert both the resulting state and the declared side -// effects. `toSystemDatabase` exposes the writable `.store` the projection needs -// while keeping transactions/actions. The `{ services }` override is used -// uniformly even though it is empty here, keeping the runner shape identical to +// Each transition's cases run against its same-named ecs action. `runActions` +// splits the case's injected services into recording overrides (via `makeDb`), +// runs the action, then asserts both the resulting state and the declared effects; +// the harness/coverage are shared. This feature injects no services, so the +// `{ services }` override is always empty — the runner shape stays identical to // the multi-service reference. -const makeDb = (services: Record) => - Database.toSystemDatabase(Database.create(MainService.plugin, { services })); -type Db = ReturnType; -type Run = (db: Db, input: Args, resolve: (specId: number) => Entity) => Promise | void; - -const covered = new Set(); -const conformsAction = ( - action: string, - config: { readonly cases: readonly ConformanceCase[]; readonly run: Run> }, -): void => { - covered.add(action); - describe(`${action} action conforms`, () => { - for (const testCase of config.cases) { - it(testCase.name, async () => { - const { services, input, calls } = splitAndRecordServices(testCase.args); - const db = makeDb(services); - const entities = fromState(db.store, testCase.before); - const bySpecId = new Map(testCase.before.sprites.map((sprite, i) => [sprite.id, entities[i]])); - const resolve = (specId: number): Entity => bySpecId.get(specId) ?? Entity.none; - await config.run(db, input as Partial, resolve); - expectStateMatches(toState(db.store), testCase.after); - expectEffects(calls, testCase.effects); - }); - } - }); -}; - -conformsAction("createSprite", { - cases: createSpriteCases, - run: (db, input) => - createSprite(db, { position: input.position ?? [0, 0], rotation: input.rotation, kind: input.kind ?? "bunny" }), -}); -conformsAction("setFilter", { - cases: setFilterCases, - run: (db, input) => setFilter(db, { filter: input.filter ?? "none" }), -}); -conformsAction("setSpriteActive", { - cases: setSpriteActiveCases, - run: (db, input, resolve) => - setSpriteActive(db, { entity: resolve(input.id ?? -1), active: input.active ?? false }), -}); -conformsAction("setSpriteHovered", { - cases: setSpriteHoveredCases, - run: (db, input, resolve) => - setSpriteHovered(db, { entity: resolve(input.id ?? -1), hovered: input.hovered ?? false }), -}); -conformsAction("toggleSpriteActive", { - cases: toggleSpriteActiveCases, - run: (db, input, resolve) => toggleSpriteActive(db, resolve(input.id ?? -1)), -}); -conformsAction("tick", { - cases: tickCases, - run: (db, input) => tick(db, { delta: input.delta ?? 0 }), -}); - -// None-missed guard: every action file must be wired above. -const kebabToCamel = (name: string): string => - name.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase()); -describe("action conformance coverage", () => { - const files = import.meta.glob([ - "../action-database/actions/*.ts", - "!../action-database/actions/index.ts", - ]); - for (const path of Object.keys(files)) { - const action = kebabToCamel(path.replace(/.*\//, "").replace(/\.ts$/, "")); - it(`${action} has a conformance case`, () => expect(covered.has(action)).toBe(true)); - } +Conformance.runActions({ + // `toSystemDatabase` exposes the writable `.store` the projection needs. The + // feature declares no injected services, so `Record` is + // assignable to the (empty) services override without a cast. + makeDb: (services) => + Database.toSystemDatabase( + Database.create(MainService.plugin, { services }), + ), + store: (db) => db.store, + fromState, + toState, + registered: registeredActions, + define: (conforms) => { + conforms("createSprite", { + cases: createSpriteCases, + run: (db, input) => + createSprite(db, { + position: input.position ?? [0, 0], + rotation: input.rotation, + kind: input.kind ?? "bunny", + }), + }); + conforms("setFilter", { + cases: setFilterCases, + run: (db, input) => setFilter(db, { filter: input.filter ?? "none" }), + }); + conforms("setSpriteActive", { + cases: setSpriteActiveCases, + run: (db, input, resolve) => + setSpriteActive(db, { + entity: resolve(input.id ?? -1), + active: input.active ?? false, + }), + }); + conforms("setSpriteHovered", { + cases: setSpriteHoveredCases, + run: (db, input, resolve) => + setSpriteHovered(db, { + entity: resolve(input.id ?? -1), + hovered: input.hovered ?? false, + }), + }); + conforms("toggleSpriteActive", { + cases: toggleSpriteActiveCases, + run: (db, input, resolve) => + toggleSpriteActive(db, resolve(input.id ?? -1)), + }); + conforms("tick", { + cases: tickCases, + run: (db, input) => tick(db, { delta: input.delta ?? 0 }), + }); + }, }); diff --git a/packages/data-react-pixie/src/features/main/services/main-service/conformance/expect-conforms.ts b/packages/data-react-pixie/src/features/main/services/main-service/conformance/expect-conforms.ts deleted file mode 100644 index eb80287e..00000000 --- a/packages/data-react-pixie/src/features/main/services/main-service/conformance/expect-conforms.ts +++ /dev/null @@ -1,48 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { it } from "vitest"; -import { Entity } from "@adobe/data/ecs"; -import type { State } from "../../../data/state/state.js"; -import type { ConformanceCase } from "../../../data/state/conformance-case.js"; -import { expectStateMatches } from "../../../data/state/expect-state-matches.js"; -import type { CoreDatabase } from "../core-database/core-database.js"; -import { createStore } from "./create-store.js"; -import { fromState } from "./from-state.js"; -import { toState } from "./to-state.js"; - -// Resolve a spec domain `id` to the ecs entity seeded for it. `fromState` -// returns the seeded entities in `sprites` order, so the i-th `before` sprite -// maps to the i-th entity; an id no sprite carries resolves to `Entity.none`, so -// an id-addressed transaction reads no such entity and is a no-op. -export type ResolveEntity = (specId: number) => Entity; - -// The conformance runner, bound to THIS feature's projection (`fromState` / -// `toState`). For each case it proves the one conformance property -// -// toState(apply(fromState(before), args)) ≡ spec(before, args) -// -// The ecs owns its entity-id space and conforms only up to a renaming of ids, -// which the `after` cases express as `anyNumber`, so the same `expectStateMatches` -// compares both halves. -export const expectConforms = (config: { - readonly cases: readonly ConformanceCase[]; - // Optional: half 1 (spec(before,args) ≡ after) is already asserted for every - // case by `data/state/spec.test.ts`, so the conformance aggregator omits it and - // this runner asserts only the ecs half. Pass `spec` to re-check it in place. - readonly spec?: (before: State, args: Args) => State; - readonly apply: (store: CoreDatabase.Store, args: Args, resolve: ResolveEntity) => void; -}): void => { - for (const testCase of config.cases) { - it(testCase.name, () => { - if (config.spec) { - expectStateMatches(config.spec(testCase.before, testCase.args), testCase.after); - } - - const store = createStore(); - const entities = fromState(store, testCase.before); - const bySpecId = new Map(testCase.before.sprites.map((sprite, i) => [sprite.id, entities[i]])); - const resolve: ResolveEntity = (specId) => bySpecId.get(specId) ?? Entity.none; - config.apply(store, testCase.args, resolve); - expectStateMatches(toState(store), testCase.after); - }); - } -}; diff --git a/packages/data-react-pixie/src/features/main/services/main-service/conformance/from-state.ts b/packages/data-react-pixie/src/features/main/services/main-service/conformance/from-state.ts index 787ca619..078de980 100644 --- a/packages/data-react-pixie/src/features/main/services/main-service/conformance/from-state.ts +++ b/packages/data-react-pixie/src/features/main/services/main-service/conformance/from-state.ts @@ -6,26 +6,35 @@ import type { CoreDatabase } from "../core-database/core-database.js"; // Seed a store to exactly match a `data/` `State`: clear every sprite, set the // `filter` resource, then insert the sprites. The inverse of `toState`. // Test-only — the bridge that lets an ecs mutation be checked against the pure -// transform it stands for (see `expect-conforms.ts`). +// transform it stands for. // // Clearing iterates tail→head so each delete is from the tail (no hole-fill // shift). The ecs assigns entity ids from its own id-space, unrelated to the -// spec's domain `id`; the seeded entities are returned in `state.sprites` order -// so the caller can map spec `id` → entity positionally (see `expect-conforms.ts`). -export const fromState = (store: CoreDatabase.Store, state: State): readonly Entity[] => { - for (const arch of store.queryArchetypes(store.archetypes.Sprite.components)) { +// spec's domain `id`. This returns the `spec id → seeded entity` map so the +// conformance runners resolve id-addressed operations generically +// (`Conformance.resolver`); nothing here assumes the two id-spaces coincide. +export const fromState = ( + store: CoreDatabase.Store, + state: State, +): ReadonlyMap => { + for (const arch of store.queryArchetypes( + store.archetypes.Sprite.components, + )) { for (let row = arch.rowCount - 1; row >= 0; row--) { store.delete(arch.columns.id.get(row)); } } store.resources.filter = state.filter; - return state.sprites.map((sprite) => - store.archetypes.Sprite.insert({ - position: sprite.position, - rotation: sprite.rotation, - kind: sprite.kind, - hovered: sprite.hovered, - active: sprite.active, - }), + return new Map( + state.sprites.map((sprite) => [ + sprite.id, + store.archetypes.Sprite.insert({ + position: sprite.position, + rotation: sprite.rotation, + kind: sprite.kind, + hovered: sprite.hovered, + active: sprite.active, + }), + ]), ); }; diff --git a/packages/data-react-pixie/src/features/main/services/main-service/conformance/projection.test.ts b/packages/data-react-pixie/src/features/main/services/main-service/conformance/projection.test.ts index 05d27c3d..a8235aee 100644 --- a/packages/data-react-pixie/src/features/main/services/main-service/conformance/projection.test.ts +++ b/packages/data-react-pixie/src/features/main/services/main-service/conformance/projection.test.ts @@ -6,9 +6,8 @@ // identity test — `toState(fromState(s)) ≡ s` over representative states — proves // the projection round-trips faithfully on its own. import { describe, it } from "vitest"; +import { Match } from "@adobe/data/testing"; import type { State } from "../../../data/state/state.js"; -import { expectStateMatches } from "../../../data/state/expect-state-matches.js"; -import { anyNumber } from "../../../data/state/matchers.js"; import { createStore } from "./create-store.js"; import { fromState } from "./from-state.js"; import { toState } from "./to-state.js"; @@ -18,9 +17,30 @@ const states: readonly { readonly name: string; readonly state: State }[] = [ name: "a mix of sprites with a scene filter", state: { sprites: [ - { id: 1, position: [100, 100], rotation: 0, kind: "bunny", hovered: false, active: false }, - { id: 2, position: [300, 200], rotation: 1, kind: "fox", hovered: true, active: false }, - { id: 3, position: [150, 250], rotation: 0.5, kind: "bunny", hovered: false, active: true }, + { + id: 1, + position: [100, 100], + rotation: 0, + kind: "bunny", + hovered: false, + active: false, + }, + { + id: 2, + position: [300, 200], + rotation: 1, + kind: "fox", + hovered: true, + active: false, + }, + { + id: 3, + position: [150, 250], + rotation: 0.5, + kind: "bunny", + hovered: false, + active: true, + }, ], filter: "sepia", }, @@ -33,8 +53,22 @@ const states: readonly { readonly name: string; readonly state: State }[] = [ name: "sprites sharing a kind, blur filter", state: { sprites: [ - { id: 1, position: [10, 10], rotation: 0, kind: "fox", hovered: false, active: false }, - { id: 2, position: [20, 20], rotation: 0, kind: "fox", hovered: false, active: false }, + { + id: 1, + position: [10, 10], + rotation: 0, + kind: "fox", + hovered: false, + active: false, + }, + { + id: 2, + position: [20, 20], + rotation: 0, + kind: "fox", + hovered: false, + active: false, + }, ], filter: "blur", }, @@ -48,9 +82,12 @@ describe("ecs/conformance projection round-trips (toState ∘ fromState ≡ iden fromState(store, state); // The ecs reassigns ids from its own id-space, so compare against the same // state with ids left open. - expectStateMatches(toState(store), { + Match.assert(toState(store), { ...state, - sprites: state.sprites.map((sprite) => ({ ...sprite, id: anyNumber })), + sprites: state.sprites.map((sprite) => ({ + ...sprite, + id: Match.anyNumber, + })), }); }); } diff --git a/packages/data-react-pixie/src/features/main/services/main-service/conformance/transactions.test.ts b/packages/data-react-pixie/src/features/main/services/main-service/conformance/transactions.test.ts index e8fd698b..691242b7 100644 --- a/packages/data-react-pixie/src/features/main/services/main-service/conformance/transactions.test.ts +++ b/packages/data-react-pixie/src/features/main/services/main-service/conformance/transactions.test.ts @@ -1,9 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -/// -import { describe, it, expect } from "vitest"; -import type { CoreDatabase } from "../core-database/core-database.js"; -import type { ConformanceCase } from "../../../data/state/conformance-case.js"; -import { expectConforms, type ResolveEntity } from "./expect-conforms.js"; +import { Conformance } from "@adobe/data/testing"; import * as registeredTransactions from "../transaction-database/transactions/index.js"; import { createSprite } from "../transaction-database/transactions/create-sprite.js"; import { setFilter } from "../transaction-database/transactions/set-filter.js"; @@ -17,47 +13,40 @@ import { cases as setSpriteActiveCases } from "../../../data/state/set-sprite-ac import { cases as setSpriteHoveredCases } from "../../../data/state/set-sprite-hovered.js"; import { cases as toggleSpriteActiveCases } from "../../../data/state/toggle-sprite-active.js"; import { cases as tickCases } from "../../../data/state/tick.js"; +import { createStore } from "./create-store.js"; +import { fromState } from "./from-state.js"; +import { toState } from "./to-state.js"; -// The single conformance test for every ecs transaction. Each transaction's -// `apply` is bespoke — an id-addressed transaction resolves its entity via the -// seeded store — and transaction files must stay single-export (the -// `transactions/` barrel is `export *`-ed straight into the plugin facet), so the -// wiring lives here rather than beside each transaction. The guard at the bottom -// asserts every registered transaction is wired below, so none can be missed. -const covered = new Set(); -const conforms = ( - transaction: string, - config: { - readonly cases: readonly ConformanceCase[]; - readonly apply: (t: CoreDatabase.Store, args: Args, resolve: ResolveEntity) => void; +// The single conformance test for every ecs transaction. `runTransactions` owns +// the harness (fresh store, `fromState` seed, `resolve`, `toState` compare, +// coverage guard keyed off the registered barrel); only the bespoke `apply` +// adapters are per-transaction — an id-addressed transaction resolves its entity. +Conformance.runTransactions({ + createStore, + fromState, + toState, + registered: registeredTransactions, + define: (conforms) => { + conforms("createSprite", { cases: createSpriteCases, apply: createSprite }); + conforms("setFilter", { cases: setFilterCases, apply: setFilter }); + conforms("setSpriteActive", { + cases: setSpriteActiveCases, + apply: (t, args, resolve) => + setSpriteActive(t, { entity: resolve(args.id), active: args.active }), + }); + conforms("setSpriteHovered", { + cases: setSpriteHoveredCases, + apply: (t, args, resolve) => + setSpriteHovered(t, { + entity: resolve(args.id), + hovered: args.hovered, + }), + }); + conforms("toggleSpriteActive", { + cases: toggleSpriteActiveCases, + apply: (t, args, resolve) => + toggleSpriteActive(t, { entity: resolve(args.id) }), + }); + conforms("tick", { cases: tickCases, apply: tick }); }, -): void => { - covered.add(transaction); - describe(`${transaction} transaction conforms`, () => expectConforms(config)); -}; - -conforms("createSprite", { cases: createSpriteCases, apply: createSprite }); -conforms("setFilter", { cases: setFilterCases, apply: setFilter }); -conforms("setSpriteActive", { - cases: setSpriteActiveCases, - apply: (t, args, resolve) => setSpriteActive(t, { entity: resolve(args.id), active: args.active }), -}); -conforms("setSpriteHovered", { - cases: setSpriteHoveredCases, - apply: (t, args, resolve) => setSpriteHovered(t, { entity: resolve(args.id), hovered: args.hovered }), -}); -conforms("toggleSpriteActive", { - cases: toggleSpriteActiveCases, - apply: (t, args, resolve) => toggleSpriteActive(t, { entity: resolve(args.id) }), -}); -conforms("tick", { cases: tickCases, apply: tick }); - -// None-missed guard: every **registered** transaction must be wired above. Keyed -// off the barrel (the transactions the plugin actually dispatches), not a file -// glob — so a shared read helper parked flat in `transactions/` (kept out of the -// barrel) is naturally excluded. -describe("transaction conformance coverage", () => { - for (const transaction of Object.keys(registeredTransactions)) { - it(`${transaction} has a conformance case`, () => expect(covered.has(transaction)).toBe(true)); - } }); diff --git a/packages/data-solid-dashboard/src/features/main/data/state/conformance-case.ts b/packages/data-solid-dashboard/src/features/main/data/state/conformance-case.ts index 095f3cb2..4426eca4 100644 --- a/packages/data-solid-dashboard/src/features/main/data/state/conformance-case.ts +++ b/packages/data-solid-dashboard/src/features/main/data/state/conformance-case.ts @@ -1,70 +1,13 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance as ConformanceApi } from "@adobe/data/testing"; import type { State } from "./state.js"; -// A service arg is an object with method members (not an array, not a function); -// this is what distinguishes an injected service from plain data args, since the -// `Service` marker itself is all-optional and would over-match. -type MethodKeys = { [K in keyof T]-?: T[K] extends (...a: never[]) => unknown ? K : never }[keyof T]; -type IsService = T extends readonly unknown[] - ? false - : T extends (...a: never[]) => unknown - ? false - : T extends object - ? [MethodKeys] extends [never] - ? false - : true - : false; - -// A strongly-typed call to one method of service `S`: `[methodName, ...its args]`. -// A no-arg method is just `[methodName]`. -export type Call = { - [M in keyof S]-?: S[M] extends (...a: infer A) => unknown ? readonly [M, ...A] : never; -}[keyof S]; - -// Expected side effects for a case, keyed by the service-typed args only. An -// `Array` value asserts these calls in this order; a `Set` value asserts the same -// calls in any order. Method names and their args are checked against the service. -export type Effects = { - readonly [K in keyof Args as IsService extends true ? K : never]?: - | readonly Call[] - | ReadonlySet>; -}; - -// One spec-owned conformance case: a `data/` transform's `{ before, args, after }` -// authored as full `State`, optionally with the side effects it makes on its -// injected service args. Shared unchanged by the spec aggregator (`spec.test.ts`) -// and the ecs conformance runners (`services/main-service/conformance/`). -export type ConformanceCase = { - readonly name: string; - readonly before: State; - readonly args: Args; - readonly after: State; - readonly effects?: Effects; -}; - -// A transform's cases, with the case `args` type derived from the transform's own -// signature — its second parameter, or `void` when it takes none. A transform -// co-locates `export const cases: Conformance = [...]`, so the -// cases cannot drift from what the function accepts, and the spec aggregator can -// discover the function without it being named twice. -export type Conformance unknown> = readonly ConformanceCase< - Parameters extends [unknown, infer Args, ...unknown[]] ? Args : void ->[]; - -// One case for a derivation (`(state) => value`): a state `input` and the `value` -// it yields. `value` may use asymmetric matchers (`anyNumber` for ids). Both the -// pure derivation and its ecs computed are checked against these. -export type DerivationCase = { - readonly name: string; - readonly input: Input; - readonly value: Value; -}; - -// A derivation's cases, with `input` and `value` types read from the derivation's -// own signature (its parameter and return) — the `Conformance` analog for -// value-producing derivations. A derivation co-locates -// `export const cases: Derivation = [...]`. -export type Derivation unknown> = readonly DerivationCase< - Parameters[0], - ReturnType ->[]; +// The conformance case types for this feature — the shared `@adobe/data/testing` +// machinery with `State` bound once, so a transform authors `Conformance` +// and a derivation `Derivation` (args/input/value read from the +// function's own signature). This tiny file is the only per-feature conformance +// type declaration; everything else lives in `@adobe/data/testing`. +export type Conformance unknown> = + ConformanceApi.Cases; +export type Derivation unknown> = + ConformanceApi.DerivationCases; diff --git a/packages/data-solid-dashboard/src/features/main/data/state/expect-state-matches.ts b/packages/data-solid-dashboard/src/features/main/data/state/expect-state-matches.ts deleted file mode 100644 index dd30dc8d..00000000 --- a/packages/data-solid-dashboard/src/features/main/data/state/expect-state-matches.ts +++ /dev/null @@ -1,49 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { expect } from "vitest"; -import type { State } from "./state.js"; - -// A vitest asymmetric matcher (`expect.any(...)`): honored on the EXPECTED side -// so a case can assert "any value" for something it does not pin. This feature's -// `State` is entirely scalar resources (no ecs-minted ids), so no case actually -// uses one today — but the comparison stays matcher-aware so it backs the shared -// spec/computed comparisons uniformly across features (see `matchers.ts` note). -const isMatcher = (value: unknown): value is { asymmetricMatch(actual: unknown): boolean } => - typeof value === "object" && - value !== null && - typeof (value as { asymmetricMatch?: unknown }).asymmetricMatch === "function"; - -// Tolerant structural match honoring asymmetric matchers and comparing arrays -// **in order** — the `log` trail is chronological, so position is significant. -// Exported so it can back other conformance comparisons (e.g. derivation values). -export const matches = (actual: unknown, expected: unknown): boolean => { - if (isMatcher(expected)) return expected.asymmetricMatch(actual); - if (Array.isArray(expected)) { - if (!Array.isArray(actual) || actual.length !== expected.length) return false; - return expected.every((exp, index) => matches(actual[index], exp)); - } - if (expected !== null && typeof expected === "object") { - if (actual === null || typeof actual !== "object" || Array.isArray(actual)) return false; - const expectedKeys = Object.keys(expected); - const actualKeys = Object.keys(actual as object); - if (expectedKeys.length !== actualKeys.length) return false; - return expectedKeys.every((key) => - matches((actual as Record)[key], (expected as Record)[key]), - ); - } - return Object.is(actual, expected); -}; - -// Spec-owned tolerant `State` equality, shared by the data/ spec test and the ecs -// conformance runners. Every field is a scalar held in a single resource slot -// (plain JS storage — no typed-buffer rounding, no archetype hole-fill), so the -// projection is id-free; the matcher path still lets `after`/`value` stay open -// where a future field warranted it. -export const expectStateMatches = (actual: State, expected: State): void => { - expectMatches(actual, expected); -}; - -// The same tolerant, matcher-aware comparison for any value — used by the spec -// aggregator for derivation cases, where the compared value is not a whole `State`. -export const expectMatches = (actual: unknown, expected: unknown): void => { - expect(matches(actual, expected), `mismatch:\n actual ${JSON.stringify(actual)}`).toBe(true); -}; diff --git a/packages/data-solid-dashboard/src/features/main/data/state/record-effects.ts b/packages/data-solid-dashboard/src/features/main/data/state/record-effects.ts deleted file mode 100644 index 8fb3d25f..00000000 --- a/packages/data-solid-dashboard/src/features/main/data/state/record-effects.ts +++ /dev/null @@ -1,107 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { expect } from "vitest"; -import { equalsUnordered } from "@adobe/data"; -import type { Effects } from "./conformance-case.js"; - -export type RecordedCall = readonly [string, ...unknown[]]; - -// Wrap a plain-object service so each method call is recorded, then delegates. -// No Proxy (services are plain objects with own enumerable methods — see -// `service.md`), so we enumerate and closure-wrap each function. -export const recordCalls = (service: S): { service: S; calls: RecordedCall[] } => { - const calls: RecordedCall[] = []; - const wrapped = Object.fromEntries( - Object.entries(service).map(([key, value]) => [ - key, - typeof value === "function" - ? (...args: unknown[]): unknown => { - calls.push([key, ...args]); - return (value as (...a: unknown[]) => unknown)(...args); - } - : value, - ]), - ) as S; - return { service: wrapped, calls }; -}; - -// Assert the calls recorded against one service match the case's expectation for -// it: an Array expects exactly these calls in order; a Set expects the same calls -// in any order (multiset). Absent expectation ⇒ no calls expected. -export const expectServiceCalls = ( - recorded: readonly RecordedCall[], - expected: readonly RecordedCall[] | ReadonlySet | undefined, -): void => { - if (expected instanceof Set) { - expect(equalsUnordered(recorded, [...expected])).toBe(true); - } else { - expect(recorded).toEqual(expected ?? []); - } -}; - -// Split a case's `args` into the injected services (objects with methods) and the -// remaining plain data. Services are wrapped for recording; the returned `calls` -// map is keyed by the same arg key so it can be matched against `effects`. -export const recordArgServices = ( - args: Args, -): { args: Args; calls: Record } => { - const calls: Record = {}; - const next = { ...args } as Record; - for (const [key, value] of Object.entries(args)) { - if (isServiceValue(value)) { - const rec = recordCalls(value); - next[key] = rec.service; - calls[key] = rec.calls; - } - } - return { args: next as Args, calls }; -}; - -// Assert each service **declared** in `effects` saw exactly its expected calls -// (an extra or missing call on a declared service fails). Services not listed — -// e.g. a value-returning dependency read like `generateName` — are ignored, so -// `effects` captures the fire-and-forget side effects you choose to assert. -export const expectEffects = ( - calls: Record, - effects: Effects> | undefined, -): void => { - const expected = (effects ?? {}) as Record>; - for (const key of Object.keys(expected)) { - expectServiceCalls(calls[key] ?? [], expected[key]); - } -}; - -// Split a case's `args` into the injected services (wrapped for recording, to be -// used as `Database.create` service overrides) and the remaining plain data (the -// action input). Keyed by the same arg name so `calls` matches against `effects`. -export const splitAndRecordServices = ( - args: Args, -): { - services: Record; - input: Record; - calls: Record; -} => { - const services: Record = {}; - const input: Record = {}; - const calls: Record = {}; - // A no-arg transition has `undefined` args — nothing to split. - if (args !== null && typeof args === "object") { - for (const [key, value] of Object.entries(args)) { - if (isServiceValue(value)) { - const recorded = recordCalls(value); - services[key] = recorded.service; - calls[key] = recorded.calls; - } else { - input[key] = value; - } - } - } - return { services, input, calls }; -}; - -// A runtime service value: a non-array object with at least one method (mirrors -// the compile-time `IsService`). -const isServiceValue = (value: unknown): value is object => - typeof value === "object" && - value !== null && - !Array.isArray(value) && - Object.values(value).some((member) => typeof member === "function"); diff --git a/packages/data-solid-dashboard/src/features/main/data/state/spec.test.ts b/packages/data-solid-dashboard/src/features/main/data/state/spec.test.ts index be7bb913..136134bd 100644 --- a/packages/data-solid-dashboard/src/features/main/data/state/spec.test.ts +++ b/packages/data-solid-dashboard/src/features/main/data/state/spec.test.ts @@ -1,59 +1,18 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. /// -import { describe, it } from "vitest"; -import type { State } from "./state.js"; -import type { ConformanceCase, DerivationCase } from "./conformance-case.js"; -import { expectStateMatches, expectMatches } from "./expect-state-matches.js"; -import { recordArgServices, expectEffects } from "./record-effects.js"; +import { Conformance } from "@adobe/data/testing"; -// The single spec test for every transform AND derivation in this folder. It -// auto-discovers each file (any sibling `.ts` that exports `cases`) via -// `import.meta.glob`, so a new one is covered the moment it ships — none can be -// forgotten. Each participating file must export exactly its function plus `cases` -// (enforced below), which lets us find the function without it being named twice. -// A case's shape selects the check: `after` → a transition `(state, args) => state`; -// `value` → a derivation `(state) => value`. -const modules = import.meta.glob>( - ["./*.ts", "!./*.test.ts", "!./*.type-test.ts"], - { eager: true }, +// The single pure-spec test for every transform AND derivation in this folder. +// `runSpec` auto-discovers each sibling that exports `cases`, requires it to +// export exactly its function plus `cases`, and dispatches on case shape (a +// `value` case is a derivation, otherwise a transition whose declared `effects` +// are also asserted). This feature's `State` is entirely scalar (the `log` trail +// is chronological), so the default ordered, matcher-aware comparison is correct. +Conformance.runSpec( + import.meta.glob>( + ["./*.ts", "!./*.test.ts", "!./*.type-test.ts"], + { + eager: true, + }, + ), ); - -const isDerivationCase = (c: unknown): c is DerivationCase => - typeof c === "object" && c !== null && "value" in c; - -for (const [path, module] of Object.entries(modules)) { - const exportNames = Object.keys(module); - if (!exportNames.includes("cases")) continue; - const functionNames = exportNames.filter((key) => typeof module[key] === "function"); - const label = functionNames.length === 1 ? functionNames[0] : path; - - describe(`State.${label}`, () => { - if (exportNames.length !== 2 || functionNames.length !== 1) { - it("exports exactly its function and `cases`", () => { - throw new Error(`${path} exports [${exportNames.join(", ")}] — expected one function + cases`); - }); - return; - } - // Runtime invariant: a participating file exports a function and its cases. - const fn = module[functionNames[0]] as (...args: unknown[]) => unknown; - const cases = module["cases"] as readonly unknown[]; - for (const testCase of cases) { - if (isDerivationCase(testCase)) { - // Derivation: the value it yields matches, honoring asymmetric matchers. - it(testCase.name, () => expectMatches(fn(testCase.input), testCase.value)); - continue; - } - // Transition: assert the resulting state and the declared side effects. - // A service-injected transition is async, so await uniformly. - const transitionCase = testCase as ConformanceCase>; - it(transitionCase.name, async () => { - const raw = transitionCase.args; - const { args, calls } = - raw && typeof raw === "object" ? recordArgServices(raw) : { args: raw, calls: {} }; - const result = (await fn(transitionCase.before, args)) as State; - expectStateMatches(result, transitionCase.after); - expectEffects(calls, transitionCase.effects); - }); - } - }); -} diff --git a/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/actions.test.ts b/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/actions.test.ts index 49d55675..6333f4e5 100644 --- a/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/actions.test.ts +++ b/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/actions.test.ts @@ -1,13 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -/// -import { describe, it, expect } from "vitest"; import { Database } from "@adobe/data/ecs"; -import type { ConformanceCase } from "../../../data/state/conformance-case.js"; -import { splitAndRecordServices, expectEffects } from "../../../data/state/record-effects.js"; +import { Conformance } from "@adobe/data/testing"; import { MainService } from "../main-service.js"; -import { fromState } from "./from-state.js"; -import { toState } from "./to-state.js"; -import { expectStateMatches } from "../../../data/state/expect-state-matches.js"; +import * as registeredActions from "../action-database/actions/index.js"; import { increment } from "../action-database/actions/increment.js"; import { decrement } from "../action-database/actions/decrement.js"; import { reset } from "../action-database/actions/reset.js"; @@ -18,59 +13,38 @@ import { cases as decrementCases } from "../../../data/state/decrement.js"; import { cases as resetCases } from "../../../data/state/reset.js"; import { cases as setUserNameCases } from "../../../data/state/set-user-name.js"; import { cases as clearLogCases } from "../../../data/state/clear-log.js"; +import { fromState } from "./from-state.js"; +import { toState } from "./to-state.js"; -// Each transition's cases run against its same-named ecs **action** (the async -// app-facing realization). The case's service args become the db's service -// overrides (wrapped so their calls are recorded), the plain args drive the -// action, and we assert both the resulting state and the declared side effects. -// This feature injects no services, so every case's `effects` is empty and the -// split yields no overrides — but the runner keeps the general shape. -// `toSystemDatabase` exposes the writable `.store` the projection needs while -// keeping transactions/actions. -const makeDb = (services: Record) => - Database.toSystemDatabase(Database.create(MainService.plugin, { services })); -type Db = ReturnType; -type Run = (db: Db, input: Args) => Promise | void; - -const covered = new Set(); -const conformsAction = ( - action: string, - config: { readonly cases: readonly ConformanceCase[]; readonly run: Run> }, -): void => { - covered.add(action); - describe(`${action} action conforms`, () => { - for (const testCase of config.cases) { - it(testCase.name, async () => { - const { services, input, calls } = splitAndRecordServices(testCase.args); - const db = makeDb(services); - fromState(db.store, testCase.before); - await config.run(db, input as Partial); - expectStateMatches(toState(db.store), testCase.after); - expectEffects(calls, testCase.effects); - }); - } - }); -}; - -conformsAction("increment", { cases: incrementCases, run: (db) => increment(db) }); -conformsAction("decrement", { cases: decrementCases, run: (db) => decrement(db) }); -conformsAction("reset", { cases: resetCases, run: (db) => reset(db) }); -conformsAction("setUserName", { - cases: setUserNameCases, - run: (db, input) => setUserName(db, { name: input.name ?? "" }), -}); -conformsAction("clearLog", { cases: clearLogCases, run: (db) => clearLog(db) }); - -// None-missed guard: every action file must be wired above. -const kebabToCamel = (name: string): string => - name.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase()); -describe("action conformance coverage", () => { - const files = import.meta.glob([ - "../action-database/actions/*.ts", - "!../action-database/actions/index.ts", - ]); - for (const path of Object.keys(files)) { - const action = kebabToCamel(path.replace(/.*\//, "").replace(/\.ts$/, "")); - it(`${action} has a conformance case`, () => expect(covered.has(action)).toBe(true)); - } +// Each transition's cases run against its same-named ecs action. `runActions` +// splits the case's injected services into recording overrides (via `makeDb`), +// runs the action, then asserts both the resulting state and the declared effects; +// the harness/coverage are shared. This feature injects no services, so every +// case's `effects` is empty and the split yields no overrides. +Conformance.runActions({ + // `toSystemDatabase` exposes the writable `.store` the projection needs. + makeDb: (services) => + Database.toSystemDatabase( + Database.create(MainService.plugin, { services }), + ), + store: (db) => db.store, + fromState, + toState, + registered: registeredActions, + define: (conforms) => { + conforms("increment", { + cases: incrementCases, + run: (db) => increment(db), + }); + conforms("decrement", { + cases: decrementCases, + run: (db) => decrement(db), + }); + conforms("reset", { cases: resetCases, run: (db) => reset(db) }); + conforms("setUserName", { + cases: setUserNameCases, + run: (db, input) => setUserName(db, { name: input.name ?? "" }), + }); + conforms("clearLog", { cases: clearLogCases, run: (db) => clearLog(db) }); + }, }); diff --git a/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/expect-conforms.ts b/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/expect-conforms.ts deleted file mode 100644 index 7420b586..00000000 --- a/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/expect-conforms.ts +++ /dev/null @@ -1,41 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { it } from "vitest"; -import type { State } from "../../../data/state/state.js"; -import type { ConformanceCase } from "../../../data/state/conformance-case.js"; -import { expectStateMatches } from "../../../data/state/expect-state-matches.js"; -import type { CoreDatabase } from "../core-database/core-database.js"; -import { createStore } from "./create-store.js"; -import { fromState } from "./from-state.js"; -import { toState } from "./to-state.js"; - -// The conformance runner, bound to this feature's projection (`fromState` / -// `toState`). For each case it proves the one conformance property -// -// toState(apply(fromState(before), args)) ≡ spec(before, args) -// -// The ecs half is always asserted here: seed `fromState(before)` → run the -// caller's `apply` → `toState ≡ after`. Half 1 (`spec(before,args) ≡ after`) is -// already asserted for every case by `data/state/spec.test.ts`, so the central -// aggregator omits it; pass `spec` to re-check it in place. -// -// `apply` receives the seeded writable store and the case args, then calls the -// raw transaction function directly (a transaction is `(store, …) => void`, so -// no `Database` is involved). This feature holds only scalar resources, so the -// projection is id-free and there are no entities to resolve. -export const expectConforms = (config: { - readonly cases: readonly ConformanceCase[]; - readonly spec?: (before: State, args: Args) => State; - readonly apply: (store: CoreDatabase.Store, args: Args) => void; -}): void => { - for (const testCase of config.cases) { - it(testCase.name, () => { - if (config.spec) { - expectStateMatches(config.spec(testCase.before, testCase.args), testCase.after); - } - const store = createStore(); - fromState(store, testCase.before); - config.apply(store, testCase.args); - expectStateMatches(toState(store), testCase.after); - }); - } -}; diff --git a/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/from-state.ts b/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/from-state.ts index f4542a6f..c3a506a8 100644 --- a/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/from-state.ts +++ b/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/from-state.ts @@ -1,13 +1,22 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Entity } from "@adobe/data/ecs"; import type { State } from "../../../data/state/state.js"; import type { CoreDatabase } from "../core-database/core-database.js"; // Seed a store to exactly match a `data/` `State`. The whole state is scalar // resources, so seeding is three assignments — the inverse of `toState`. // Test-only bridge that lets an ecs mutation be checked against the pure -// transform it stands for (see `expect-conforms.ts`). -export const fromState = (store: CoreDatabase.Store, state: State): void => { +// transform it stands for. +// +// The conformance runners resolve id-addressed operations generically through the +// `spec id → seeded entity` map this returns. This feature has no entities (only +// scalar resources), so nothing is id-addressed and the map is always empty. +export const fromState = ( + store: CoreDatabase.Store, + state: State, +): ReadonlyMap => { store.resources.count = state.count; store.resources.log = state.log; store.resources.userName = state.userName; + return new Map(); }; diff --git a/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/projection.test.ts b/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/projection.test.ts index a1dbedce..396c0231 100644 --- a/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/projection.test.ts +++ b/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/projection.test.ts @@ -7,8 +7,8 @@ // the projection round-trips faithfully on its own. The state is entirely scalar // resources (no ecs-minted ids), so the compare is exact. import { describe, it } from "vitest"; +import { Match } from "@adobe/data/testing"; import type { State } from "../../../data/state/state.js"; -import { expectStateMatches } from "../../../data/state/expect-state-matches.js"; import { createStore } from "./create-store.js"; import { fromState } from "./from-state.js"; import { toState } from "./to-state.js"; @@ -16,7 +16,11 @@ import { toState } from "./to-state.js"; const states: readonly { readonly name: string; readonly state: State }[] = [ { name: "a populated dashboard: positive count, multi-entry log, named user", - state: { count: 3, log: ["Incremented to 1", "Name changed to Ada"], userName: "Ada" }, + state: { + count: 3, + log: ["Incremented to 1", "Name changed to Ada"], + userName: "Ada", + }, }, { name: "the initial defaults: zero count, empty log, guest user", @@ -29,7 +33,7 @@ describe("ecs conformance projection round-trips (toState ∘ fromState ≡ iden it(name, () => { const store = createStore(); fromState(store, state); - expectStateMatches(toState(store), state); + Match.assert(toState(store), state); }); } }); diff --git a/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/transactions.test.ts b/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/transactions.test.ts index f127e24e..e99d90c9 100644 --- a/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/transactions.test.ts +++ b/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/transactions.test.ts @@ -1,8 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it, expect } from "vitest"; -import type { CoreDatabase } from "../core-database/core-database.js"; -import type { ConformanceCase } from "../../../data/state/conformance-case.js"; -import { expectConforms } from "./expect-conforms.js"; +import { Conformance } from "@adobe/data/testing"; import * as registeredTransactions from "../transaction-database/transactions/index.js"; import { increment } from "../transaction-database/transactions/increment.js"; import { decrement } from "../transaction-database/transactions/decrement.js"; @@ -14,38 +11,25 @@ import { cases as decrementCases } from "../../../data/state/decrement.js"; import { cases as resetCases } from "../../../data/state/reset.js"; import { cases as setUserNameCases } from "../../../data/state/set-user-name.js"; import { cases as clearLogCases } from "../../../data/state/clear-log.js"; +import { createStore } from "./create-store.js"; +import { fromState } from "./from-state.js"; +import { toState } from "./to-state.js"; -// The single conformance test for every ecs transaction. Each transaction's shared -// `data/state` cases are replayed against it (`fromState(before)` → apply → -// `toState ≡ after`); half 1 of the property is covered by `data/state/spec.test.ts`, -// so no `spec` is passed here. Transaction files must stay single-export (the -// `transactions/` barrel is `export *`-ed straight into the plugin facet), so the -// wiring lives here rather than beside each transaction. The guard at the bottom -// asserts every registered transaction is wired below, so none can be missed. -const covered = new Set(); -const conforms = ( - transaction: string, - config: { - readonly cases: readonly ConformanceCase[]; - readonly apply: (t: CoreDatabase.Store, args: Args) => void; +// The single conformance test for every ecs transaction. `runTransactions` owns +// the harness (fresh store, `fromState` seed, `toState` compare, coverage guard +// keyed off the registered barrel); each transaction's `apply` calls the raw +// transaction directly. This feature holds only scalar resources, so nothing is +// id-addressed and the shared `resolve` is unused. +Conformance.runTransactions({ + createStore, + fromState, + toState, + registered: registeredTransactions, + define: (conforms) => { + conforms("increment", { cases: incrementCases, apply: increment }); + conforms("decrement", { cases: decrementCases, apply: decrement }); + conforms("reset", { cases: resetCases, apply: reset }); + conforms("setUserName", { cases: setUserNameCases, apply: setUserName }); + conforms("clearLog", { cases: clearLogCases, apply: clearLog }); }, -): void => { - covered.add(transaction); - describe(`${transaction} transaction conforms`, () => expectConforms(config)); -}; - -conforms("increment", { cases: incrementCases, apply: increment }); -conforms("decrement", { cases: decrementCases, apply: decrement }); -conforms("reset", { cases: resetCases, apply: reset }); -conforms("setUserName", { cases: setUserNameCases, apply: setUserName }); -conforms("clearLog", { cases: clearLogCases, apply: clearLog }); - -// None-missed guard: every **registered** transaction must be wired above. Keyed -// off the barrel (the transactions the plugin actually dispatches), not a file -// glob — so a shared read helper parked flat in `transactions/` (kept out of the -// barrel) is naturally excluded. -describe("transaction conformance coverage", () => { - for (const transaction of Object.keys(registeredTransactions)) { - it(`${transaction} has a conformance case`, () => expect(covered.has(transaction)).toBe(true)); - } }); diff --git a/packages/data/src/testing/conformance/record-effects.ts b/packages/data/src/testing/conformance/record-effects.ts index cea368b0..2db73639 100644 --- a/packages/data/src/testing/conformance/record-effects.ts +++ b/packages/data/src/testing/conformance/record-effects.ts @@ -40,7 +40,7 @@ export const recordArgServices = ( args: Args, ): { args: Args; calls: Record } => { const calls: Record = {}; - if (args === null || typeof args !== "object") return { args, calls }; + if (args === null || typeof args !== "object" || Array.isArray(args)) return { args, calls }; const next = { ...(args as object) } as Record; for (const [key, value] of Object.entries(args as object)) { if (isServiceValue(value)) { @@ -61,7 +61,7 @@ export const splitAndRecordServices = ( const services: Record = {}; const input: Record = {}; const calls: Record = {}; - if (args !== null && typeof args === "object") { + if (args !== null && typeof args === "object" && !Array.isArray(args)) { for (const [key, value] of Object.entries(args)) { if (isServiceValue(value)) { const recorded = recordCalls(value); diff --git a/packages/data/src/testing/conformance/resolve.ts b/packages/data/src/testing/conformance/resolve.ts index 946744c5..052b3a9a 100644 --- a/packages/data/src/testing/conformance/resolve.ts +++ b/packages/data/src/testing/conformance/resolve.ts @@ -8,5 +8,9 @@ import { Entity } from "../../ecs/entity/entity.js"; // id-addressed transaction reads no such entity and is a no-op. export type Resolve = (id: Id) => Entity; -export const resolver = (seeded: ReadonlyMap): Resolve => (id) => - seeded.get(id) ?? Entity.none; +// Build a resolver from a `fromState` seed map. A feature whose transactions are +// addressed by index or are singleton (no id → entity mapping) returns `void` +// from `fromState`; its resolver is then never called, and any id resolves to +// `Entity.none`. +export const resolver = (seeded: ReadonlyMap | void): Resolve => (id) => + (seeded ? seeded.get(id) : undefined) ?? Entity.none; diff --git a/packages/data/src/testing/conformance/run-actions.ts b/packages/data/src/testing/conformance/run-actions.ts index 94682042..dddfc18a 100644 --- a/packages/data/src/testing/conformance/run-actions.ts +++ b/packages/data/src/testing/conformance/run-actions.ts @@ -25,7 +25,7 @@ export interface ActionRunConfig { readonly makeDb: (services: Record) => Db; // The writable store exposed by that db (usually `(db) => db.store`). readonly store: (db: Db) => Store; - readonly fromState: (store: Store, before: State) => ReadonlyMap; + readonly fromState: (store: Store, before: State) => ReadonlyMap | void; readonly toState: (store: Store) => State; // The registered-actions barrel — coverage requires every key wired. readonly registered: Record; diff --git a/packages/data/src/testing/conformance/run-transactions.ts b/packages/data/src/testing/conformance/run-transactions.ts index a8ca8339..ee9aa811 100644 --- a/packages/data/src/testing/conformance/run-transactions.ts +++ b/packages/data/src/testing/conformance/run-transactions.ts @@ -19,12 +19,18 @@ export type TransactionConforms = ( export interface TransactionRunConfig { readonly createStore: () => Store; - // Seed a fresh store to `before`, returning the `spec id → seeded entity` map. - readonly fromState: (store: Store, before: State) => ReadonlyMap; + // Seed a fresh store to `before`, returning the `spec id → seeded entity` map + // (or `void` when the feature is index/singleton-addressed and needs no + // resolution). + readonly fromState: (store: Store, before: State) => ReadonlyMap | void; readonly toState: (store: Store) => State; // The registered-transactions barrel — the coverage guard requires every key // here to be wired, so none can be missed. readonly registered: Record; + // Transactions asserted OUTSIDE the shared-cases mechanism (e.g. one with no + // `data/` transform, checked with a direct resource assertion) — named here so + // the coverage guard counts them as covered. + readonly covers?: readonly string[]; readonly match?: MatchOptions; readonly define: (conforms: TransactionConforms) => void; } @@ -58,6 +64,7 @@ export const runTransactions = (config: TransactionRunConfig { for (const transaction of Object.keys(config.registered)) { it(`${transaction} has a conformance case`, () => { From aaeba48d649f4425c2483d902c5f8c0f10ae0a6a Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Thu, 6 Aug 2026 23:20:33 -0700 Subject: [PATCH 24/37] feat: migrate p2p to @adobe/data/testing; rewrite data-ai rules for the toolkit - data-p2p-tictactoe: both features migrated. presence reproduces its per-peer userId seeding through runActions via a test-only concurrency that reads the peer id at apply time; actions `registered` is the transition-backed set (the action barrel also holds a streaming trackPresence with no transition analogue). - data-ai rules: state.md / conformance.md / transactions.md / actions.md / computed.md / index.md now teach the shared @adobe/data/testing API (thin conformance-case alias, Match matchers incl. ref, the four Conformance.run* drivers, generic id resolution, covers/unordered/tolerance) instead of the deleted per-feature machinery. Co-Authored-By: Claude Sonnet 4.6 --- .../.claude/rules/features/data/state.md | 83 ++++--- .../data-ai/.claude/rules/features/index.md | 15 +- .../features/services/main-service/actions.md | 12 +- .../services/main-service/computed.md | 11 +- .../services/main-service/conformance.md | 213 +++++++++++------- .../services/main-service/transactions.md | 21 +- .../data/state/conformance-case.ts | 77 +------ .../data/state/expect-state-matches.ts | 57 ----- .../negotiation/data/state/record-effects.ts | 107 --------- .../negotiation/data/state/spec.test.ts | 69 ++---- .../main-service/conformance/actions.test.ts | 155 +++++++------ .../conformance/expect-conforms.ts | 36 --- .../main-service/conformance/from-state.ts | 13 +- .../conformance/projection.test.ts | 4 +- .../conformance/transactions.test.ts | 94 ++++---- .../presence/data/state/conformance-case.ts | 69 +----- .../data/state/expect-state-matches.ts | 52 ----- .../presence/data/state/record-effects.ts | 105 --------- .../features/presence/data/state/spec.test.ts | 65 ++---- .../main-service/conformance/actions.test.ts | 116 +++++----- .../conformance/expect-conforms.ts | 33 --- .../main-service/conformance/from-state.ts | 11 +- .../conformance/projection.test.ts | 9 +- .../conformance/transactions.test.ts | 58 ++--- 24 files changed, 492 insertions(+), 993 deletions(-) delete mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/data/state/expect-state-matches.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/data/state/record-effects.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/expect-conforms.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/presence/data/state/expect-state-matches.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/presence/data/state/record-effects.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/expect-conforms.ts diff --git a/packages/data-ai/.claude/rules/features/data/state.md b/packages/data-ai/.claude/rules/features/data/state.md index 43f7ac1d..d6bfee3b 100644 --- a/packages/data-ai/.claude/rules/features/data/state.md +++ b/packages/data-ai/.claude/rules/features/data/state.md @@ -33,13 +33,29 @@ beside the thing they specify, and `Conformance` binds them to the signature so they can't drift. Kept inert (no `describe`; one aggregator runs them) they also sidestep the double execution vitest triggers when a single file both exports cases and runs its own `describe`. Coverage is then enforced -centrally by the aggregator's barrel-driven guard rather than by eyeballing one -test file per transform — and genuine non-transition helpers still keep their own -`*.test.ts` (see below). +centrally by the shared driver's barrel-driven guard rather than by eyeballing +one test file per transform — and genuine non-transition helpers still keep +their own `*.test.ts` (see below). + +The case types, matchers, and runners all live in the shared +**`@adobe/data/testing`** module (two namespaces, `Match` and `Conformance`; +`vitest` is an *optional* peer dependency, already satisfied here). Only one +tiny per-feature file remains — a ~10-line alias, `conformance-case.ts`, that +binds `State` once so transform/derivation files can write a one-parameter type: + +```ts +// data/state/conformance-case.ts — the only per-feature conformance declaration +import type { Conformance as ConformanceApi } from "@adobe/data/testing"; +import type { State } from "./state.js"; +export type Conformance unknown> = ConformanceApi.Cases; +export type Derivation unknown> = ConformanceApi.DerivationCases; +export type Effects = ConformanceApi.Effects; +``` ```ts // create-todo.ts -import { anyNumber } from "./matchers.js"; // vitest expect.any(Number) — see below +import { Match } from "@adobe/data/testing"; +import type { Conformance } from "./conformance-case.js"; // the thin per-feature alias above export const createTodo = >( state: T, { name, complete, analytics }: { name: string; complete?: boolean; analytics: AnalyticsService }, @@ -49,7 +65,7 @@ export const cases: Conformance = [ { name: "appends the first todo", before: { todos: [], displayCompleted: false }, args: { name: "a", analytics: AnalyticsService.createFake() }, - after: { todos: [{ id: anyNumber, name: "a", complete: false }], displayCompleted: false }, + after: { todos: [{ id: Match.anyNumber, name: "a", complete: false }], displayCompleted: false }, effects: { analytics: [["todoCreated", { name: "a" }]] } }, ]; ``` @@ -58,39 +74,42 @@ export const cases: Conformance = [ the smallest `Pick` slice so it lifts to full-state. **All non-state inputs go in the single `args` object** (`Conformance` reads `Parameters[1]`) — bundle a `dt`, an injected service, etc. into it, never as a - third positional. Args may be narrowed/omitted. **Guard no-ops by returning - `state` unchanged**, never throw. + third positional. Args may be narrowed/omitted; a transform that takes **none** + omits `args` from each case entirely (the shared `Case` type makes `args` + optional exactly then). **Guard no-ops by returning `state` unchanged**, never + throw. - **Co-located `cases` must not touch the feature's `public.js` barrel at module load** — that barrel re-exports this very file, so calling `State.create()` (or any barrel member) in a top-level `cases` literal dead-locks the import cycle. Import the concrete helper directly (`import { create } from "./create.js"`) or inline full-`State` literals. -- **`Conformance`** derives the case `args` type from the function's - own signature — author it once, and cases can't drift from what the function - accepts. `before`/`after` are full `State`. -- **`after` leaves minted values open** with the `anyNumber`/`anyString` - matchers. These are just **vitest's asymmetric matchers**, centralised so the - `vitest` import lives in one place and tree-shakes out of the app build: - - ```ts - // matchers.ts - import { expect } from "vitest"; - export const anyNumber = expect.any(Number); - export const anyString = expect.any(String); - ``` - - An id the ECS assigns from its own id-space is `id: anyNumber` (i.e. - `expect.any(Number)`), so the pure spec and the ECS satisfy the same case — - match by content, not by the value you don't control. `matches()` honors any - vitest asymmetric matcher here (`expect.stringContaining`, …), not only these - two. Add `matchers.ts` only when a case needs one — a feature whose `State` - exposes no ECS-minted ids (values abstracted behind a scalar/string) never does. -- No per-transform test. The single **`spec.test.ts`** auto-discovers every file - exporting `cases` and asserts the pure result (see `conformance.md`). Only the - redundant per-transform tests are removed. A genuine **non-transition helper** in +- **`Conformance`** (the alias above) derives the case `args` type from + the function's own signature — author it once, and cases can't drift from what + the function accepts. `before`/`after` are full `State`. +- **`after` leaves minted values open** with the shared matchers `Match.anyNumber` + / `Match.anyString`, imported from `@adobe/data/testing` — there is **no** + per-feature `matchers.ts` anymore. An id the ECS assigns from its own id-space is + `id: Match.anyNumber`, so the pure spec and the ECS satisfy the same case — match + by content, not by the value you don't control. `Match` is framework-agnostic and + honors any asymmetric matcher, so vitest's `expect.stringContaining(...)` interops + on the expected side too. When an id must **line up in two places** within one + comparison — a `selectedId` that points at a specific todo, say — use + `Match.ref(label)`: it asserts id *correspondence* (a bijection up to renaming), + not a pinned value, so the two occurrences of the label must resolve to the same + actual id and two labels can't collide. `anyNumber`/`anyString` are for an id a + case does not pin at all; `ref` for one that must be consistent across the case. +- No per-transform test. The single **`spec.test.ts`** is one call — + `Conformance.runSpec(import.meta.glob(["./*.ts", "!./*.test.ts"], { eager: true }))` + — that auto-discovers every sibling exporting `cases`, enforces the two-exports + rule, and dispatches on case shape (a `value` case → derivation; otherwise a + transition whose declared `effects` are also asserted). Pass `{ match }` only when + the feature needs float tolerance or unordered collections (see `conformance.md`). + There is no per-feature `expect-state-matches.ts`, `record-effects.ts`, or + `expect-conforms.ts` — those are gone; the shared driver owns comparison, effect + recording, and the coverage guard. A genuine **non-transition helper** in `state/` — a `create()` constructor, a single-field predicate — has no `cases` - and isn't a `(state,args)=>state` transform, so `spec.test.ts` skips it: **keep - its own sibling `*.test.ts`** rather than deleting it and losing coverage. + and isn't a `(state,args)=>state` transform, so `runSpec` skips it: **keep its own + sibling `*.test.ts`** rather than deleting it and losing coverage. ## Injected services and side effects diff --git a/packages/data-ai/.claude/rules/features/index.md b/packages/data-ai/.claude/rules/features/index.md index 9c76e1ed..d55d1536 100644 --- a/packages/data-ai/.claude/rules/features/index.md +++ b/packages/data-ai/.claude/rules/features/index.md @@ -101,13 +101,14 @@ The tie between `data/` (spec) and `main-service` (implementation) is **conformance**, one property — `toState(apply(fromState(before), args)) ≡ transform(before, args)`: each main-service mutation, seeded and read back through a test-only store↔`State` -projection, equals the pure `data/` transform it stands for. The projection and -its runner live in `services/main-service/conformance/` (see -`services/main-service/conformance.md`); the shared `{ before, args, after }` -cases are spec-owned (exported from `data/state/.test.ts`), so conforming the -implementation is "substitute the implementation, reuse the expectations." This -lets `main-service` be largely mechanical and agent-generated, with the spec as -oracle. *How* to author each layer lives in the per-folder rules below. +projection, equals the pure `data/` transform it stands for. The per-feature +projection lives in `services/main-service/conformance/` and is replayed by the +shared `@adobe/data/testing` runners (see `services/main-service/conformance.md`); +the shared `{ before, args, after }` cases are spec-owned — co-located in each +`data/state/.ts`, which exports its function plus `cases` — so +conforming the implementation is "substitute the implementation, reuse the +expectations." This lets `main-service` be largely mechanical and agent-generated, +with the spec as oracle. *How* to author each layer lives in the per-folder rules below. ## Reference implementations diff --git a/packages/data-ai/.claude/rules/features/services/main-service/actions.md b/packages/data-ai/.claude/rules/features/services/main-service/actions.md index 4f189f87..d2fdcfbf 100644 --- a/packages/data-ai/.claude/rules/features/services/main-service/actions.md +++ b/packages/data-ai/.claude/rules/features/services/main-service/actions.md @@ -48,9 +48,13 @@ export const addRandomTodo = async (service: ServiceDatabase) => { Reactive computeds refresh only on a committed transaction, so an imperative read of one can hand back a stale shared cache (and it's the UI's layer, not the action's). This also keeps the action correct under the conformance seed. -- **Conformance** (`conformance/actions.test.ts`) runs each transition's shared - cases against its action, asserting **state and effects**: build the db with - fake services via `Database.create(MainService.plugin, { services })`, run the - action, then `matches(toState, after)` and check the recorded service calls +- **Conformance** (`conformance/actions.test.ts`) is a single + `Conformance.runActions({ makeDb, store, fromState, toState, registered, define })` + call: each `conforms(name, { cases, run })` runs the transition's shared cases + against its action, asserting **state and effects**. `makeDb(services)` builds + the db with the case's recording service overrides via + `Database.toSystemDatabase(Database.create(MainService.plugin, { services }))`; + the driver splits the case `args` into services and plain input, runs the action, + then `Match.assert`s `toState ≡ after` and checks the recorded service calls against the case's `effects` (see `conformance.md`). - An `index.ts` barrel feeds the `actions` plugin facet. diff --git a/packages/data-ai/.claude/rules/features/services/main-service/computed.md b/packages/data-ai/.claude/rules/features/services/main-service/computed.md index cf8d8353..466a67c9 100644 --- a/packages/data-ai/.claude/rules/features/services/main-service/computed.md +++ b/packages/data-ai/.claude/rules/features/services/main-service/computed.md @@ -25,7 +25,8 @@ full-state projection and re-runs on *any* field change (fine for a small featur wasteful on a large or hot one, where you hand-wire the minimal resource/index reads instead). A derivation takes **no services**, so its co-located `cases` are inert `{ input, value }` data (no test-doubles) that tree-shake out of the app -build like `matchers.ts` does — the one hazard is a `cases` literal touching the +build (they reference the `@adobe/data/testing` matchers only, and that module is +`sideEffects: false`) — the one hazard is a `cases` literal touching the `public.js` barrel at load, which `state.md` already forbids. **Performance is the first-class constraint**: reuse freely where it doesn't matter, hand-wire minimal reads where it does. @@ -47,9 +48,11 @@ registers it under the `computed` facet. **Conform a computed to its `data/state` derivation** whenever one exists. The derivation co-locates `{ input, value }` cases (`Derivation`), and -`conformance/computeds.test.ts` seeds the store from `input`, reads the computed's -value, and `matches(value)` (see `conformance.md`). A list-computed returning -entity ids needs no adapter — the runner hydrates through `toData`. +`conformance/computeds.test.ts` — one `Conformance.runComputeds(...)` call whose +`define` wires each computed — seeds the store from `input`, reads the computed's +value, and `Match.assert`s it against `value` (see `conformance.md`). A +list-computed returning entity ids needs no adapter — the runner hydrates through +`toData` by default (override `project` only for a scalar / single-entity output). **What needs conformance is proportional to wiring logic.** A computed that composes/branches over the aggregate *is* a `state/` derivation (composes ≥2 diff --git a/packages/data-ai/.claude/rules/features/services/main-service/conformance.md b/packages/data-ai/.claude/rules/features/services/main-service/conformance.md index cb07f568..662a0db9 100644 --- a/packages/data-ai/.claude/rules/features/services/main-service/conformance.md +++ b/packages/data-ai/.claude/rules/features/services/main-service/conformance.md @@ -6,97 +6,144 @@ paths: # services/main-service/conformance/ — keeping the ECS honest against the spec Test-only (imported only by `*.test.ts`, in no facet barrel). The `data/state` -cases are the shared truth; these runners replay them against the ECS. Reference: -`data-lit-todo`'s `conformance/` + its `spec.test.ts`. +cases are the shared truth; the shared **`@adobe/data/testing`** drivers replay +them against the ECS. This folder holds only the *feature-specific projection* +plus one thin runner test per surface. Reference: `data-lit-todo`'s +`conformance/` + its `data/state/spec.test.ts` (and `data-lit-space-rock-game` +for the entity-bag / real-time variants). -**These `conformance/` projection helpers — `fromState`, `toState`, and the -`toData(store, entity)` reader defined here — are strictly for conformance tests -and MUST NEVER run in production code, ever.** `fromState`/`toState` rewrite the -whole store out-of-band; runtime code reads through observables/indexes and writes -through transactions. (This conformance `toData(store, entity)` reader is unrelated -to the library's `db.toData()` store-serialization method, which *is* a normal -runtime API — the collision is only in the name.) +**The `conformance/` projection helpers — `fromState`, `toState`, and the +`toData(store, entity)` reader — are strictly for conformance tests and MUST +NEVER run in production code, ever.** `fromState`/`toState` rewrite the whole +store out-of-band; runtime code reads through observables/indexes and writes +through transactions. (This conformance `toData(store, entity)` reader is +unrelated to the library's `db.toData()` store-serialization method, which *is* +a normal runtime API — the collision is only in the name.) Likewise +`Match.matches` / `Match.assert` are comparison helpers for `*.test.ts` only — +never a runtime branch. -## Projection (store ⇄ State) +## What `@adobe/data/testing` gives you +Two namespaces, imported only from `*.test.ts` (the module is +`sideEffects: false`, and `vitest` is an optional peer dependency, satisfied +here): + +- **`Match`** — the tolerant, matcher-aware value comparison: `matches(actual, + expected, options?)` and its throwing wrapper `assert(...)`, plus the matchers + `Match.anyNumber` / `Match.anyString` / `Match.ref(label)`. Options are + `{ unordered?: ReadonlySet; tolerance?: number }` — arrays compare **in + order** (default) unless a key is named in `unordered`, and numbers snap to + `tolerance` (default `0.01`) to absorb F32↔f64 / trig noise. Framework-agnostic: + it honors any asymmetric matcher, so vitest's `expect.any(...)` interops. +- **`Conformance`** — the case types (`Case`, `Cases`, `DerivationCase`, + `DerivationCases`, `Effects`, `ServiceCall`), the id `resolver(map)`, and the + four runner drivers `runSpec` / `runTransactions` / `runActions` / + `runComputeds`. Effect recording and the coverage guard are built **into** the + drivers — no per-feature helper writes them. + +## Projection (store ⇄ State) — the one per-feature piece + +Only these files are feature-specific; each is small and mechanical: + +- `create-store.ts` — a fresh writable store carrying the whole schema, built + cast-free from the lowest schema layer (`Store.create(IndexDatabase.plugin)`). - `from-state.ts` — `fromState(store, state)` seeds a store to a `State` (clear - tail→head, insert entities, set resources). + tail→head, insert entities, set resources). It **returns the `id → entity` + map** (`ReadonlyMap`) it built while seeding — the ECS assigns ids + from its own id-space, so the drivers turn this map into a `resolve` via + `Conformance.resolver`; **no feature writes id resolution by hand**. A feature + whose transactions are index-addressed or singleton returns `void`, and any id + then resolves to `Entity.none`. - `to-data.ts` — **`toData(store, entity)`**: read one entity as its `data/` - value. The single place the ECS↔data mapping lives. + value. The single place the ECS↔data mapping lives; the computed runner reuses + it to hydrate id-list computed outputs. - `to-state.ts` — `toState(store)` reads the whole store back, built on `toData`. -## Comparison — `expect-state-matches.ts` - -One matcher-aware `matches(actual, expected)` (exported; also backs derivations): -honors vitest **asymmetric matchers** on the expected side (so `after`/`value` -use `anyNumber` — i.e. `expect.any(Number)`, see `state.md` — for ECS-assigned -ids), quantizes numbers to absorb F32↔f64 noise, -and compares arrays **in order** by default (`toState` reads a display-ordered -collection in order — this is what verifies a reorder; and ordered tuples like a -`Vec2` must stay in order). No separate id-ignoring variant. - -**Ordering is per-collection.** A collection the ECS materialises with **no -display order** (an entity *bag* — bullets, asteroids — whose row order is -nondeterministic) must compare as a **multiset**: expose a `matchesUnordered` and -apply it to just those fields in that feature's `expectStateMatches`. Ordered -default + multiset for orderless bags — never blanket-unordered (it would conflate -`[100,180]` with `[180,100]`). `expectStateMatches` / `expectMatches` wrap it. - -## The runners — one aggregator per surface, each with a coverage guard - -The same cases drive every surface. Each aggregator's **coverage guard** asserts -every item is wired, so none are missed — keyed off the **registered set** (the -`transactions/index.ts` barrel, the derivation files), not a raw file glob, so a -shared read helper parked flat in `transactions/` (kept out of the barrel) or a -non-participating file never trips it. Pair by name. Runners must tolerate a -**no-arg transition** (`args: undefined`) — split/record on args only when it is -an object. - -- **`spec.test.ts` (in `data/state/`)** — the pure suite. Discovers every file - exporting `cases`; dispatches on shape: `"after"` → transition - (`matches(fn(before,args), after)` + effects), `"value"` → derivation - (`matches(fn(input), value)`). Enforces the two-exports rule. -- **`transactions.test.ts`** — each transition's transaction, state only. Store - built cast-free via `Store.create(IndexDatabase.plugin)` (lowest schema layer). - Per transaction: `fromState(before)` → `apply(store, args, resolve)` → - `matches(toState, after)`. An id-addressed transaction resolves entities via - `resolve` (spec id → seeded entity); a differently-named transaction (`dragTodo` - ⇄ `reorderTodo`) just wires its cases explicitly. -- **`actions.test.ts`** — each transition's action, state **and** effects. Build - the db with fake services via the `Database.create` override: - `Database.toSystemDatabase(Database.create(MainService.plugin, { services }))`. - Split the case `args` by key: service-typed keys become the (recording) service - overrides, the rest is the action input. Run the (async) action, then - `matches(toState, after)` + assert the recorded calls against `effects`. -- **`computeds.test.ts`** — each derivation's ECS computed. `fromState(input)` → - read the computed's synchronous emission (`readComputed`: subscribe once) → - `matches(value)`. ECS list-computeds are entity-id based, so the runner - **hydrates** the output through `toData` by default — an id-based computed needs - no adapter; override only for a non-entity output (a scalar). - **Build the db from the `ComputedDatabase` layer (the lowest layer exposing the - computeds), not the assembled `MainService`.** A behaviour layer above it (a - service/action that subscribes to a computed at construction) would `withCache` - the pre-seed value, and a direct `fromState` seed emits no transaction to - invalidate it — reading on the computed layer keeps the seed authoritative. - Only computeds backing a `state/` derivation are conformed here; the coverage - guard checks every `state/` derivation, not every computed. A computed that - projects a single `data/`'s math (`winner`/`status` from the board) is - conformed by **that type's** helper tests, not here — and a feature with no - `state/` derivation **omits `computeds.test.ts` entirely** (an aggregator whose - guard registers zero tests fails vitest; don't ship an empty one). - -## Recording side effects — no Proxy - -A one-liner wraps a plain-object service so each method call is recorded, then -delegates (enumerate its own methods and closure-wrap — no `Proxy`, per the repo -rule). The spec test wraps the case's injected services; the action runner wraps -the `db.services` overrides. `effects` asserts each **declared** service's calls -exactly (Array = ordered, Set = any order); undeclared services (value-returning -reads) are ignored. +## The four runner test files — one `Conformance.run*` call each + +Each surface is a single driver call whose `define` callback wires each item's +bespoke adapter. The driver owns the fresh store, the `fromState` seed, `resolve` +(built from the returned map), the `toState` compare, effect recording, and a +**coverage guard keyed off the registered barrel** (every registered item must be +wired, or the guard fails). Pair by name; the adapters are the only per-feature +logic. + +- **`data/state/spec.test.ts`** (in `data/state/`, not here) — the pure suite: + `Conformance.runSpec(import.meta.glob([...], { eager: true }), { match? })`. + Discovers every file exporting `cases`, enforces the two-exports rule, and + dispatches on case shape (transition → state + effects, derivation → + `fn(input) ≡ value`). +- **`transactions.test.ts`** — `Conformance.runTransactions({ createStore, + fromState, toState, registered, covers?, match?, define })`. Each + `conforms(name, { cases, apply })` wires a transaction's `apply(store, args, + resolve)` — an id-addressed transaction calls `resolve(args.id)`; a + differently-named transaction (`dragTodo` ⇄ `reorderTodo`) just names the cases + it reuses. A transaction with **no `data/` transform** (e.g. `setInput` / + `setBounds`, which only record a resource) is asserted directly with its own + `describe` + `Match.assert` and named in **`covers`** so the guard still counts + it. +- **`actions.test.ts`** — `Conformance.runActions({ makeDb, store, fromState, + toState, registered, match?, define })`. `makeDb(services)` builds the db with + the case's recording service overrides — + `Database.toSystemDatabase(Database.create(MainService.plugin, { services }))` + — and each `conforms(name, { cases, run })` runs the (async) action; the driver + splits the case `args` into services (wrapped for recording) and plain input, + then asserts **state and the declared `effects`**. A transition realized only by + a transaction (not an action) is covered by `runTransactions`, not here. +- **`computeds.test.ts`** — `Conformance.runComputeds({ makeDb, store, fromState, + toData?, derivationModules, match?, define })`. Each `conforms(name, { cases, + computed, project? })` seeds from the case `input`, reads the computed's + synchronous emission, hydrates it, and matches the derivation's `value`. An + id-list computed (`visibleTodos`) needs **no adapter** — the default `project` + hydrates through `toData`; override `project` only for a scalar / single-entity + output. **Build `makeDb` from the `ComputedDatabase` layer** + (`Database.toSystemDatabase(Database.create(ComputedDatabase.plugin))`), not the + assembled `MainService`: a behaviour layer above may `withCache` a pre-seed + value that a direct `fromState` seed emits no transaction to invalidate — the + computed layer keeps the seed authoritative. Coverage is keyed off the + `derivationModules` glob (every `data/state/` derivation must be wired). A + feature with **no `state/` derivation omits `computeds.test.ts` entirely** (a + guard registering zero tests fails vitest; don't ship an empty one). A computed + that trivially projects one `data/`'s math (`winner`/`status`) is + conformed by that type's helper tests, not here. + +## Ordering, tolerance, `ref` — all via `Match` options + +The drivers thread a `match?: MatchOptions` through to every comparison, so +per-feature tuning is data, not code: + +- **Ordered by default.** `toState` reads a display-ordered collection in order — + that is what verifies a reorder — and positional tuples (a `Vec2`) must stay in + order. +- **Entity bags compare as multisets.** A collection the ECS materialises with no + display order (bullets, asteroids) is named in + `match: { unordered: new Set(["bullets", "asteroids"]) }` so just those fields + compare order-independently — never blanket-unordered (it would conflate + `[100,180]` with `[180,100]`). +- **Float noise** is absorbed by the default `tolerance` (`0.01`); raise it only + when a case genuinely needs a looser grid. +- **`Match.ref(label)`** on the expected side asserts id correspondence for a + referential feature (an id that must line up in two places); `Match.anyNumber` / + `anyString` leave an id a case doesn't pin fully open. + +## Recording side effects — built in, no Proxy + +Effect recording lives in the drivers: they enumerate a plain-object service's own +methods and closure-wrap each to record `[method, ...args]` calls, then delegate +(no `Proxy`, per the repo rule). `runSpec` records the case's injected service +`args`; `runActions` records the `db.services` overrides. A case's `effects` +asserts each **declared** service's calls exactly (`Array` = ordered, `Set` = any +order); undeclared services (value-returning reads like `generateName`) are +ignored. ## Structural guard Guard the projection with one `fromState → toState` identity test on -representative states (`projection.test.ts`), comparing with `anyNumber` ids. -Systems reach the store through the db — drive one frame on -`Database.toSystemDatabase(Database.create(plugin))`, then `toState(db.store)`. +representative states (`projection.test.ts`), comparing with `Match.assert` and +`Match.anyNumber` ids (and the same `unordered` option when the feature has entity +bags). This proves the pair round-trips faithfully so a symmetric bug in +`fromState`/`toState` can't cancel out and mask a real ECS defect. For a +real-time feature the whole-tick equivalent lives beside the system loop +(`system-database/tick-loop.test.ts`): drive one headless frame and +`Match.assert(toState(db.store), after, { unordered })` against the shared +`step` cases (see `systems.md`). diff --git a/packages/data-ai/.claude/rules/features/services/main-service/transactions.md b/packages/data-ai/.claude/rules/features/services/main-service/transactions.md index e7ff998d..76a6e066 100644 --- a/packages/data-ai/.claude/rules/features/services/main-service/transactions.md +++ b/packages/data-ai/.claude/rules/features/services/main-service/transactions.md @@ -32,14 +32,19 @@ export const playMove = (t: CoreDatabase.Store, { index }: PlayMoveArgs) => { result — read the touched slice, call the `data/` transform, write the diff. - Keep transaction files **single-export** (the `transactions/` barrel is `export *`-ed into the plugin facet, so a second export would pollute it). -- **Conformance is wired once, centrally** — not per-file. `conformance/transactions.test.ts` - runs each transition's shared `data/state` cases against its transaction - (`fromState(before)` → apply → `matches(toState, after)`), with a coverage - guard so none are missed (see `conformance.md`). A transaction taking **entity - ids** resolves them from the seeded store; a differently-named or reused - transaction (`dragTodo` ⇄ `reorderTodo`) wires its cases explicitly; an extra - transaction with no `data/` analogue (`setBounds`, `setInput`) gets a direct - resource assertion. +- **Conformance is wired once, centrally** — not per-file. + `conformance/transactions.test.ts` is a single `Conformance.runTransactions({ + createStore, fromState, toState, registered, covers?, define })` call: each + `conforms(name, { cases, apply })` runs the transition's shared `data/state` + cases against its transaction — the shared driver seeds `fromState(before)`, + calls `apply(store, args, resolve)`, then `Match.assert`s `toState ≡ after` — + with a coverage guard keyed off the registered barrel so none are missed (see + `conformance.md`). A transaction taking **entity ids** resolves them with the + driver's `resolve` (`resolve(args.id)`, from the seeded `id → entity` map); a + differently-named or reused transaction (`dragTodo` ⇄ `reorderTodo`) just names + the cases it reuses; an extra transaction with no `data/` analogue (`setBounds`, + `setInput`) gets a direct `Match.assert` / resource check and is named in + `covers` so the guard still counts it. - An `index.ts` barrel feeds the `transactions` plugin facet — so it must re-export **only** the mutations. A read/query helper shared by several transactions (`readShip`, `readBoard` — a `(t) => value` function) may live diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/conformance-case.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/conformance-case.ts index 095f3cb2..4426eca4 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/conformance-case.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/conformance-case.ts @@ -1,70 +1,13 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance as ConformanceApi } from "@adobe/data/testing"; import type { State } from "./state.js"; -// A service arg is an object with method members (not an array, not a function); -// this is what distinguishes an injected service from plain data args, since the -// `Service` marker itself is all-optional and would over-match. -type MethodKeys = { [K in keyof T]-?: T[K] extends (...a: never[]) => unknown ? K : never }[keyof T]; -type IsService = T extends readonly unknown[] - ? false - : T extends (...a: never[]) => unknown - ? false - : T extends object - ? [MethodKeys] extends [never] - ? false - : true - : false; - -// A strongly-typed call to one method of service `S`: `[methodName, ...its args]`. -// A no-arg method is just `[methodName]`. -export type Call = { - [M in keyof S]-?: S[M] extends (...a: infer A) => unknown ? readonly [M, ...A] : never; -}[keyof S]; - -// Expected side effects for a case, keyed by the service-typed args only. An -// `Array` value asserts these calls in this order; a `Set` value asserts the same -// calls in any order. Method names and their args are checked against the service. -export type Effects = { - readonly [K in keyof Args as IsService extends true ? K : never]?: - | readonly Call[] - | ReadonlySet>; -}; - -// One spec-owned conformance case: a `data/` transform's `{ before, args, after }` -// authored as full `State`, optionally with the side effects it makes on its -// injected service args. Shared unchanged by the spec aggregator (`spec.test.ts`) -// and the ecs conformance runners (`services/main-service/conformance/`). -export type ConformanceCase = { - readonly name: string; - readonly before: State; - readonly args: Args; - readonly after: State; - readonly effects?: Effects; -}; - -// A transform's cases, with the case `args` type derived from the transform's own -// signature — its second parameter, or `void` when it takes none. A transform -// co-locates `export const cases: Conformance = [...]`, so the -// cases cannot drift from what the function accepts, and the spec aggregator can -// discover the function without it being named twice. -export type Conformance unknown> = readonly ConformanceCase< - Parameters extends [unknown, infer Args, ...unknown[]] ? Args : void ->[]; - -// One case for a derivation (`(state) => value`): a state `input` and the `value` -// it yields. `value` may use asymmetric matchers (`anyNumber` for ids). Both the -// pure derivation and its ecs computed are checked against these. -export type DerivationCase = { - readonly name: string; - readonly input: Input; - readonly value: Value; -}; - -// A derivation's cases, with `input` and `value` types read from the derivation's -// own signature (its parameter and return) — the `Conformance` analog for -// value-producing derivations. A derivation co-locates -// `export const cases: Derivation = [...]`. -export type Derivation unknown> = readonly DerivationCase< - Parameters[0], - ReturnType ->[]; +// The conformance case types for this feature — the shared `@adobe/data/testing` +// machinery with `State` bound once, so a transform authors `Conformance` +// and a derivation `Derivation` (args/input/value read from the +// function's own signature). This tiny file is the only per-feature conformance +// type declaration; everything else lives in `@adobe/data/testing`. +export type Conformance unknown> = + ConformanceApi.Cases; +export type Derivation unknown> = + ConformanceApi.DerivationCases; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/expect-state-matches.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/expect-state-matches.ts deleted file mode 100644 index cda18980..00000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/expect-state-matches.ts +++ /dev/null @@ -1,57 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { expect } from "vitest"; -import type { State } from "./state.js"; - -// A vitest asymmetric matcher (`expect.any(...)`): honored on the EXPECTED side -// so a case can assert "any number" for a value it does not pin. Negotiation's -// `State` is all scalars/strings and exposes no ecs-minted ids, so no case needs -// one today — but the comparison stays matcher-aware to match the shared pattern -// and stay robust if one is ever added. -const isMatcher = (value: unknown): value is { asymmetricMatch(actual: unknown): boolean } => - typeof value === "object" && - value !== null && - typeof (value as { asymmetricMatch?: unknown }).asymmetricMatch === "function"; - -// Collapse F32↔f64 storage rounding onto a small grid so float noise compares -// equal. `+ 0` normalises `-0` to `0`. (Negotiation stores only strings, booleans -// and enums, so this is a no-op here — kept to mirror the shared pattern and stay -// robust if a numeric field is added.) -const quantize = (n: number): number => Math.round(Math.fround(n) * 1e6) / 1e6 + 0; - -// Tolerant structural match honoring asymmetric matchers, float precision, and -// order-sensitive arrays. Exported so it can back other conformance comparisons. -export const matches = (actual: unknown, expected: unknown): boolean => { - if (isMatcher(expected)) return expected.asymmetricMatch(actual); - if (typeof expected === "number" && typeof actual === "number") { - return quantize(actual) === quantize(expected); - } - if (Array.isArray(expected)) { - if (!Array.isArray(actual) || actual.length !== expected.length) return false; - return expected.every((exp, index) => matches(actual[index], exp)); - } - if (expected !== null && typeof expected === "object") { - if (actual === null || typeof actual !== "object" || Array.isArray(actual)) return false; - const expectedKeys = Object.keys(expected); - const actualKeys = Object.keys(actual as object); - if (expectedKeys.length !== actualKeys.length) return false; - return expectedKeys.every((key) => - matches((actual as Record)[key], (expected as Record)[key]), - ); - } - return Object.is(actual, expected); -}; - -// Spec-owned tolerant `State` equality, shared by the data/ transform spec and the -// ecs conformance runners. `after` may use asymmetric matchers, so this one -// comparison serves both the pure spec and the ecs projection — no separate -// id-ignoring variant is needed. -export const expectStateMatches = (actual: State, expected: State): void => { - expectMatches(actual, expected); -}; - -// The same tolerant, matcher-aware comparison for any value — used by derivation -// spec tests and computed conformance, where the compared value may be a scalar -// rather than a whole `State`. -export const expectMatches = (actual: unknown, expected: unknown): void => { - expect(matches(actual, expected), `mismatch:\n actual ${JSON.stringify(actual)}`).toBe(true); -}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/record-effects.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/record-effects.ts deleted file mode 100644 index 6950484a..00000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/record-effects.ts +++ /dev/null @@ -1,107 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { expect } from "vitest"; -import { equalsUnordered } from "@adobe/data"; -import type { Effects } from "./conformance-case.js"; - -export type RecordedCall = readonly [string, ...unknown[]]; - -// Wrap a plain-object service so each method call is recorded, then delegates. -// No Proxy (services are plain objects with own enumerable methods — see -// `service.md`), so we enumerate and closure-wrap each function. -export const recordCalls = (service: S): { service: S; calls: RecordedCall[] } => { - const calls: RecordedCall[] = []; - const wrapped = Object.fromEntries( - Object.entries(service).map(([key, value]) => [ - key, - typeof value === "function" - ? (...args: unknown[]): unknown => { - calls.push([key, ...args]); - return (value as (...a: unknown[]) => unknown)(...args); - } - : value, - ]), - ) as S; - return { service: wrapped, calls }; -}; - -// Assert the calls recorded against one service match the case's expectation for -// it: an Array expects exactly these calls in order; a Set expects the same calls -// in any order (multiset). Absent expectation ⇒ no calls expected. -export const expectServiceCalls = ( - recorded: readonly RecordedCall[], - expected: readonly RecordedCall[] | ReadonlySet | undefined, -): void => { - if (expected instanceof Set) { - expect(equalsUnordered(recorded, [...expected])).toBe(true); - } else { - expect(recorded).toEqual(expected ?? []); - } -}; - -// Split a case's `args` into the injected services (objects with methods) and the -// remaining plain data. Services are wrapped for recording; the returned `calls` -// map is keyed by the same arg key so it can be matched against `effects`. -export const recordArgServices = ( - args: Args, -): { args: Args; calls: Record } => { - const calls: Record = {}; - const next = { ...args } as Record; - for (const [key, value] of Object.entries(args)) { - if (isServiceValue(value)) { - const rec = recordCalls(value); - next[key] = rec.service; - calls[key] = rec.calls; - } - } - return { args: next as Args, calls }; -}; - -// Assert each service **declared** in `effects` saw exactly its expected calls -// (an extra or missing call on a declared service fails). Services not listed — -// e.g. a value-returning dependency read — are ignored, so `effects` captures the -// fire-and-forget side effects you choose to assert. -export const expectEffects = ( - calls: Record, - effects: Effects> | undefined, -): void => { - const expected = (effects ?? {}) as Record>; - for (const key of Object.keys(expected)) { - expectServiceCalls(calls[key] ?? [], expected[key]); - } -}; - -// Split a case's `args` into the injected services (wrapped for recording, to be -// used as `Database.create` service overrides) and the remaining plain data (the -// action input). Keyed by the same arg name so `calls` matches against `effects`. -export const splitAndRecordServices = ( - args: Args, -): { - services: Record; - input: Record; - calls: Record; -} => { - const services: Record = {}; - const input: Record = {}; - const calls: Record = {}; - // A no-arg transition has `undefined` args — nothing to split. - if (args !== null && typeof args === "object") { - for (const [key, value] of Object.entries(args)) { - if (isServiceValue(value)) { - const recorded = recordCalls(value); - services[key] = recorded.service; - calls[key] = recorded.calls; - } else { - input[key] = value; - } - } - } - return { services, input, calls }; -}; - -// A runtime service value: a non-array object with at least one method (mirrors -// the compile-time `IsService`). -const isServiceValue = (value: unknown): value is object => - typeof value === "object" && - value !== null && - !Array.isArray(value) && - Object.values(value).some((member) => typeof member === "function"); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/spec.test.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/spec.test.ts index d398242c..7a202fc1 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/spec.test.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/spec.test.ts @@ -1,59 +1,18 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. /// -import { describe, it } from "vitest"; -import type { State } from "./state.js"; -import type { ConformanceCase, DerivationCase } from "./conformance-case.js"; -import { expectStateMatches, expectMatches } from "./expect-state-matches.js"; -import { recordArgServices, expectEffects } from "./record-effects.js"; +import { Conformance } from "@adobe/data/testing"; -// The single spec test for every transform AND derivation in this folder. It -// auto-discovers each file (any sibling `.ts` that exports `cases`) via -// `import.meta.glob`, so a new one is covered the moment it ships — none can be -// forgotten. Each participating file must export exactly its function plus `cases` -// (enforced below), which lets us find the function without it being named twice. -// A case's shape selects the check: `after` → a transition `(state, args) => state`; -// `value` → a derivation `(state) => value`. -const modules = import.meta.glob>( - ["./*.ts", "!./*.test.ts", "!./*.type-test.ts"], - { eager: true }, +// The single pure-spec test for every transform AND derivation in this folder. +// `runSpec` auto-discovers each sibling that exports `cases`, requires it to +// export exactly its function plus `cases`, and dispatches on case shape (a +// `value` case is a derivation, otherwise a transition whose declared `effects` +// are also asserted). Negotiation's `State` is scalars/strings/enums, so the +// default (ordered, matcher-aware) comparison is correct — no options needed. +Conformance.runSpec( + import.meta.glob>( + ["./*.ts", "!./*.test.ts", "!./*.type-test.ts"], + { + eager: true, + }, + ), ); - -const isDerivationCase = (c: unknown): c is DerivationCase => - typeof c === "object" && c !== null && "value" in c; - -for (const [path, module] of Object.entries(modules)) { - const exportNames = Object.keys(module); - if (!exportNames.includes("cases")) continue; - const functionNames = exportNames.filter((key) => typeof module[key] === "function"); - const label = functionNames.length === 1 ? functionNames[0] : path; - - describe(`State.${label}`, () => { - if (exportNames.length !== 2 || functionNames.length !== 1) { - it("exports exactly its function and `cases`", () => { - throw new Error(`${path} exports [${exportNames.join(", ")}] — expected one function + cases`); - }); - return; - } - // Runtime invariant: a participating file exports a function and its cases. - const fn = module[functionNames[0]] as (...args: unknown[]) => unknown; - const cases = module["cases"] as readonly unknown[]; - for (const testCase of cases) { - if (isDerivationCase(testCase)) { - // Derivation: the value it yields matches, honoring `anyNumber`. - it(testCase.name, () => expectMatches(fn(testCase.input), testCase.value)); - continue; - } - // Transition: assert the resulting state and the declared side effects. - // A service-injected transition is async, so await uniformly. - const transitionCase = testCase as ConformanceCase>; - it(transitionCase.name, async () => { - const raw = transitionCase.args; - const { args, calls } = - raw && typeof raw === "object" ? recordArgServices(raw) : { args: raw, calls: {} }; - const result = (await fn(transitionCase.before, args)) as State; - expectStateMatches(result, transitionCase.after); - expectEffects(calls, transitionCase.effects); - }); - } - }); -} diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/actions.test.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/actions.test.ts index 69c4ed1b..50ee804e 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/actions.test.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/actions.test.ts @@ -1,14 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -/// -import { describe, it, expect } from "vitest"; import { Database } from "@adobe/data/ecs"; -import type { ConformanceCase } from "../../../data/state/conformance-case.js"; -import { splitAndRecordServices, expectEffects } from "../../../data/state/record-effects.js"; +import { Conformance } from "@adobe/data/testing"; import type { ConnectionService } from "../service-database/services/create-connection-service.js"; -import { expectStateMatches } from "../../../data/state/expect-state-matches.js"; import { MainService } from "../main-service.js"; -import { fromState } from "./from-state.js"; -import { toState } from "./to-state.js"; import { startHostSignaling } from "../action-database/actions/start-host-signaling.js"; import { startJoinSignaling } from "../action-database/actions/start-join-signaling.js"; import { setOfferCode } from "../action-database/actions/set-offer-code.js"; @@ -27,76 +21,81 @@ import { cases as setConnectionCases } from "../../../data/state/set-connection. import { cases as setHostAnswerInputCases } from "../../../data/state/set-host-answer-input.js"; import { cases as setJoinerOfferInputCases } from "../../../data/state/set-joiner-offer-input.js"; import { cases as enterGameCases } from "../../../data/state/enter-game.js"; +import { fromState } from "./from-state.js"; +import { toState } from "./to-state.js"; -// Each transition's cases run against its same-named ecs **action**, asserting both -// the resulting state and the declared side effects. The case's service args become -// the db's service overrides (wrapped so their calls are recorded); the plain args -// drive the action. `toSystemDatabase` exposes the writable `.store` the projection -// needs while keeping services/transactions/actions. Runtime invariant: the empty -// override object is a valid partial `services` factory map. -const makeDb = (services: { connection?: ConnectionService }) => - Database.toSystemDatabase(Database.create(MainService.plugin, { services })); -type Db = ReturnType; -type Run = (db: Db, input: Partial) => Promise | void; - -const covered = new Set(); -const conformsAction = ( - action: string, - config: { readonly cases: readonly ConformanceCase[]; readonly run: Run }, -): void => { - covered.add(action); - describe(`${action} action conforms`, () => { - for (const testCase of config.cases) { - it(testCase.name, async () => { - const { services, input, calls } = splitAndRecordServices(testCase.args); - // Runtime invariant: negotiation transitions inject no services, so the - // recorded overrides are the empty connection-service partial. - const db = makeDb(services as { connection?: ConnectionService }); - fromState(db.store, testCase.before); - await config.run(db, input as Partial); - expectStateMatches(toState(db.store), testCase.after); - expectEffects(calls, testCase.effects); - }); - } - }); -}; - -conformsAction("startHostSignaling", { cases: startHostSignalingCases, run: (db) => startHostSignaling(db) }); -conformsAction("startJoinSignaling", { cases: startJoinSignalingCases, run: (db) => startJoinSignaling(db) }); -conformsAction("setOfferCode", { cases: setOfferCodeCases, run: (db, input) => setOfferCode(db, { code: input.code ?? "" }) }); -conformsAction("setAnswerCode", { cases: setAnswerCodeCases, run: (db, input) => setAnswerCode(db, { code: input.code ?? "" }) }); -conformsAction("setBanner", { cases: setBannerCases, run: (db, input) => setBanner(db, { text: input.text ?? "", error: input.error }) }); -conformsAction("setConnection", { - cases: setConnectionCases, - run: (db, input) => setConnection(db, { connection: input.connection ?? "idle", sessionId: input.sessionId }), -}); -conformsAction("setHostAnswerInput", { cases: setHostAnswerInputCases, run: (db, input) => setHostAnswerInput(db, { value: input.value ?? "" }) }); -conformsAction("setJoinerOfferInput", { cases: setJoinerOfferInputCases, run: (db, input) => setJoinerOfferInput(db, { value: input.value ?? "" }) }); -conformsAction("enterGame", { cases: enterGameCases, run: (db) => enterGame(db) }); - -// None-missed guard: every data/state **transition** (a file whose `cases` are -// `{ before, args, after }`) must have a same-named action wired above. Iterating -// the transitions — not the action files — is deliberate: the capability -// orchestration actions (`startHost`, `submitAnswer`, …) drive the imperative -// `connection` service and have no pure-transition analogue, so they are not -// conformance-tested here. -const kebabToCamel = (name: string): string => - name.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase()); -const stateModules = import.meta.glob>( - ["../../../data/state/*.ts", "!../../../data/state/*.test.ts"], - { eager: true }, -); -describe("action conformance coverage", () => { - for (const [path, module] of Object.entries(stateModules)) { - const cases = module["cases"]; - const isTransition = - Array.isArray(cases) && - cases.length > 0 && - typeof cases[0] === "object" && - cases[0] !== null && - "after" in cases[0]; - if (!isTransition) continue; - const name = kebabToCamel(path.replace(/.*\//, "").replace(/\.ts$/, "")); - it(`${name} has an action conformance case`, () => expect(covered.has(name)).toBe(true)); - } +// Each transition's cases run against its same-named ecs action, asserting state +// and effects. `runActions` splits the case's injected services into recording +// overrides via `makeDb`; negotiation transitions inject none, so the override is +// empty. The registered set the coverage guard checks is the transition-backed +// actions below — NOT the `actions` barrel, whose members are capability +// orchestration verbs (`startHost`, `submitAnswer`, …) with no pure-transition +// analogue that are not conformed here. +Conformance.runActions({ + // `toSystemDatabase` exposes the writable `.store` the projection needs. Runtime + // invariant: negotiation transitions inject no services, so the empty override + // object is a valid partial `services` factory map. + makeDb: (services) => + Database.toSystemDatabase( + Database.create(MainService.plugin, { + services: services as { connection?: ConnectionService }, + }), + ), + store: (db) => db.store, + fromState, + toState, + registered: { + startHostSignaling, + startJoinSignaling, + setOfferCode, + setAnswerCode, + setBanner, + setConnection, + setHostAnswerInput, + setJoinerOfferInput, + enterGame, + }, + define: (conforms) => { + conforms("startHostSignaling", { + cases: startHostSignalingCases, + run: (db) => startHostSignaling(db), + }); + conforms("startJoinSignaling", { + cases: startJoinSignalingCases, + run: (db) => startJoinSignaling(db), + }); + conforms("setOfferCode", { + cases: setOfferCodeCases, + run: (db, input) => setOfferCode(db, { code: input.code ?? "" }), + }); + conforms("setAnswerCode", { + cases: setAnswerCodeCases, + run: (db, input) => setAnswerCode(db, { code: input.code ?? "" }), + }); + conforms("setBanner", { + cases: setBannerCases, + run: (db, input) => + setBanner(db, { text: input.text ?? "", error: input.error }), + }); + conforms("setConnection", { + cases: setConnectionCases, + run: (db, input) => + setConnection(db, { + connection: input.connection ?? "idle", + sessionId: input.sessionId, + }), + }); + conforms("setHostAnswerInput", { + cases: setHostAnswerInputCases, + run: (db, input) => setHostAnswerInput(db, { value: input.value ?? "" }), + }); + conforms("setJoinerOfferInput", { + cases: setJoinerOfferInputCases, + run: (db, input) => setJoinerOfferInput(db, { value: input.value ?? "" }), + }); + conforms("enterGame", { + cases: enterGameCases, + run: (db) => enterGame(db), + }); + }, }); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/expect-conforms.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/expect-conforms.ts deleted file mode 100644 index 9bb73652..00000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/expect-conforms.ts +++ /dev/null @@ -1,36 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { it } from "vitest"; -import type { State } from "../../../data/state/state.js"; -import type { ConformanceCase } from "../../../data/state/conformance-case.js"; -import { expectStateMatches } from "../../../data/state/expect-state-matches.js"; -import type { CoreDatabase } from "../core-database/core-database.js"; -import { createStore } from "./create-store.js"; -import { fromState } from "./from-state.js"; -import { toState } from "./to-state.js"; - -// The conformance runner, bound to the negotiation projection (`fromState` / -// `toState`). For each case it proves the one conformance property -// -// toState(apply(fromState(before), args)) ≡ spec(before, args) -// -// The pure half (`spec(before, args) ≡ after`) is asserted once, centrally, by -// `data/state/spec.test.ts`, so this runner asserts only the ecs half; pass `spec` -// to re-check it in place for a differently-named transaction. -export const expectConforms = (config: { - readonly cases: readonly ConformanceCase[]; - readonly spec?: (before: State, args: Args) => State; - readonly apply: (store: CoreDatabase.Store, args: Args) => void; -}): void => { - for (const testCase of config.cases) { - it(testCase.name, () => { - if (config.spec) { - expectStateMatches(config.spec(testCase.before, testCase.args), testCase.after); - } - - const store = createStore(); - fromState(store, testCase.before); - config.apply(store, testCase.args); - expectStateMatches(toState(store), testCase.after); - }); - } -}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/from-state.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/from-state.ts index 3c286256..62f636c9 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/from-state.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/from-state.ts @@ -1,11 +1,17 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Entity } from "@adobe/data/ecs"; import type { State } from "../../../data/state/state.js"; import type { CoreDatabase } from "../core-database/core-database.js"; // Seed a store to exactly match a `data/` negotiation `State` — the scalar -// resources. The non-serializable `gameDb` resource is left at its default; it -// is invisible to the spec. The inverse of `toState`. Test-only. -export const fromState = (store: CoreDatabase.Store, state: State): void => { +// resources. The non-serializable `gameDb` resource is left at its default; it is +// invisible to the spec. Negotiation has no entity collections, so the returned +// `spec id → seeded entity` map is empty and the conformance runners' `resolve` is +// never used. The inverse of `toState`. Test-only. +export const fromState = ( + store: CoreDatabase.Store, + state: State, +): ReadonlyMap => { store.resources.phase = state.phase; store.resources.connection = state.connection; store.resources.role = state.role; @@ -16,4 +22,5 @@ export const fromState = (store: CoreDatabase.Store, state: State): void => { store.resources.bannerError = state.bannerError; store.resources.hostAnswerInput = state.hostAnswerInput; store.resources.joinerOfferInput = state.joinerOfferInput; + return new Map(); }; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/projection.test.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/projection.test.ts index f8401b92..bdf8c06d 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/projection.test.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/projection.test.ts @@ -3,8 +3,8 @@ // Guards the projection itself: `toState(fromState(s)) ≡ s` over representative // states, so a symmetric bug in the pair can't mask a real ecs defect. import { describe, it } from "vitest"; +import { Match } from "@adobe/data/testing"; import { State } from "../../../data/state/state.js"; -import { expectStateMatches } from "../../../data/state/expect-state-matches.js"; import { createStore } from "./create-store.js"; import { fromState } from "./from-state.js"; import { toState } from "./to-state.js"; @@ -40,7 +40,7 @@ describe("negotiation conformance projection round-trips (toState ∘ fromState it(name, () => { const store = createStore(); fromState(store, state); - expectStateMatches(toState(store), state); + Match.assert(toState(store), state); }); } }); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/transactions.test.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/transactions.test.ts index 5699caee..7b40b823 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/transactions.test.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/transactions.test.ts @@ -1,9 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it, expect } from "vitest"; -import { State } from "../../../data/state/state.js"; -import type { CoreDatabase } from "../core-database/core-database.js"; -import type { ConformanceCase } from "../../../data/state/conformance-case.js"; -import { expectConforms } from "./expect-conforms.js"; +import { Conformance } from "@adobe/data/testing"; import * as registeredTransactions from "../transaction-database/transactions/index.js"; import { startHostSignaling } from "../transaction-database/transactions/start-host-signaling.js"; import { startJoinSignaling } from "../transaction-database/transactions/start-join-signaling.js"; @@ -23,47 +19,53 @@ import { cases as setConnectionCases } from "../../../data/state/set-connection. import { cases as setHostAnswerInputCases } from "../../../data/state/set-host-answer-input.js"; import { cases as setJoinerOfferInputCases } from "../../../data/state/set-joiner-offer-input.js"; import { cases as enterGameCases } from "../../../data/state/enter-game.js"; +import { createStore } from "./create-store.js"; +import { fromState } from "./from-state.js"; +import { toState } from "./to-state.js"; -// The single conformance test for every ecs transaction. Each transaction's -// shared `data/state` cases run through its raw `apply` (`fromState(before)` → -// apply → `matches(toState, after)`); the pure half is asserted once, centrally, -// by `data/state/spec.test.ts`. The guard at the bottom asserts every REGISTERED -// transaction (the `transactions/index.ts` barrel, not a file glob) is wired -// below, so none can be missed. -const covered = new Set(); -const conforms = ( - transaction: string, - config: { - readonly cases: readonly ConformanceCase[]; - readonly spec?: (before: State, args: Args) => State; - readonly apply: (t: CoreDatabase.Store, args: Args) => void; +// The single conformance test for every ecs transaction. `runTransactions` owns +// the harness (fresh store, `fromState` seed, `toState` compare, coverage guard +// keyed off the registered barrel); the pure half is asserted centrally by +// `data/state/spec.test.ts`. Negotiation resources are addressed by name, not +// entity id, so the `apply` adapters need no `resolve`. `setGameDb` is a +// differently-named transaction whose visible effect equals `State.enterGame`, so +// it wires the `enterGame` cases explicitly (passing `gameDb: null` isolates the +// serializable effect the spec observes). +Conformance.runTransactions({ + createStore, + fromState, + toState, + registered: registeredTransactions, + define: (conforms) => { + conforms("startHostSignaling", { + cases: startHostSignalingCases, + apply: (t) => startHostSignaling(t), + }); + conforms("startJoinSignaling", { + cases: startJoinSignalingCases, + apply: (t) => startJoinSignaling(t), + }); + conforms("setOfferCode", { cases: setOfferCodeCases, apply: setOfferCode }); + conforms("setAnswerCode", { + cases: setAnswerCodeCases, + apply: setAnswerCode, + }); + conforms("setBanner", { cases: setBannerCases, apply: setBanner }); + conforms("setConnection", { + cases: setConnectionCases, + apply: setConnection, + }); + conforms("setHostAnswerInput", { + cases: setHostAnswerInputCases, + apply: setHostAnswerInput, + }); + conforms("setJoinerOfferInput", { + cases: setJoinerOfferInputCases, + apply: setJoinerOfferInput, + }); + conforms("setGameDb", { + cases: enterGameCases, + apply: (t) => setGameDb(t, { gameDb: null }), + }); }, -): void => { - covered.add(transaction); - describe(`${transaction} transaction conforms`, () => expectConforms(config)); -}; - -conforms("startHostSignaling", { cases: startHostSignalingCases, apply: (t) => startHostSignaling(t) }); -conforms("startJoinSignaling", { cases: startJoinSignalingCases, apply: (t) => startJoinSignaling(t) }); -conforms("setOfferCode", { cases: setOfferCodeCases, apply: setOfferCode }); -conforms("setAnswerCode", { cases: setAnswerCodeCases, apply: setAnswerCode }); -conforms("setBanner", { cases: setBannerCases, apply: setBanner }); -conforms("setConnection", { cases: setConnectionCases, apply: setConnection }); -conforms("setHostAnswerInput", { cases: setHostAnswerInputCases, apply: setHostAnswerInput }); -conforms("setJoinerOfferInput", { cases: setJoinerOfferInputCases, apply: setJoinerOfferInput }); -// `setGameDb` also stores a non-serializable game-database handle the spec's -// serializable `State` never observes; passing `gameDb: null` isolates its visible -// effect, which equals the differently-named `State.enterGame` transition. Its -// pure half is checked here in place (the shared spec test runs `enterGame`). -conforms("setGameDb", { - cases: enterGameCases, - spec: (before) => State.enterGame(before), - apply: (t) => setGameDb(t, { gameDb: null }), -}); - -// None-missed guard: every **registered** transaction must be wired above. -describe("transaction conformance coverage", () => { - for (const transaction of Object.keys(registeredTransactions)) { - it(`${transaction} has a conformance case`, () => expect(covered.has(transaction)).toBe(true)); - } }); diff --git a/packages/data-p2p-tictactoe/src/features/presence/data/state/conformance-case.ts b/packages/data-p2p-tictactoe/src/features/presence/data/state/conformance-case.ts index e596e9e5..4426eca4 100644 --- a/packages/data-p2p-tictactoe/src/features/presence/data/state/conformance-case.ts +++ b/packages/data-p2p-tictactoe/src/features/presence/data/state/conformance-case.ts @@ -1,62 +1,13 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance as ConformanceApi } from "@adobe/data/testing"; import type { State } from "./state.js"; -// A service arg is an object with method members (not an array, not a function); -// this is what distinguishes an injected service from plain data args, since the -// `Service` marker itself is all-optional and would over-match. -type MethodKeys = { [K in keyof T]-?: T[K] extends (...a: never[]) => unknown ? K : never }[keyof T]; -type IsService = T extends readonly unknown[] - ? false - : T extends (...a: never[]) => unknown - ? false - : T extends object - ? [MethodKeys] extends [never] - ? false - : true - : false; - -// A strongly-typed call to one method of service `S`: `[methodName, ...its args]`. -export type Call = { - [M in keyof S]-?: S[M] extends (...a: infer A) => unknown ? readonly [M, ...A] : never; -}[keyof S]; - -// Expected side effects for a case, keyed by the service-typed args only. An -// `Array` value asserts these calls in this order; a `Set` value asserts the same -// calls in any order. -export type Effects = { - readonly [K in keyof Args as IsService extends true ? K : never]?: - | readonly Call[] - | ReadonlySet>; -}; - -// One spec-owned conformance case: a `data/` transform's `{ before, args, after }` -// authored as full `State`, optionally with the side effects it makes on its -// injected service args. Shared unchanged by the spec aggregator (`spec.test.ts`) -// and the ecs conformance runners (`services/main-service/conformance/`). -export type ConformanceCase = { - readonly name: string; - readonly before: State; - readonly args: Args; - readonly after: State; - readonly effects?: Effects; -}; - -// A transform's cases, with the case `args` type derived from the transform's own -// signature — its second parameter, or `void` when it takes none. -export type Conformance unknown> = readonly ConformanceCase< - Parameters extends [unknown, infer Args, ...unknown[]] ? Args : void ->[]; - -// One case for a derivation (`(state) => value`): a state `input` and the `value` -// it yields. `value` may use asymmetric matchers. -export type DerivationCase = { - readonly name: string; - readonly input: Input; - readonly value: Value; -}; - -// A derivation's cases, with `input`/`value` read from the derivation's signature. -export type Derivation unknown> = readonly DerivationCase< - Parameters[0], - ReturnType ->[]; +// The conformance case types for this feature — the shared `@adobe/data/testing` +// machinery with `State` bound once, so a transform authors `Conformance` +// and a derivation `Derivation` (args/input/value read from the +// function's own signature). This tiny file is the only per-feature conformance +// type declaration; everything else lives in `@adobe/data/testing`. +export type Conformance unknown> = + ConformanceApi.Cases; +export type Derivation unknown> = + ConformanceApi.DerivationCases; diff --git a/packages/data-p2p-tictactoe/src/features/presence/data/state/expect-state-matches.ts b/packages/data-p2p-tictactoe/src/features/presence/data/state/expect-state-matches.ts deleted file mode 100644 index a6487989..00000000 --- a/packages/data-p2p-tictactoe/src/features/presence/data/state/expect-state-matches.ts +++ /dev/null @@ -1,52 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { expect } from "vitest"; -import type { State } from "./state.js"; - -// A vitest asymmetric matcher (`expect.any(...)`): honored on the EXPECTED side so -// a case can assert "any number" for a value it does not pin. Presence's `State` -// keys cursors by the peer's `PlayerMark` (no ecs-minted ids), so no case needs -// one today — but the comparison stays matcher-aware to match the shared pattern. -const isMatcher = (value: unknown): value is { asymmetricMatch(actual: unknown): boolean } => - typeof value === "object" && - value !== null && - typeof (value as { asymmetricMatch?: unknown }).asymmetricMatch === "function"; - -// Cursor positions are `Vec2` (F32 tuples), so numbers are quantized onto a shared -// grid to absorb F32↔f64 storage rounding before comparison. `+ 0` normalises `-0`. -const quantize = (n: number): number => Math.round(Math.fround(n) * 1e6) / 1e6 + 0; - -// Tolerant structural match honoring asymmetric matchers, float precision, and -// order-sensitive arrays (cursor tuples). Objects compare by key set, so the -// `cursors` map is order-independent. Exported so it can back other comparisons. -export const matches = (actual: unknown, expected: unknown): boolean => { - if (isMatcher(expected)) return expected.asymmetricMatch(actual); - if (typeof expected === "number" && typeof actual === "number") { - return quantize(actual) === quantize(expected); - } - if (Array.isArray(expected)) { - if (!Array.isArray(actual) || actual.length !== expected.length) return false; - return expected.every((exp, index) => matches(actual[index], exp)); - } - if (expected !== null && typeof expected === "object") { - if (actual === null || typeof actual !== "object" || Array.isArray(actual)) return false; - const expectedKeys = Object.keys(expected); - const actualKeys = Object.keys(actual as object); - if (expectedKeys.length !== actualKeys.length) return false; - return expectedKeys.every((key) => - matches((actual as Record)[key], (expected as Record)[key]), - ); - } - return Object.is(actual, expected); -}; - -// Spec-owned tolerant `State` equality, shared by the data/ transform spec and the -// ecs conformance runners. `after` may use asymmetric matchers, so this one -// comparison serves both the pure spec and the ecs projection. -export const expectStateMatches = (actual: State, expected: State): void => { - expectMatches(actual, expected); -}; - -// The same tolerant, matcher-aware comparison for any value. -export const expectMatches = (actual: unknown, expected: unknown): void => { - expect(matches(actual, expected), `mismatch:\n actual ${JSON.stringify(actual)}`).toBe(true); -}; diff --git a/packages/data-p2p-tictactoe/src/features/presence/data/state/record-effects.ts b/packages/data-p2p-tictactoe/src/features/presence/data/state/record-effects.ts deleted file mode 100644 index 5989feea..00000000 --- a/packages/data-p2p-tictactoe/src/features/presence/data/state/record-effects.ts +++ /dev/null @@ -1,105 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { expect } from "vitest"; -import { equalsUnordered } from "@adobe/data"; -import type { Effects } from "./conformance-case.js"; - -export type RecordedCall = readonly [string, ...unknown[]]; - -// Wrap a plain-object service so each method call is recorded, then delegates. -// No Proxy (services are plain objects with own enumerable methods — see -// `service.md`), so we enumerate and closure-wrap each function. -export const recordCalls = (service: S): { service: S; calls: RecordedCall[] } => { - const calls: RecordedCall[] = []; - const wrapped = Object.fromEntries( - Object.entries(service).map(([key, value]) => [ - key, - typeof value === "function" - ? (...args: unknown[]): unknown => { - calls.push([key, ...args]); - return (value as (...a: unknown[]) => unknown)(...args); - } - : value, - ]), - ) as S; - return { service: wrapped, calls }; -}; - -// Assert the calls recorded against one service match the case's expectation for -// it: an Array expects exactly these calls in order; a Set expects the same calls -// in any order (multiset). Absent expectation ⇒ no calls expected. -export const expectServiceCalls = ( - recorded: readonly RecordedCall[], - expected: readonly RecordedCall[] | ReadonlySet | undefined, -): void => { - if (expected instanceof Set) { - expect(equalsUnordered(recorded, [...expected])).toBe(true); - } else { - expect(recorded).toEqual(expected ?? []); - } -}; - -// Split a case's `args` into the injected services (objects with methods) and the -// remaining plain data. Services are wrapped for recording; the returned `calls` -// map is keyed by the same arg key so it can be matched against `effects`. -export const recordArgServices = ( - args: Args, -): { args: Args; calls: Record } => { - const calls: Record = {}; - const next = { ...args } as Record; - for (const [key, value] of Object.entries(args)) { - if (isServiceValue(value)) { - const rec = recordCalls(value); - next[key] = rec.service; - calls[key] = rec.calls; - } - } - return { args: next as Args, calls }; -}; - -// Assert each service **declared** in `effects` saw exactly its expected calls. -// Services not listed are ignored, so `effects` captures the fire-and-forget side -// effects you choose to assert. -export const expectEffects = ( - calls: Record, - effects: Effects> | undefined, -): void => { - const expected = (effects ?? {}) as Record>; - for (const key of Object.keys(expected)) { - expectServiceCalls(calls[key] ?? [], expected[key]); - } -}; - -// Split a case's `args` into the injected services (wrapped for recording, to be -// used as `Database.create` service overrides) and the remaining plain data (the -// action input). Keyed by the same arg name so `calls` matches against `effects`. -export const splitAndRecordServices = ( - args: Args, -): { - services: Record; - input: Record; - calls: Record; -} => { - const services: Record = {}; - const input: Record = {}; - const calls: Record = {}; - if (args !== null && typeof args === "object") { - for (const [key, value] of Object.entries(args)) { - if (isServiceValue(value)) { - const recorded = recordCalls(value); - services[key] = recorded.service; - calls[key] = recorded.calls; - } else { - input[key] = value; - } - } - } - return { services, input, calls }; -}; - -// A runtime service value: a non-array object with at least one method (mirrors -// the compile-time `IsService`). -const isServiceValue = (value: unknown): value is object => - typeof value === "object" && - value !== null && - !Array.isArray(value) && - Object.values(value).some((member) => typeof member === "function"); diff --git a/packages/data-p2p-tictactoe/src/features/presence/data/state/spec.test.ts b/packages/data-p2p-tictactoe/src/features/presence/data/state/spec.test.ts index f9cd53c1..c92aea50 100644 --- a/packages/data-p2p-tictactoe/src/features/presence/data/state/spec.test.ts +++ b/packages/data-p2p-tictactoe/src/features/presence/data/state/spec.test.ts @@ -1,55 +1,18 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. /// -import { describe, it } from "vitest"; -import type { State } from "./state.js"; -import type { ConformanceCase, DerivationCase } from "./conformance-case.js"; -import { expectStateMatches, expectMatches } from "./expect-state-matches.js"; -import { recordArgServices, expectEffects } from "./record-effects.js"; +import { Conformance } from "@adobe/data/testing"; -// The single spec test for every transform AND derivation in this folder. It -// auto-discovers each file (any sibling `.ts` that exports `cases`) via -// `import.meta.glob`, so a new one is covered the moment it ships. Each -// participating file must export exactly its function plus `cases` (enforced -// below). A case's shape selects the check: `after` → a transition; `value` → a -// derivation. -const modules = import.meta.glob>( - ["./*.ts", "!./*.test.ts", "!./*.type-test.ts"], - { eager: true }, +// The single pure-spec test for every transform AND derivation in this folder. +// `runSpec` auto-discovers each sibling that exports `cases`, requires it to +// export exactly its function plus `cases`, and dispatches on case shape (a +// `value` case is a derivation, otherwise a transition whose declared `effects` +// are also asserted). Cursor positions are `Vec2` tuples compared in order and the +// `cursors` map compares by key set, so the default comparison is correct. +Conformance.runSpec( + import.meta.glob>( + ["./*.ts", "!./*.test.ts", "!./*.type-test.ts"], + { + eager: true, + }, + ), ); - -const isDerivationCase = (c: unknown): c is DerivationCase => - typeof c === "object" && c !== null && "value" in c; - -for (const [path, module] of Object.entries(modules)) { - const exportNames = Object.keys(module); - if (!exportNames.includes("cases")) continue; - const functionNames = exportNames.filter((key) => typeof module[key] === "function"); - const label = functionNames.length === 1 ? functionNames[0] : path; - - describe(`State.${label}`, () => { - if (exportNames.length !== 2 || functionNames.length !== 1) { - it("exports exactly its function and `cases`", () => { - throw new Error(`${path} exports [${exportNames.join(", ")}] — expected one function + cases`); - }); - return; - } - // Runtime invariant: a participating file exports a function and its cases. - const fn = module[functionNames[0]] as (...args: unknown[]) => unknown; - const cases = module["cases"] as readonly unknown[]; - for (const testCase of cases) { - if (isDerivationCase(testCase)) { - it(testCase.name, () => expectMatches(fn(testCase.input), testCase.value)); - continue; - } - const transitionCase = testCase as ConformanceCase>; - it(transitionCase.name, async () => { - const raw = transitionCase.args; - const { args, calls } = - raw && typeof raw === "object" ? recordArgServices(raw) : { args: raw, calls: {} }; - const result = (await fn(transitionCase.before, args)) as State; - expectStateMatches(result, transitionCase.after); - expectEffects(calls, transitionCase.effects); - }); - } - }); -} diff --git a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/actions.test.ts b/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/actions.test.ts index 7b19e4dc..a7f05c20 100644 --- a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/actions.test.ts +++ b/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/actions.test.ts @@ -1,74 +1,60 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -/// -import { describe, it, expect } from "vitest"; -import { Database, createRebaseReplayConcurrency } from "@adobe/data/ecs"; -import type { ConformanceCase } from "../../../data/state/conformance-case.js"; -import { splitAndRecordServices, expectEffects } from "../../../data/state/record-effects.js"; -import { expectStateMatches } from "../../../data/state/expect-state-matches.js"; +import { Database } from "@adobe/data/ecs"; +import type { ConcurrencyStrategyFactory } from "@adobe/data/ecs"; +import { Conformance } from "@adobe/data/testing"; import { MainService } from "../main-service.js"; -import { fromState } from "./from-state.js"; -import { toState } from "./to-state.js"; import { movePresence } from "../action-database/actions/move-presence.js"; import { cases as movePresenceCases } from "../../../data/state/move-presence.js"; +import { fromState } from "./from-state.js"; +import { toState } from "./to-state.js"; -// Each transition's cases run against its same-named ecs **action**, asserting the -// resulting state and any declared side effects. `movePresence`'s peer identity is -// the transaction `userId`, so the db is created with a rebase-replay concurrency -// stamped with the case's `mark` — exactly how the live game database assigns each -// peer its id — then the action commits the plain `{ x, y }` payload. -const makeDb = (userId: string) => - Database.toSystemDatabase(Database.create(MainService.plugin, { concurrency: createRebaseReplayConcurrency(userId) })); -type Db = ReturnType; - -const covered = new Set(); -const conformsAction = ( - action: string, - config: { - readonly cases: readonly ConformanceCase[]; - readonly run: (db: Db, input: Partial) => Promise | void; +// `movePresence`'s peer identity is the transaction `userId` (the peer's assigned +// mark). A live game database stamps it via its concurrency strategy at db +// construction — before the shared `runActions` driver knows the case, and the +// mark travels as plain action input (never a service) so `makeDb` cannot see it. +// So a test-only concurrency reads the peer id from a closure the `run` adapter +// primes immediately before dispatch; otherwise it commits immediately, like +// `createImmediateConcurrency`. This reproduces the old runner's per-case +// `createRebaseReplayConcurrency(mark)` db through the generic driver. +let peerUserId: string | undefined; +const peerConcurrency: ConcurrencyStrategyFactory = ( + execute, + getTransaction, +) => ({ + deferredCommit: false, + apply: (envelope) => { + if (envelope.time === 0) return undefined; + const transaction = getTransaction(envelope.name); + if (!transaction) throw new Error(`Unknown transaction: ${envelope.name}`); + return execute((t) => transaction(t, envelope.args), { + intermediate: envelope.time < 0, + userId: peerUserId, + }); }, -): void => { - covered.add(action); - describe(`${action} action conforms`, () => { - for (const testCase of config.cases) { - it(testCase.name, async () => { - const { input, calls } = splitAndRecordServices(testCase.args); - const db = makeDb(testCase.args.mark); - fromState(db.store, testCase.before); - await config.run(db, input as Partial); - expectStateMatches(toState(db.store), testCase.after); - expectEffects(calls, testCase.effects); - }); - } - }); -}; - -conformsAction("movePresence", { - cases: movePresenceCases, - run: (db, input) => movePresence(db, { x: input.x ?? 0, y: input.y ?? 0 }), + cancel: () => {}, + onReset: () => {}, }); -// None-missed guard: every data/state **transition** (a file whose `cases` are -// `{ before, args, after }`) must have a same-named action wired above. Iterating -// transitions — not action files — is deliberate: the UI-facing streaming -// `trackPresence` action has no pure-transition analogue and is not conformed here. -const kebabToCamel = (name: string): string => - name.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase()); -const stateModules = import.meta.glob>( - ["../../../data/state/*.ts", "!../../../data/state/*.test.ts"], - { eager: true }, -); -describe("action conformance coverage", () => { - for (const [path, module] of Object.entries(stateModules)) { - const cases = module["cases"]; - const isTransition = - Array.isArray(cases) && - cases.length > 0 && - typeof cases[0] === "object" && - cases[0] !== null && - "after" in cases[0]; - if (!isTransition) continue; - const name = kebabToCamel(path.replace(/.*\//, "").replace(/\.ts$/, "")); - it(`${name} has an action conformance case`, () => expect(covered.has(name)).toBe(true)); - } +// The registered set the coverage guard checks: only the transition-backed +// `movePresence` action. The UI-facing streaming `trackPresence` action (the sole +// member of the `actions` barrel) has no pure-transition analogue and is not +// conformed here, so the barrel is not the registered set. +Conformance.runActions({ + makeDb: () => + Database.toSystemDatabase( + Database.create(MainService.plugin, { concurrency: peerConcurrency }), + ), + store: (db) => db.store, + fromState, + toState, + registered: { movePresence }, + define: (conforms) => { + conforms("movePresence", { + cases: movePresenceCases, + run: (db, input) => { + peerUserId = input.mark; + return movePresence(db, { x: input.x ?? 0, y: input.y ?? 0 }); + }, + }); + }, }); diff --git a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/expect-conforms.ts b/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/expect-conforms.ts deleted file mode 100644 index 6690f14d..00000000 --- a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/expect-conforms.ts +++ /dev/null @@ -1,33 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { it } from "vitest"; -import type { State } from "../../../data/state/state.js"; -import type { ConformanceCase } from "../../../data/state/conformance-case.js"; -import { expectStateMatches } from "../../../data/state/expect-state-matches.js"; -import type { CoreDatabase } from "../core-database/core-database.js"; -import { createStore } from "./create-store.js"; -import { fromState } from "./from-state.js"; -import { toState } from "./to-state.js"; - -// The conformance runner, bound to the presence projection. For each case it -// proves `toState(apply(fromState(before), args)) ≡ spec(before, args)`. The pure -// half (`spec(before, args) ≡ after`) is asserted once, centrally, by -// `data/state/spec.test.ts`, so this runner asserts only the ecs half; pass `spec` -// to re-check it in place. -export const expectConforms = (config: { - readonly cases: readonly ConformanceCase[]; - readonly spec?: (before: State, args: Args) => State; - readonly apply: (store: CoreDatabase.Store, args: Args) => void; -}): void => { - for (const testCase of config.cases) { - it(testCase.name, () => { - if (config.spec) { - expectStateMatches(config.spec(testCase.before, testCase.args), testCase.after); - } - - const store = createStore(); - fromState(store, testCase.before); - config.apply(store, testCase.args); - expectStateMatches(toState(store), testCase.after); - }); - } -}; diff --git a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/from-state.ts b/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/from-state.ts index 3bce6ad8..e8748c6e 100644 --- a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/from-state.ts +++ b/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/from-state.ts @@ -1,9 +1,16 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Entity } from "@adobe/data/ecs"; import type { State } from "../../../data/state/state.js"; import type { CoreDatabase } from "../core-database/core-database.js"; // Seed a store to match a `data/` presence `State`. The inverse of `toState`. -// Test-only. -export const fromState = (store: CoreDatabase.Store, state: State): void => { +// Presence keeps its whole state in the `cursors` resource — there are no entity +// collections — so the returned `spec id → seeded entity` map is empty and the +// conformance runners' `resolve` is never used. Test-only. +export const fromState = ( + store: CoreDatabase.Store, + state: State, +): ReadonlyMap => { store.resources.cursors = state.cursors; + return new Map(); }; diff --git a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/projection.test.ts b/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/projection.test.ts index 48fdea56..452488d6 100644 --- a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/projection.test.ts +++ b/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/projection.test.ts @@ -4,8 +4,8 @@ // states. import { describe, it } from "vitest"; import type { Vec2 } from "@adobe/data/math"; +import { Match } from "@adobe/data/testing"; import { State } from "../../../data/state/state.js"; -import { expectStateMatches } from "../../../data/state/expect-state-matches.js"; import { createStore } from "./create-store.js"; import { fromState } from "./from-state.js"; import { toState } from "./to-state.js"; @@ -15,7 +15,10 @@ const at = (x: number, y: number): Vec2 => [x, y] as Vec2; const states: readonly { readonly name: string; readonly state: State }[] = [ { name: "no cursors reported yet", state: State.create() }, { name: "one peer cursor", state: { cursors: { X: at(0.5, 0.25) } } }, - { name: "both peer cursors", state: { cursors: { X: at(0.5, 0.25), O: at(0.75, 0.5) } } }, + { + name: "both peer cursors", + state: { cursors: { X: at(0.5, 0.25), O: at(0.75, 0.5) } }, + }, ]; describe("presence conformance projection round-trips (toState ∘ fromState ≡ identity)", () => { @@ -23,7 +26,7 @@ describe("presence conformance projection round-trips (toState ∘ fromState ≡ it(name, () => { const store = createStore(); fromState(store, state); - expectStateMatches(toState(store), state); + Match.assert(toState(store), state); }); } }); diff --git a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/transactions.test.ts b/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/transactions.test.ts index 65ef2372..81f85385 100644 --- a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/transactions.test.ts +++ b/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/transactions.test.ts @@ -1,41 +1,31 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it, expect } from "vitest"; -import type { CoreDatabase } from "../core-database/core-database.js"; -import type { ConformanceCase } from "../../../data/state/conformance-case.js"; -import { expectConforms } from "./expect-conforms.js"; -import { seedUserId } from "./seed-user-id.js"; +import { Conformance } from "@adobe/data/testing"; import * as registeredTransactions from "../transaction-database/transactions/index.js"; import { movePresence } from "../transaction-database/transactions/move-presence.js"; import { cases as movePresenceCases } from "../../../data/state/move-presence.js"; +import { createStore } from "./create-store.js"; +import { seedUserId } from "./seed-user-id.js"; +import { fromState } from "./from-state.js"; +import { toState } from "./to-state.js"; -// The single conformance test for every ecs transaction. `movePresence` reads the -// peer identity from the transaction `userId` (the peer's assigned mark), so the -// `apply` closure seeds that identity from the case's `mark`, then dispatches the -// raw transaction with the plain `{ x, y }` payload. The guard asserts every -// REGISTERED transaction (the barrel) is wired below. -const covered = new Set(); -const conforms = ( - transaction: string, - config: { - readonly cases: readonly ConformanceCase[]; - readonly apply: (t: CoreDatabase.Store, args: Args) => void; - }, -): void => { - covered.add(transaction); - describe(`${transaction} transaction conforms`, () => expectConforms(config)); -}; - -conforms("movePresence", { - cases: movePresenceCases, - apply: (store, { mark, x, y }) => { - seedUserId(store, mark); - movePresence(store, { x, y }); +// The single conformance test for every ecs transaction. `runTransactions` owns +// the harness (fresh store, `fromState` seed, `toState` compare, coverage guard +// keyed off the registered barrel). `movePresence` reads the peer identity from +// the transaction `userId` (the peer's assigned mark), so the bespoke `apply` +// seeds that identity from the case's `mark` before dispatching the raw +// transaction with the plain `{ x, y }` payload. +Conformance.runTransactions({ + createStore, + fromState, + toState, + registered: registeredTransactions, + define: (conforms) => { + conforms("movePresence", { + cases: movePresenceCases, + apply: (store, { mark, x, y }) => { + seedUserId(store, mark); + movePresence(store, { x, y }); + }, + }); }, }); - -// None-missed guard: every **registered** transaction must be wired above. -describe("transaction conformance coverage", () => { - for (const transaction of Object.keys(registeredTransactions)) { - it(`${transaction} has a conformance case`, () => expect(covered.has(transaction)).toBe(true)); - } -}); From d09029d3625b23ec720f02fd55c87c1ed783a76c Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 7 Aug 2026 01:23:16 -0700 Subject: [PATCH 25/37] feat(data): auto-pairing conformance runners (zero overrides); refactor todo + tictactoe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ECS conformance runners now discover by glob and pair transition↔ecs-op by name — no per-item `define`/`conforms` wiring. Overrides are eliminated by closing the spec↔ecs vocabulary gaps at the source: - services: the action reads db.services (conform the action, not the raw tx). - identity: a `Conformance.entity(specId)` arg marker the runner resolves per side (spec→data-id, ecs→seeded entity); ecs ops take the entity under the transition's arg key. - arg shape: precise (non-Partial) invocation, so same-shape ops need no reshape. - granularity/infra: an ecs op with no same-named transition is skipped (infra or system-realized); coverage is inherent to auto-pairing, so the guards are gone. - computed output: identity compare by default; entity-id lists opt into `hydrate`. tictactoe: 3 tiny auto-pairing files, zero markers (index/service args). todo: same, with `entity()` markers on addressed ids; entity-addressed ops take `{ id }`, and a new `reorderTodo` action gives name-parity (dragTodo stays the UI-drag transaction). `define` is kept temporarily so the not-yet-migrated samples stay green. Co-Authored-By: Claude Sonnet 4.6 --- .../main-service/conformance/actions.test.ts | 43 +++------ .../conformance/computeds.test.ts | 23 ++--- .../conformance/transactions.test.ts | 33 ++++--- .../main/data/state/conformance-case.ts | 7 +- .../features/main/data/state/delete-todo.ts | 8 +- .../features/main/data/state/reorder-todo.ts | 10 +- .../main/data/state/toggle-complete.ts | 8 +- .../action-database/actions/delete-todo.ts | 4 +- .../action-database/actions/index.ts | 1 + .../action-database/actions/reorder-todo.ts | 18 ++++ .../actions/toggle-complete.ts | 4 +- .../main-service/conformance/actions.test.ts | 70 +++----------- .../conformance/computeds.test.ts | 24 ++--- .../conformance/transactions.test.ts | 69 ++++---------- .../transactions/delete-todo.ts | 2 +- .../transactions/toggle-complete.ts | 2 +- .../main/ui/todo-row/todo-row-element.ts | 4 +- .../data/src/testing/conformance/discover.ts | 36 +++++++ .../src/testing/conformance/entity-ref.ts | 36 +++++++ .../data/src/testing/conformance/public.ts | 3 +- .../src/testing/conformance/run-actions.ts | 68 ++++++++++--- .../src/testing/conformance/run-computeds.ts | 95 ++++++------------- .../data/src/testing/conformance/run-spec.ts | 5 +- .../testing/conformance/run-transactions.ts | 69 ++++++++++---- 24 files changed, 333 insertions(+), 309 deletions(-) create mode 100644 packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/reorder-todo.ts create mode 100644 packages/data/src/testing/conformance/discover.ts create mode 100644 packages/data/src/testing/conformance/entity-ref.ts diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/actions.test.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/actions.test.ts index 7d73855e..f3db8be3 100644 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/actions.test.ts +++ b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/actions.test.ts @@ -1,25 +1,18 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +/// import { Database } from "@adobe/data/ecs"; import { Conformance } from "@adobe/data/testing"; import type { OpponentService } from "../../opponent-service/opponent-service.js"; import { MainService } from "../main-service.js"; -import * as registeredActions from "../action-database/actions/index.js"; -import { playMove } from "../action-database/actions/play-move.js"; -import { playOpponentMove } from "../action-database/actions/play-opponent-move.js"; -import { restartGame } from "../action-database/actions/restart-game.js"; -import { cases as playMoveCases } from "../../../data/state/play-move.js"; -import { cases as playOpponentMoveCases } from "../../../data/state/play-opponent-move.js"; -import { cases as restartGameCases } from "../../../data/state/restart-game.js"; +import * as actions from "../action-database/actions/index.js"; import { fromState } from "./from-state.js"; import { toState } from "./to-state.js"; -// Each transition's cases run against its same-named ecs action, asserting state -// and declared effects. `runActions` splits the case's injected services into -// recording overrides via `makeDb`; the harness/coverage are shared. +// Every ecs action, conformed by name against its transition. `runActions` +// discovers transitions, turns each case's injected services into recording +// overrides via `makeDb`, runs the action, and asserts state + declared effects. Conformance.runActions({ - // `toSystemDatabase` exposes the writable `.store` the projection needs. Runtime - // invariant: the recording wrappers preserve the service's shape, so they are a - // valid factory override. + // Runtime invariant: the recording wrappers preserve the service's shape. makeDb: (services) => Database.toSystemDatabase( Database.create(MainService.plugin, { @@ -29,19 +22,13 @@ Conformance.runActions({ store: (db) => db.store, fromState, toState, - registered: registeredActions, - define: (conforms) => { - conforms("playMove", { - cases: playMoveCases, - run: (db, input) => playMove(db, { index: input.index ?? -1 }), - }); - conforms("playOpponentMove", { - cases: playOpponentMoveCases, - run: (db) => playOpponentMove(db), - }); - conforms("restartGame", { - cases: restartGameCases, - run: (db) => restartGame(db), - }); - }, + transitions: import.meta.glob( + [ + "../../../data/state/*.ts", + "!../../../data/state/*.test.ts", + "!../../../data/state/*.type-test.ts", + ], + { eager: true }, + ), + actions, }); diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/computeds.test.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/computeds.test.ts index 483a8f8c..bec373ee 100644 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/computeds.test.ts +++ b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/computeds.test.ts @@ -3,23 +3,21 @@ import { Database } from "@adobe/data/ecs"; import { Conformance } from "@adobe/data/testing"; import { ComputedDatabase } from "../computed-database/computed-database.js"; -import { currentPlayer } from "../computed-database/computed/current-player.js"; -import { cases as currentPlayerCases } from "../../../data/state/current-player.js"; +import * as computeds from "../computed-database/computed/index.js"; import { fromState } from "./from-state.js"; import { toData } from "./to-data.js"; -// Each `data/state` derivation's cases run against its same-named ecs computed. -// Built from the `ComputedDatabase` layer so a `withCache` above it cannot serve a -// stale pre-seed value. Only `currentPlayer` is a `state/` derivation (it composes -// board + firstPlayer); the single-`data/board-state` computeds (winner/status/…) -// are covered by their helper's unit test, per the rules. +// Every ecs computed backing a `data/state` derivation, conformed by name. Only +// `currentPlayer` is a derivation (composes board + firstPlayer); the single-type +// board computeds (winner/status/…) have no derivation and are covered by their +// `data/board-state` helper tests. Built from the ComputedDatabase layer. Conformance.runComputeds({ makeDb: () => Database.toSystemDatabase(Database.create(ComputedDatabase.plugin)), store: (db) => db.store, fromState, toData, - derivationModules: import.meta.glob>( + derivations: import.meta.glob( [ "../../../data/state/*.ts", "!../../../data/state/*.test.ts", @@ -27,12 +25,5 @@ Conformance.runComputeds({ ], { eager: true }, ), - define: (conforms) => { - // `currentPlayer` emits a scalar `PlayerMark`, so the projection is identity. - conforms("currentPlayer", { - cases: currentPlayerCases, - computed: currentPlayer, - project: (raw) => raw, - }); - }, + computeds, }); diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/transactions.test.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/transactions.test.ts index 131e6194..08deec30 100644 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/transactions.test.ts +++ b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/transactions.test.ts @@ -1,28 +1,27 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +/// import { Conformance } from "@adobe/data/testing"; -import * as registeredTransactions from "../transaction-database/transactions/index.js"; -import { playMove } from "../transaction-database/transactions/play-move.js"; -import { restartGame } from "../transaction-database/transactions/restart-game.js"; -import { cases as playMoveCases } from "../../../data/state/play-move.js"; -import { cases as restartGameCases } from "../../../data/state/restart-game.js"; +import * as transactions from "../transaction-database/transactions/index.js"; import { createStore } from "./create-store.js"; import { fromState } from "./from-state.js"; import { toState } from "./to-state.js"; -// The single conformance test for every ecs transaction. `runTransactions` owns -// the harness (fresh store, `fromState` seed, `toState` compare, coverage guard -// keyed off the registered barrel). Tic-tac-toe moves are addressed by board -// index, not entity id, so the `apply` adapters need no `resolve`. +// Every ecs transaction, conformed by name against its `data/state` transition — +// no per-item wiring. `runTransactions` discovers the transitions (the glob), +// pairs each registered transaction to the same-named one, seeds `fromState`, +// applies, and compares `toState`. Moves are addressed by board index (plain +// data), so no `entity()` markers are needed. Conformance.runTransactions({ createStore, fromState, toState, - registered: registeredTransactions, - define: (conforms) => { - conforms("playMove", { cases: playMoveCases, apply: playMove }); - conforms("restartGame", { - cases: restartGameCases, - apply: (t) => restartGame(t), - }); - }, + transitions: import.meta.glob( + [ + "../../../data/state/*.ts", + "!../../../data/state/*.test.ts", + "!../../../data/state/*.type-test.ts", + ], + { eager: true }, + ), + transactions, }); diff --git a/packages/data-lit-todo/src/features/main/data/state/conformance-case.ts b/packages/data-lit-todo/src/features/main/data/state/conformance-case.ts index 0b87028e..6064cb56 100644 --- a/packages/data-lit-todo/src/features/main/data/state/conformance-case.ts +++ b/packages/data-lit-todo/src/features/main/data/state/conformance-case.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Conformance as ConformanceApi } from "@adobe/data/testing"; +import { Conformance as ConformanceApi } from "@adobe/data/testing"; import type { State } from "./state.js"; // The conformance case types for this feature — the shared `@adobe/data/testing` @@ -12,3 +12,8 @@ export type Conformance unknown> = export type Derivation unknown> = ConformanceApi.DerivationCases; export type Effects = ConformanceApi.Effects; + +// The entity-reference marker for case args: `args: { id: entity(2) }` names "the +// entity seeded for spec-id 2". The pure spec reads `2`; the ecs runner resolves +// it to the seeded entity. Re-exported here so cases import it beside `Conformance`. +export const entity = ConformanceApi.entity; diff --git a/packages/data-lit-todo/src/features/main/data/state/delete-todo.ts b/packages/data-lit-todo/src/features/main/data/state/delete-todo.ts index 2aff09e8..9d27be28 100644 --- a/packages/data-lit-todo/src/features/main/data/state/delete-todo.ts +++ b/packages/data-lit-todo/src/features/main/data/state/delete-todo.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { AnalyticsService } from "../../services/analytics-service/analytics-service.js"; import type { State } from "./state.js"; -import type { Conformance } from "./conformance-case.js"; +import { entity, type Conformance } from "./conformance-case.js"; import { Match } from "@adobe/data/testing"; export const deleteTodo = >( state: T, @@ -28,7 +28,7 @@ export const cases: Conformance = [ { name: "removes a middle todo", before: { todos: [...three], displayCompleted: false }, - args: { id: 2, analytics: AnalyticsService.createFake() }, + args: { id: entity(2), analytics: AnalyticsService.createFake() }, after: { todos: [ { id: Match.anyNumber, name: "a", complete: false }, @@ -41,7 +41,7 @@ export const cases: Conformance = [ { name: "removes the first todo", before: { todos: [...three], displayCompleted: true }, - args: { id: 1, analytics: AnalyticsService.createFake() }, + args: { id: entity(1), analytics: AnalyticsService.createFake() }, after: { todos: [ { id: Match.anyNumber, name: "b", complete: true }, @@ -54,7 +54,7 @@ export const cases: Conformance = [ { name: "is a no-op for an unknown id but still logs the delete", before: { todos: [...three], displayCompleted: false }, - args: { id: 99, analytics: AnalyticsService.createFake() }, + args: { id: entity(99), analytics: AnalyticsService.createFake() }, after: { todos: [ { id: Match.anyNumber, name: "a", complete: false }, diff --git a/packages/data-lit-todo/src/features/main/data/state/reorder-todo.ts b/packages/data-lit-todo/src/features/main/data/state/reorder-todo.ts index c026fde5..9d794d12 100644 --- a/packages/data-lit-todo/src/features/main/data/state/reorder-todo.ts +++ b/packages/data-lit-todo/src/features/main/data/state/reorder-todo.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { State } from "./state.js"; -import type { Conformance } from "./conformance-case.js"; +import { entity, type Conformance } from "./conformance-case.js"; import { Match } from "@adobe/data/testing"; /** * Moves the todo with the given id to `toIndex` within the list, preserving the @@ -40,7 +40,7 @@ export const cases: Conformance = [ { name: "moves the first todo to the end", before: { todos: [...three], displayCompleted: true }, - args: { id: 1, toIndex: 2 }, + args: { id: entity(1), toIndex: 2 }, after: { todos: [ { id: Match.anyNumber, name: "b", complete: false }, @@ -53,7 +53,7 @@ export const cases: Conformance = [ { name: "moves the last todo to the front", before: { todos: [...three], displayCompleted: true }, - args: { id: 3, toIndex: 0 }, + args: { id: entity(3), toIndex: 0 }, after: { todos: [ { id: Match.anyNumber, name: "c", complete: false }, @@ -66,7 +66,7 @@ export const cases: Conformance = [ { name: "clamps an out-of-range index to the end", before: { todos: [...three], displayCompleted: true }, - args: { id: 1, toIndex: 99 }, + args: { id: entity(1), toIndex: 99 }, after: { todos: [ { id: Match.anyNumber, name: "b", complete: false }, @@ -79,7 +79,7 @@ export const cases: Conformance = [ { name: "keeps the order when moving to the same index", before: { todos: [...three], displayCompleted: true }, - args: { id: 2, toIndex: 1 }, + args: { id: entity(2), toIndex: 1 }, after: { todos: [ { id: Match.anyNumber, name: "a", complete: false }, diff --git a/packages/data-lit-todo/src/features/main/data/state/toggle-complete.ts b/packages/data-lit-todo/src/features/main/data/state/toggle-complete.ts index 819bbddc..0443dae3 100644 --- a/packages/data-lit-todo/src/features/main/data/state/toggle-complete.ts +++ b/packages/data-lit-todo/src/features/main/data/state/toggle-complete.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { AnalyticsService } from "../../services/analytics-service/analytics-service.js"; import type { State } from "./state.js"; -import type { Conformance } from "./conformance-case.js"; +import { entity, type Conformance } from "./conformance-case.js"; import { Match } from "@adobe/data/testing"; export const toggleComplete = >( state: T, @@ -33,7 +33,7 @@ export const cases: Conformance = [ ], displayCompleted: false, }, - args: { id: 1, analytics: AnalyticsService.createFake() }, + args: { id: entity(1), analytics: AnalyticsService.createFake() }, after: { todos: [ { id: Match.anyNumber, name: "a", complete: true }, @@ -52,7 +52,7 @@ export const cases: Conformance = [ ], displayCompleted: true, }, - args: { id: 1, analytics: AnalyticsService.createFake() }, + args: { id: entity(1), analytics: AnalyticsService.createFake() }, after: { todos: [ { id: Match.anyNumber, name: "a", complete: false }, @@ -68,7 +68,7 @@ export const cases: Conformance = [ todos: [{ id: 1, name: "a", complete: false }], displayCompleted: false, }, - args: { id: 99, analytics: AnalyticsService.createFake() }, + args: { id: entity(99), analytics: AnalyticsService.createFake() }, after: { todos: [{ id: Match.anyNumber, name: "a", complete: false }], displayCompleted: false, diff --git a/packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/delete-todo.ts b/packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/delete-todo.ts index 45aa09da..07a4da8e 100644 --- a/packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/delete-todo.ts +++ b/packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/delete-todo.ts @@ -2,7 +2,7 @@ import type { Entity } from "@adobe/data/ecs"; import type { ServiceDatabase } from "../../service-database/service-database.js"; -export const deleteTodo = (db: ServiceDatabase, id: Entity) => { +export const deleteTodo = (db: ServiceDatabase, { id }: { id: Entity }) => { db.services.analytics.todoDeleted(); - db.transactions.deleteTodo(id); + db.transactions.deleteTodo({ id }); }; diff --git a/packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/index.ts b/packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/index.ts index 30633e23..b9746cb3 100644 --- a/packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/index.ts +++ b/packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/index.ts @@ -6,3 +6,4 @@ export * from "./toggle-complete.js"; export * from "./delete-todo.js"; export * from "./delete-all-todos.js"; export * from "./toggle-display-completed.js"; +export * from "./reorder-todo.js"; diff --git a/packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/reorder-todo.ts b/packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/reorder-todo.ts new file mode 100644 index 00000000..c236d909 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/reorder-todo.ts @@ -0,0 +1,18 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Entity } from "@adobe/data/ecs"; +import type { ServiceDatabase } from "../../service-database/service-database.js"; + +// Move a todo to `toIndex` — the programmatic counterpart of the drag UI (which +// dispatches the richer `dragTodo` transaction directly). Reproduces +// `State.reorderTodo` as a single final-drop `dragTodo` commit, so it is the +// same-named action that conforms the `reorderTodo` transition. +export const reorderTodo = ( + db: ServiceDatabase, + { id, toIndex }: { id: Entity; toIndex: number }, +) => { + db.transactions.dragTodo({ + entity: id, + dragPosition: 0, + finalIndex: toIndex, + }); +}; diff --git a/packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/toggle-complete.ts b/packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/toggle-complete.ts index 887fa6cf..1c28b624 100644 --- a/packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/toggle-complete.ts +++ b/packages/data-lit-todo/src/features/main/services/main-service/action-database/actions/toggle-complete.ts @@ -2,7 +2,7 @@ import type { Entity } from "@adobe/data/ecs"; import type { ServiceDatabase } from "../../service-database/service-database.js"; -export const toggleComplete = (db: ServiceDatabase, id: Entity) => { +export const toggleComplete = (db: ServiceDatabase, { id }: { id: Entity }) => { db.services.analytics.todoToggled(); - db.transactions.toggleComplete(id); + db.transactions.toggleComplete({ id }); }; diff --git a/packages/data-lit-todo/src/features/main/services/main-service/conformance/actions.test.ts b/packages/data-lit-todo/src/features/main/services/main-service/conformance/actions.test.ts index bf692edd..f95a4524 100644 --- a/packages/data-lit-todo/src/features/main/services/main-service/conformance/actions.test.ts +++ b/packages/data-lit-todo/src/features/main/services/main-service/conformance/actions.test.ts @@ -1,36 +1,19 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +/// import { Database } from "@adobe/data/ecs"; import { Conformance } from "@adobe/data/testing"; import type { AnalyticsService } from "../../analytics-service/analytics-service.js"; import type { NameGeneratorService } from "../../name-generator-service/name-generator-service.js"; import { MainService } from "../main-service.js"; -import * as registeredActions from "../action-database/actions/index.js"; -import { createTodo } from "../action-database/actions/create-todo.js"; -import { createBulkTodos } from "../action-database/actions/create-bulk-todos.js"; -import { createRandomTodo } from "../action-database/actions/create-random-todo.js"; -import { deleteTodo } from "../action-database/actions/delete-todo.js"; -import { deleteAllTodos } from "../action-database/actions/delete-all-todos.js"; -import { toggleComplete } from "../action-database/actions/toggle-complete.js"; -import { toggleDisplayCompleted } from "../action-database/actions/toggle-display-completed.js"; -import { cases as createTodoCases } from "../../../data/state/create-todo.js"; -import { cases as createBulkTodosCases } from "../../../data/state/create-bulk-todos.js"; -import { cases as createRandomTodoCases } from "../../../data/state/create-random-todo.js"; -import { cases as deleteTodoCases } from "../../../data/state/delete-todo.js"; -import { cases as deleteAllTodosCases } from "../../../data/state/delete-all-todos.js"; -import { cases as toggleCompleteCases } from "../../../data/state/toggle-complete.js"; -import { cases as toggleDisplayCompletedCases } from "../../../data/state/toggle-display-completed.js"; +import * as actions from "../action-database/actions/index.js"; import { fromState } from "./from-state.js"; import { toState } from "./to-state.js"; -// Each transition's cases run against its same-named ecs action. `runActions` -// splits the case's injected services into recording overrides (via `makeDb`), -// runs the action, then asserts both the resulting state and the declared effects; -// the harness/coverage are shared. A transition realized only by a transaction -// (e.g. `reorderTodo`) is covered by `transactions.test.ts`, not here. +// Every ecs action, conformed by name against its transition. The case's injected +// services become recording overrides via `makeDb`; the runner splits them out, +// resolves `entity()` arg markers, runs the action, and asserts state + effects. Conformance.runActions({ - // `toSystemDatabase` exposes the writable `.store` the projection needs. Runtime - // invariant: the recording wrappers preserve each service's shape, so they are - // valid factory overrides. + // Runtime invariant: the recording wrappers preserve each service's shape. makeDb: (services) => Database.toSystemDatabase( Database.create(MainService.plugin, { @@ -43,36 +26,13 @@ Conformance.runActions({ store: (db) => db.store, fromState, toState, - registered: registeredActions, - define: (conforms) => { - conforms("createTodo", { - cases: createTodoCases, - run: (db, input) => - createTodo(db, { name: input.name ?? "", complete: input.complete }), - }); - conforms("createBulkTodos", { - cases: createBulkTodosCases, - run: (db, input) => createBulkTodos(db, { count: input.count ?? 0 }), - }); - conforms("createRandomTodo", { - cases: createRandomTodoCases, - run: (db) => createRandomTodo(db), - }); - conforms("deleteTodo", { - cases: deleteTodoCases, - run: (db, input, resolve) => deleteTodo(db, resolve(input.id ?? -1)), - }); - conforms("deleteAllTodos", { - cases: deleteAllTodosCases, - run: (db) => deleteAllTodos(db), - }); - conforms("toggleComplete", { - cases: toggleCompleteCases, - run: (db, input, resolve) => toggleComplete(db, resolve(input.id ?? -1)), - }); - conforms("toggleDisplayCompleted", { - cases: toggleDisplayCompletedCases, - run: (db) => toggleDisplayCompleted(db), - }); - }, + transitions: import.meta.glob( + [ + "../../../data/state/*.ts", + "!../../../data/state/*.test.ts", + "!../../../data/state/*.type-test.ts", + ], + { eager: true }, + ), + actions, }); diff --git a/packages/data-lit-todo/src/features/main/services/main-service/conformance/computeds.test.ts b/packages/data-lit-todo/src/features/main/services/main-service/conformance/computeds.test.ts index 4b9f607c..a827970d 100644 --- a/packages/data-lit-todo/src/features/main/services/main-service/conformance/computeds.test.ts +++ b/packages/data-lit-todo/src/features/main/services/main-service/conformance/computeds.test.ts @@ -3,25 +3,21 @@ import { Database } from "@adobe/data/ecs"; import { Conformance } from "@adobe/data/testing"; import { ComputedDatabase } from "../computed-database/computed-database.js"; -import { visibleTodos } from "../computed-database/computed/visible-todos.js"; -import { cases as visibleTodosCases } from "../../../data/state/visible-todos.js"; +import * as computeds from "../computed-database/computed/index.js"; import { fromState } from "./from-state.js"; import { toData } from "./to-data.js"; -// Each derivation's cases run against its same-named ecs computed. `runComputeds` -// seeds the store from the case `input`, reads the computed's synchronous -// emission, hydrates an entity-id list through `toData` (so an id-based computed -// like `visibleTodos` needs no adapter), and matches the derivation's `value`. -// Built from the `ComputedDatabase` layer (not the assembled db) so a `withCache` -// above it cannot serve a stale pre-seed value. Coverage is keyed off the -// `data/state/` derivation modules — every one must be wired. +// Every ecs computed backing a `data/state` derivation, conformed by name, built +// from the ComputedDatabase layer. `visibleTodos` emits entity ids, so it is named +// in `hydrate` to project each through `toData` into the `Todo[]` the derivation +// yields; scalar/value computeds compare directly. Conformance.runComputeds({ makeDb: () => Database.toSystemDatabase(Database.create(ComputedDatabase.plugin)), store: (db) => db.store, fromState, toData, - derivationModules: import.meta.glob>( + derivations: import.meta.glob( [ "../../../data/state/*.ts", "!../../../data/state/*.test.ts", @@ -29,10 +25,6 @@ Conformance.runComputeds({ ], { eager: true }, ), - define: (conforms) => { - conforms("visibleTodos", { - cases: visibleTodosCases, - computed: visibleTodos, - }); - }, + computeds, + hydrate: ["visibleTodos"], }); diff --git a/packages/data-lit-todo/src/features/main/services/main-service/conformance/transactions.test.ts b/packages/data-lit-todo/src/features/main/services/main-service/conformance/transactions.test.ts index a8ed0647..55f4cd2d 100644 --- a/packages/data-lit-todo/src/features/main/services/main-service/conformance/transactions.test.ts +++ b/packages/data-lit-todo/src/features/main/services/main-service/conformance/transactions.test.ts @@ -1,64 +1,27 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +/// import { Conformance } from "@adobe/data/testing"; -import * as registeredTransactions from "../transaction-database/transactions/index.js"; -import { createTodo } from "../transaction-database/transactions/create-todo.js"; -import { createBulkTodos } from "../transaction-database/transactions/create-bulk-todos.js"; -import { deleteTodo } from "../transaction-database/transactions/delete-todo.js"; -import { deleteAllTodos } from "../transaction-database/transactions/delete-all-todos.js"; -import { dragTodo } from "../transaction-database/transactions/drag-todo.js"; -import { toggleComplete } from "../transaction-database/transactions/toggle-complete.js"; -import { toggleDisplayCompleted } from "../transaction-database/transactions/toggle-display-completed.js"; -import { cases as createTodoCases } from "../../../data/state/create-todo.js"; -import { cases as createBulkTodosCases } from "../../../data/state/create-bulk-todos.js"; -import { cases as deleteTodoCases } from "../../../data/state/delete-todo.js"; -import { cases as deleteAllTodosCases } from "../../../data/state/delete-all-todos.js"; -import { cases as reorderTodoCases } from "../../../data/state/reorder-todo.js"; -import { cases as toggleCompleteCases } from "../../../data/state/toggle-complete.js"; -import { cases as toggleDisplayCompletedCases } from "../../../data/state/toggle-display-completed.js"; +import * as transactions from "../transaction-database/transactions/index.js"; import { createStore } from "./create-store.js"; import { fromState } from "./from-state.js"; import { toState } from "./to-state.js"; -// The single conformance test for every ecs transaction. `runTransactions` owns -// the harness (fresh store, `fromState` seed, `resolve`, `toState` compare, -// coverage guard keyed off the registered barrel); only the bespoke `apply` -// adapters are per-transaction — an id-addressed transaction resolves its entity, -// `dragTodo` remaps to a final-drop reproducing `State.reorderTodo`. +// Every ecs transaction, conformed by name against its `data/state` transition — +// no per-item wiring. Entity-addressed transitions carry a `Conformance.entity` +// marker in their case args, which the runner resolves via the `fromState` id map. +// `dragTodo` has no same-named transition (the drag UI transaction) and is skipped; +// `reorderTodo` is conformed through its action. Conformance.runTransactions({ createStore, fromState, toState, - registered: registeredTransactions, - define: (conforms) => { - conforms("createTodo", { cases: createTodoCases, apply: createTodo }); - conforms("createBulkTodos", { - cases: createBulkTodosCases, - apply: createBulkTodos, - }); - conforms("deleteTodo", { - cases: deleteTodoCases, - apply: (t, args, resolve) => deleteTodo(t, resolve(args.id)), - }); - conforms("deleteAllTodos", { - cases: deleteAllTodosCases, - apply: deleteAllTodos, - }); - conforms("dragTodo", { - cases: reorderTodoCases, - apply: (t, args, resolve) => - dragTodo(t, { - entity: resolve(args.id), - dragPosition: 0, - finalIndex: args.toIndex, - }), - }); - conforms("toggleComplete", { - cases: toggleCompleteCases, - apply: (t, args, resolve) => toggleComplete(t, resolve(args.id)), - }); - conforms("toggleDisplayCompleted", { - cases: toggleDisplayCompletedCases, - apply: toggleDisplayCompleted, - }); - }, + transitions: import.meta.glob( + [ + "../../../data/state/*.ts", + "!../../../data/state/*.test.ts", + "!../../../data/state/*.type-test.ts", + ], + { eager: true }, + ), + transactions, }); diff --git a/packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/delete-todo.ts b/packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/delete-todo.ts index b3d8bc8a..ae375c23 100644 --- a/packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/delete-todo.ts +++ b/packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/delete-todo.ts @@ -2,7 +2,7 @@ import type { Entity } from "@adobe/data/ecs"; import type { CoreDatabase } from "../../core-database/core-database.js"; -export const deleteTodo = (t: CoreDatabase.Store, id: Entity) => { +export const deleteTodo = (t: CoreDatabase.Store, { id }: { id: Entity }) => { const todo = t.read(id); if (todo) { t.delete(id); diff --git a/packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/toggle-complete.ts b/packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/toggle-complete.ts index 85e3b963..704bcbe0 100644 --- a/packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/toggle-complete.ts +++ b/packages/data-lit-todo/src/features/main/services/main-service/transaction-database/transactions/toggle-complete.ts @@ -2,7 +2,7 @@ import type { Entity } from "@adobe/data/ecs"; import type { CoreDatabase } from "../../core-database/core-database.js"; -export const toggleComplete = (t: CoreDatabase.Store, id: Entity) => { +export const toggleComplete = (t: CoreDatabase.Store, { id }: { id: Entity }) => { const todo = t.read(id); if (todo) { t.update(id, { complete: !todo.complete }); diff --git a/packages/data-lit-todo/src/features/main/ui/todo-row/todo-row-element.ts b/packages/data-lit-todo/src/features/main/ui/todo-row/todo-row-element.ts index e5bd82f7..fa60cc1f 100644 --- a/packages/data-lit-todo/src/features/main/ui/todo-row/todo-row-element.ts +++ b/packages/data-lit-todo/src/features/main/ui/todo-row/todo-row-element.ts @@ -75,8 +75,8 @@ export class TodoRowElement extends TodoElement { toggleEditing: () => setEditing(!editing), index: this.index, entity: this.entity, - toggleComplete: () => this.service.actions.toggleComplete(this.entity), - deleteTodo: () => this.service.actions.deleteTodo(this.entity), + toggleComplete: () => this.service.actions.toggleComplete({ id: this.entity }), + deleteTodo: () => this.service.actions.deleteTodo({ id: this.entity }), }); } } diff --git a/packages/data/src/testing/conformance/discover.ts b/packages/data/src/testing/conformance/discover.ts new file mode 100644 index 00000000..d9489943 --- /dev/null +++ b/packages/data/src/testing/conformance/discover.ts @@ -0,0 +1,36 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +// One discovered `data/state` file: its function and its cases. +export interface Discovered { + readonly fn: (...args: unknown[]) => unknown; + readonly cases: readonly Record[]; +} + +const scan = ( + modules: Record>, + isKind: (firstCase: Record) => boolean, +): Map => { + const out = new Map(); + for (const [path, module] of Object.entries(modules)) { + const names = Object.keys(module); + if (!names.includes("cases")) continue; + const cases = module["cases"]; + if (!Array.isArray(cases) || cases.length === 0) continue; + const first = cases[0]; + if (typeof first !== "object" || first === null || !isKind(first as Record)) continue; + const fnName = names.find((key) => typeof module[key] === "function"); + if (!fnName) throw new Error(`${path} exports \`cases\` but no function to pair`); + out.set(fnName, { fn: module[fnName] as Discovered["fn"], cases: cases as Discovered["cases"] }); + } + return out; +}; + +// Transitions — files whose cases are `{ before, args?, after }` — keyed by the +// transform's function name (the name the ecs transaction/action must share). +export const discoverTransitions = (modules: Record>): Map => + scan(modules, (c) => "after" in c); + +// Derivations — files whose cases are `{ input, value }` — keyed by the +// derivation's function name (the name the ecs computed must share). +export const discoverDerivations = (modules: Record>): Map => + scan(modules, (c) => "value" in c); diff --git a/packages/data/src/testing/conformance/entity-ref.ts b/packages/data/src/testing/conformance/entity-ref.ts new file mode 100644 index 00000000..292ea7c2 --- /dev/null +++ b/packages/data/src/testing/conformance/entity-ref.ts @@ -0,0 +1,36 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Entity } from "../../ecs/entity/entity.js"; +import type { Resolve } from "./resolve.js"; + +// A spec entity reference inside a case's `args`: `{ id: entity(1) }` names "the +// entity seeded for spec-id 1". It closes the one irreducible spec↔ecs vocabulary +// gap — identity: the pure spec reads the data-id, the ecs reads the entity the +// runner resolves. Typed as the id it stands in for, so it slots into the +// transform's own arg type (`{ id: number }`), exactly like `Match.anyNumber`. +const ENTITY_REF = Symbol.for("@adobe/data/testing:entity-ref"); + +export const entity = (specId: T): T => ({ [ENTITY_REF]: specId }) as unknown as T; + +const isEntityRef = (value: unknown): value is { readonly [ENTITY_REF]: unknown } => + typeof value === "object" && value !== null && ENTITY_REF in value; + +// Adapt a case's `args` for one side of the conformance. `resolve` present → the +// ecs side (refs become seeded entities); absent → the pure-spec side (refs become +// their data-id). Only the top-level arg values are inspected — a `ref` is always a +// direct arg field. Non-ref values pass through untouched; a non-object `args` +// (a scalar `dt`, or `undefined`) passes through whole. +export const adaptArgs = (args: Args, resolve?: Resolve): Args => { + if (args === null || typeof args !== "object" || Array.isArray(args)) return args; + let changed = false; + const next: Record = { ...(args as object) }; + for (const [key, value] of Object.entries(next)) { + if (isEntityRef(value)) { + const specId = value[ENTITY_REF]; + next[key] = resolve ? (resolve(specId) as unknown) : specId; + changed = true; + } + } + return (changed ? next : args) as Args; +}; + +export type { Entity }; diff --git a/packages/data/src/testing/conformance/public.ts b/packages/data/src/testing/conformance/public.ts index 9736964e..3433b491 100644 --- a/packages/data/src/testing/conformance/public.ts +++ b/packages/data/src/testing/conformance/public.ts @@ -1,8 +1,9 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. export type { Case, Cases, DerivationCase, DerivationCases, Effects, ServiceCall } from "./types.js"; +export { entity } from "./entity-ref.js"; export { recordCalls, recordArgServices, splitAndRecordServices, expectEffects, type RecordedCall } from "./record-effects.js"; export { resolver, type Resolve } from "./resolve.js"; export { runSpec, type SpecOptions } from "./run-spec.js"; export { runTransactions, type TransactionConforms, type TransactionRunConfig } from "./run-transactions.js"; export { runActions, type ActionConforms, type ActionRunConfig } from "./run-actions.js"; -export { runComputeds, type ComputedConforms, type ComputedRunConfig } from "./run-computeds.js"; +export { runComputeds, type ComputedRunConfig } from "./run-computeds.js"; diff --git a/packages/data/src/testing/conformance/run-actions.ts b/packages/data/src/testing/conformance/run-actions.ts index dddfc18a..98fd3169 100644 --- a/packages/data/src/testing/conformance/run-actions.ts +++ b/packages/data/src/testing/conformance/run-actions.ts @@ -3,14 +3,32 @@ import { describe, it } from "vitest"; import type { Entity } from "../../ecs/entity/entity.js"; import { assert } from "../match/assert.js"; import type { MatchOptions } from "../match/match.js"; +import { adaptArgs } from "./entity-ref.js"; +import { discoverTransitions } from "./discover.js"; import { splitAndRecordServices, expectEffects } from "./record-effects.js"; import { resolver, type Resolve } from "./resolve.js"; import type { Case } from "./types.js"; -// Wire one action to a transition's shared cases. The case's service args become -// the db's recording service overrides (via `makeDb`), the plain args drive the -// action through `run`, and both the resulting state AND the declared effects are -// asserted. +// Auto-pairing config: discover transitions and the registered actions, pair by +// name, and conform each — no per-item wiring. The action is the app-facing seam: +// its injected services come from `db.services` (the case's service args become +// recording overrides via `makeDb`), and its plain args are the case args with +// service fields removed and `entity(specId)` markers resolved. Both the resulting +// state and the declared `effects` are asserted. +export interface ActionRunConfig { + readonly makeDb: (services: Record) => Db; + readonly store: (db: Db) => Store; + readonly fromState: (store: Store, before: State) => ReadonlyMap | void; + readonly toState: (store: Store) => State; + readonly transitions: Record>; + readonly actions: Record; + // Optional ambient, non-spec context a user-scoped feature needs before dispatch + // (e.g. the acting peer's `userId`) — the one seam not derivable from cases. + readonly seedContext?: (db: Db, before: State, args: unknown) => void; + readonly match?: MatchOptions; +} + +// Legacy explicit-wiring config (being retired as samples move to auto-pairing). export type ActionConforms = ( action: string, config: { @@ -18,25 +36,46 @@ export type ActionConforms = ( readonly run: (db: Db, input: Partial, resolve: Resolve) => Promise | void; }, ) => void; - -export interface ActionRunConfig { - // Build a db with the given (recording) service overrides — typically - // `Database.toSystemDatabase(Database.create(MainService.plugin, { services }))`. +export interface ActionDefineConfig { readonly makeDb: (services: Record) => Db; - // The writable store exposed by that db (usually `(db) => db.store`). readonly store: (db: Db) => Store; readonly fromState: (store: Store, before: State) => ReadonlyMap | void; readonly toState: (store: Store) => State; - // The registered-actions barrel — coverage requires every key wired. readonly registered: Record; readonly match?: MatchOptions; readonly define: (conforms: ActionConforms) => void; } // The single conformance test for every ecs action: each transition's cases run -// against its same-named async action, asserting state and effects. Transitions -// realized only by a transaction (not an action) are covered by `runTransactions`. -export const runActions = (config: ActionRunConfig): void => { +// against its same-named action, asserting state and the declared effects. +export function runActions(config: ActionRunConfig): void; +export function runActions(config: ActionDefineConfig): void; +export function runActions( + config: ActionRunConfig | ActionDefineConfig, +): void { + if ("transitions" in config) { + const transitions = discoverTransitions(config.transitions); + for (const [name, action] of Object.entries(config.actions)) { + if (typeof action !== "function") continue; + const paired = transitions.get(name); + if (!paired) continue; // action with no transition (e.g. a streaming port) — not conformed here + describe(`${name} action conforms`, () => { + for (const testCase of paired.cases) { + it(testCase.name as string, async () => { + const { services, input, calls } = splitAndRecordServices(testCase.args); + const db = config.makeDb(services); + const resolve = resolver(config.fromState(config.store(db), testCase.before as State)); + config.seedContext?.(db, testCase.before as State, testCase.args); + await (action as (d: Db, a?: unknown) => Promise | void)(db, adaptArgs(input, resolve)); + assert(config.toState(config.store(db)), testCase.after, config.match); + expectEffects(calls, testCase.effects as never); + }); + } + }); + } + return; + } + const covered = new Set(); const conforms = ( action: string, @@ -49,7 +88,6 @@ export const runActions = (config: ActionRunConfig { for (const testCase of aconfig.cases) { it(testCase.name, async () => { - // A void-arg case omits `args` (see `Case`); split yields empty maps. const args = (testCase as { readonly args?: Args }).args as Args; const { services, input, calls } = splitAndRecordServices(args); const db = config.makeDb(services); @@ -69,4 +107,4 @@ export const runActions = (config: ActionRunConfig(observe: Observe): T => { @@ -18,83 +19,47 @@ const readComputed = (observe: Observe): T => { return value; }; -const kebabToCamel = (name: string): string => - name.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase()); - -const isDerivationModule = (module: Record): boolean => { - const cases = module["cases"]; - return ( - Array.isArray(cases) && - cases.length > 0 && - typeof cases[0] === "object" && - cases[0] !== null && - "value" in (cases[0] as object) - ); -}; - -// Wire one computed to a derivation's shared `{ input, value }` cases. -export type ComputedConforms = ( - name: string, - config: { - readonly cases: readonly { readonly name: string; readonly input: unknown; readonly value: Value }[]; - readonly computed: (db: Db) => Observe; - // Project the raw emission into the value shape a derivation yields. Defaults - // to hydrating an entity-id list through `toData` (so an id-based list - // computed needs no adapter). Override for a scalar / single-entity output. - readonly project?: (raw: unknown, db: Db) => unknown; - }, -) => void; - +// Auto-pairing config: discover derivations and the registered computeds, pair by +// name, and conform each — no per-item wiring. A computed with no same-named +// derivation is skipped (single-`data/` math is covered by that helper's own +// test). The comparison is identity by default; a computed that emits an entity-id +// list names itself in `hydrate` so the runner maps each id through `toData` into +// the value shape the derivation yields. export interface ComputedRunConfig { - // Build a db from the COMPUTED layer (`Database.toSystemDatabase(Database.create( - // ComputedDatabase.plugin))`) — not the assembled feature db, whose higher - // layers may `withCache` a pre-seed value that a direct `fromState` seed cannot - // invalidate. readonly makeDb: () => Db; readonly store: (db: Db) => Store; readonly fromState: (store: Store, input: State) => unknown; - // Per-entity projection used by the default `project` to hydrate id lists. readonly toData?: (store: Store, entity: Entity) => unknown; - // The `data/state/` modules glob (eager) — coverage requires every derivation - // among them (a file whose cases are `{ input, value }`) to be wired. - readonly derivationModules: Record>; + readonly derivations: Record>; + readonly computeds: Record; + readonly hydrate?: readonly string[]; readonly match?: MatchOptions; - readonly define: (conforms: ComputedConforms) => void; } // The single conformance test for every ecs computed backing a `data/state` -// derivation: seed the store from the case `input`, read the computed's emission, -// hydrate it, and match the derivation's `value`. -export const runComputeds = (config: ComputedRunConfig): void => { - const hydrateEntities = (raw: unknown, db: Db): unknown => { - const toData = config.toData; - if (!toData) throw new Error("runComputeds: a list computed needs `toData` (or a `project`)"); - return (raw as readonly Entity[]).map((entity) => toData(config.store(db), entity)); - }; - const covered = new Set(); - const conforms: ComputedConforms = (name, cconfig) => { - covered.add(name); - const project = cconfig.project ?? hydrateEntities; +// derivation. Build `makeDb` from the `ComputedDatabase` layer (not the assembled +// db): a behaviour layer above may `withCache` a pre-seed value that a direct +// `fromState` seed cannot invalidate. +export function runComputeds(config: ComputedRunConfig): void { + const derivations = discoverDerivations(config.derivations); + const hydrate = new Set(config.hydrate ?? []); + for (const [name, computed] of Object.entries(config.computeds)) { + if (typeof computed !== "function") continue; + const paired = derivations.get(name); + if (!paired) continue; // computed with no `state/` derivation — covered by its data/ helper describe(`${name} computed conforms`, () => { - for (const testCase of cconfig.cases) { - it(testCase.name, () => { + for (const testCase of paired.cases) { + it(testCase.name as string, () => { const db = config.makeDb(); - // Runtime invariant: a derivation's `input` is authored as a full State. config.fromState(config.store(db), testCase.input as State); - const raw = readComputed(cconfig.computed(db)); - assert(project(raw, db), testCase.value, config.match); + const raw = readComputed((computed as (d: Db) => Observe)(db)); + const value = + hydrate.has(name) && config.toData + ? (raw as readonly Entity[]).map((e) => config.toData!(config.store(db), e)) + : raw; + assert(value, testCase.value, config.match); }); } }); - }; - config.define(conforms); - describe("computed conformance coverage", () => { - for (const [path, module] of Object.entries(config.derivationModules)) { - if (!isDerivationModule(module)) continue; - const name = kebabToCamel(path.replace(/.*\//, "").replace(/\.ts$/, "")); - it(`${name} has a computed conformance case`, () => { - if (!covered.has(name)) throw new Error(`${name} has no computed conformance case`); - }); - } - }); -}; + } +} diff --git a/packages/data/src/testing/conformance/run-spec.ts b/packages/data/src/testing/conformance/run-spec.ts index 65f7d6d7..8cb1e99a 100644 --- a/packages/data/src/testing/conformance/run-spec.ts +++ b/packages/data/src/testing/conformance/run-spec.ts @@ -2,6 +2,7 @@ import { describe, it } from "vitest"; import { assert } from "../match/assert.js"; import type { MatchOptions } from "../match/match.js"; +import { adaptArgs } from "./entity-ref.js"; import { recordArgServices, expectEffects } from "./record-effects.js"; import type { DerivationCase, Effects } from "./types.js"; @@ -52,7 +53,9 @@ export const runSpec = (modules: Record>, option readonly effects?: Effects>; }; it(tc.name, async () => { - const { args, calls } = recordArgServices(tc.args); + // Unwrap `entity(specId)` markers to their data-id for the pure spec, then + // wrap injected services so their calls are recorded. + const { args, calls } = recordArgServices(adaptArgs(tc.args)); assert(await fn(tc.before, args), tc.after, options.match); expectEffects(calls, tc.effects); }); diff --git a/packages/data/src/testing/conformance/run-transactions.ts b/packages/data/src/testing/conformance/run-transactions.ts index ee9aa811..feed4306 100644 --- a/packages/data/src/testing/conformance/run-transactions.ts +++ b/packages/data/src/testing/conformance/run-transactions.ts @@ -3,12 +3,30 @@ import { describe, it } from "vitest"; import type { Entity } from "../../ecs/entity/entity.js"; import { assert } from "../match/assert.js"; import type { MatchOptions } from "../match/match.js"; +import { adaptArgs } from "./entity-ref.js"; +import { discoverTransitions } from "./discover.js"; import { resolver, type Resolve } from "./resolve.js"; import type { Case } from "./types.js"; -// Wire one transaction to a transform's shared cases. `apply` receives the seeded -// writable store, the case args, and a `resolve` mapping a spec id to the seeded -// entity — then calls the raw transaction directly. +// Auto-pairing config: discover transitions (from the `data/state` glob) and the +// registered transactions (the facet barrel), pair by name, and conform each — +// no per-item wiring. A transaction with no same-named transition is +// infrastructure (e.g. `setInput`) and is skipped; a transition realized by an +// action or a system is conformed there. Entity-addressed args carry a +// `Conformance.entity(specId)` marker the runner resolves; a transaction ignores +// any injected-service arg (its effects are asserted through the action). +export interface TransactionRunConfig { + readonly createStore: () => Store; + readonly fromState: (store: Store, before: State) => ReadonlyMap | void; + readonly toState: (store: Store) => State; + // `import.meta.glob("../../../data/state/*.ts", { eager: true })`. + readonly transitions: Record>; + // `import * as transactions from "../transaction-database/transactions/index.js"`. + readonly transactions: Record; + readonly match?: MatchOptions; +} + +// Legacy explicit-wiring config (being retired as samples move to auto-pairing). export type TransactionConforms = ( transaction: string, config: { @@ -16,31 +34,43 @@ export type TransactionConforms = ( readonly apply: (store: Store, args: Args, resolve: Resolve) => void; }, ) => void; - -export interface TransactionRunConfig { +export interface TransactionDefineConfig { readonly createStore: () => Store; - // Seed a fresh store to `before`, returning the `spec id → seeded entity` map - // (or `void` when the feature is index/singleton-addressed and needs no - // resolution). readonly fromState: (store: Store, before: State) => ReadonlyMap | void; readonly toState: (store: Store) => State; - // The registered-transactions barrel — the coverage guard requires every key - // here to be wired, so none can be missed. readonly registered: Record; - // Transactions asserted OUTSIDE the shared-cases mechanism (e.g. one with no - // `data/` transform, checked with a direct resource assertion) — named here so - // the coverage guard counts them as covered. readonly covers?: readonly string[]; readonly match?: MatchOptions; readonly define: (conforms: TransactionConforms) => void; } // The single conformance test for every ecs transaction, proving -// `toState(apply(fromState(before), args)) ≡ after` for each shared case (half 1, -// `spec(before,args) ≡ after`, is asserted by `runSpec`). Bespoke `apply` -// adapters stay per-feature (an id-addressed transaction resolves its entity); the -// seed, projection, matching, and coverage guard are all shared here. -export const runTransactions = (config: TransactionRunConfig): void => { +// `toState(apply(fromState(before), args)) ≡ after` for each shared case. +export function runTransactions(config: TransactionRunConfig): void; +export function runTransactions(config: TransactionDefineConfig): void; +export function runTransactions( + config: TransactionRunConfig | TransactionDefineConfig, +): void { + if ("transitions" in config) { + const transitions = discoverTransitions(config.transitions); + for (const [name, transaction] of Object.entries(config.transactions)) { + if (typeof transaction !== "function") continue; + const paired = transitions.get(name); + if (!paired) continue; // infrastructure transaction — no transition to conform to + describe(`${name} transaction conforms`, () => { + for (const testCase of paired.cases) { + it(testCase.name as string, () => { + const store = config.createStore(); + const resolve = resolver(config.fromState(store, testCase.before as State)); + (transaction as (s: Store, a?: unknown) => void)(store, adaptArgs(testCase.args, resolve)); + assert(config.toState(store), testCase.after, config.match); + }); + } + }); + } + return; + } + const covered = new Set(); const conforms = ( transaction: string, @@ -55,7 +85,6 @@ export const runTransactions = (config: TransactionRunConfig { const store = config.createStore(); const resolve = resolver(config.fromState(store, testCase.before)); - // A void-arg case omits `args` (see `Case`); reading yields the correct `undefined`. const args = (testCase as { readonly args?: Args }).args as Args; tconfig.apply(store, args, resolve); assert(config.toState(store), testCase.after, config.match); @@ -72,4 +101,4 @@ export const runTransactions = (config: TransactionRunConfig Date: Fri, 7 Aug 2026 03:03:52 -0700 Subject: [PATCH 26/37] =?UTF-8?q?feat(data):=20finish=20zero-override=20co?= =?UTF-8?q?nformance=20=E2=80=94=20all=20samples=20auto-pair;=20remove=20d?= =?UTF-8?q?efine;=20rules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the auto-pairing migration across the fleet and removes the interim `define` code path. Every ECS conformance runner discovers by glob and pairs transition↔op by name; no per-item wiring, no coverage guards. Lib (@adobe/data/testing): - runTransactions/runActions/runComputeds are auto-only (name-pairing via discoverOps, which accepts a facet barrel OR a directory glob so ops kept out of the plugin facet still pair). `define`/`registered`/`covers` removed. - `Conformance.entity(specId)` arg marker resolves per side (spec→data-id, ecs→seeded entity); `seedContext` residual hook for user-scoped ambient context. - `Effects` type-test moved here once (per-feature type-tests deleted). Samples (all zero-override, Node 24 green): solid-dashboard 32, react-pixie 42 (entity markers + {id} arg keys), p2p 53 (presence seedContext for userId; per-transition actions discovered via actions-dir glob), space-rock 135 (createInitial transaction for name-parity; hitAsteroid/loseLife conformance dropped — collision covered by collision-detection.test + spec). todo 102, tictactoe 58 unchanged. Service fakes: create-fake.ts is now a single export; published schedules folded inline and cases inject their own values. Rules: state/conformance/transactions/actions/computed/index + services/index rewritten for auto-pairing (entity marker, seedContext, hydrate, name-parity, inject-own-values doubles). Co-Authored-By: Claude Sonnet 4.6 --- .../.claude/rules/features/data/state.md | 18 +- .../data-ai/.claude/rules/features/index.md | 3 +- .../.claude/rules/features/services/index.md | 22 +-- .../features/services/main-service/actions.md | 25 ++- .../services/main-service/computed.md | 15 +- .../services/main-service/conformance.md | 120 ++++++++----- .../services/main-service/transactions.md | 25 +-- .../main/data/state/spawn-random-wave.ts | 17 +- .../main-service/conformance/actions.test.ts | 37 ++-- .../conformance/transactions.test.ts | 168 +++--------------- .../transactions/create-initial.ts | 17 ++ .../transactions/index.ts | 1 + .../services/random-service/create-fake.ts | 19 +- .../main/services/random-service/public.ts | 2 +- .../main/data/state/play-opponent-move.ts | 17 +- .../services/opponent-service/create-fake.ts | 19 +- .../main/services/opponent-service/public.ts | 2 +- .../main/data/state/create-random-todo.ts | 25 ++- .../services/analytics-service/create-fake.ts | 21 +-- .../main/services/analytics-service/public.ts | 2 +- .../name-generator-service/create-fake.ts | 17 +- .../services/name-generator-service/public.ts | 2 +- .../main-service/conformance/actions.test.ts | 105 ++--------- .../conformance/transactions.test.ts | 74 ++------ .../create-connection-service.test.ts | 8 +- .../services/signaling-service/create-fake.ts | 27 ++- .../services/signaling-service/public.ts | 2 +- .../main-service/conformance/actions.test.ts | 44 +++-- .../conformance/transactions.test.ts | 35 ++-- .../main/data/state/conformance-case.ts | 7 +- .../main/data/state/set-sprite-active.ts | 6 +- .../main/data/state/set-sprite-hovered.ts | 6 +- .../main/data/state/toggle-sprite-active.ts | 8 +- .../actions/set-sprite-active.ts | 2 +- .../actions/set-sprite-hovered.ts | 2 +- .../actions/toggle-sprite-active.ts | 7 +- .../main-service/conformance/actions.test.ts | 79 ++------ .../conformance/transactions.test.ts | 57 ++---- .../transactions/set-sprite-active.ts | 6 +- .../transactions/set-sprite-hovered.ts | 6 +- .../transactions/toggle-sprite-active.ts | 9 +- .../src/features/main/ui/sprite/sprite.tsx | 9 +- .../main-service/conformance/actions.test.ts | 49 ++--- .../conformance/transactions.test.ts | 40 ++--- .../data/src/testing/conformance/discover.ts | 20 +++ .../testing/conformance/effects.type-test.ts} | 34 ++-- .../data/src/testing/conformance/public.ts | 6 +- .../src/testing/conformance/run-actions.ts | 104 +++-------- .../src/testing/conformance/run-computeds.ts | 5 +- .../testing/conformance/run-transactions.ts | 106 +++-------- 50 files changed, 539 insertions(+), 918 deletions(-) create mode 100644 packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/create-initial.ts rename packages/{data-lit-todo/src/features/main/data/state/conformance-case.type-test.ts => data/src/testing/conformance/effects.type-test.ts} (52%) diff --git a/packages/data-ai/.claude/rules/features/data/state.md b/packages/data-ai/.claude/rules/features/data/state.md index d6bfee3b..9703c6fc 100644 --- a/packages/data-ai/.claude/rules/features/data/state.md +++ b/packages/data-ai/.claude/rules/features/data/state.md @@ -45,11 +45,14 @@ binds `State` once so transform/derivation files can write a one-parameter type: ```ts // data/state/conformance-case.ts — the only per-feature conformance declaration -import type { Conformance as ConformanceApi } from "@adobe/data/testing"; +import { Conformance as ConformanceApi } from "@adobe/data/testing"; import type { State } from "./state.js"; export type Conformance unknown> = ConformanceApi.Cases; export type Derivation unknown> = ConformanceApi.DerivationCases; export type Effects = ConformanceApi.Effects; +// The entity-reference marker for identity-addressed case args, re-exported so +// cases import it beside `Conformance`: `args: { id: entity(2) }`. +export const entity = ConformanceApi.entity; ``` ```ts @@ -98,15 +101,22 @@ export const cases: Conformance = [ not a pinned value, so the two occurrences of the label must resolve to the same actual id and two labels can't collide. `anyNumber`/`anyString` are for an id a case does not pin at all; `ref` for one that must be consistent across the case. +- **Entity-addressed cases use `entity(specId)`.** A transition that addresses an + entity by id writes it as `args: { id: entity(2) }` — `entity` imported from the + feature's `conformance-case.ts` (re-exported from `@adobe/data/testing`). It types + as the id it stands for (like `Match.anyNumber`), so it slots into the transform's + own arg type. `runSpec` unwraps it to the plain data-id for the pure side; the ECS + runners resolve it to the seeded entity (see `conformance.md`). - No per-transform test. The single **`spec.test.ts`** is one call — `Conformance.runSpec(import.meta.glob(["./*.ts", "!./*.test.ts"], { eager: true }))` — that auto-discovers every sibling exporting `cases`, enforces the two-exports rule, and dispatches on case shape (a `value` case → derivation; otherwise a transition whose declared `effects` are also asserted). Pass `{ match }` only when the feature needs float tolerance or unordered collections (see `conformance.md`). - There is no per-feature `expect-state-matches.ts`, `record-effects.ts`, or - `expect-conforms.ts` — those are gone; the shared driver owns comparison, effect - recording, and the coverage guard. A genuine **non-transition helper** in + There is no per-feature `expect-state-matches.ts`, `record-effects.ts`, + `expect-conforms.ts`, or `conformance-case.type-test.ts` — those are gone; the + shared driver owns comparison, effect recording, and name-based auto-pairing, and + the `Effects` type-test now lives once in `@adobe/data/testing`. A genuine **non-transition helper** in `state/` — a `create()` constructor, a single-field predicate — has no `cases` and isn't a `(state,args)=>state` transform, so `runSpec` skips it: **keep its own sibling `*.test.ts`** rather than deleting it and losing coverage. diff --git a/packages/data-ai/.claude/rules/features/index.md b/packages/data-ai/.claude/rules/features/index.md index d55d1536..00f36bb9 100644 --- a/packages/data-ai/.claude/rules/features/index.md +++ b/packages/data-ai/.claude/rules/features/index.md @@ -103,7 +103,8 @@ The tie between `data/` (spec) and `main-service` (implementation) is main-service mutation, seeded and read back through a test-only store↔`State` projection, equals the pure `data/` transform it stands for. The per-feature projection lives in `services/main-service/conformance/` and is replayed by the -shared `@adobe/data/testing` runners (see `services/main-service/conformance.md`); +shared `@adobe/data/testing` runners, which pair each ECS op to its same-named +transition automatically (see `services/main-service/conformance.md`); the shared `{ before, args, after }` cases are spec-owned — co-located in each `data/state/.ts`, which exports its function plus `cases` — so conforming the implementation is "substitute the implementation, reuse the diff --git a/packages/data-ai/.claude/rules/features/services/index.md b/packages/data-ai/.claude/rules/features/services/index.md index 0ea44c2d..90e7d7ba 100644 --- a/packages/data-ai/.claude/rules/features/services/index.md +++ b/packages/data-ai/.claude/rules/features/services/index.md @@ -38,17 +38,19 @@ portability and lazy loading (`AsyncDataService.createLazy`). A service is the seam consumers swap out under test — `data/` transitions that take it as an injected dependency (`data/state.md`), actions, systems. Ship a **deterministic test double** alongside the interface, in the same namespace -folder and under the same `global/namespace.md` standard: implement it in a -sub-folder like the real factory and re-export through `public.ts` so callers -reach it as `MyService.createFake` (mirroring the `create` / `factory` pair). +folder and under the same `global/namespace.md` standard: `create-fake.ts` is a +**single export**, `createFake`, re-exported through `public.ts` so callers reach +it as `MyService.createFake` (mirroring the `create` / `factory` pair). -Because tests assert on exact `after` values, the double MUST **publish the -precise responses it gives** — the fixed value or the ordered sequence each -method returns — as part of its documented contract, not as a hidden -implementation detail. A test then relies on those published responses to derive -its expected result. If the double's outputs were opaque or free to change, -every consumer's assertions would be guessing; the published response schedule -is exactly what makes the double a dependable oracle. +Because tests assert on exact `after` values, the double must be **deterministic +and its responses caller-controlled**: `createFake` takes the exact response — the +fixed value, or the ordered sequence each method returns — as a parameter, with a +small inline default. A conformance case then **injects the responses it needs** +(`createFake(["random task"])`, `createFake([4])`) and authors its `after` / +`effects` against those values it supplied — nothing is read from a shared +published constant (that would be a second export, and it makes the assertion +guess at a value it doesn't own). The injected schedule is exactly what makes the +double a dependable oracle: the test controls both the input and the expectation. ## Where the I/O types live diff --git a/packages/data-ai/.claude/rules/features/services/main-service/actions.md b/packages/data-ai/.claude/rules/features/services/main-service/actions.md index d2fdcfbf..2543351a 100644 --- a/packages/data-ai/.claude/rules/features/services/main-service/actions.md +++ b/packages/data-ai/.claude/rules/features/services/main-service/actions.md @@ -48,13 +48,20 @@ export const addRandomTodo = async (service: ServiceDatabase) => { Reactive computeds refresh only on a committed transaction, so an imperative read of one can hand back a stale shared cache (and it's the UI's layer, not the action's). This also keeps the action correct under the conformance seed. -- **Conformance** (`conformance/actions.test.ts`) is a single - `Conformance.runActions({ makeDb, store, fromState, toState, registered, define })` - call: each `conforms(name, { cases, run })` runs the transition's shared cases - against its action, asserting **state and effects**. `makeDb(services)` builds - the db with the case's recording service overrides via - `Database.toSystemDatabase(Database.create(MainService.plugin, { services }))`; - the driver splits the case `args` into services and plain input, runs the action, - then `Match.assert`s `toState ≡ after` and checks the recorded service calls - against the case's `effects` (see `conformance.md`). +- **Conformance** (`conformance/actions.test.ts`) is a single, auto-paired + `Conformance.runActions({ makeDb, store, fromState, toState, transitions, actions, + match?, seedContext? })` call: it discovers the `data/state` transitions and pairs + each `actions` entry to the **same-named** transition. **The action is the primary + seam** — it reads injected services from `db.services`, so the case's service args + become recording overrides via `makeDb(services)` (built as + `Database.toSystemDatabase(Database.create(MainService.plugin, { services }))`); the + driver splits the case `args` into services and plain input, runs the action, then + `Match.assert`s `toState ≡ after` **and** checks the recorded calls against the + case's `effects`. There is no `define`/`conforms` and no coverage guard. A thin + **same-named** action gives a transaction-only or renamed transition something to + pair with (todo's `reorderTodo`). A streaming/capability action with no transition + is skipped — if it isn't in the facet barrel (p2p's `movePresence`, streamed via + `trackPresence`), point `actions:` at a directory glob + (`import.meta.glob([".../actions/*.ts", "!.../actions/index.ts"], { eager: true })`) + so it is still discovered (see `conformance.md`). - An `index.ts` barrel feeds the `actions` plugin facet. diff --git a/packages/data-ai/.claude/rules/features/services/main-service/computed.md b/packages/data-ai/.claude/rules/features/services/main-service/computed.md index 466a67c9..30d9ce04 100644 --- a/packages/data-ai/.claude/rules/features/services/main-service/computed.md +++ b/packages/data-ai/.claude/rules/features/services/main-service/computed.md @@ -48,11 +48,16 @@ registers it under the `computed` facet. **Conform a computed to its `data/state` derivation** whenever one exists. The derivation co-locates `{ input, value }` cases (`Derivation`), and -`conformance/computeds.test.ts` — one `Conformance.runComputeds(...)` call whose -`define` wires each computed — seeds the store from `input`, reads the computed's -value, and `Match.assert`s it against `value` (see `conformance.md`). A -list-computed returning entity ids needs no adapter — the runner hydrates through -`toData` by default (override `project` only for a scalar / single-entity output). +`conformance/computeds.test.ts` — one auto-paired `Conformance.runComputeds({ makeDb, +store, fromState, toData, derivations, computeds, hydrate?, match? })` call — pairs +each computed to its **same-named** derivation, seeds the store from `input`, reads +the computed's synchronous emission, and `Match.assert`s it against `value` (see +`conformance.md`). There is no `define`/`conforms` wiring. Build `makeDb` from the +**`ComputedDatabase`** layer. Comparison is identity by default; a computed that +emits an **entity-id list** names itself in `hydrate: [...]` (todo's `visibleTodos`) +so the runner projects each id through `toData`. A computed with **no `state/` +derivation is skipped** — single-`data/` math is covered by that helper's own +test. **What needs conformance is proportional to wiring logic.** A computed that composes/branches over the aggregate *is* a `state/` derivation (composes ≥2 diff --git a/packages/data-ai/.claude/rules/features/services/main-service/conformance.md b/packages/data-ai/.claude/rules/features/services/main-service/conformance.md index 662a0db9..1bab064b 100644 --- a/packages/data-ai/.claude/rules/features/services/main-service/conformance.md +++ b/packages/data-ai/.claude/rules/features/services/main-service/conformance.md @@ -36,10 +36,12 @@ here): `tolerance` (default `0.01`) to absorb F32↔f64 / trig noise. Framework-agnostic: it honors any asymmetric matcher, so vitest's `expect.any(...)` interops. - **`Conformance`** — the case types (`Case`, `Cases`, `DerivationCase`, - `DerivationCases`, `Effects`, `ServiceCall`), the id `resolver(map)`, and the - four runner drivers `runSpec` / `runTransactions` / `runActions` / - `runComputeds`. Effect recording and the coverage guard are built **into** the - drivers — no per-feature helper writes them. + `DerivationCases`, `Effects`, `ServiceCall`), the `entity(specId)` identity + marker, the id `resolver(map)`, and the four runner drivers `runSpec` / + `runTransactions` / `runActions` / `runComputeds`. Auto-pairing (transition ⇄ op + by name), effect recording, and id resolution are built **into** the drivers — + no per-feature helper writes them. The `Effects` type-test also lives here (once), + so there is no per-feature `conformance-case.type-test.ts` to author. ## Projection (store ⇄ State) — the one per-feature piece @@ -61,51 +63,89 @@ Only these files are feature-specific; each is small and mechanical: ## The four runner test files — one `Conformance.run*` call each -Each surface is a single driver call whose `define` callback wires each item's -bespoke adapter. The driver owns the fresh store, the `fromState` seed, `resolve` -(built from the returned map), the `toState` compare, effect recording, and a -**coverage guard keyed off the registered barrel** (every registered item must be -wired, or the guard fails). Pair by name; the adapters are the only per-feature -logic. +Each surface is a **single driver call, with no per-item wiring** — no `define` +callback, no `conforms(...)` adapters, no `registered`/`covers` coverage guards. +Each ECS runner takes the `data/state` **transitions** (or **derivations**) glob +and the ECS **ops** (a facet barrel `import * as x`, OR a directory glob +`import.meta.glob([".../ops/*.ts", "!.../index.ts"], { eager: true })` when an op +isn't registered in the facet), and **pairs them by name**: each ECS op is +conformed against the same-named transition/derivation. The driver owns the fresh +store, the `fromState` seed, `resolve` (built from the returned id map), the +`toState` compare, and effect recording. Auto-pairing can't forget an item, so +**there is no coverage guard**: an op with **no same-named transition** is +infrastructure or system-dispatched and is simply **skipped** (a streaming action +with no transition is skipped too). - **`data/state/spec.test.ts`** (in `data/state/`, not here) — the pure suite: `Conformance.runSpec(import.meta.glob([...], { eager: true }), { match? })`. Discovers every file exporting `cases`, enforces the two-exports rule, and dispatches on case shape (transition → state + effects, derivation → - `fn(input) ≡ value`). + `fn(input) ≡ value`). Unwraps any `entity(specId)` arg marker to its plain + data-id for the pure side. - **`transactions.test.ts`** — `Conformance.runTransactions({ createStore, - fromState, toState, registered, covers?, match?, define })`. Each - `conforms(name, { cases, apply })` wires a transaction's `apply(store, args, - resolve)` — an id-addressed transaction calls `resolve(args.id)`; a - differently-named transaction (`dragTodo` ⇄ `reorderTodo`) just names the cases - it reuses. A transaction with **no `data/` transform** (e.g. `setInput` / - `setBounds`, which only record a resource) is asserted directly with its own - `describe` + `Match.assert` and named in **`covers`** so the guard still counts - it. + fromState, toState, transitions, transactions, match?, seedContext? })`. + `transitions` is the `data/state` glob; `transactions` is the facet barrel. Each + transaction pairs to its same-named transition and is conformed **state-only** + (seed `fromState(before)`, apply, `Match.assert` `toState ≡ after`) — service + effects are asserted through the action. A transaction with no same-named + transition is skipped: `dragTodo` (the drag UI op), `setInput` / `setBounds` / + `newGame` (infra, no `data/` transform), `hitAsteroid` / `loseLife` + (system-dispatched). tictactoe is the zero-config example (moves are board-index + addressed — no `entity()` markers). - **`actions.test.ts`** — `Conformance.runActions({ makeDb, store, fromState, - toState, registered, match?, define })`. `makeDb(services)` builds the db with - the case's recording service overrides — - `Database.toSystemDatabase(Database.create(MainService.plugin, { services }))` - — and each `conforms(name, { cases, run })` runs the (async) action; the driver - splits the case `args` into services (wrapped for recording) and plain input, - then asserts **state and the declared `effects`**. A transition realized only by - a transaction (not an action) is covered by `runTransactions`, not here. + toState, transitions, actions, match?, seedContext? })`. **The action is the + primary, app-facing seam**: it reads injected services from `db.services`, so the + case's service args become **recording overrides** via `makeDb(services)` — + `Database.toSystemDatabase(Database.create(MainService.plugin, { services }))`. + The driver splits the case `args` into services (wrapped for recording) and plain + input, runs the (async) action, then asserts **state and the declared + `effects`**. A same-named thin action gives a transaction-only or renamed + transition something to pair with; `actions` may be the facet barrel OR a + directory glob when the op isn't in the barrel (p2p's `movePresence` — the UI + streams via `trackPresence` — is discovered via the actions glob). A + streaming/capability action with no transition is skipped. - **`computeds.test.ts`** — `Conformance.runComputeds({ makeDb, store, fromState, - toData?, derivationModules, match?, define })`. Each `conforms(name, { cases, - computed, project? })` seeds from the case `input`, reads the computed's - synchronous emission, hydrates it, and matches the derivation's `value`. An - id-list computed (`visibleTodos`) needs **no adapter** — the default `project` - hydrates through `toData`; override `project` only for a scalar / single-entity - output. **Build `makeDb` from the `ComputedDatabase` layer** + toData, derivations, computeds, hydrate?, match? })`. Pairs each computed to its + same-named `data/state` derivation, seeds from the case `input`, reads the + computed's synchronous emission, and matches the derivation's `value`. Comparison + is **identity by default**; a computed that emits an entity-id list names itself + in **`hydrate: [...]`** (todo's `visibleTodos`) so the runner projects each id + through `toData` into the value shape the derivation yields. **Build `makeDb` from + the `ComputedDatabase` layer** (`Database.toSystemDatabase(Database.create(ComputedDatabase.plugin))`), not the - assembled `MainService`: a behaviour layer above may `withCache` a pre-seed - value that a direct `fromState` seed emits no transaction to invalidate — the - computed layer keeps the seed authoritative. Coverage is keyed off the - `derivationModules` glob (every `data/state/` derivation must be wired). A - feature with **no `state/` derivation omits `computeds.test.ts` entirely** (a - guard registering zero tests fails vitest; don't ship an empty one). A computed - that trivially projects one `data/`'s math (`winner`/`status`) is - conformed by that type's helper tests, not here. + assembled `MainService`: a behaviour layer above may `withCache` a pre-seed value + that a direct `fromState` seed emits no transaction to invalidate — the computed + layer keeps the seed authoritative. A computed with **no `state/` derivation is + skipped** — single-`data/` math (tictactoe's `winner` / `status`) is covered + by that helper's own test. A feature with no `state/` derivation ships **no + `computeds.test.ts` at all** (a test file that registers zero tests fails vitest). + +## Identity — the `entity(specId)` marker + +An entity-addressed transition writes its addressed id as `args: { id: entity(2) }` +— import `entity`, re-exported from the feature's `data/state/conformance-case.ts`. +`runSpec` unwraps it to the plain data-id; the ECS runners resolve it to the +**seeded entity** via the id→entity map `fromState` returns (turned into a `resolve` +by `Conformance.resolver` — no feature writes `resolve` by hand). Two conventions +make the wiring vanish: the ECS op takes the entity **under the transition's own arg +key** (`{ id }`, same-shape args, no reshape), and `fromState` returns the +`ReadonlyMap` id→entity map (or `void` for an index-addressed / singleton +feature, whose ids then resolve to `Entity.none`). todo is the reference. + +## Name-parity — add a same-named op, never a per-item adapter + +Every app transition is realized by a **same-named** transaction and/or action. When +the real UI op is richer or renamed — todo's `dragTodo`, space-rock's `newGame` — that +op is infra, and a thin **same-named** op (todo's `reorderTodo` action, space-rock's +`createInitial` transaction) gives the transition something to pair with. Do **not** +reintroduce a per-item adapter to bridge a name mismatch — add the same-named op. + +## The residual seam — `seedContext` + +The one thing not derivable from cases is ambient, user-scoped context. Pass +`seedContext?: (store|db, before, args) => void` — it runs after `fromState`, before +the op — only when a feature needs it (p2p seeds the acting peer's `userId` from the +case's `mark`). Ordinary features omit it entirely. ## Ordering, tolerance, `ref` — all via `Match` options diff --git a/packages/data-ai/.claude/rules/features/services/main-service/transactions.md b/packages/data-ai/.claude/rules/features/services/main-service/transactions.md index 76a6e066..1992b6ac 100644 --- a/packages/data-ai/.claude/rules/features/services/main-service/transactions.md +++ b/packages/data-ai/.claude/rules/features/services/main-service/transactions.md @@ -32,19 +32,20 @@ export const playMove = (t: CoreDatabase.Store, { index }: PlayMoveArgs) => { result — read the touched slice, call the `data/` transform, write the diff. - Keep transaction files **single-export** (the `transactions/` barrel is `export *`-ed into the plugin facet, so a second export would pollute it). -- **Conformance is wired once, centrally** — not per-file. +- **Conformance is wired once, centrally, and auto-paired** — not per-file. `conformance/transactions.test.ts` is a single `Conformance.runTransactions({ - createStore, fromState, toState, registered, covers?, define })` call: each - `conforms(name, { cases, apply })` runs the transition's shared `data/state` - cases against its transaction — the shared driver seeds `fromState(before)`, - calls `apply(store, args, resolve)`, then `Match.assert`s `toState ≡ after` — - with a coverage guard keyed off the registered barrel so none are missed (see - `conformance.md`). A transaction taking **entity ids** resolves them with the - driver's `resolve` (`resolve(args.id)`, from the seeded `id → entity` map); a - differently-named or reused transaction (`dragTodo` ⇄ `reorderTodo`) just names - the cases it reuses; an extra transaction with no `data/` analogue (`setBounds`, - `setInput`) gets a direct `Match.assert` / resource check and is named in - `covers` so the guard still counts it. + createStore, fromState, toState, transitions, transactions, match?, seedContext? + })` call: it discovers the `data/state` transitions (the `transitions:` glob), + pairs each registered `transactions` barrel entry to the **same-named** transition, + and conforms it — seed `fromState(before)`, apply, `Match.assert` `toState ≡ after` + (state only; service effects are asserted through the action). There is **no** + `define`/`conforms` adapter and **no** `covers` guard. A transaction taking + **entity ids** takes them under the transition's own arg key (`{ id }`); the driver + resolves each `entity(specId)` marker via the id→entity map `fromState` returns. A + transaction with **no same-named transition** is infrastructure (`setInput`, + `setBounds`) or the drag UI op (`dragTodo`) or system-dispatched — it is simply + **skipped**, no guard needed. Don't add a per-item adapter for a renamed op; add a + thin same-named transaction instead (see `conformance.md`). - An `index.ts` barrel feeds the `transactions` plugin facet — so it must re-export **only** the mutations. A read/query helper shared by several transactions (`readShip`, `readBoard` — a `(t) => value` function) may live diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-random-wave.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-random-wave.ts index a00f1fdd..42758101 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-random-wave.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-random-wave.ts @@ -54,24 +54,25 @@ export const spawnRandomWave = < return { ...state, wave, asteroids }; }; -// Spec-owned cases, shared with the ecs `spawnRandomWave` transaction. The args -// carry the SAME injected double on both sides — `RandomService.createFake()` -// replays `RandomService.fakeRandoms` — so the randomized velocities are exact and -// the two sides agree: conformance stays honest even though the transition draws +// Spec-owned cases, shared with the ecs `spawnRandomWave` transaction. Each case +// injects its own fixed random sequence and authors `after` against it — the same +// double flows to both sides, so the randomized velocities are exact and the two +// sides agree: conformance stays honest even though the transition draws // randomness. `next` is a value-returning read (not a fire-and-forget side // effect), so it is NOT declared in `effects`. // -// The published sequence has length 4 and a spawn draws exactly 4 values (one per +// The injected sequence has length 4 and a spawn draws exactly 4 values (one per // asteroid at `asteroidsFor(1) = 4`). Field 200×200 → centre [100,100], ring // radius 80; positions match `spawnWave`, only drift SPEED is jittered: -// `speed(i) = 60·(0.5 + fakeRandoms[i])` → [30, 60, 45, 75] in ring order. +// `speed(i) = 60·(0.5 + sequence[i])` → [30, 60, 45, 75] in ring order. const field = { ...create(), bounds: [200, 200] as [number, number] }; +const randoms = [0, 0.5, 0.25, 0.75]; export const cases: Conformance = [ { name: "spawns a randomized wave (jittered drift speeds) when the field is clear", before: { ...field, asteroids: [], wave: 0 }, - args: { random: RandomService.createFake() }, + args: { random: RandomService.createFake(randoms) }, after: { ...field, wave: 1, @@ -90,7 +91,7 @@ export const cases: Conformance = [ wave: 1, asteroids: [{ position: [10, 10], velocity: [0, 0], size: "large" }], }, - args: { random: RandomService.createFake() }, + args: { random: RandomService.createFake(randoms) }, after: { ...field, wave: 1, diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/actions.test.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/actions.test.ts index c76557fc..fdca407b 100644 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/actions.test.ts +++ b/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/actions.test.ts @@ -1,21 +1,18 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +/// import { Database } from "@adobe/data/ecs"; import { Conformance } from "@adobe/data/testing"; import type { RandomService } from "../../random-service/random-service.js"; import { MainService } from "../main-service.js"; -import * as registeredActions from "../action-database/actions/index.js"; -import { fireBullet } from "../action-database/actions/fire-bullet.js"; -import { spawnRandomWave } from "../action-database/actions/spawn-random-wave.js"; -import { cases as fireBulletCases } from "../../../data/state/fire-bullet.js"; -import { cases as spawnRandomWaveCases } from "../../../data/state/spawn-random-wave.js"; +import * as actions from "../action-database/actions/index.js"; import { fromState } from "./from-state.js"; import { toState } from "./to-state.js"; -// Only the app-facing, single-transaction transitions get an action: `fireBullet` -// (no service) and `spawnRandomWave` (injects the `random` service — a -// value-returning read, so nothing is declared in `effects`). The per-frame step -// transitions are realized by the systems layer and conformed by the tick-loop -// test, not here. Entity bags compare as multisets via the `match` option. +// Every ecs action, conformed by name against its transition. `runActions` +// discovers transitions, turns each case's injected services into recording +// overrides via `makeDb`, runs the action, and asserts state + declared effects. +// Auto-pairs `fireBullet` and `spawnRandomWave`. Entity bags compare as +// multisets via the `match` option. Conformance.runActions({ // Runtime invariant: the recording wrappers preserve the service's shape, so // they are a valid factory override. @@ -28,16 +25,14 @@ Conformance.runActions({ store: (db) => db.store, fromState, toState, - registered: registeredActions, + transitions: import.meta.glob( + [ + "../../../data/state/*.ts", + "!../../../data/state/*.test.ts", + "!../../../data/state/*.type-test.ts", + ], + { eager: true }, + ), + actions, match: { unordered: new Set(["bullets", "asteroids"]) }, - define: (conforms) => { - conforms("fireBullet", { - cases: fireBulletCases, - run: (db) => fireBullet(db), - }); - conforms("spawnRandomWave", { - cases: spawnRandomWaveCases, - run: (db) => spawnRandomWave(db), - }); - }, }); diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/transactions.test.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/transactions.test.ts index 059d1842..193a6eb8 100644 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/transactions.test.ts +++ b/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/transactions.test.ts @@ -1,159 +1,33 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it, expect } from "vitest"; -import type { Entity } from "@adobe/data/ecs"; -import { Vec2 } from "@adobe/data/math"; +/// import { Conformance } from "@adobe/data/testing"; -import { Collision } from "../../../data/collision/collision.js"; -import { Bullet } from "../../../data/bullet/bullet.js"; -import { Asteroid } from "../../../data/asteroid/asteroid.js"; -import { Ship } from "../../../data/ship/ship.js"; +import * as transactions from "../transaction-database/transactions/index.js"; import { createStore } from "./create-store.js"; import { fromState } from "./from-state.js"; import { toState } from "./to-state.js"; -import * as registeredTransactions from "../transaction-database/transactions/index.js"; -import { setInput } from "../transaction-database/transactions/set-input.js"; -import { setBounds } from "../transaction-database/transactions/set-bounds.js"; -import { newGame } from "../transaction-database/transactions/new-game.js"; -import { spawnRandomWave } from "../transaction-database/transactions/spawn-random-wave.js"; -import { fireBullet } from "../transaction-database/transactions/fire-bullet.js"; -import { hitAsteroid } from "../transaction-database/transactions/hit-asteroid.js"; -import { loseLife } from "../transaction-database/transactions/lose-life.js"; -import { cases as createInitialCases } from "../../../data/state/create-initial.js"; -import { cases as spawnRandomWaveCases } from "../../../data/state/spawn-random-wave.js"; -import { cases as fireBulletCases } from "../../../data/state/fire-bullet.js"; -import { cases as resolveBulletHitsCases } from "../../../data/state/resolve-bullet-hits.js"; -import { cases as resolveShipHitsCases } from "../../../data/state/resolve-ship-hits.js"; -// The single conformance test for every ecs transaction. `runTransactions` owns the -// harness (fresh store, `fromState` seed, `toState` compare, coverage guard keyed -// off the registered barrel); the bespoke `apply` adapters stay here — several -// reproduce a collision system's per-pair dispatch against the seeded store. Entity -// bags compare as multisets via the `match` option. `setInput` / `setBounds` have no -// `data/` transform (they only record a resource), so they are asserted directly -// below and named in `covers` so the guard still counts them. +// Every ecs transaction, conformed by name against its `data/state` transition — +// no per-item wiring. `runTransactions` discovers the transitions (the glob), +// pairs each registered transaction to the same-named one, seeds `fromState`, +// applies, and compares `toState`. Auto-pairs `createInitial`, `spawnRandomWave`, +// and `fireBullet`; `newGame`/`setInput`/`setBounds` (infra — no `data/` +// transform) and `hitAsteroid`/`loseLife` (system-dispatched — the collision +// system's behavior is covered by `system-database/collision-detection.test.ts` +// and the `resolveBulletHits`/`resolveShipHits` transitions by +// `data/state/spec.test.ts`) have no same-named transition and are skipped. +// Entity bags compare as multisets via the `match` option. Conformance.runTransactions({ createStore, fromState, toState, - registered: registeredTransactions, - covers: ["setInput", "setBounds"], + transitions: import.meta.glob( + [ + "../../../data/state/*.ts", + "!../../../data/state/*.test.ts", + "!../../../data/state/*.type-test.ts", + ], + { eager: true }, + ), + transactions, match: { unordered: new Set(["bullets", "asteroids"]) }, - define: (conforms) => { - // newGame ⇄ createInitial: seed the bounds the transform reads, then rebuild. - conforms("newGame", { - cases: createInitialCases, - apply: (t, { bounds }) => { - setBounds(t, bounds); - newGame(t); - }, - }); - - // spawnRandomWave ⇄ State.spawnRandomWave: the same injected double drives both - // sides (carried in each case's `args.random`), so the jittered velocities agree. - conforms("spawnRandomWave", { - cases: spawnRandomWaveCases, - apply: spawnRandomWave, - }); - - // fireBullet ⇄ State.fireBullet: reads the seeded ship, inserts the muzzle bullet. - conforms("fireBullet", { - cases: fireBulletCases, - apply: (t) => fireBullet(t), - }); - - // hitAsteroid ⇄ State.resolveBulletHits. The transform resolves EVERY bullet's hit - // in one pass; the transaction resolves ONE (bullet, asteroid) pair — the collision - // system dispatches it once per overlapping bullet. This `apply` reproduces that - // dispatch loop: detect every pair FIRST against the untouched store (so no child a - // split spawns this pass can be a target), each asteroid claimed by at most one - // bullet, using the same SWEPT segment test, then apply. - conforms("hitAsteroid", { - cases: resolveBulletHitsCases, - apply: (t, dt: number) => { - const asteroids: readonly Entity[] = [ - ...t.select(t.archetypes.Asteroid.components), - ]; - const claimed = new Set(); - const hits: { readonly bullet: Entity; readonly asteroid: Entity }[] = - []; - for (const bullet of t.select(t.archetypes.Bullet.components)) { - const bulletRow = t.read(bullet, t.archetypes.Bullet); - if (bulletRow === null) continue; - const prev = Vec2.subtract( - bulletRow.position, - Vec2.scale(bulletRow.velocity, dt), - ); - for (const asteroid of asteroids) { - if (claimed.has(asteroid)) continue; - const asteroidRow = t.read(asteroid, t.archetypes.Asteroid); - if (asteroidRow === null) continue; - if ( - Collision.segmentCircleOverlap( - prev, - bulletRow.position, - asteroidRow.position, - Bullet.radius + Asteroid.radius(asteroidRow), - ) - ) { - claimed.add(asteroid); - hits.push({ bullet, asteroid }); - break; - } - } - } - for (const hit of hits) hitAsteroid(t, hit); - }, - }); - - // loseLife ⇄ State.resolveShipHits. The transform decides whether the ship is - // struck AND applies the consequence; the transaction is only the struck branch - // (spend a life, respawn). This `apply` reproduces that decision from the seeded - // store: dispatch `loseLife` iff the ship overlaps an asteroid. - conforms("loseLife", { - cases: resolveShipHitsCases, - apply: (t) => { - const [shipId] = t.select(t.archetypes.Ship.components); - if (shipId === undefined) return; - const shipRow = t.read(shipId, t.archetypes.Ship); - if (shipRow === null) return; - let struck = false; - for (const asteroid of t.select(t.archetypes.Asteroid.components)) { - const asteroidRow = t.read(asteroid, t.archetypes.Asteroid); - if (asteroidRow === null) continue; - if ( - Collision.circlesOverlap( - shipRow.position, - Ship.radius, - asteroidRow.position, - Asteroid.radius(asteroidRow), - ) - ) { - struck = true; - break; - } - } - if (struck) loseLife(t); - }, - }); - }, -}); - -// setInput / setBounds have no `data/` transform to conform to — they only record a -// resource — so they get a direct resource assertion (per transactions.md); they are -// named in `covers` above so the coverage guard still counts them. -describe("setInput transaction", () => { - it("writes the dispatched input to the resource verbatim", () => { - const store = createStore(); - const input = { turn: 1, thrust: true, fire: false }; - setInput(store, input); - expect(store.resources.input).toEqual(input); - }); -}); - -describe("setBounds transaction", () => { - it("writes the dispatched bounds to the resource verbatim", () => { - const store = createStore(); - setBounds(store, [1024, 768]); - expect(store.resources.bounds).toEqual([1024, 768]); - }); }); diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/create-initial.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/create-initial.ts new file mode 100644 index 00000000..f5c7e9ce --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/create-initial.ts @@ -0,0 +1,17 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Vec2 } from "@adobe/data/math"; +import type { CoreDatabase } from "../../core-database/core-database.js"; +import { setBounds } from "./set-bounds.js"; +import { newGame } from "./new-game.js"; + +// createInitial ⇄ State.createInitial: seed the play-field `bounds` the reset +// reads, then start a fresh game. This single transaction realizes the +// `createInitial` transition end-to-end so conformance can pair it by name; +// `newGame`/`setBounds` remain the infra pieces the UI drives directly. +export const createInitial = ( + t: CoreDatabase.Store, + { bounds }: { bounds: Vec2 }, +): void => { + setBounds(t, bounds); + newGame(t); +}; diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/index.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/index.ts index d19eb5c3..650d1eb7 100644 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/index.ts +++ b/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/index.ts @@ -2,6 +2,7 @@ export * from "./set-input.js"; export * from "./set-bounds.js"; export * from "./new-game.js"; +export * from "./create-initial.js"; export * from "./spawn-random-wave.js"; export * from "./fire-bullet.js"; export * from "./hit-asteroid.js"; diff --git a/packages/data-lit-space-rock-game/src/features/main/services/random-service/create-fake.ts b/packages/data-lit-space-rock-game/src/features/main/services/random-service/create-fake.ts index 7df943d8..cfe42b18 100644 --- a/packages/data-lit-space-rock-game/src/features/main/services/random-service/create-fake.ts +++ b/packages/data-lit-space-rock-game/src/features/main/services/random-service/create-fake.ts @@ -1,24 +1,15 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { RandomService } from "./random-service.js"; -/** - * Published response schedule for the deterministic double. `next` returns these - * values **in this exact order**, wrapping back to the start once exhausted. - * Consumers' tests rely on this sequence to compute their expected `after` — it - * is part of the double's contract, not a hidden detail. Four entries so a wave - * of `asteroidsFor(1) = 4` rocks draws exactly one full cycle (see - * `spawn-random-wave.cases.ts`). - */ -export const fakeRandoms = [0, 0.5, 0.25, 0.75] as const; - /** * Deterministic test double for {@link RandomService}. Unlike the real source it - * uses no `Math.random`: `next` returns the `sequence` in order (defaulting to - * {@link fakeRandoms}), cycling once exhausted. This is the implementation tests - * inject so their assertions are predictable — see `features/services/index.md`. + * uses no `Math.random`: `next` returns the `sequence` in order, cycling once + * exhausted. A case that needs specific draws passes its own `sequence`; the + * default is a harmless placeholder. This is the implementation tests inject so + * their assertions are predictable — see `features/services/index.md`. */ export const createFake = ( - sequence: readonly number[] = fakeRandoms, + sequence: readonly number[] = [0], ): RandomService => { let index = 0; return { diff --git a/packages/data-lit-space-rock-game/src/features/main/services/random-service/public.ts b/packages/data-lit-space-rock-game/src/features/main/services/random-service/public.ts index 8dac10e3..56c941d3 100644 --- a/packages/data-lit-space-rock-game/src/features/main/services/random-service/public.ts +++ b/packages/data-lit-space-rock-game/src/features/main/services/random-service/public.ts @@ -1,3 +1,3 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. export { create } from "./create.js"; -export { createFake, fakeRandoms } from "./create-fake.js"; +export { createFake } from "./create-fake.js"; diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/play-opponent-move.ts b/packages/data-lit-tictactoe/src/features/main/data/state/play-opponent-move.ts index 4006a5cc..f2da333d 100644 --- a/packages/data-lit-tictactoe/src/features/main/data/state/play-opponent-move.ts +++ b/packages/data-lit-tictactoe/src/features/main/data/state/play-opponent-move.ts @@ -27,10 +27,11 @@ export const playOpponentMove = async < }; // Spec-owned cases, shared with the ecs `playOpponentMove` action. Each injects -// the deterministic double and authors `after` against its PUBLISHED move -// schedule (`OpponentService.fakeMoves`, resolved in order). `selectMove` is a -// value-returning read, not a fire-and-forget side effect, so it is NOT declared -// in `effects` — the transition's whole observable result is the placed mark. +// the deterministic double with the exact move schedule it needs and authors +// `after` against it — the case owns its fixture, not a shared published const. +// `selectMove` is a value-returning read, not a fire-and-forget side effect, so +// it is NOT declared in `effects` — the transition's whole observable result is +// the placed mark. export const cases: Conformance = [ { name: "plays the opponent's first selected move for the current player", @@ -41,9 +42,9 @@ export const cases: Conformance = [ oWins: 0, draws: 0, }, - args: { opponent: OpponentService.createFake() }, - // fakeMoves[0] === 4; the current player on an empty board is the first - // player (X), so an X lands in the centre cell. + args: { opponent: OpponentService.createFake([4]) }, + // The injected move is cell 4; the current player on an empty board is the + // first player (X), so an X lands in the centre cell. after: { board: " X ", firstPlayer: "X", @@ -62,7 +63,7 @@ export const cases: Conformance = [ draws: 0, }, args: { opponent: OpponentService.createFake([0]) }, - // The published move is cell 0; the current player alternates to O by move + // The injected move is cell 0; the current player alternates to O by move // count, so an O lands in the top-left cell. after: { board: "O X ", diff --git a/packages/data-lit-tictactoe/src/features/main/services/opponent-service/create-fake.ts b/packages/data-lit-tictactoe/src/features/main/services/opponent-service/create-fake.ts index e8cbab0b..6b8a02c2 100644 --- a/packages/data-lit-tictactoe/src/features/main/services/opponent-service/create-fake.ts +++ b/packages/data-lit-tictactoe/src/features/main/services/opponent-service/create-fake.ts @@ -1,24 +1,15 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { OpponentService } from "./opponent-service.js"; -/** - * Published move schedule for the deterministic double. `selectMove` resolves - * with these indices **in this exact order**, wrapping back to the start once - * exhausted. Consumers' tests rely on this sequence to compute their expected - * `after` — it is part of the double's contract, not a hidden detail. - */ -export const fakeMoves = [4, 0, 8, 2, 6] as const; - /** * Deterministic test double for {@link OpponentService}. Unlike the real * selector it uses no randomness and no timers: `selectMove` returns the - * `moves` in order (defaulting to {@link fakeMoves}), cycling, each resolved on - * the microtask queue. This is the implementation tests inject so their - * assertions are predictable — see `features/services/index.md`. + * `moves` in order, cycling once exhausted, each resolved on the microtask + * queue. A case that needs a specific schedule passes its own `moves`; the + * default is a harmless placeholder. This is the implementation tests inject so + * their assertions are predictable — see `features/services/index.md`. */ -export const createFake = ( - moves: readonly number[] = fakeMoves, -): OpponentService => { +export const createFake = (moves: readonly number[] = [4]): OpponentService => { let index = 0; return { serviceName: "opponent", diff --git a/packages/data-lit-tictactoe/src/features/main/services/opponent-service/public.ts b/packages/data-lit-tictactoe/src/features/main/services/opponent-service/public.ts index acd839c3..56c941d3 100644 --- a/packages/data-lit-tictactoe/src/features/main/services/opponent-service/public.ts +++ b/packages/data-lit-tictactoe/src/features/main/services/opponent-service/public.ts @@ -1,3 +1,3 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. export { create } from "./create.js"; -export { createFake, fakeMoves } from "./create-fake.js"; +export { createFake } from "./create-fake.js"; diff --git a/packages/data-lit-todo/src/features/main/data/state/create-random-todo.ts b/packages/data-lit-todo/src/features/main/data/state/create-random-todo.ts index bc96dbc1..770f7646 100644 --- a/packages/data-lit-todo/src/features/main/data/state/create-random-todo.ts +++ b/packages/data-lit-todo/src/features/main/data/state/create-random-todo.ts @@ -29,24 +29,26 @@ export const createRandomTodo = async >( return next; }; -// Spec-owned cases. Each injects deterministic doubles and authors `after` + -// `effects` against their published responses (`fakeNames`, `fakeTiming`). The -// value-returning reads (`randomTodoRequested`, `generateName`) are still calls on -// `analytics`, so — analytics being a declared service — its full call sequence is -// listed; `nameGenerator` is not declared, so its read is ignored. +// Spec-owned cases. Each injects deterministic doubles with the exact responses +// it needs and authors `after` + `effects` against those self-owned values (the +// name it schedules, the fixed `{ startedAt: 0 }` timing the analytics double +// resolves). The value-returning reads (`randomTodoRequested`, `generateName`) +// are still calls on `analytics`, so — analytics being a declared service — its +// full call sequence is listed; `nameGenerator` is not declared, so its read is +// ignored. export const cases: Conformance = [ { name: "names the new todo from the generator and logs the timed add", before: { todos: [], displayCompleted: false }, args: { - nameGenerator: NameGeneratorService.createFake(), + nameGenerator: NameGeneratorService.createFake(["random task"]), analytics: AnalyticsService.createFake(), }, after: { todos: [ { id: Match.anyNumber, - name: NameGeneratorService.fakeNames[0], + name: "random task", complete: false, }, ], @@ -58,8 +60,8 @@ export const cases: Conformance = [ [ "randomTodoAdded", { - timing: AnalyticsService.fakeTiming, - name: NameGeneratorService.fakeNames[0], + timing: { startedAt: 0 }, + name: "random task", }, ], ], @@ -79,10 +81,7 @@ export const cases: Conformance = [ effects: { analytics: [ ["randomTodoRequested"], - [ - "randomTodoAdded", - { timing: AnalyticsService.fakeTiming, name: "only name" }, - ], + ["randomTodoAdded", { timing: { startedAt: 0 }, name: "only name" }], ], }, }, diff --git a/packages/data-lit-todo/src/features/main/services/analytics-service/create-fake.ts b/packages/data-lit-todo/src/features/main/services/analytics-service/create-fake.ts index 56c1fef2..da40c49c 100644 --- a/packages/data-lit-todo/src/features/main/services/analytics-service/create-fake.ts +++ b/packages/data-lit-todo/src/features/main/services/analytics-service/create-fake.ts @@ -1,20 +1,13 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Timing, AnalyticsService } from "./analytics-service.js"; - -/** - * Published response for the deterministic double. `randomTodoRequested` - * always resolves this exact {@link Timing} — consumers' tests rely on this - * fixed value to compute their expected `after`, it is part of the double's - * contract, not a hidden detail. - */ -export const fakeTiming: Timing = { startedAt: 0 }; +import type { AnalyticsService } from "./analytics-service.js"; /** * Deterministic test double for {@link AnalyticsService}. The `void` - * fire-and-forget methods do nothing; `randomTodoRequested` resolves - * {@link fakeTiming} on the microtask queue instead of reading the clock. This - * is the implementation tests inject so their assertions are predictable — - * see `features/services/index.md`. + * fire-and-forget methods do nothing; `randomTodoRequested` resolves a fixed + * timing of `{ startedAt: 0 }` on the microtask queue instead of reading the + * clock. A case that asserts on the timing references that literal directly. + * This is the implementation tests inject so their + * assertions are predictable — see `features/services/index.md`. */ export const createFake = (): AnalyticsService => ({ serviceName: "analytics", @@ -24,6 +17,6 @@ export const createFake = (): AnalyticsService => ({ todoDeleted: () => {}, allTodosCleared: () => {}, displayCompletedToggled: () => {}, - randomTodoRequested: () => Promise.resolve(fakeTiming), + randomTodoRequested: () => Promise.resolve({ startedAt: 0 }), randomTodoAdded: () => {}, }); diff --git a/packages/data-lit-todo/src/features/main/services/analytics-service/public.ts b/packages/data-lit-todo/src/features/main/services/analytics-service/public.ts index 9b636bab..56c941d3 100644 --- a/packages/data-lit-todo/src/features/main/services/analytics-service/public.ts +++ b/packages/data-lit-todo/src/features/main/services/analytics-service/public.ts @@ -1,3 +1,3 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. export { create } from "./create.js"; -export { createFake, fakeTiming } from "./create-fake.js"; +export { createFake } from "./create-fake.js"; diff --git a/packages/data-lit-todo/src/features/main/services/name-generator-service/create-fake.ts b/packages/data-lit-todo/src/features/main/services/name-generator-service/create-fake.ts index e6a43902..67a55ea7 100644 --- a/packages/data-lit-todo/src/features/main/services/name-generator-service/create-fake.ts +++ b/packages/data-lit-todo/src/features/main/services/name-generator-service/create-fake.ts @@ -1,23 +1,16 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { NameGeneratorService } from "./name-generator-service.js"; -/** - * Published response schedule for the deterministic double. `generateName` - * resolves with these names **in this exact order**, wrapping back to the start - * once exhausted. Consumers' tests rely on this sequence to compute their - * expected `after` — it is part of the double's contract, not a hidden detail. - */ -export const fakeNames = ["alpha task", "beta task", "gamma task"] as const; - /** * Deterministic test double for {@link NameGeneratorService}. Unlike the real * generator it uses no randomness and no timers: `generateName` returns the - * `responses` in order (defaulting to {@link fakeNames}), cycling, each resolved - * on the microtask queue. This is the implementation tests inject so their - * assertions are predictable — see `features/services/index.md`. + * `responses` in order, cycling once exhausted, each resolved on the microtask + * queue. A case that needs specific names passes its own `responses`; the + * default is a harmless placeholder. This is the implementation tests inject so + * their assertions are predictable — see `features/services/index.md`. */ export const createFake = ( - responses: readonly string[] = fakeNames, + responses: readonly string[] = ["a task"], ): NameGeneratorService => { let index = 0; return { diff --git a/packages/data-lit-todo/src/features/main/services/name-generator-service/public.ts b/packages/data-lit-todo/src/features/main/services/name-generator-service/public.ts index 31bb84fc..56c941d3 100644 --- a/packages/data-lit-todo/src/features/main/services/name-generator-service/public.ts +++ b/packages/data-lit-todo/src/features/main/services/name-generator-service/public.ts @@ -1,3 +1,3 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. export { create } from "./create.js"; -export { createFake, fakeNames } from "./create-fake.js"; +export { createFake } from "./create-fake.js"; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/actions.test.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/actions.test.ts index 50ee804e..b0025ae7 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/actions.test.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/actions.test.ts @@ -1,101 +1,34 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +/// import { Database } from "@adobe/data/ecs"; import { Conformance } from "@adobe/data/testing"; -import type { ConnectionService } from "../service-database/services/create-connection-service.js"; import { MainService } from "../main-service.js"; -import { startHostSignaling } from "../action-database/actions/start-host-signaling.js"; -import { startJoinSignaling } from "../action-database/actions/start-join-signaling.js"; -import { setOfferCode } from "../action-database/actions/set-offer-code.js"; -import { setAnswerCode } from "../action-database/actions/set-answer-code.js"; -import { setBanner } from "../action-database/actions/set-banner.js"; -import { setConnection } from "../action-database/actions/set-connection.js"; -import { setHostAnswerInput } from "../action-database/actions/set-host-answer-input.js"; -import { setJoinerOfferInput } from "../action-database/actions/set-joiner-offer-input.js"; -import { enterGame } from "../action-database/actions/enter-game.js"; -import { cases as startHostSignalingCases } from "../../../data/state/start-host-signaling.js"; -import { cases as startJoinSignalingCases } from "../../../data/state/start-join-signaling.js"; -import { cases as setOfferCodeCases } from "../../../data/state/set-offer-code.js"; -import { cases as setAnswerCodeCases } from "../../../data/state/set-answer-code.js"; -import { cases as setBannerCases } from "../../../data/state/set-banner.js"; -import { cases as setConnectionCases } from "../../../data/state/set-connection.js"; -import { cases as setHostAnswerInputCases } from "../../../data/state/set-host-answer-input.js"; -import { cases as setJoinerOfferInputCases } from "../../../data/state/set-joiner-offer-input.js"; -import { cases as enterGameCases } from "../../../data/state/enter-game.js"; import { fromState } from "./from-state.js"; import { toState } from "./to-state.js"; -// Each transition's cases run against its same-named ecs action, asserting state -// and effects. `runActions` splits the case's injected services into recording -// overrides via `makeDb`; negotiation transitions inject none, so the override is -// empty. The registered set the coverage guard checks is the transition-backed -// actions below — NOT the `actions` barrel, whose members are capability -// orchestration verbs (`startHost`, `submitAnswer`, …) with no pure-transition -// analogue that are not conformed here. +// Every ecs action, conformed by name against its transition. The per-transition +// actions live beside the barrel but aren't registered in the facet (that would +// grow the plugin type past tsc's budget), so we discover them by globbing the +// actions directory; the capability-orchestration verbs (configure/startHost/…) +// have no transition and are skipped. Negotiation injects no services. Conformance.runActions({ - // `toSystemDatabase` exposes the writable `.store` the projection needs. Runtime - // invariant: negotiation transitions inject no services, so the empty override - // object is a valid partial `services` factory map. makeDb: (services) => Database.toSystemDatabase( - Database.create(MainService.plugin, { - services: services as { connection?: ConnectionService }, - }), + Database.create(MainService.plugin, { services }), ), store: (db) => db.store, fromState, toState, - registered: { - startHostSignaling, - startJoinSignaling, - setOfferCode, - setAnswerCode, - setBanner, - setConnection, - setHostAnswerInput, - setJoinerOfferInput, - enterGame, - }, - define: (conforms) => { - conforms("startHostSignaling", { - cases: startHostSignalingCases, - run: (db) => startHostSignaling(db), - }); - conforms("startJoinSignaling", { - cases: startJoinSignalingCases, - run: (db) => startJoinSignaling(db), - }); - conforms("setOfferCode", { - cases: setOfferCodeCases, - run: (db, input) => setOfferCode(db, { code: input.code ?? "" }), - }); - conforms("setAnswerCode", { - cases: setAnswerCodeCases, - run: (db, input) => setAnswerCode(db, { code: input.code ?? "" }), - }); - conforms("setBanner", { - cases: setBannerCases, - run: (db, input) => - setBanner(db, { text: input.text ?? "", error: input.error }), - }); - conforms("setConnection", { - cases: setConnectionCases, - run: (db, input) => - setConnection(db, { - connection: input.connection ?? "idle", - sessionId: input.sessionId, - }), - }); - conforms("setHostAnswerInput", { - cases: setHostAnswerInputCases, - run: (db, input) => setHostAnswerInput(db, { value: input.value ?? "" }), - }); - conforms("setJoinerOfferInput", { - cases: setJoinerOfferInputCases, - run: (db, input) => setJoinerOfferInput(db, { value: input.value ?? "" }), - }); - conforms("enterGame", { - cases: enterGameCases, - run: (db) => enterGame(db), - }); - }, + transitions: import.meta.glob( + [ + "../../../data/state/*.ts", + "!../../../data/state/*.test.ts", + "!../../../data/state/*.type-test.ts", + ], + { eager: true }, + ), + actions: import.meta.glob( + ["../action-database/actions/*.ts", "!../action-database/actions/index.ts"], + { eager: true }, + ), }); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/transactions.test.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/transactions.test.ts index 7b40b823..e570cbd2 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/transactions.test.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/transactions.test.ts @@ -1,71 +1,25 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +/// import { Conformance } from "@adobe/data/testing"; -import * as registeredTransactions from "../transaction-database/transactions/index.js"; -import { startHostSignaling } from "../transaction-database/transactions/start-host-signaling.js"; -import { startJoinSignaling } from "../transaction-database/transactions/start-join-signaling.js"; -import { setOfferCode } from "../transaction-database/transactions/set-offer-code.js"; -import { setAnswerCode } from "../transaction-database/transactions/set-answer-code.js"; -import { setBanner } from "../transaction-database/transactions/set-banner.js"; -import { setConnection } from "../transaction-database/transactions/set-connection.js"; -import { setHostAnswerInput } from "../transaction-database/transactions/set-host-answer-input.js"; -import { setJoinerOfferInput } from "../transaction-database/transactions/set-joiner-offer-input.js"; -import { setGameDb } from "../transaction-database/transactions/set-game-db.js"; -import { cases as startHostSignalingCases } from "../../../data/state/start-host-signaling.js"; -import { cases as startJoinSignalingCases } from "../../../data/state/start-join-signaling.js"; -import { cases as setOfferCodeCases } from "../../../data/state/set-offer-code.js"; -import { cases as setAnswerCodeCases } from "../../../data/state/set-answer-code.js"; -import { cases as setBannerCases } from "../../../data/state/set-banner.js"; -import { cases as setConnectionCases } from "../../../data/state/set-connection.js"; -import { cases as setHostAnswerInputCases } from "../../../data/state/set-host-answer-input.js"; -import { cases as setJoinerOfferInputCases } from "../../../data/state/set-joiner-offer-input.js"; -import { cases as enterGameCases } from "../../../data/state/enter-game.js"; +import * as transactions from "../transaction-database/transactions/index.js"; import { createStore } from "./create-store.js"; import { fromState } from "./from-state.js"; import { toState } from "./to-state.js"; -// The single conformance test for every ecs transaction. `runTransactions` owns -// the harness (fresh store, `fromState` seed, `toState` compare, coverage guard -// keyed off the registered barrel); the pure half is asserted centrally by -// `data/state/spec.test.ts`. Negotiation resources are addressed by name, not -// entity id, so the `apply` adapters need no `resolve`. `setGameDb` is a -// differently-named transaction whose visible effect equals `State.enterGame`, so -// it wires the `enterGame` cases explicitly (passing `gameDb: null` isolates the -// serializable effect the spec observes). +// Every ecs transaction, conformed by name against its `data/state` transition. +// `setGameDb` has no transition (infra) and is skipped; `enterGame` has no +// transaction and is conformed through its action. Conformance.runTransactions({ createStore, fromState, toState, - registered: registeredTransactions, - define: (conforms) => { - conforms("startHostSignaling", { - cases: startHostSignalingCases, - apply: (t) => startHostSignaling(t), - }); - conforms("startJoinSignaling", { - cases: startJoinSignalingCases, - apply: (t) => startJoinSignaling(t), - }); - conforms("setOfferCode", { cases: setOfferCodeCases, apply: setOfferCode }); - conforms("setAnswerCode", { - cases: setAnswerCodeCases, - apply: setAnswerCode, - }); - conforms("setBanner", { cases: setBannerCases, apply: setBanner }); - conforms("setConnection", { - cases: setConnectionCases, - apply: setConnection, - }); - conforms("setHostAnswerInput", { - cases: setHostAnswerInputCases, - apply: setHostAnswerInput, - }); - conforms("setJoinerOfferInput", { - cases: setJoinerOfferInputCases, - apply: setJoinerOfferInput, - }); - conforms("setGameDb", { - cases: enterGameCases, - apply: (t) => setGameDb(t, { gameDb: null }), - }); - }, + transitions: import.meta.glob( + [ + "../../../data/state/*.ts", + "!../../../data/state/*.test.ts", + "!../../../data/state/*.type-test.ts", + ], + { eager: true }, + ), + transactions, }); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/service-database/services/create-connection-service.test.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/service-database/services/create-connection-service.test.ts index 5fe7e2f0..acec12bc 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/service-database/services/create-connection-service.test.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/service-database/services/create-connection-service.test.ts @@ -9,7 +9,7 @@ import { createConnectionService } from "./create-connection-service.js"; const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); describe("connection service (deterministic fake signaling)", () => { - it("startHost enters host-signaling and records the published invite code", async () => { + it("startHost enters host-signaling and records the invite code", async () => { const db = Database.create(TransactionDatabase.plugin); const service = createConnectionService(db, SignalingService.createFake); @@ -19,10 +19,10 @@ describe("connection service (deterministic fake signaling)", () => { expect(db.resources.connection).toBe("connecting"); await flush(); - expect(db.resources.offerCode).toBe(SignalingService.fakeInviteCode); + expect(db.resources.offerCode).toBe("fake-invite-code"); }); - it("generateAnswer records the published answer code", async () => { + it("generateAnswer records the answer code", async () => { const db = Database.create(TransactionDatabase.plugin); const service = createConnectionService(db, SignalingService.createFake); @@ -32,6 +32,6 @@ describe("connection service (deterministic fake signaling)", () => { service.generateAnswer(); await flush(); - expect(db.resources.answerCode).toBe(SignalingService.fakeAnswerCode); + expect(db.resources.answerCode).toBe("fake-answer-code"); }); }); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/signaling-service/create-fake.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/signaling-service/create-fake.ts index 0ac912ab..0f865d2f 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/signaling-service/create-fake.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/services/signaling-service/create-fake.ts @@ -3,30 +3,23 @@ import type { SignalingService } from "./signaling-service.js"; import type { Handlers } from "./types.js"; /** - * Published response schedule for the deterministic double. Unlike the real + * Deterministic test double for {@link SignalingService}. Unlike the real * service it performs no WebRTC I/O: `createHostInvite` always resolves - * {@link fakeInviteCode}, `createJoinAnswer` always resolves {@link fakeAnswerCode}, + * `"fake-invite-code"`, `createJoinAnswer` always resolves `"fake-answer-code"`, * and the connection-completion path (`onConnected`) never fires — the double * exercises the deterministic *code-exchange* orchestration, which is exactly the - * part a unit test can assert on. Consumers' tests rely on these fixed codes to - * compute their expected `after`; they are part of the contract, not a hidden - * detail. (Full transport wiring is integration-tested against the live app.) - */ -export const fakeInviteCode = "fake-invite-code"; -export const fakeAnswerCode = "fake-answer-code"; - -/** - * Deterministic test double for {@link SignalingService}. The `handlers` are - * accepted for signature parity with `create` but `onConnected` is intentionally - * never invoked (see above). This is the implementation the negotiation - * main-service tests inject so their assertions are predictable — see - * `features/services/index.md`. + * part a unit test can assert on. A test asserts those fixed codes as literals it + * controls. The `handlers` are accepted for signature parity with `create` but + * `onConnected` is intentionally never invoked. (Full transport wiring is + * integration-tested against the live app.) This is the implementation the + * negotiation main-service tests inject so their assertions are predictable — + * see `features/services/index.md`. */ export const createFake = (_handlers: Handlers): SignalingService => ({ serviceName: "signaling", - createHostInvite: () => Promise.resolve(fakeInviteCode), + createHostInvite: () => Promise.resolve("fake-invite-code"), acceptHostAnswer: () => Promise.resolve(), - createJoinAnswer: () => Promise.resolve(fakeAnswerCode), + createJoinAnswer: () => Promise.resolve("fake-answer-code"), reset: () => {}, dispose: () => {}, }); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/signaling-service/public.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/signaling-service/public.ts index 15d18bc5..3e5a7d56 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/signaling-service/public.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/services/signaling-service/public.ts @@ -1,4 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. export type * from "./types.js"; export { create } from "./create.js"; -export { createFake, fakeInviteCode, fakeAnswerCode } from "./create-fake.js"; +export { createFake } from "./create-fake.js"; diff --git a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/actions.test.ts b/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/actions.test.ts index a7f05c20..e2989767 100644 --- a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/actions.test.ts +++ b/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/actions.test.ts @@ -1,21 +1,18 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +/// import { Database } from "@adobe/data/ecs"; import type { ConcurrencyStrategyFactory } from "@adobe/data/ecs"; import { Conformance } from "@adobe/data/testing"; import { MainService } from "../main-service.js"; -import { movePresence } from "../action-database/actions/move-presence.js"; -import { cases as movePresenceCases } from "../../../data/state/move-presence.js"; import { fromState } from "./from-state.js"; import { toState } from "./to-state.js"; -// `movePresence`'s peer identity is the transaction `userId` (the peer's assigned -// mark). A live game database stamps it via its concurrency strategy at db -// construction — before the shared `runActions` driver knows the case, and the -// mark travels as plain action input (never a service) so `makeDb` cannot see it. -// So a test-only concurrency reads the peer id from a closure the `run` adapter -// primes immediately before dispatch; otherwise it commits immediately, like -// `createImmediateConcurrency`. This reproduces the old runner's per-case -// `createRebaseReplayConcurrency(mark)` db through the generic driver. +// `movePresence`'s peer identity is the transaction `userId`, stamped by the db's +// concurrency at dispatch. A test-only concurrency reads it from a closure that +// `seedContext` primes with the case's `mark` just before the action runs; it +// otherwise commits immediately. This is the one residual seam (user-scoped +// context). The per-transition `movePresence` action isn't in the facet barrel +// (the UI streams via `trackPresence`), so it's discovered via the actions glob. let peerUserId: string | undefined; const peerConcurrency: ConcurrencyStrategyFactory = ( execute, @@ -35,10 +32,6 @@ const peerConcurrency: ConcurrencyStrategyFactory = ( onReset: () => {}, }); -// The registered set the coverage guard checks: only the transition-backed -// `movePresence` action. The UI-facing streaming `trackPresence` action (the sole -// member of the `actions` barrel) has no pure-transition analogue and is not -// conformed here, so the barrel is not the registered set. Conformance.runActions({ makeDb: () => Database.toSystemDatabase( @@ -47,14 +40,19 @@ Conformance.runActions({ store: (db) => db.store, fromState, toState, - registered: { movePresence }, - define: (conforms) => { - conforms("movePresence", { - cases: movePresenceCases, - run: (db, input) => { - peerUserId = input.mark; - return movePresence(db, { x: input.x ?? 0, y: input.y ?? 0 }); - }, - }); + transitions: import.meta.glob( + [ + "../../../data/state/*.ts", + "!../../../data/state/*.test.ts", + "!../../../data/state/*.type-test.ts", + ], + { eager: true }, + ), + actions: import.meta.glob( + ["../action-database/actions/*.ts", "!../action-database/actions/index.ts"], + { eager: true }, + ), + seedContext: (_db, _before, args) => { + peerUserId = (args as { mark: string }).mark; }, }); diff --git a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/transactions.test.ts b/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/transactions.test.ts index 81f85385..57adeee7 100644 --- a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/transactions.test.ts +++ b/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/transactions.test.ts @@ -1,31 +1,28 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +/// import { Conformance } from "@adobe/data/testing"; -import * as registeredTransactions from "../transaction-database/transactions/index.js"; -import { movePresence } from "../transaction-database/transactions/move-presence.js"; -import { cases as movePresenceCases } from "../../../data/state/move-presence.js"; +import * as transactions from "../transaction-database/transactions/index.js"; import { createStore } from "./create-store.js"; import { seedUserId } from "./seed-user-id.js"; import { fromState } from "./from-state.js"; import { toState } from "./to-state.js"; -// The single conformance test for every ecs transaction. `runTransactions` owns -// the harness (fresh store, `fromState` seed, `toState` compare, coverage guard -// keyed off the registered barrel). `movePresence` reads the peer identity from -// the transaction `userId` (the peer's assigned mark), so the bespoke `apply` -// seeds that identity from the case's `mark` before dispatching the raw -// transaction with the plain `{ x, y }` payload. +// `movePresence` reads the peer identity from the transaction `userId` (the peer's +// mark) — ambient context not derivable from the case, so `seedContext` seeds it +// from the case's `mark` before the raw transaction runs (the one residual hook). Conformance.runTransactions({ createStore, fromState, toState, - registered: registeredTransactions, - define: (conforms) => { - conforms("movePresence", { - cases: movePresenceCases, - apply: (store, { mark, x, y }) => { - seedUserId(store, mark); - movePresence(store, { x, y }); - }, - }); - }, + transitions: import.meta.glob( + [ + "../../../data/state/*.ts", + "!../../../data/state/*.test.ts", + "!../../../data/state/*.type-test.ts", + ], + { eager: true }, + ), + transactions, + seedContext: (store, _before, args) => + seedUserId(store, (args as { mark: string }).mark), }); diff --git a/packages/data-react-pixie/src/features/main/data/state/conformance-case.ts b/packages/data-react-pixie/src/features/main/data/state/conformance-case.ts index 4426eca4..6d387ed4 100644 --- a/packages/data-react-pixie/src/features/main/data/state/conformance-case.ts +++ b/packages/data-react-pixie/src/features/main/data/state/conformance-case.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Conformance as ConformanceApi } from "@adobe/data/testing"; +import { Conformance as ConformanceApi } from "@adobe/data/testing"; import type { State } from "./state.js"; // The conformance case types for this feature — the shared `@adobe/data/testing` @@ -11,3 +11,8 @@ export type Conformance unknown> = ConformanceApi.Cases; export type Derivation unknown> = ConformanceApi.DerivationCases; + +// The entity-reference marker for case args: `args: { id: entity(2) }` names "the +// entity seeded for spec-id 2". The pure spec reads `2`; the ecs runner resolves +// it to the seeded entity. Re-exported here so cases import it beside `Conformance`. +export const entity = ConformanceApi.entity; diff --git a/packages/data-react-pixie/src/features/main/data/state/set-sprite-active.ts b/packages/data-react-pixie/src/features/main/data/state/set-sprite-active.ts index f42e6b18..efb92f94 100644 --- a/packages/data-react-pixie/src/features/main/data/state/set-sprite-active.ts +++ b/packages/data-react-pixie/src/features/main/data/state/set-sprite-active.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Sprite } from "../sprite/sprite.js"; import type { State } from "./state.js"; -import type { Conformance } from "./conformance-case.js"; +import { entity, type Conformance } from "./conformance-case.js"; import { Match } from "@adobe/data/testing"; export const setSpriteActive = >( @@ -38,7 +38,7 @@ export const cases: Conformance = [ { name: "sets active true on the addressed sprite only", before: { sprites: [bunny, fox], filter: "none" }, - args: { id: 2, active: true }, + args: { id: entity(2), active: true }, after: { sprites: [ { ...bunny, id: Match.anyNumber }, @@ -50,7 +50,7 @@ export const cases: Conformance = [ { name: "is a no-op for an unknown id", before: { sprites: [bunny, fox], filter: "none" }, - args: { id: 99, active: true }, + args: { id: entity(99), active: true }, after: { sprites: [ { ...bunny, id: Match.anyNumber }, diff --git a/packages/data-react-pixie/src/features/main/data/state/set-sprite-hovered.ts b/packages/data-react-pixie/src/features/main/data/state/set-sprite-hovered.ts index 69d9b639..2aa615a2 100644 --- a/packages/data-react-pixie/src/features/main/data/state/set-sprite-hovered.ts +++ b/packages/data-react-pixie/src/features/main/data/state/set-sprite-hovered.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Sprite } from "../sprite/sprite.js"; import type { State } from "./state.js"; -import type { Conformance } from "./conformance-case.js"; +import { entity, type Conformance } from "./conformance-case.js"; import { Match } from "@adobe/data/testing"; export const setSpriteHovered = >( @@ -38,7 +38,7 @@ export const cases: Conformance = [ { name: "sets hovered true on the addressed sprite only", before: { sprites: [bunny, fox], filter: "none" }, - args: { id: 1, hovered: true }, + args: { id: entity(1), hovered: true }, after: { sprites: [ { ...bunny, id: Match.anyNumber, hovered: true }, @@ -50,7 +50,7 @@ export const cases: Conformance = [ { name: "is a no-op for an unknown id", before: { sprites: [bunny, fox], filter: "none" }, - args: { id: 99, hovered: true }, + args: { id: entity(99), hovered: true }, after: { sprites: [ { ...bunny, id: Match.anyNumber }, diff --git a/packages/data-react-pixie/src/features/main/data/state/toggle-sprite-active.ts b/packages/data-react-pixie/src/features/main/data/state/toggle-sprite-active.ts index 17135047..3bf8d6de 100644 --- a/packages/data-react-pixie/src/features/main/data/state/toggle-sprite-active.ts +++ b/packages/data-react-pixie/src/features/main/data/state/toggle-sprite-active.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Sprite } from "../sprite/sprite.js"; import type { State } from "./state.js"; -import type { Conformance } from "./conformance-case.js"; +import { entity, type Conformance } from "./conformance-case.js"; import { Match } from "@adobe/data/testing"; export const toggleSpriteActive = >( @@ -38,7 +38,7 @@ export const cases: Conformance = [ { name: "toggles a sprite from inactive to active", before: { sprites: [bunny, activeFox], filter: "none" }, - args: { id: 1 }, + args: { id: entity(1) }, after: { sprites: [ { ...bunny, id: Match.anyNumber, active: true }, @@ -50,7 +50,7 @@ export const cases: Conformance = [ { name: "toggles a sprite from active to inactive", before: { sprites: [bunny, activeFox], filter: "none" }, - args: { id: 2 }, + args: { id: entity(2) }, after: { sprites: [ { ...bunny, id: Match.anyNumber }, @@ -62,7 +62,7 @@ export const cases: Conformance = [ { name: "is a no-op for an unknown id", before: { sprites: [bunny, activeFox], filter: "none" }, - args: { id: 99 }, + args: { id: entity(99) }, after: { sprites: [ { ...bunny, id: Match.anyNumber }, diff --git a/packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/set-sprite-active.ts b/packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/set-sprite-active.ts index 2cd48aaa..64339b29 100644 --- a/packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/set-sprite-active.ts +++ b/packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/set-sprite-active.ts @@ -6,7 +6,7 @@ import type { TransactionDatabase } from "../../transaction-database/transaction // entity directly; the action commits through the same-named transaction. export const setSpriteActive = ( db: TransactionDatabase, - input: { readonly entity: Entity; readonly active: boolean }, + input: { readonly id: Entity; readonly active: boolean }, ) => { db.transactions.setSpriteActive(input); }; diff --git a/packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/set-sprite-hovered.ts b/packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/set-sprite-hovered.ts index 3d01a852..01c206ed 100644 --- a/packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/set-sprite-hovered.ts +++ b/packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/set-sprite-hovered.ts @@ -5,7 +5,7 @@ import type { TransactionDatabase } from "../../transaction-database/transaction // The app-facing realization of `State.setSpriteHovered`. export const setSpriteHovered = ( db: TransactionDatabase, - input: { readonly entity: Entity; readonly hovered: boolean }, + input: { readonly id: Entity; readonly hovered: boolean }, ) => { db.transactions.setSpriteHovered(input); }; diff --git a/packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/toggle-sprite-active.ts b/packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/toggle-sprite-active.ts index 165841c2..a12f6786 100644 --- a/packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/toggle-sprite-active.ts +++ b/packages/data-react-pixie/src/features/main/services/main-service/action-database/actions/toggle-sprite-active.ts @@ -3,6 +3,9 @@ import type { Entity } from "@adobe/data/ecs"; import type { TransactionDatabase } from "../../transaction-database/transaction-database.js"; // The app-facing realization of `State.toggleSpriteActive`. -export const toggleSpriteActive = (db: TransactionDatabase, entity: Entity) => { - db.transactions.toggleSpriteActive({ entity }); +export const toggleSpriteActive = ( + db: TransactionDatabase, + { id }: { readonly id: Entity }, +) => { + db.transactions.toggleSpriteActive({ id }); }; diff --git a/packages/data-react-pixie/src/features/main/services/main-service/conformance/actions.test.ts b/packages/data-react-pixie/src/features/main/services/main-service/conformance/actions.test.ts index 554c7198..0a22629e 100644 --- a/packages/data-react-pixie/src/features/main/services/main-service/conformance/actions.test.ts +++ b/packages/data-react-pixie/src/features/main/services/main-service/conformance/actions.test.ts @@ -1,33 +1,18 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +/// import { Database } from "@adobe/data/ecs"; import { Conformance } from "@adobe/data/testing"; import { MainService } from "../main-service.js"; -import * as registeredActions from "../action-database/actions/index.js"; -import { createSprite } from "../action-database/actions/create-sprite.js"; -import { setFilter } from "../action-database/actions/set-filter.js"; -import { setSpriteActive } from "../action-database/actions/set-sprite-active.js"; -import { setSpriteHovered } from "../action-database/actions/set-sprite-hovered.js"; -import { toggleSpriteActive } from "../action-database/actions/toggle-sprite-active.js"; -import { tick } from "../action-database/actions/tick.js"; -import { cases as createSpriteCases } from "../../../data/state/create-sprite.js"; -import { cases as setFilterCases } from "../../../data/state/set-filter.js"; -import { cases as setSpriteActiveCases } from "../../../data/state/set-sprite-active.js"; -import { cases as setSpriteHoveredCases } from "../../../data/state/set-sprite-hovered.js"; -import { cases as toggleSpriteActiveCases } from "../../../data/state/toggle-sprite-active.js"; -import { cases as tickCases } from "../../../data/state/tick.js"; +import * as actions from "../action-database/actions/index.js"; import { fromState } from "./from-state.js"; import { toState } from "./to-state.js"; -// Each transition's cases run against its same-named ecs action. `runActions` -// splits the case's injected services into recording overrides (via `makeDb`), -// runs the action, then asserts both the resulting state and the declared effects; -// the harness/coverage are shared. This feature injects no services, so the -// `{ services }` override is always empty — the runner shape stays identical to -// the multi-service reference. +// Every ecs action, conformed by name against its transition. The runner splits +// each case's injected services into recording overrides via `makeDb`, resolves +// `entity()` arg markers to the seeded entity, runs the action, and asserts state +// + declared effects. This feature injects no services, so the override is always +// empty (no cast). Conformance.runActions({ - // `toSystemDatabase` exposes the writable `.store` the projection needs. The - // feature declares no injected services, so `Record` is - // assignable to the (empty) services override without a cast. makeDb: (services) => Database.toSystemDatabase( Database.create(MainService.plugin, { services }), @@ -35,45 +20,13 @@ Conformance.runActions({ store: (db) => db.store, fromState, toState, - registered: registeredActions, - define: (conforms) => { - conforms("createSprite", { - cases: createSpriteCases, - run: (db, input) => - createSprite(db, { - position: input.position ?? [0, 0], - rotation: input.rotation, - kind: input.kind ?? "bunny", - }), - }); - conforms("setFilter", { - cases: setFilterCases, - run: (db, input) => setFilter(db, { filter: input.filter ?? "none" }), - }); - conforms("setSpriteActive", { - cases: setSpriteActiveCases, - run: (db, input, resolve) => - setSpriteActive(db, { - entity: resolve(input.id ?? -1), - active: input.active ?? false, - }), - }); - conforms("setSpriteHovered", { - cases: setSpriteHoveredCases, - run: (db, input, resolve) => - setSpriteHovered(db, { - entity: resolve(input.id ?? -1), - hovered: input.hovered ?? false, - }), - }); - conforms("toggleSpriteActive", { - cases: toggleSpriteActiveCases, - run: (db, input, resolve) => - toggleSpriteActive(db, resolve(input.id ?? -1)), - }); - conforms("tick", { - cases: tickCases, - run: (db, input) => tick(db, { delta: input.delta ?? 0 }), - }); - }, + transitions: import.meta.glob( + [ + "../../../data/state/*.ts", + "!../../../data/state/*.test.ts", + "!../../../data/state/*.type-test.ts", + ], + { eager: true }, + ), + actions, }); diff --git a/packages/data-react-pixie/src/features/main/services/main-service/conformance/transactions.test.ts b/packages/data-react-pixie/src/features/main/services/main-service/conformance/transactions.test.ts index 691242b7..9de85e1a 100644 --- a/packages/data-react-pixie/src/features/main/services/main-service/conformance/transactions.test.ts +++ b/packages/data-react-pixie/src/features/main/services/main-service/conformance/transactions.test.ts @@ -1,52 +1,27 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +/// import { Conformance } from "@adobe/data/testing"; -import * as registeredTransactions from "../transaction-database/transactions/index.js"; -import { createSprite } from "../transaction-database/transactions/create-sprite.js"; -import { setFilter } from "../transaction-database/transactions/set-filter.js"; -import { setSpriteActive } from "../transaction-database/transactions/set-sprite-active.js"; -import { setSpriteHovered } from "../transaction-database/transactions/set-sprite-hovered.js"; -import { toggleSpriteActive } from "../transaction-database/transactions/toggle-sprite-active.js"; -import { tick } from "../transaction-database/transactions/tick.js"; -import { cases as createSpriteCases } from "../../../data/state/create-sprite.js"; -import { cases as setFilterCases } from "../../../data/state/set-filter.js"; -import { cases as setSpriteActiveCases } from "../../../data/state/set-sprite-active.js"; -import { cases as setSpriteHoveredCases } from "../../../data/state/set-sprite-hovered.js"; -import { cases as toggleSpriteActiveCases } from "../../../data/state/toggle-sprite-active.js"; -import { cases as tickCases } from "../../../data/state/tick.js"; +import * as transactions from "../transaction-database/transactions/index.js"; import { createStore } from "./create-store.js"; import { fromState } from "./from-state.js"; import { toState } from "./to-state.js"; -// The single conformance test for every ecs transaction. `runTransactions` owns -// the harness (fresh store, `fromState` seed, `resolve`, `toState` compare, -// coverage guard keyed off the registered barrel); only the bespoke `apply` -// adapters are per-transaction — an id-addressed transaction resolves its entity. +// Every ecs transaction, conformed by name against its `data/state` transition — +// no per-item wiring. Entity-addressed transitions (setSpriteActive / +// setSpriteHovered / toggleSpriteActive) carry a `Conformance.entity` marker in +// their case args, which the runner resolves to the seeded entity via the +// `fromState` id map. createSprite / setFilter / tick are plain-data addressed. Conformance.runTransactions({ createStore, fromState, toState, - registered: registeredTransactions, - define: (conforms) => { - conforms("createSprite", { cases: createSpriteCases, apply: createSprite }); - conforms("setFilter", { cases: setFilterCases, apply: setFilter }); - conforms("setSpriteActive", { - cases: setSpriteActiveCases, - apply: (t, args, resolve) => - setSpriteActive(t, { entity: resolve(args.id), active: args.active }), - }); - conforms("setSpriteHovered", { - cases: setSpriteHoveredCases, - apply: (t, args, resolve) => - setSpriteHovered(t, { - entity: resolve(args.id), - hovered: args.hovered, - }), - }); - conforms("toggleSpriteActive", { - cases: toggleSpriteActiveCases, - apply: (t, args, resolve) => - toggleSpriteActive(t, { entity: resolve(args.id) }), - }); - conforms("tick", { cases: tickCases, apply: tick }); - }, + transitions: import.meta.glob( + [ + "../../../data/state/*.ts", + "!../../../data/state/*.test.ts", + "!../../../data/state/*.type-test.ts", + ], + { eager: true }, + ), + transactions, }); diff --git a/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/set-sprite-active.ts b/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/set-sprite-active.ts index 21b20217..d47581b1 100644 --- a/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/set-sprite-active.ts +++ b/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/set-sprite-active.ts @@ -4,9 +4,9 @@ import type { CoreDatabase } from "../../core-database/core-database.js"; export const setSpriteActive = ( t: CoreDatabase.Store, - args: { readonly entity: Entity; readonly active: boolean }, + args: { readonly id: Entity; readonly active: boolean }, ) => { - if (t.read(args.entity)) { - t.update(args.entity, { active: args.active }); + if (t.read(args.id)) { + t.update(args.id, { active: args.active }); } }; diff --git a/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/set-sprite-hovered.ts b/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/set-sprite-hovered.ts index 193e5caf..de162ff5 100644 --- a/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/set-sprite-hovered.ts +++ b/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/set-sprite-hovered.ts @@ -4,9 +4,9 @@ import type { CoreDatabase } from "../../core-database/core-database.js"; export const setSpriteHovered = ( t: CoreDatabase.Store, - args: { readonly entity: Entity; readonly hovered: boolean }, + args: { readonly id: Entity; readonly hovered: boolean }, ) => { - if (t.read(args.entity)) { - t.update(args.entity, { hovered: args.hovered }); + if (t.read(args.id)) { + t.update(args.id, { hovered: args.hovered }); } }; diff --git a/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/toggle-sprite-active.ts b/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/toggle-sprite-active.ts index 3fb29d76..c0c89d7a 100644 --- a/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/toggle-sprite-active.ts +++ b/packages/data-react-pixie/src/features/main/services/main-service/transaction-database/transactions/toggle-sprite-active.ts @@ -2,9 +2,12 @@ import type { Entity } from "@adobe/data/ecs"; import type { CoreDatabase } from "../../core-database/core-database.js"; -export const toggleSpriteActive = (t: CoreDatabase.Store, args: { readonly entity: Entity }) => { - const sprite = t.read(args.entity); +export const toggleSpriteActive = ( + t: CoreDatabase.Store, + args: { readonly id: Entity }, +) => { + const sprite = t.read(args.id); if (sprite && sprite.active !== undefined) { - t.update(args.entity, { active: !sprite.active }); + t.update(args.id, { active: !sprite.active }); } }; diff --git a/packages/data-react-pixie/src/features/main/ui/sprite/sprite.tsx b/packages/data-react-pixie/src/features/main/ui/sprite/sprite.tsx index 2a3a41a0..675f6320 100644 --- a/packages/data-react-pixie/src/features/main/ui/sprite/sprite.tsx +++ b/packages/data-react-pixie/src/features/main/ui/sprite/sprite.tsx @@ -30,8 +30,11 @@ export function Sprite({ entity }: { entity: Entity }) { y: sprite.position[1], rotation: sprite.rotation, scale, - toggleSpriteActive: () => db.transactions.toggleSpriteActive({ entity }), - setSpriteHoveredTrue: () => db.transactions.setSpriteHovered({ entity, hovered: true }), - setSpriteHoveredFalse: () => db.transactions.setSpriteHovered({ entity, hovered: false }), + toggleSpriteActive: () => + db.transactions.toggleSpriteActive({ id: entity }), + setSpriteHoveredTrue: () => + db.transactions.setSpriteHovered({ id: entity, hovered: true }), + setSpriteHoveredFalse: () => + db.transactions.setSpriteHovered({ id: entity, hovered: false }), }); } diff --git a/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/actions.test.ts b/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/actions.test.ts index 6333f4e5..58e540c1 100644 --- a/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/actions.test.ts +++ b/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/actions.test.ts @@ -1,28 +1,17 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +/// import { Database } from "@adobe/data/ecs"; import { Conformance } from "@adobe/data/testing"; import { MainService } from "../main-service.js"; -import * as registeredActions from "../action-database/actions/index.js"; -import { increment } from "../action-database/actions/increment.js"; -import { decrement } from "../action-database/actions/decrement.js"; -import { reset } from "../action-database/actions/reset.js"; -import { setUserName } from "../action-database/actions/set-user-name.js"; -import { clearLog } from "../action-database/actions/clear-log.js"; -import { cases as incrementCases } from "../../../data/state/increment.js"; -import { cases as decrementCases } from "../../../data/state/decrement.js"; -import { cases as resetCases } from "../../../data/state/reset.js"; -import { cases as setUserNameCases } from "../../../data/state/set-user-name.js"; -import { cases as clearLogCases } from "../../../data/state/clear-log.js"; +import * as actions from "../action-database/actions/index.js"; import { fromState } from "./from-state.js"; import { toState } from "./to-state.js"; -// Each transition's cases run against its same-named ecs action. `runActions` -// splits the case's injected services into recording overrides (via `makeDb`), -// runs the action, then asserts both the resulting state and the declared effects; -// the harness/coverage are shared. This feature injects no services, so every -// case's `effects` is empty and the split yields no overrides. +// Every ecs action, conformed by name against its transition. `runActions` +// discovers transitions, turns each case's injected services into recording +// overrides via `makeDb`, runs the action, and asserts state + declared effects. +// This feature injects no services, so the override is always empty (no cast). Conformance.runActions({ - // `toSystemDatabase` exposes the writable `.store` the projection needs. makeDb: (services) => Database.toSystemDatabase( Database.create(MainService.plugin, { services }), @@ -30,21 +19,13 @@ Conformance.runActions({ store: (db) => db.store, fromState, toState, - registered: registeredActions, - define: (conforms) => { - conforms("increment", { - cases: incrementCases, - run: (db) => increment(db), - }); - conforms("decrement", { - cases: decrementCases, - run: (db) => decrement(db), - }); - conforms("reset", { cases: resetCases, run: (db) => reset(db) }); - conforms("setUserName", { - cases: setUserNameCases, - run: (db, input) => setUserName(db, { name: input.name ?? "" }), - }); - conforms("clearLog", { cases: clearLogCases, run: (db) => clearLog(db) }); - }, + transitions: import.meta.glob( + [ + "../../../data/state/*.ts", + "!../../../data/state/*.test.ts", + "!../../../data/state/*.type-test.ts", + ], + { eager: true }, + ), + actions, }); diff --git a/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/transactions.test.ts b/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/transactions.test.ts index e99d90c9..aff810c6 100644 --- a/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/transactions.test.ts +++ b/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/transactions.test.ts @@ -1,35 +1,27 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +/// import { Conformance } from "@adobe/data/testing"; -import * as registeredTransactions from "../transaction-database/transactions/index.js"; -import { increment } from "../transaction-database/transactions/increment.js"; -import { decrement } from "../transaction-database/transactions/decrement.js"; -import { reset } from "../transaction-database/transactions/reset.js"; -import { setUserName } from "../transaction-database/transactions/set-user-name.js"; -import { clearLog } from "../transaction-database/transactions/clear-log.js"; -import { cases as incrementCases } from "../../../data/state/increment.js"; -import { cases as decrementCases } from "../../../data/state/decrement.js"; -import { cases as resetCases } from "../../../data/state/reset.js"; -import { cases as setUserNameCases } from "../../../data/state/set-user-name.js"; -import { cases as clearLogCases } from "../../../data/state/clear-log.js"; +import * as transactions from "../transaction-database/transactions/index.js"; import { createStore } from "./create-store.js"; import { fromState } from "./from-state.js"; import { toState } from "./to-state.js"; -// The single conformance test for every ecs transaction. `runTransactions` owns -// the harness (fresh store, `fromState` seed, `toState` compare, coverage guard -// keyed off the registered barrel); each transaction's `apply` calls the raw -// transaction directly. This feature holds only scalar resources, so nothing is -// id-addressed and the shared `resolve` is unused. +// Every ecs transaction, conformed by name against its `data/state` transition — +// no per-item wiring. `runTransactions` discovers the transitions (the glob), +// pairs each registered transaction to the same-named one, seeds `fromState`, +// applies, and compares `toState`. This feature holds only scalar resources, so +// nothing is id-addressed and no `entity()` markers are needed. Conformance.runTransactions({ createStore, fromState, toState, - registered: registeredTransactions, - define: (conforms) => { - conforms("increment", { cases: incrementCases, apply: increment }); - conforms("decrement", { cases: decrementCases, apply: decrement }); - conforms("reset", { cases: resetCases, apply: reset }); - conforms("setUserName", { cases: setUserNameCases, apply: setUserName }); - conforms("clearLog", { cases: clearLogCases, apply: clearLog }); - }, + transitions: import.meta.glob( + [ + "../../../data/state/*.ts", + "!../../../data/state/*.test.ts", + "!../../../data/state/*.type-test.ts", + ], + { eager: true }, + ), + transactions, }); diff --git a/packages/data/src/testing/conformance/discover.ts b/packages/data/src/testing/conformance/discover.ts index d9489943..c501dee6 100644 --- a/packages/data/src/testing/conformance/discover.ts +++ b/packages/data/src/testing/conformance/discover.ts @@ -34,3 +34,23 @@ export const discoverTransitions = (modules: Record>): Map => scan(modules, (c) => "value" in c); + +// Normalize the ecs-op source to `name → fn`. Accepts EITHER a facet barrel +// (`import * as x` — values are the functions, keyed by export name) OR a directory +// glob (`import.meta.glob(..., { eager: true })` — values are modules, each +// contributing its function exports). The glob form finds ops that live beside a +// barrel but aren't registered in it (a conformance-only action kept out of the +// plugin facet), so they still pair by name. +export const discoverOps = (source: Record): Map unknown> => { + const out = new Map unknown>(); + for (const [key, value] of Object.entries(source)) { + if (typeof value === "function") { + out.set(key, value as (...a: never[]) => unknown); + } else if (value !== null && typeof value === "object") { + for (const [name, member] of Object.entries(value)) { + if (typeof member === "function") out.set(name, member as (...a: never[]) => unknown); + } + } + } + return out; +}; diff --git a/packages/data-lit-todo/src/features/main/data/state/conformance-case.type-test.ts b/packages/data/src/testing/conformance/effects.type-test.ts similarity index 52% rename from packages/data-lit-todo/src/features/main/data/state/conformance-case.type-test.ts rename to packages/data/src/testing/conformance/effects.type-test.ts index bf27d248..72691120 100644 --- a/packages/data-lit-todo/src/features/main/data/state/conformance-case.type-test.ts +++ b/packages/data/src/testing/conformance/effects.type-test.ts @@ -1,24 +1,26 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. // -// Compile-time only (no runtime tests): proves the `Effects` conformance shape -// accepts valid side-effect declarations and REJECTS invalid ones. `tsc` checks -// this file; vitest does not run it (it is not a `.test.ts`). If any -// `@ts-expect-error` below stops erroring, or any positive stops compiling, the -// build fails — which is the point. -import type { AnalyticsService } from "../../services/analytics-service/analytics-service.js"; -import type { Effects } from "./conformance-case.js"; +// Compile-time only (no runtime tests): proves the shared `Effects` conformance +// shape accepts valid side-effect declarations and REJECTS invalid ones, ONCE for +// the whole library rather than per feature. `tsc` checks this file; vitest does +// not run it (it is not a `.test.ts`). If any `@ts-expect-error` stops erroring, or +// any positive stops compiling, the build fails — which is the point. +import type { Effects } from "./types.js"; -// A representative transition arg shape: plain data + one injected service. -type Args = { - readonly name: string; - readonly complete?: boolean; - readonly analytics: AnalyticsService; -}; +// A representative injected service, and a transition arg shape: plain data + the +// service. (A stand-in for any feature's `SomethingService` — the type machinery +// is identical, which is exactly why this test lives here and not per feature.) +interface AnalyticsService { + readonly serviceName: "analytics"; + todoCreated(input: { readonly name: string }): void; + todoToggled(): void; + allTodosCleared(): void; + displayCompletedToggled(): void; +} +type Args = { readonly name: string; readonly complete?: boolean; readonly analytics: AnalyticsService }; // ===== POSITIVE — must compile ===== -const ordered: Effects = { - analytics: [["todoCreated", { name: "a" }], ["todoToggled"]], -}; +const ordered: Effects = { analytics: [["todoCreated", { name: "a" }], ["todoToggled"]] }; const anyOrder: Effects = { analytics: new Set([["todoToggled"] as const, ["allTodosCleared"] as const]), }; diff --git a/packages/data/src/testing/conformance/public.ts b/packages/data/src/testing/conformance/public.ts index 3433b491..7769220e 100644 --- a/packages/data/src/testing/conformance/public.ts +++ b/packages/data/src/testing/conformance/public.ts @@ -1,9 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. export type { Case, Cases, DerivationCase, DerivationCases, Effects, ServiceCall } from "./types.js"; export { entity } from "./entity-ref.js"; -export { recordCalls, recordArgServices, splitAndRecordServices, expectEffects, type RecordedCall } from "./record-effects.js"; -export { resolver, type Resolve } from "./resolve.js"; export { runSpec, type SpecOptions } from "./run-spec.js"; -export { runTransactions, type TransactionConforms, type TransactionRunConfig } from "./run-transactions.js"; -export { runActions, type ActionConforms, type ActionRunConfig } from "./run-actions.js"; +export { runTransactions, type TransactionRunConfig } from "./run-transactions.js"; +export { runActions, type ActionRunConfig } from "./run-actions.js"; export { runComputeds, type ComputedRunConfig } from "./run-computeds.js"; diff --git a/packages/data/src/testing/conformance/run-actions.ts b/packages/data/src/testing/conformance/run-actions.ts index 98fd3169..4b948e07 100644 --- a/packages/data/src/testing/conformance/run-actions.ts +++ b/packages/data/src/testing/conformance/run-actions.ts @@ -4,17 +4,17 @@ import type { Entity } from "../../ecs/entity/entity.js"; import { assert } from "../match/assert.js"; import type { MatchOptions } from "../match/match.js"; import { adaptArgs } from "./entity-ref.js"; -import { discoverTransitions } from "./discover.js"; +import { discoverTransitions, discoverOps } from "./discover.js"; import { splitAndRecordServices, expectEffects } from "./record-effects.js"; -import { resolver, type Resolve } from "./resolve.js"; -import type { Case } from "./types.js"; +import { resolver } from "./resolve.js"; -// Auto-pairing config: discover transitions and the registered actions, pair by -// name, and conform each — no per-item wiring. The action is the app-facing seam: -// its injected services come from `db.services` (the case's service args become -// recording overrides via `makeDb`), and its plain args are the case args with -// service fields removed and `entity(specId)` markers resolved. Both the resulting -// state and the declared `effects` are asserted. +// Discover transitions and the ecs actions (a facet barrel or a directory glob), +// pair by name, and conform each — no per-item wiring. The action is the app-facing +// seam: its injected services come from `db.services` (the case's service args +// become recording overrides via `makeDb`), and its plain args are the case args +// with service fields removed and `entity(specId)` markers resolved. Both the +// resulting state and the declared `effects` are asserted. An action with no +// same-named transition (e.g. a streaming port) is skipped. export interface ActionRunConfig { readonly makeDb: (services: Record) => Db; readonly store: (db: Db) => Store; @@ -28,83 +28,25 @@ export interface ActionRunConfig { readonly match?: MatchOptions; } -// Legacy explicit-wiring config (being retired as samples move to auto-pairing). -export type ActionConforms = ( - action: string, - config: { - readonly cases: readonly Case[]; - readonly run: (db: Db, input: Partial, resolve: Resolve) => Promise | void; - }, -) => void; -export interface ActionDefineConfig { - readonly makeDb: (services: Record) => Db; - readonly store: (db: Db) => Store; - readonly fromState: (store: Store, before: State) => ReadonlyMap | void; - readonly toState: (store: Store) => State; - readonly registered: Record; - readonly match?: MatchOptions; - readonly define: (conforms: ActionConforms) => void; -} - // The single conformance test for every ecs action: each transition's cases run // against its same-named action, asserting state and the declared effects. -export function runActions(config: ActionRunConfig): void; -export function runActions(config: ActionDefineConfig): void; -export function runActions( - config: ActionRunConfig | ActionDefineConfig, -): void { - if ("transitions" in config) { - const transitions = discoverTransitions(config.transitions); - for (const [name, action] of Object.entries(config.actions)) { - if (typeof action !== "function") continue; - const paired = transitions.get(name); - if (!paired) continue; // action with no transition (e.g. a streaming port) — not conformed here - describe(`${name} action conforms`, () => { - for (const testCase of paired.cases) { - it(testCase.name as string, async () => { - const { services, input, calls } = splitAndRecordServices(testCase.args); - const db = config.makeDb(services); - const resolve = resolver(config.fromState(config.store(db), testCase.before as State)); - config.seedContext?.(db, testCase.before as State, testCase.args); - await (action as (d: Db, a?: unknown) => Promise | void)(db, adaptArgs(input, resolve)); - assert(config.toState(config.store(db)), testCase.after, config.match); - expectEffects(calls, testCase.effects as never); - }); - } - }); - } - return; - } - - const covered = new Set(); - const conforms = ( - action: string, - aconfig: { - readonly cases: readonly Case[]; - readonly run: (db: Db, input: Partial, resolve: Resolve) => Promise | void; - }, - ): void => { - covered.add(action); - describe(`${action} action conforms`, () => { - for (const testCase of aconfig.cases) { - it(testCase.name, async () => { - const args = (testCase as { readonly args?: Args }).args as Args; - const { services, input, calls } = splitAndRecordServices(args); +export function runActions(config: ActionRunConfig): void { + const transitions = discoverTransitions(config.transitions); + for (const [name, action] of discoverOps(config.actions)) { + const paired = transitions.get(name); + if (!paired) continue; // action with no transition (e.g. a streaming port) — not conformed here + describe(`${name} action conforms`, () => { + for (const testCase of paired.cases) { + it(testCase.name as string, async () => { + const { services, input, calls } = splitAndRecordServices(testCase.args); const db = config.makeDb(services); - const resolve = resolver(config.fromState(config.store(db), testCase.before)); - await aconfig.run(db, input as Partial, resolve); + const resolve = resolver(config.fromState(config.store(db), testCase.before as State)); + config.seedContext?.(db, testCase.before as State, testCase.args); + await (action as (d: Db, a?: unknown) => Promise | void)(db, adaptArgs(input, resolve)); assert(config.toState(config.store(db)), testCase.after, config.match); - expectEffects(calls, (testCase as { readonly effects?: never }).effects); + expectEffects(calls, testCase.effects as never); }); } }); - }; - config.define(conforms); - describe("action conformance coverage", () => { - for (const action of Object.keys(config.registered)) { - it(`${action} has a conformance case`, () => { - if (!covered.has(action)) throw new Error(`${action} has no conformance case`); - }); - } - }); + } } diff --git a/packages/data/src/testing/conformance/run-computeds.ts b/packages/data/src/testing/conformance/run-computeds.ts index d3fc4766..3d87824b 100644 --- a/packages/data/src/testing/conformance/run-computeds.ts +++ b/packages/data/src/testing/conformance/run-computeds.ts @@ -4,7 +4,7 @@ import type { Observe } from "../../observe/index.js"; import type { Entity } from "../../ecs/entity/entity.js"; import { assert } from "../match/assert.js"; import type { MatchOptions } from "../match/match.js"; -import { discoverDerivations } from "./discover.js"; +import { discoverDerivations, discoverOps } from "./discover.js"; // Read a computed's synchronous emission: subscribe once, capture, unsubscribe. const readComputed = (observe: Observe): T => { @@ -43,8 +43,7 @@ export interface ComputedRunConfig { export function runComputeds(config: ComputedRunConfig): void { const derivations = discoverDerivations(config.derivations); const hydrate = new Set(config.hydrate ?? []); - for (const [name, computed] of Object.entries(config.computeds)) { - if (typeof computed !== "function") continue; + for (const [name, computed] of discoverOps(config.computeds)) { const paired = derivations.get(name); if (!paired) continue; // computed with no `state/` derivation — covered by its data/ helper describe(`${name} computed conforms`, () => { diff --git a/packages/data/src/testing/conformance/run-transactions.ts b/packages/data/src/testing/conformance/run-transactions.ts index feed4306..bd067860 100644 --- a/packages/data/src/testing/conformance/run-transactions.ts +++ b/packages/data/src/testing/conformance/run-transactions.ts @@ -4,101 +4,49 @@ import type { Entity } from "../../ecs/entity/entity.js"; import { assert } from "../match/assert.js"; import type { MatchOptions } from "../match/match.js"; import { adaptArgs } from "./entity-ref.js"; -import { discoverTransitions } from "./discover.js"; -import { resolver, type Resolve } from "./resolve.js"; -import type { Case } from "./types.js"; +import { discoverTransitions, discoverOps } from "./discover.js"; +import { resolver } from "./resolve.js"; -// Auto-pairing config: discover transitions (from the `data/state` glob) and the -// registered transactions (the facet barrel), pair by name, and conform each — -// no per-item wiring. A transaction with no same-named transition is -// infrastructure (e.g. `setInput`) and is skipped; a transition realized by an -// action or a system is conformed there. Entity-addressed args carry a -// `Conformance.entity(specId)` marker the runner resolves; a transaction ignores -// any injected-service arg (its effects are asserted through the action). +// Discover transitions (the `data/state` glob) and the ecs transactions (a facet +// barrel or a directory glob), pair them by name, and conform each — no per-item +// wiring. A transaction with no same-named transition is infrastructure (e.g. +// `setInput`) or system-dispatched and is skipped; a transition realized by an +// action is conformed there. Entity-addressed args carry a `Conformance.entity` +// marker the runner resolves; a transaction ignores any injected-service arg (its +// effects are asserted through the action). export interface TransactionRunConfig { readonly createStore: () => Store; readonly fromState: (store: Store, before: State) => ReadonlyMap | void; readonly toState: (store: Store) => State; // `import.meta.glob("../../../data/state/*.ts", { eager: true })`. readonly transitions: Record>; - // `import * as transactions from "../transaction-database/transactions/index.js"`. + // `import * as transactions from ".../transactions/index.js"`, OR a directory + // glob when ops live beside a barrel they aren't registered in. readonly transactions: Record; + // Optional ambient, non-spec context a user-scoped feature needs before the raw + // transaction runs (e.g. seed the acting peer's `userId`) — the one seam not + // derivable from cases. Runs after `fromState`, before the transaction. + readonly seedContext?: (store: Store, before: State, args: unknown) => void; readonly match?: MatchOptions; } -// Legacy explicit-wiring config (being retired as samples move to auto-pairing). -export type TransactionConforms = ( - transaction: string, - config: { - readonly cases: readonly Case[]; - readonly apply: (store: Store, args: Args, resolve: Resolve) => void; - }, -) => void; -export interface TransactionDefineConfig { - readonly createStore: () => Store; - readonly fromState: (store: Store, before: State) => ReadonlyMap | void; - readonly toState: (store: Store) => State; - readonly registered: Record; - readonly covers?: readonly string[]; - readonly match?: MatchOptions; - readonly define: (conforms: TransactionConforms) => void; -} - // The single conformance test for every ecs transaction, proving // `toState(apply(fromState(before), args)) ≡ after` for each shared case. -export function runTransactions(config: TransactionRunConfig): void; -export function runTransactions(config: TransactionDefineConfig): void; -export function runTransactions( - config: TransactionRunConfig | TransactionDefineConfig, -): void { - if ("transitions" in config) { - const transitions = discoverTransitions(config.transitions); - for (const [name, transaction] of Object.entries(config.transactions)) { - if (typeof transaction !== "function") continue; - const paired = transitions.get(name); - if (!paired) continue; // infrastructure transaction — no transition to conform to - describe(`${name} transaction conforms`, () => { - for (const testCase of paired.cases) { - it(testCase.name as string, () => { - const store = config.createStore(); - const resolve = resolver(config.fromState(store, testCase.before as State)); - (transaction as (s: Store, a?: unknown) => void)(store, adaptArgs(testCase.args, resolve)); - assert(config.toState(store), testCase.after, config.match); - }); - } - }); - } - return; - } - - const covered = new Set(); - const conforms = ( - transaction: string, - tconfig: { - readonly cases: readonly Case[]; - readonly apply: (store: Store, args: Args, resolve: Resolve) => void; - }, - ): void => { - covered.add(transaction); - describe(`${transaction} transaction conforms`, () => { - for (const testCase of tconfig.cases) { - it(testCase.name, () => { +export function runTransactions(config: TransactionRunConfig): void { + const transitions = discoverTransitions(config.transitions); + for (const [name, transaction] of discoverOps(config.transactions)) { + const paired = transitions.get(name); + if (!paired) continue; // infrastructure / system-dispatched — no transition to conform to + describe(`${name} transaction conforms`, () => { + for (const testCase of paired.cases) { + it(testCase.name as string, () => { const store = config.createStore(); - const resolve = resolver(config.fromState(store, testCase.before)); - const args = (testCase as { readonly args?: Args }).args as Args; - tconfig.apply(store, args, resolve); + const resolve = resolver(config.fromState(store, testCase.before as State)); + config.seedContext?.(store, testCase.before as State, testCase.args); + (transaction as (s: Store, a?: unknown) => void)(store, adaptArgs(testCase.args, resolve)); assert(config.toState(store), testCase.after, config.match); }); } }); - }; - config.define(conforms); - for (const transaction of config.covers ?? []) covered.add(transaction); - describe("transaction conformance coverage", () => { - for (const transaction of Object.keys(config.registered)) { - it(`${transaction} has a conformance case`, () => { - if (!covered.has(transaction)) throw new Error(`${transaction} has no conformance case`); - }); - } - }); + } } From a4764243d40a0137dece2f1b5c1fa295a09729b2 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 7 Aug 2026 11:25:00 -0700 Subject: [PATCH 27/37] =?UTF-8?q?feat(data):=20patch-shaped=20transitions?= =?UTF-8?q?=20(option=20B)=20=E2=80=94=20lib=20+=20tictactoe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Transitions are now honest read→write patches: `(Pick, args) => Pick` instead of ` => T`. Cases author `before` (and `after`) as deltas over a per-feature `initial` default; the runners seed `{...initial, ...before}` and compare against `{...initial, ...before, ...after}`. Backward-compatible: a full `before`/`after` with no `initial` merges to itself (the other samples still pass unchanged). - Case.before/after are Partial; runSpec/runTransactions/runActions/ runComputeds take `initial?` and merge. - tictactoe: add State.create(); playMove/playOpponentMove return `{ board }` patches; cases carry only their deltas. 58 tests green. Co-Authored-By: Claude Sonnet 4.6 --- .../src/features/main/data/state/create.ts | 14 +++ .../main/data/state/current-player.ts | 17 +-- .../src/features/main/data/state/play-move.ts | 119 ++++-------------- .../main/data/state/play-opponent-move.ts | 56 ++------- .../src/features/main/data/state/public.ts | 1 + .../features/main/data/state/restart-game.ts | 32 +---- .../src/features/main/data/state/spec.test.ts | 6 +- .../main-service/conformance/actions.test.ts | 2 + .../conformance/computeds.test.ts | 2 + .../conformance/transactions.test.ts | 2 + .../src/testing/conformance/run-actions.ts | 11 +- .../src/testing/conformance/run-computeds.ts | 6 +- .../data/src/testing/conformance/run-spec.ts | 9 +- .../testing/conformance/run-transactions.ts | 12 +- .../data/src/testing/conformance/types.ts | 16 ++- 15 files changed, 105 insertions(+), 200 deletions(-) create mode 100644 packages/data-lit-tictactoe/src/features/main/data/state/create.ts diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/create.ts b/packages/data-lit-tictactoe/src/features/main/data/state/create.ts new file mode 100644 index 00000000..5f3f275f --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/state/create.ts @@ -0,0 +1,14 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { BoardState } from "../board-state/board-state.js"; +import type { State } from "./state.js"; + +// The default game state: an empty board, X to move first, a zeroed scoreboard. +// It is the baseline the conformance cases author their `before`/`input` as deltas +// over (passed to the runners as `initial`), and the state a fresh game starts in. +export const create = (): State => ({ + board: BoardState.createInitialBoard(), + firstPlayer: "X", + xWins: 0, + oWins: 0, + draws: 0, +}); diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/current-player.ts b/packages/data-lit-tictactoe/src/features/main/data/state/current-player.ts index c984a1e6..678d2288 100644 --- a/packages/data-lit-tictactoe/src/features/main/data/state/current-player.ts +++ b/packages/data-lit-tictactoe/src/features/main/data/state/current-player.ts @@ -9,8 +9,9 @@ import type { Derivation } from "./conformance-case.js"; // (see `data/state.md`). The mark-counting itself is the board's own helper // (`BoardState.currentPlayer`), which the ecs implementation reuses directly; this // derivation is the spec the ecs `currentPlayer` computed is conformed against. -export const currentPlayer = (state: State): PlayerMark => - BoardState.currentPlayer(state.board, state.firstPlayer); +export const currentPlayer = ( + state: Pick, +): PlayerMark => BoardState.currentPlayer(state.board, state.firstPlayer); // Spec-owned cases, shared with the ecs `currentPlayer` computed. A derivation // case is `{ input, value }`; `input` is a full `State`, `value` the mark to move. @@ -20,9 +21,6 @@ export const cases: Derivation = [ input: { board: " ", firstPlayer: "X", - xWins: 0, - oWins: 0, - draws: 0, }, value: "X", }, @@ -31,9 +29,6 @@ export const cases: Derivation = [ input: { board: " ", firstPlayer: "O", - xWins: 0, - oWins: 0, - draws: 0, }, value: "O", }, @@ -42,9 +37,6 @@ export const cases: Derivation = [ input: { board: " X ", firstPlayer: "X", - xWins: 0, - oWins: 0, - draws: 0, }, value: "O", }, @@ -53,9 +45,6 @@ export const cases: Derivation = [ input: { board: "XO ", firstPlayer: "X", - xWins: 1, - oWins: 2, - draws: 0, }, value: "X", }, diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/play-move.ts b/packages/data-lit-tictactoe/src/features/main/data/state/play-move.ts index f9c56b20..fe19e08c 100644 --- a/packages/data-lit-tictactoe/src/features/main/data/state/play-move.ts +++ b/packages/data-lit-tictactoe/src/features/main/data/state/play-move.ts @@ -4,20 +4,20 @@ import { PlayMoveArgs } from "../play-move-args/play-move-args.js"; import type { State } from "./state.js"; import type { Conformance } from "./conformance-case.js"; -// Place the current player's mark into `index`. Illegal moves (out of bounds, -// occupied, game over) are ignored, keeping the transform idempotent. -export const playMove = >( - state: T, +// Place the current player's mark into `index`. Reads the board + first player, +// writes the board — a `{ board }` patch. Illegal moves (out of bounds, occupied, +// game over) leave the board unchanged, keeping the transform idempotent. +export const playMove = ( + state: Pick, input: PlayMoveArgs, -): T => { +): Pick => { if ( !PlayMoveArgs.canPlayMove({ board: state.board, index: input.index }).ok ) { - return state; + return { board: state.board }; } const mark = BoardState.currentPlayer(state.board, state.firstPlayer); return { - ...state, board: BoardState.setBoardCell({ board: state.board, index: input.index, @@ -26,117 +26,46 @@ export const playMove = >( }; }; -// Spec-owned cases, shared with the ecs `playMove` transaction. Covers every -// branch of the move guard — a legal placement, turn alternation by move count, a -// winning placement, plus the three rejections (occupied cell, out of bounds, game -// already over) that each leave the state unchanged. +// Spec-owned cases, shared with the ecs `playMove` transaction. `before` is a +// delta over `State.create()` (empty board, X first, zeroed scores); `after` lists +// only what the move writes — the board. Covers a legal placement, turn alternation +// by move count, a winning placement, and the three rejections (occupied, out of +// bounds, already won) that leave the board as-is. export const cases: Conformance = [ { name: "places the first player's mark into an empty cell", - before: { - board: " ", - firstPlayer: "X", - xWins: 0, - oWins: 0, - draws: 0, - }, + before: {}, args: { index: 4 }, - after: { - board: " X ", - firstPlayer: "X", - xWins: 0, - oWins: 0, - draws: 0, - }, + after: { board: " X " }, }, { name: "alternates to the opponent by move count", - before: { - board: " X ", - firstPlayer: "X", - xWins: 0, - oWins: 0, - draws: 0, - }, + before: { board: " X " }, args: { index: 0 }, - after: { - board: "O X ", - firstPlayer: "X", - xWins: 0, - oWins: 0, - draws: 0, - }, + after: { board: "O X " }, }, { name: "completes a three-in-a-row (winning placement is still just a placement)", - before: { - board: "XX OO ", - firstPlayer: "X", - xWins: 1, - oWins: 2, - draws: 0, - }, + before: { board: "XX OO ", xWins: 1, oWins: 2 }, args: { index: 2 }, - after: { - board: "XXX OO ", - firstPlayer: "X", - xWins: 1, - oWins: 2, - draws: 0, - }, + after: { board: "XXX OO " }, }, { name: "ignores an occupied cell (no-op)", - before: { - board: " X ", - firstPlayer: "X", - xWins: 0, - oWins: 0, - draws: 0, - }, + before: { board: " X " }, args: { index: 4 }, - after: { - board: " X ", - firstPlayer: "X", - xWins: 0, - oWins: 0, - draws: 0, - }, + after: { board: " X " }, }, { name: "ignores an out-of-bounds index (no-op)", - before: { - board: " ", - firstPlayer: "O", - xWins: 0, - oWins: 0, - draws: 0, - }, + before: { firstPlayer: "O" }, args: { index: 9 }, - after: { - board: " ", - firstPlayer: "O", - xWins: 0, - oWins: 0, - draws: 0, - }, + after: { board: " " }, }, { name: "ignores a move once the game is already won (no-op)", - before: { - board: "XXX ", - firstPlayer: "X", - xWins: 0, - oWins: 0, - draws: 0, - }, + before: { board: "XXX " }, args: { index: 4 }, - after: { - board: "XXX ", - firstPlayer: "X", - xWins: 0, - oWins: 0, - draws: 0, - }, + after: { board: "XXX " }, }, ]; diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/play-opponent-move.ts b/packages/data-lit-tictactoe/src/features/main/data/state/play-opponent-move.ts index f2da333d..ebbf5b09 100644 --- a/packages/data-lit-tictactoe/src/features/main/data/state/play-opponent-move.ts +++ b/packages/data-lit-tictactoe/src/features/main/data/state/play-opponent-move.ts @@ -16,12 +16,10 @@ import type { Conformance } from "./conformance-case.js"; * is still **deterministic given its dependency**: inject a fixed opponent and * the result is fixed — which is exactly how it is unit-tested. */ -export const playOpponentMove = async < - T extends Pick, ->( - state: T, +export const playOpponentMove = async ( + state: Pick, { opponent }: { opponent: OpponentService }, -): Promise => { +): Promise> => { const index = await opponent.selectMove(state.board); return playMove(state, { index }); }; @@ -35,61 +33,25 @@ export const playOpponentMove = async < export const cases: Conformance = [ { name: "plays the opponent's first selected move for the current player", - before: { - board: " ", - firstPlayer: "X", - xWins: 0, - oWins: 0, - draws: 0, - }, + before: {}, args: { opponent: OpponentService.createFake([4]) }, // The injected move is cell 4; the current player on an empty board is the // first player (X), so an X lands in the centre cell. - after: { - board: " X ", - firstPlayer: "X", - xWins: 0, - oWins: 0, - draws: 0, - }, + after: { board: " X " }, }, { name: "plays the next mark onto a running board", - before: { - board: " X ", - firstPlayer: "X", - xWins: 0, - oWins: 0, - draws: 0, - }, + before: { board: " X " }, args: { opponent: OpponentService.createFake([0]) }, // The injected move is cell 0; the current player alternates to O by move // count, so an O lands in the top-left cell. - after: { - board: "O X ", - firstPlayer: "X", - xWins: 0, - oWins: 0, - draws: 0, - }, + after: { board: "O X " }, }, { name: "ignores an illegal selected move, leaving the state unchanged", - before: { - board: " X ", - firstPlayer: "X", - xWins: 0, - oWins: 0, - draws: 0, - }, + before: { board: " X " }, args: { opponent: OpponentService.createFake([4]) }, // Cell 4 is occupied — `playMove` rejects it, so the transition is a no-op. - after: { - board: " X ", - firstPlayer: "X", - xWins: 0, - oWins: 0, - draws: 0, - }, + after: { board: " X " }, }, ]; diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/public.ts b/packages/data-lit-tictactoe/src/features/main/data/state/public.ts index 2602faec..181d71d5 100644 --- a/packages/data-lit-tictactoe/src/features/main/data/state/public.ts +++ b/packages/data-lit-tictactoe/src/features/main/data/state/public.ts @@ -1,4 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +export { create } from "./create.js"; export { playMove } from "./play-move.js"; export { playOpponentMove } from "./play-opponent-move.js"; export { restartGame } from "./restart-game.js"; diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/restart-game.ts b/packages/data-lit-tictactoe/src/features/main/data/state/restart-game.ts index 41f2d815..6919116d 100644 --- a/packages/data-lit-tictactoe/src/features/main/data/state/restart-game.ts +++ b/packages/data-lit-tictactoe/src/features/main/data/state/restart-game.ts @@ -25,13 +25,7 @@ export const restartGame = (state: State): State => { export const cases: Conformance = [ { name: "tallies an X win, alternates first player, clears the board", - before: { - board: "XXX ", - firstPlayer: "X", - xWins: 0, - oWins: 0, - draws: 0, - }, + before: { board: "XXX " }, args: undefined, after: { board: " ", @@ -43,13 +37,7 @@ export const cases: Conformance = [ }, { name: "tallies an O win", - before: { - board: "OOOXX ", - firstPlayer: "O", - xWins: 1, - oWins: 2, - draws: 0, - }, + before: { board: "OOOXX ", firstPlayer: "O", xWins: 1, oWins: 2 }, args: undefined, after: { board: " ", @@ -61,13 +49,7 @@ export const cases: Conformance = [ }, { name: "tallies a draw (full board, no line)", - before: { - board: "XOXXOOOXX", - firstPlayer: "O", - xWins: 2, - oWins: 1, - draws: 0, - }, + before: { board: "XOXXOOOXX", firstPlayer: "O", xWins: 2, oWins: 1 }, args: undefined, after: { board: " ", @@ -79,13 +61,7 @@ export const cases: Conformance = [ }, { name: "restarts an unfinished game without touching any counter", - before: { - board: "X O ", - firstPlayer: "X", - xWins: 1, - oWins: 1, - draws: 1, - }, + before: { board: "X O ", xWins: 1, oWins: 1, draws: 1 }, args: undefined, after: { board: " ", diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/spec.test.ts b/packages/data-lit-tictactoe/src/features/main/data/state/spec.test.ts index 3d1fc60a..3d24208a 100644 --- a/packages/data-lit-tictactoe/src/features/main/data/state/spec.test.ts +++ b/packages/data-lit-tictactoe/src/features/main/data/state/spec.test.ts @@ -1,12 +1,13 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. /// import { Conformance } from "@adobe/data/testing"; +import { State } from "./state.js"; // The single pure-spec test for every transform AND derivation in this folder. // `runSpec` auto-discovers each sibling that exports `cases`, requires exactly its // function plus `cases`, and dispatches on case shape (a `value` case is a -// derivation, otherwise a transition). Tic-tac-toe's `State` is scalar (a board -// string + counters, no arrays or minted ids), so the default comparison applies. +// derivation, otherwise a transition). Each case's `before` is a delta over +// `initial` (the default state), so cases carry only what they change. Conformance.runSpec( import.meta.glob>( ["./*.ts", "!./*.test.ts", "!./*.type-test.ts"], @@ -14,4 +15,5 @@ Conformance.runSpec( eager: true, }, ), + { initial: State.create() }, ); diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/actions.test.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/actions.test.ts index f3db8be3..ae535787 100644 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/actions.test.ts +++ b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/actions.test.ts @@ -2,6 +2,7 @@ /// import { Database } from "@adobe/data/ecs"; import { Conformance } from "@adobe/data/testing"; +import { State } from "../../../data/state/state.js"; import type { OpponentService } from "../../opponent-service/opponent-service.js"; import { MainService } from "../main-service.js"; import * as actions from "../action-database/actions/index.js"; @@ -31,4 +32,5 @@ Conformance.runActions({ { eager: true }, ), actions, + initial: State.create(), }); diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/computeds.test.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/computeds.test.ts index bec373ee..0fb002a1 100644 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/computeds.test.ts +++ b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/computeds.test.ts @@ -2,6 +2,7 @@ /// import { Database } from "@adobe/data/ecs"; import { Conformance } from "@adobe/data/testing"; +import { State } from "../../../data/state/state.js"; import { ComputedDatabase } from "../computed-database/computed-database.js"; import * as computeds from "../computed-database/computed/index.js"; import { fromState } from "./from-state.js"; @@ -26,4 +27,5 @@ Conformance.runComputeds({ { eager: true }, ), computeds, + initial: State.create(), }); diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/transactions.test.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/transactions.test.ts index 08deec30..bbc5f770 100644 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/transactions.test.ts +++ b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/transactions.test.ts @@ -1,6 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. /// import { Conformance } from "@adobe/data/testing"; +import { State } from "../../../data/state/state.js"; import * as transactions from "../transaction-database/transactions/index.js"; import { createStore } from "./create-store.js"; import { fromState } from "./from-state.js"; @@ -24,4 +25,5 @@ Conformance.runTransactions({ { eager: true }, ), transactions, + initial: State.create(), }); diff --git a/packages/data/src/testing/conformance/run-actions.ts b/packages/data/src/testing/conformance/run-actions.ts index 4b948e07..62df8b84 100644 --- a/packages/data/src/testing/conformance/run-actions.ts +++ b/packages/data/src/testing/conformance/run-actions.ts @@ -22,6 +22,8 @@ export interface ActionRunConfig { readonly toState: (store: Store) => State; readonly transitions: Record>; readonly actions: Record; + // The feature's default `State`; each case's `before` is merged over it. + readonly initial?: State; // Optional ambient, non-spec context a user-scoped feature needs before dispatch // (e.g. the acting peer's `userId`) — the one seam not derivable from cases. readonly seedContext?: (db: Db, before: State, args: unknown) => void; @@ -39,11 +41,14 @@ export function runActions(config: ActionRunConfig { const { services, input, calls } = splitAndRecordServices(testCase.args); + // Case `before` is a delta over the feature default. + const before = { ...(config.initial ?? {}), ...(testCase.before as object) } as State; const db = config.makeDb(services); - const resolve = resolver(config.fromState(config.store(db), testCase.before as State)); - config.seedContext?.(db, testCase.before as State, testCase.args); + const resolve = resolver(config.fromState(config.store(db), before)); + config.seedContext?.(db, before, testCase.args); await (action as (d: Db, a?: unknown) => Promise | void)(db, adaptArgs(input, resolve)); - assert(config.toState(config.store(db)), testCase.after, config.match); + // `after` is a writes patch — compare `toState` against it merged over `before`. + assert(config.toState(config.store(db)), { ...(before as object), ...(testCase.after as object) }, config.match); expectEffects(calls, testCase.effects as never); }); } diff --git a/packages/data/src/testing/conformance/run-computeds.ts b/packages/data/src/testing/conformance/run-computeds.ts index 3d87824b..2cbc6ef5 100644 --- a/packages/data/src/testing/conformance/run-computeds.ts +++ b/packages/data/src/testing/conformance/run-computeds.ts @@ -33,6 +33,9 @@ export interface ComputedRunConfig { readonly derivations: Record>; readonly computeds: Record; readonly hydrate?: readonly string[]; + // The feature's default `State`; each case's `input` is merged over it before + // seeding, so a derivation case names only the fields it reads. + readonly initial?: State; readonly match?: MatchOptions; } @@ -50,7 +53,8 @@ export function runComputeds(config: ComputedRunConfig { const db = config.makeDb(); - config.fromState(config.store(db), testCase.input as State); + const input = { ...(config.initial ?? {}), ...(testCase.input as object) } as State; + config.fromState(config.store(db), input); const raw = readComputed((computed as (d: Db) => Observe)(db)); const value = hydrate.has(name) && config.toData diff --git a/packages/data/src/testing/conformance/run-spec.ts b/packages/data/src/testing/conformance/run-spec.ts index 8cb1e99a..c40abe70 100644 --- a/packages/data/src/testing/conformance/run-spec.ts +++ b/packages/data/src/testing/conformance/run-spec.ts @@ -10,6 +10,10 @@ const isDerivationCase = (c: unknown): c is DerivationCase => typeof c === "object" && c !== null && "value" in c; export interface SpecOptions { + // The feature's default `State`. Each case's `before` is merged over it, so a + // case names only the fields it sets differently from the default. Omit it and + // cases must carry a full `before`. + readonly initial?: object; // Passed through to `matches` (float tolerance, unordered collections). readonly match?: MatchOptions; // Override the `describe` label per module (default `State.`). @@ -56,7 +60,10 @@ export const runSpec = (modules: Record>, option // Unwrap `entity(specId)` markers to their data-id for the pure spec, then // wrap injected services so their calls are recorded. const { args, calls } = recordArgServices(adaptArgs(tc.args)); - assert(await fn(tc.before, args), tc.after, options.match); + // Case `before` is a delta over the feature default; `after` a writes patch. + const before = { ...(options.initial ?? {}), ...(tc.before as Record) }; + const result = (await fn(before, args)) as Record; + assert({ ...before, ...result }, { ...before, ...(tc.after as Record) }, options.match); expectEffects(calls, tc.effects); }); } diff --git a/packages/data/src/testing/conformance/run-transactions.ts b/packages/data/src/testing/conformance/run-transactions.ts index bd067860..686396c9 100644 --- a/packages/data/src/testing/conformance/run-transactions.ts +++ b/packages/data/src/testing/conformance/run-transactions.ts @@ -23,6 +23,9 @@ export interface TransactionRunConfig { // `import * as transactions from ".../transactions/index.js"`, OR a directory // glob when ops live beside a barrel they aren't registered in. readonly transactions: Record; + // The feature's default `State`; each case's `before` is merged over it, so a + // case names only what differs from the default. + readonly initial?: State; // Optional ambient, non-spec context a user-scoped feature needs before the raw // transaction runs (e.g. seed the acting peer's `userId`) — the one seam not // derivable from cases. Runs after `fromState`, before the transaction. @@ -40,11 +43,14 @@ export function runTransactions(config: TransactionRunConfig { for (const testCase of paired.cases) { it(testCase.name as string, () => { + // Case `before` is a delta over the feature default. + const before = { ...(config.initial ?? {}), ...(testCase.before as object) } as State; const store = config.createStore(); - const resolve = resolver(config.fromState(store, testCase.before as State)); - config.seedContext?.(store, testCase.before as State, testCase.args); + const resolve = resolver(config.fromState(store, before)); + config.seedContext?.(store, before, testCase.args); (transaction as (s: Store, a?: unknown) => void)(store, adaptArgs(testCase.args, resolve)); - assert(config.toState(store), testCase.after, config.match); + // `after` is a writes patch — compare `toState` against it merged over `before`. + assert(config.toState(store), { ...(before as object), ...(testCase.after as object) }, config.match); }); } }); diff --git a/packages/data/src/testing/conformance/types.ts b/packages/data/src/testing/conformance/types.ts index 95484e31..e5574fea 100644 --- a/packages/data/src/testing/conformance/types.ts +++ b/packages/data/src/testing/conformance/types.ts @@ -34,14 +34,18 @@ type ArgsOf unknown> = Parameters extends [un ? Args : void; -// One spec-owned conformance case: a `data/` transform's `{ before, args, after }` -// authored as full `State`, optionally with the side effects it makes on its -// injected service args. `args` is OMITTABLE exactly when the transform takes -// none. Shared unchanged by the spec aggregator and the ecs conformance runners. +// One spec-owned conformance case, authored as **deltas over the feature default** +// (the runner's `initial` state). `before` lists only the fields this case sets +// differently from the default; `after` is the transform's **writes patch** — only +// the fields it changes. The runner seeds `{ ...initial, ...before }` and compares +// against `{ ...initial, ...before, ...after }`, so every field a case doesn't +// mention is the default and stays unchanged. (A full `before`/`after` still works +// — it just overrides the default wholesale.) `args` is OMITTABLE exactly when the +// transform takes none. Shared by the spec aggregator and the ecs runners. export type Case = { readonly name: string; - readonly before: State; - readonly after: State; + readonly before: Partial; + readonly after: Partial; readonly effects?: Effects; } & ([Args] extends [void] ? { readonly args?: undefined } : { readonly args: Args }); From f2c3780482fd31b586e9cfcedaab382e383eb831 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 7 Aug 2026 11:44:29 -0700 Subject: [PATCH 28/37] feat(data): todo patch transitions (B); drop tictactoe opponent demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - data-lit-todo: all nine transitions are now (Pick, args) => Pick returning only writes; cases are deltas over State.create(). 102 tests. - data-lit-tictactoe: remove the playOpponentMove transition + OpponentService (async-service demo surface — the base game is human-vs-human via playMove, and the async-port pattern is already shown by data-lit-todo's nameGenerator). 52 tests. - space-rock tick-loop.test: materialise the full seed via {...State.create(), ...before} now that Case.before is Partial. Co-Authored-By: Claude Sonnet 4.6 --- .../system-database/tick-loop.test.ts | 7 ++- .../main/data/state/play-opponent-move.ts | 57 ------------------- .../src/features/main/data/state/public.ts | 1 - .../action-database/actions/index.ts | 1 - .../actions/play-opponent-move.ts | 22 ------- .../main-service/conformance/actions.test.ts | 14 +---- .../service-database/service-database.ts | 6 -- .../services/opponent-service/create-fake.ts | 22 ------- .../main/services/opponent-service/create.ts | 24 -------- .../opponent-service/opponent-service.ts | 20 ------- .../main/services/opponent-service/public.ts | 3 - .../features/main/data/state/append-todo.ts | 9 ++- .../main/data/state/create-bulk-todos.ts | 33 +++++------ .../main/data/state/create-random-todo.ts | 22 +++---- .../features/main/data/state/create-todo.ts | 26 ++++----- .../src/features/main/data/state/create.ts | 7 +++ .../main/data/state/delete-all-todos.ts | 19 ++++--- .../features/main/data/state/delete-todo.ts | 27 ++++----- .../src/features/main/data/state/public.ts | 1 + .../features/main/data/state/reorder-todo.ts | 29 +++++----- .../src/features/main/data/state/spec.test.ts | 8 ++- .../main/data/state/toggle-complete.ts | 18 +++--- .../data/state/toggle-display-completed.ts | 25 ++++---- .../main-service/conformance/actions.test.ts | 2 + .../conformance/computeds.test.ts | 2 + .../conformance/transactions.test.ts | 2 + 26 files changed, 130 insertions(+), 277 deletions(-) delete mode 100644 packages/data-lit-tictactoe/src/features/main/data/state/play-opponent-move.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/services/main-service/action-database/actions/play-opponent-move.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/services/opponent-service/create-fake.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/services/opponent-service/create.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/services/opponent-service/opponent-service.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/services/opponent-service/public.ts create mode 100644 packages/data-lit-todo/src/features/main/data/state/create.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/system-database/tick-loop.test.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/system-database/tick-loop.test.ts index 4669bae7..dd9a8d87 100644 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/system-database/tick-loop.test.ts +++ b/packages/data-lit-space-rock-game/src/features/main/services/main-service/system-database/tick-loop.test.ts @@ -37,16 +37,19 @@ describe("ECS system tick loop conforms to State.step (one frame = one step)", ( it(testCase.name, () => { const { dt, input } = testCase.args; const unordered = { unordered: new Set(["bullets", "asteroids"]) }; + // A case `before` is a delta over the feature default (`Case.before` is + // `Partial`), so materialise the full seed the same way the runners do. + const before = { ...State.create(), ...testCase.before }; // The co-located case carries its own inert `random` double (no case clears // the field, so it is never drawn), so drive the oracle with the case args. Match.assert( - State.step(testCase.before, testCase.args), + State.step(before, testCase.args), testCase.after, unordered, ); const db = createSystemDatabase(); - fromState(db.store, testCase.before); + fromState(db.store, before); db.store.resources.frameDelta = dt; db.transactions.setInput(input); driveFrame(db); diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/play-opponent-move.ts b/packages/data-lit-tictactoe/src/features/main/data/state/play-opponent-move.ts deleted file mode 100644 index ebbf5b09..00000000 --- a/packages/data-lit-tictactoe/src/features/main/data/state/play-opponent-move.ts +++ /dev/null @@ -1,57 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { OpponentService } from "../../services/opponent-service/opponent-service.js"; -import { playMove } from "./play-move.js"; -import type { State } from "./state.js"; -import type { Conformance } from "./conformance-case.js"; - -/** - * Move-selection as an **injected service dependency**: the opponent's choice - * cannot be computed from `state` alone, so the transition receives the - * `opponent` port — keyed by the service name minus its `-service` suffix — - * awaits its selected index, then delegates to the pure {@link playMove}. An - * illegal index the port returns is ignored by `playMove`, keeping the - * transition idempotent. - * - * Awaiting the async port makes the transition async (`Promise`), but it - * is still **deterministic given its dependency**: inject a fixed opponent and - * the result is fixed — which is exactly how it is unit-tested. - */ -export const playOpponentMove = async ( - state: Pick, - { opponent }: { opponent: OpponentService }, -): Promise> => { - const index = await opponent.selectMove(state.board); - return playMove(state, { index }); -}; - -// Spec-owned cases, shared with the ecs `playOpponentMove` action. Each injects -// the deterministic double with the exact move schedule it needs and authors -// `after` against it — the case owns its fixture, not a shared published const. -// `selectMove` is a value-returning read, not a fire-and-forget side effect, so -// it is NOT declared in `effects` — the transition's whole observable result is -// the placed mark. -export const cases: Conformance = [ - { - name: "plays the opponent's first selected move for the current player", - before: {}, - args: { opponent: OpponentService.createFake([4]) }, - // The injected move is cell 4; the current player on an empty board is the - // first player (X), so an X lands in the centre cell. - after: { board: " X " }, - }, - { - name: "plays the next mark onto a running board", - before: { board: " X " }, - args: { opponent: OpponentService.createFake([0]) }, - // The injected move is cell 0; the current player alternates to O by move - // count, so an O lands in the top-left cell. - after: { board: "O X " }, - }, - { - name: "ignores an illegal selected move, leaving the state unchanged", - before: { board: " X " }, - args: { opponent: OpponentService.createFake([4]) }, - // Cell 4 is occupied — `playMove` rejects it, so the transition is a no-op. - after: { board: " X " }, - }, -]; diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/public.ts b/packages/data-lit-tictactoe/src/features/main/data/state/public.ts index 181d71d5..cdecee7b 100644 --- a/packages/data-lit-tictactoe/src/features/main/data/state/public.ts +++ b/packages/data-lit-tictactoe/src/features/main/data/state/public.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. export { create } from "./create.js"; export { playMove } from "./play-move.js"; -export { playOpponentMove } from "./play-opponent-move.js"; export { restartGame } from "./restart-game.js"; export { currentPlayer } from "./current-player.js"; diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/action-database/actions/index.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/action-database/actions/index.ts index 432a4421..72eba314 100644 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/action-database/actions/index.ts +++ b/packages/data-lit-tictactoe/src/features/main/services/main-service/action-database/actions/index.ts @@ -1,4 +1,3 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. export * from "./play-move.js"; -export * from "./play-opponent-move.js"; export * from "./restart-game.js"; diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/action-database/actions/play-opponent-move.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/action-database/actions/play-opponent-move.ts deleted file mode 100644 index 8c1e6213..00000000 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/action-database/actions/play-opponent-move.ts +++ /dev/null @@ -1,22 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { BoardState } from "../../../../data/board-state/board-state.js"; -import type { PlacedMark } from "../../../../data/placed-mark/placed-mark.js"; -import type { ServiceDatabase } from "../../service-database/service-database.js"; - -// The app-facing realization of `State.playOpponentMove`: read the current board -// **synchronously from the store** (not a cached computed — reactive computeds -// refresh only on committed transactions, so an imperative read of one can be -// stale), await the opponent port's selected index (the async outside-world -// work), then commit exactly one placement through `playMove`. `selectMove` is a -// value-returning read, not a fire-and-forget effect. -export const playOpponentMove = async (db: ServiceDatabase) => { - const marks: PlacedMark[] = []; - for (const id of db.select(db.archetypes.PlacedMark.components)) { - const mark = db.read(id); - if (mark && mark.mark !== undefined && mark.index !== undefined) { - marks.push({ mark: mark.mark, index: mark.index }); - } - } - const index = await db.services.opponent.selectMove(BoardState.fromMarks(marks)); - db.transactions.playMove({ index }); -}; diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/actions.test.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/actions.test.ts index ae535787..8a529dba 100644 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/actions.test.ts +++ b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/actions.test.ts @@ -3,23 +3,15 @@ import { Database } from "@adobe/data/ecs"; import { Conformance } from "@adobe/data/testing"; import { State } from "../../../data/state/state.js"; -import type { OpponentService } from "../../opponent-service/opponent-service.js"; import { MainService } from "../main-service.js"; import * as actions from "../action-database/actions/index.js"; import { fromState } from "./from-state.js"; import { toState } from "./to-state.js"; -// Every ecs action, conformed by name against its transition. `runActions` -// discovers transitions, turns each case's injected services into recording -// overrides via `makeDb`, runs the action, and asserts state + declared effects. +// Every ecs action, conformed by name against its transition. No tictactoe +// transition injects a service, so `makeDb` needs no recording service overrides. Conformance.runActions({ - // Runtime invariant: the recording wrappers preserve the service's shape. - makeDb: (services) => - Database.toSystemDatabase( - Database.create(MainService.plugin, { - services: services as { opponent?: OpponentService }, - }), - ), + makeDb: () => Database.toSystemDatabase(Database.create(MainService.plugin)), store: (db) => db.store, fromState, toState, diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/service-database/service-database.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/service-database/service-database.ts index 78b7be48..1acf6473 100644 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/service-database/service-database.ts +++ b/packages/data-lit-tictactoe/src/features/main/services/main-service/service-database/service-database.ts @@ -2,7 +2,6 @@ import { Database } from "@adobe/data/ecs"; import { AgenticService } from "@adobe/data/service"; import { ComputedDatabase } from "../computed-database/computed-database.js"; -import { OpponentService } from "../../opponent-service/opponent-service.js"; import { createAgentService, createRootAgentService, @@ -14,11 +13,6 @@ const serviceDatabasePlugin = Database.Plugin.create({ agent: (db): AgenticService => createRootAgentService(db), agentX: (db): AgenticService => createAgentService(db, "X"), agentO: (db): AgenticService => createAgentService(db, "O"), - // The move-selection capability contract (async port, no ECS state to bind) - // registered directly from its `services/` factory — like data-lit-todo's - // nameGenerator. Its deterministic double (`OpponentService.createFake`) is - // what unit tests inject; production selects a legal move here. - opponent: OpponentService.create, }, }); diff --git a/packages/data-lit-tictactoe/src/features/main/services/opponent-service/create-fake.ts b/packages/data-lit-tictactoe/src/features/main/services/opponent-service/create-fake.ts deleted file mode 100644 index 6b8a02c2..00000000 --- a/packages/data-lit-tictactoe/src/features/main/services/opponent-service/create-fake.ts +++ /dev/null @@ -1,22 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { OpponentService } from "./opponent-service.js"; - -/** - * Deterministic test double for {@link OpponentService}. Unlike the real - * selector it uses no randomness and no timers: `selectMove` returns the - * `moves` in order, cycling once exhausted, each resolved on the microtask - * queue. A case that needs a specific schedule passes its own `moves`; the - * default is a harmless placeholder. This is the implementation tests inject so - * their assertions are predictable — see `features/services/index.md`. - */ -export const createFake = (moves: readonly number[] = [4]): OpponentService => { - let index = 0; - return { - serviceName: "opponent", - selectMove: () => { - const move = moves[index % moves.length]; - index += 1; - return Promise.resolve(move); - }, - }; -}; diff --git a/packages/data-lit-tictactoe/src/features/main/services/opponent-service/create.ts b/packages/data-lit-tictactoe/src/features/main/services/opponent-service/create.ts deleted file mode 100644 index fec4d227..00000000 --- a/packages/data-lit-tictactoe/src/features/main/services/opponent-service/create.ts +++ /dev/null @@ -1,24 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { BoardCell } from "../../data/board-cell/board-cell.js"; -import type { OpponentService } from "./opponent-service.js"; - -/** - * Production opponent. Chooses uniformly at random among the board's empty - * cells after a short "thinking" delay, simulating a model-backed selector so - * the async boundary — and its latency — is real. Returns -1 when the board is - * full (the caller guards illegal moves). - */ -export const create = (): OpponentService => ({ - serviceName: "opponent", - selectMove: (board) => - new Promise((resolve) => { - const empty: number[] = []; - for (let i = 0; i < board.length; i++) { - if (board[i] === BoardCell.blank) empty.push(i); - } - const choice = - empty.length === 0 ? -1 : empty[Math.floor(Math.random() * empty.length)]; - const delayMs = 100 + Math.floor(Math.random() * 300); - setTimeout(() => resolve(choice), delayMs); - }), -}); diff --git a/packages/data-lit-tictactoe/src/features/main/services/opponent-service/opponent-service.ts b/packages/data-lit-tictactoe/src/features/main/services/opponent-service/opponent-service.ts deleted file mode 100644 index 34fa202e..00000000 --- a/packages/data-lit-tictactoe/src/features/main/services/opponent-service/opponent-service.ts +++ /dev/null @@ -1,20 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Service } from "@adobe/data/service"; -import { AsyncDataService } from "@adobe/data/service"; -import type { Assert } from "@adobe/data/types"; -import type { BoardState } from "../../data/board-state/board-state.js"; - -/** - * Async port that chooses the opponent's next move: given the current board it - * resolves with the index (0-8) of the cell to play. Async so it can stand in - * for a network- or model-backed move selector — the latency across this - * boundary is real. The deterministic double replaces it under test. - */ -export interface OpponentService extends Service { - selectMove: (board: BoardState) => Promise; -} - -// Contract conforms to the async-data-service pattern (async-only members). -type _Valid = Assert>; - -export * as OpponentService from "./public.js"; diff --git a/packages/data-lit-tictactoe/src/features/main/services/opponent-service/public.ts b/packages/data-lit-tictactoe/src/features/main/services/opponent-service/public.ts deleted file mode 100644 index 56c941d3..00000000 --- a/packages/data-lit-tictactoe/src/features/main/services/opponent-service/public.ts +++ /dev/null @@ -1,3 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -export { create } from "./create.js"; -export { createFake } from "./create-fake.js"; diff --git a/packages/data-lit-todo/src/features/main/data/state/append-todo.ts b/packages/data-lit-todo/src/features/main/data/state/append-todo.ts index 81325d2b..d9248d0b 100644 --- a/packages/data-lit-todo/src/features/main/data/state/append-todo.ts +++ b/packages/data-lit-todo/src/features/main/data/state/append-todo.ts @@ -5,15 +5,14 @@ import type { State } from "./state.js"; // re-exported through `public.ts`, so it is not a public `State.` transform and // carries no conformance cases. Shared by the `createTodo` and `createRandomTodo` // transitions so the pure append stays single-sourced while each fires its own -// analytics side effect. -export const appendTodo = >( - state: T, +// analytics side effect. Reads the todos, writes the todos — a `{ todos }` patch. +export const appendTodo = ( + state: Pick, input: { readonly name: string; readonly complete?: boolean }, -): T => { +): Pick => { const nextId = state.todos.reduce((max, todo) => Math.max(max, todo.id), 0) + 1; return { - ...state, todos: [ ...state.todos, { id: nextId, name: input.name, complete: input.complete ?? false }, diff --git a/packages/data-lit-todo/src/features/main/data/state/create-bulk-todos.ts b/packages/data-lit-todo/src/features/main/data/state/create-bulk-todos.ts index bfa0f738..cf083f78 100644 --- a/packages/data-lit-todo/src/features/main/data/state/create-bulk-todos.ts +++ b/packages/data-lit-todo/src/features/main/data/state/create-bulk-todos.ts @@ -4,31 +4,33 @@ import type { State } from "./state.js"; import type { Conformance } from "./conformance-case.js"; import { appendTodo } from "./append-todo.js"; import { Match } from "@adobe/data/testing"; -/** Adds numbered placeholder todos for demos and performance testing. */ -export const createBulkTodos = >( - state: T, +/** Adds numbered placeholder todos for demos and performance testing. Reads and + * writes the todos — a `{ todos }` patch. */ +export const createBulkTodos = ( + state: Pick, { count, analytics, }: { readonly count: number; readonly analytics: AnalyticsService }, -): T => { +): Pick => { analytics.bulkTodosCreated({ count }); const total = Math.max(0, Math.floor(count)); - let next = state; + let next: Pick = state; for (let index = 0; index < total; index++) { next = appendTodo(next, { name: `Todo ${state.todos.length + index}` }); } return next; }; -// Spec-owned cases, shared with the ecs `createBulkTodos` transaction. `count` -// (floored, clamped at 0) numbered todos are appended; the transition logs -// `bulkTodosCreated` with the raw count (as the action does), even on a no-op. -// Minted ids are left open (`Match.anyNumber`) — the ecs assigns its own. +// Spec-owned cases, shared with the ecs `createBulkTodos` transaction. `before` +// is a delta over `State.create()`; `after` lists only the written todos. +// `count` (floored, clamped at 0) numbered todos are appended; the transition +// logs `bulkTodosCreated` with the raw count (as the action does), even on a +// no-op. Minted ids are left open (`Match.anyNumber`) — the ecs assigns its own. export const cases: Conformance = [ { name: "appends count numbered todos to an empty list", - before: { todos: [], displayCompleted: false }, + before: {}, args: { count: 3, analytics: AnalyticsService.createFake() }, after: { todos: [ @@ -36,16 +38,12 @@ export const cases: Conformance = [ { id: Match.anyNumber, name: "Todo 1", complete: false }, { id: Match.anyNumber, name: "Todo 2", complete: false }, ], - displayCompleted: false, }, effects: { analytics: [["bulkTodosCreated", { count: 3 }]] }, }, { name: "continues names after existing todos", - before: { - todos: [{ id: 1, name: "a", complete: false }], - displayCompleted: false, - }, + before: { todos: [{ id: 1, name: "a", complete: false }] }, args: { count: 2, analytics: AnalyticsService.createFake() }, after: { todos: [ @@ -53,20 +51,18 @@ export const cases: Conformance = [ { id: Match.anyNumber, name: "Todo 1", complete: false }, { id: Match.anyNumber, name: "Todo 2", complete: false }, ], - displayCompleted: false, }, effects: { analytics: [["bulkTodosCreated", { count: 2 }]] }, }, { name: "floors a fractional count", - before: { todos: [], displayCompleted: false }, + before: {}, args: { count: 2.9, analytics: AnalyticsService.createFake() }, after: { todos: [ { id: Match.anyNumber, name: "Todo 0", complete: false }, { id: Match.anyNumber, name: "Todo 1", complete: false }, ], - displayCompleted: false, }, effects: { analytics: [["bulkTodosCreated", { count: 2.9 }]] }, }, @@ -79,7 +75,6 @@ export const cases: Conformance = [ args: { count: 0, analytics: AnalyticsService.createFake() }, after: { todos: [{ id: Match.anyNumber, name: "a", complete: false }], - displayCompleted: true, }, effects: { analytics: [["bulkTodosCreated", { count: 0 }]] }, }, diff --git a/packages/data-lit-todo/src/features/main/data/state/create-random-todo.ts b/packages/data-lit-todo/src/features/main/data/state/create-random-todo.ts index 770f7646..528a0b91 100644 --- a/packages/data-lit-todo/src/features/main/data/state/create-random-todo.ts +++ b/packages/data-lit-todo/src/features/main/data/state/create-random-todo.ts @@ -8,12 +8,13 @@ import { Match } from "@adobe/data/testing"; /** * Async, service-injected transition: brackets the slow name generation with * analytics timing, then appends the todo via the shared {@link appendTodo} (so - * it does NOT fire `todoCreated` — it logs its own `randomTodoAdded`). Awaiting an - * async port makes it `Promise`, but it stays deterministic given its - * injected services — which is how it is unit-tested. + * it does NOT fire `todoCreated` — it logs its own `randomTodoAdded`). Reads and + * writes the todos — a `{ todos }` patch. Awaiting an async port makes it + * `Promise>`, but it stays deterministic given its injected + * services — which is how it is unit-tested. */ -export const createRandomTodo = async >( - state: T, +export const createRandomTodo = async ( + state: Pick, { nameGenerator, analytics, @@ -21,7 +22,7 @@ export const createRandomTodo = async >( readonly nameGenerator: NameGeneratorService; readonly analytics: AnalyticsService; }, -): Promise => { +): Promise> => { const timing = await analytics.randomTodoRequested(); const name = await nameGenerator.generateName(); const next = appendTodo(state, { name }); @@ -29,7 +30,8 @@ export const createRandomTodo = async >( return next; }; -// Spec-owned cases. Each injects deterministic doubles with the exact responses +// Spec-owned cases. `before` is a delta over `State.create()`; `after` lists only +// the written todos. Each injects deterministic doubles with the exact responses // it needs and authors `after` + `effects` against those self-owned values (the // name it schedules, the fixed `{ startedAt: 0 }` timing the analytics double // resolves). The value-returning reads (`randomTodoRequested`, `generateName`) @@ -39,7 +41,7 @@ export const createRandomTodo = async >( export const cases: Conformance = [ { name: "names the new todo from the generator and logs the timed add", - before: { todos: [], displayCompleted: false }, + before: {}, args: { nameGenerator: NameGeneratorService.createFake(["random task"]), analytics: AnalyticsService.createFake(), @@ -52,7 +54,6 @@ export const cases: Conformance = [ complete: false, }, ], - displayCompleted: false, }, effects: { analytics: [ @@ -69,14 +70,13 @@ export const cases: Conformance = [ }, { name: "uses an explicit response schedule when supplied", - before: { todos: [], displayCompleted: false }, + before: {}, args: { nameGenerator: NameGeneratorService.createFake(["only name"]), analytics: AnalyticsService.createFake(), }, after: { todos: [{ id: Match.anyNumber, name: "only name", complete: false }], - displayCompleted: false, }, effects: { analytics: [ diff --git a/packages/data-lit-todo/src/features/main/data/state/create-todo.ts b/packages/data-lit-todo/src/features/main/data/state/create-todo.ts index 8848652c..c8209f91 100644 --- a/packages/data-lit-todo/src/features/main/data/state/create-todo.ts +++ b/packages/data-lit-todo/src/features/main/data/state/create-todo.ts @@ -4,8 +4,11 @@ import type { State } from "./state.js"; import type { Conformance } from "./conformance-case.js"; import { appendTodo } from "./append-todo.js"; import { Match } from "@adobe/data/testing"; -export const createTodo = >( - state: T, + +// Reads the todos, writes the todos — a `{ todos }` patch — by delegating to the +// shared `appendTodo`; also logs `todoCreated`. +export const createTodo = ( + state: Pick, { name, complete, @@ -15,31 +18,28 @@ export const createTodo = >( readonly complete?: boolean; readonly analytics: AnalyticsService; }, -): T => { +): Pick => { analytics.todoCreated({ name }); return appendTodo(state, { name, complete }); }; -// Spec-owned cases, shared with the ecs `createTodo` transaction. A todo is -// appended (minted id left open as `Match.anyNumber` — the ecs assigns its own) with -// `complete` defaulting to false; the transition logs `todoCreated`. +// Spec-owned cases, shared with the ecs `createTodo` transaction. `before` is a +// delta over `State.create()` (no todos, completed hidden); `after` lists only +// what the transition writes — the todos (minted id left open as `Match.anyNumber`, +// the ecs assigns its own). `complete` defaults to false; it logs `todoCreated`. export const cases: Conformance = [ { name: "appends the first todo to an empty list", - before: { todos: [], displayCompleted: false }, + before: {}, args: { name: "a", analytics: AnalyticsService.createFake() }, after: { todos: [{ id: Match.anyNumber, name: "a", complete: false }], - displayCompleted: false, }, effects: { analytics: [["todoCreated", { name: "a" }]] }, }, { name: "appends a complete todo", - before: { - todos: [{ id: 1, name: "a", complete: false }], - displayCompleted: false, - }, + before: { todos: [{ id: 1, name: "a", complete: false }] }, args: { name: "b", complete: true, @@ -50,7 +50,6 @@ export const cases: Conformance = [ { id: Match.anyNumber, name: "a", complete: false }, { id: Match.anyNumber, name: "b", complete: true }, ], - displayCompleted: false, }, effects: { analytics: [["todoCreated", { name: "b" }]] }, }, @@ -72,7 +71,6 @@ export const cases: Conformance = [ { id: Match.anyNumber, name: "c", complete: false }, { id: Match.anyNumber, name: "d", complete: false }, ], - displayCompleted: true, }, effects: { analytics: [["todoCreated", { name: "d" }]] }, }, diff --git a/packages/data-lit-todo/src/features/main/data/state/create.ts b/packages/data-lit-todo/src/features/main/data/state/create.ts new file mode 100644 index 00000000..3517e7c8 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/data/state/create.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { State } from "./state.js"; + +// The default application state: no todos, completed items hidden. It is the +// baseline the conformance cases author their `before`/`input` as deltas over +// (passed to the runners as `initial`), and the state a fresh app starts in. +export const create = (): State => ({ todos: [], displayCompleted: false }); diff --git a/packages/data-lit-todo/src/features/main/data/state/delete-all-todos.ts b/packages/data-lit-todo/src/features/main/data/state/delete-all-todos.ts index 355e50b7..7b7f413a 100644 --- a/packages/data-lit-todo/src/features/main/data/state/delete-all-todos.ts +++ b/packages/data-lit-todo/src/features/main/data/state/delete-all-todos.ts @@ -3,15 +3,18 @@ import { AnalyticsService } from "../../services/analytics-service/analytics-ser import type { State } from "./state.js"; import type { Conformance } from "./conformance-case.js"; -export const deleteAllTodos = >( - state: T, +// Reads the todos, writes the todos — a `{ todos }` patch — clearing them; +// `displayCompleted` is untouched. Logs `allTodosCleared`. +export const deleteAllTodos = ( + state: Pick, { analytics }: { readonly analytics: AnalyticsService }, -): T => { +): Pick => { analytics.allTodosCleared(); - return { ...state, todos: [] }; + return { todos: [] }; }; -// Spec-owned cases, shared with the ecs `deleteAllTodos` transaction. Every todo +// Spec-owned cases, shared with the ecs `deleteAllTodos` transaction. `before` is +// a delta over `State.create()`; `after` lists only the written todos. Every todo // is removed and `displayCompleted` is untouched; the transition logs // `allTodosCleared` (as the action does). export const cases: Conformance = [ @@ -26,14 +29,14 @@ export const cases: Conformance = [ displayCompleted: true, }, args: { analytics: AnalyticsService.createFake() }, - after: { todos: [], displayCompleted: true }, + after: { todos: [] }, effects: { analytics: [["allTodosCleared"]] }, }, { name: "is a no-op on an already empty list but still logs the clear", - before: { todos: [], displayCompleted: false }, + before: {}, args: { analytics: AnalyticsService.createFake() }, - after: { todos: [], displayCompleted: false }, + after: { todos: [] }, effects: { analytics: [["allTodosCleared"]] }, }, ]; diff --git a/packages/data-lit-todo/src/features/main/data/state/delete-todo.ts b/packages/data-lit-todo/src/features/main/data/state/delete-todo.ts index 9d27be28..9617455e 100644 --- a/packages/data-lit-todo/src/features/main/data/state/delete-todo.ts +++ b/packages/data-lit-todo/src/features/main/data/state/delete-todo.ts @@ -3,15 +3,18 @@ import { AnalyticsService } from "../../services/analytics-service/analytics-ser import type { State } from "./state.js"; import { entity, type Conformance } from "./conformance-case.js"; import { Match } from "@adobe/data/testing"; -export const deleteTodo = >( - state: T, + +// Reads the todos, writes the todos — a `{ todos }` patch — dropping the +// addressed id; also logs `todoDeleted`. +export const deleteTodo = ( + state: Pick, { id, analytics, }: { readonly id: number; readonly analytics: AnalyticsService }, -): T => { +): Pick => { analytics.todoDeleted(); - return { ...state, todos: state.todos.filter((todo) => todo.id !== id) }; + return { todos: state.todos.filter((todo) => todo.id !== id) }; }; const three = [ @@ -20,21 +23,21 @@ const three = [ { id: 3, name: "c", complete: false }, ]; -// Spec-owned cases, shared with the ecs `deleteTodo` transaction. The addressed -// todo is removed; an unknown id is a no-op. The transition logs `todoDeleted`. -// `before` ids are concrete (they address the delete); surviving `after` ids are -// left open (`Match.anyNumber`) — the ecs assigns its own. +// Spec-owned cases, shared with the ecs `deleteTodo` transaction. `before` is a +// delta over `State.create()`; `after` lists only the written todos. The +// addressed todo is removed; an unknown id is a no-op. The transition logs +// `todoDeleted`. `before` ids are concrete (they address the delete); surviving +// `after` ids are left open (`Match.anyNumber`) — the ecs assigns its own. export const cases: Conformance = [ { name: "removes a middle todo", - before: { todos: [...three], displayCompleted: false }, + before: { todos: [...three] }, args: { id: entity(2), analytics: AnalyticsService.createFake() }, after: { todos: [ { id: Match.anyNumber, name: "a", complete: false }, { id: Match.anyNumber, name: "c", complete: false }, ], - displayCompleted: false, }, effects: { analytics: [["todoDeleted"]] }, }, @@ -47,13 +50,12 @@ export const cases: Conformance = [ { id: Match.anyNumber, name: "b", complete: true }, { id: Match.anyNumber, name: "c", complete: false }, ], - displayCompleted: true, }, effects: { analytics: [["todoDeleted"]] }, }, { name: "is a no-op for an unknown id but still logs the delete", - before: { todos: [...three], displayCompleted: false }, + before: { todos: [...three] }, args: { id: entity(99), analytics: AnalyticsService.createFake() }, after: { todos: [ @@ -61,7 +63,6 @@ export const cases: Conformance = [ { id: Match.anyNumber, name: "b", complete: true }, { id: Match.anyNumber, name: "c", complete: false }, ], - displayCompleted: false, }, effects: { analytics: [["todoDeleted"]] }, }, diff --git a/packages/data-lit-todo/src/features/main/data/state/public.ts b/packages/data-lit-todo/src/features/main/data/state/public.ts index 73dbdea9..8f4d00bb 100644 --- a/packages/data-lit-todo/src/features/main/data/state/public.ts +++ b/packages/data-lit-todo/src/features/main/data/state/public.ts @@ -1,4 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +export { create } from "./create.js"; export { createTodo } from "./create-todo.js"; export { createRandomTodo } from "./create-random-todo.js"; export { createBulkTodos } from "./create-bulk-todos.js"; diff --git a/packages/data-lit-todo/src/features/main/data/state/reorder-todo.ts b/packages/data-lit-todo/src/features/main/data/state/reorder-todo.ts index 9d794d12..157a977a 100644 --- a/packages/data-lit-todo/src/features/main/data/state/reorder-todo.ts +++ b/packages/data-lit-todo/src/features/main/data/state/reorder-todo.ts @@ -4,22 +4,22 @@ import { entity, type Conformance } from "./conformance-case.js"; import { Match } from "@adobe/data/testing"; /** * Moves the todo with the given id to `toIndex` within the list, preserving the - * relative order of every other todo. Out-of-range indices are clamped and an - * unknown id is a no-op. A pure reorder — no side effects. + * relative order of every other todo. Reads the todos, writes the todos — a + * `{ todos }` patch. Out-of-range indices are clamped and an unknown id is a + * no-op. A pure reorder — no side effects. */ -export const reorderTodo = >( - state: T, +export const reorderTodo = ( + state: Pick, input: { readonly id: number; readonly toIndex: number }, -): T => { +): Pick => { const fromIndex = state.todos.findIndex((todo) => todo.id === input.id); - if (fromIndex === -1) return state; + if (fromIndex === -1) return { todos: state.todos }; const moved = state.todos[fromIndex]; const without = state.todos.filter((todo) => todo.id !== input.id); const toIndex = Math.max(0, Math.min(input.toIndex, without.length)); return { - ...state, todos: [...without.slice(0, toIndex), moved, ...without.slice(toIndex)], }; }; @@ -31,11 +31,12 @@ const three = [ ]; // Spec-owned cases, shared with the ecs `dragTodo` transaction (its final drop is -// the same move — `finalIndex` is `toIndex`). Every case keeps all todos -// incomplete with `displayCompleted` true, so the visible list `dragTodo` indexes -// equals the full list. `before` ids address the move; `after` ids are open -// (`Match.anyNumber`) but their *order* is verified. The unknown-id no-op is exercised -// only by the pure transform — `dragTodo` has no such guard. +// the same move — `finalIndex` is `toIndex`). `before` is a delta over +// `State.create()`; `after` lists only the written todos. Every case keeps all +// todos incomplete with `displayCompleted` true, so the visible list `dragTodo` +// indexes equals the full list. `before` ids address the move; `after` ids are +// open (`Match.anyNumber`) but their *order* is verified. The unknown-id no-op is +// exercised only by the pure transform — `dragTodo` has no such guard. export const cases: Conformance = [ { name: "moves the first todo to the end", @@ -47,7 +48,6 @@ export const cases: Conformance = [ { id: Match.anyNumber, name: "c", complete: false }, { id: Match.anyNumber, name: "a", complete: false }, ], - displayCompleted: true, }, }, { @@ -60,7 +60,6 @@ export const cases: Conformance = [ { id: Match.anyNumber, name: "a", complete: false }, { id: Match.anyNumber, name: "b", complete: false }, ], - displayCompleted: true, }, }, { @@ -73,7 +72,6 @@ export const cases: Conformance = [ { id: Match.anyNumber, name: "c", complete: false }, { id: Match.anyNumber, name: "a", complete: false }, ], - displayCompleted: true, }, }, { @@ -86,7 +84,6 @@ export const cases: Conformance = [ { id: Match.anyNumber, name: "b", complete: false }, { id: Match.anyNumber, name: "c", complete: false }, ], - displayCompleted: true, }, }, ]; diff --git a/packages/data-lit-todo/src/features/main/data/state/spec.test.ts b/packages/data-lit-todo/src/features/main/data/state/spec.test.ts index e46f21ac..afa7f3dd 100644 --- a/packages/data-lit-todo/src/features/main/data/state/spec.test.ts +++ b/packages/data-lit-todo/src/features/main/data/state/spec.test.ts @@ -1,13 +1,16 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. /// import { Conformance } from "@adobe/data/testing"; +import { State } from "./state.js"; // The single pure-spec test for every transform AND derivation in this folder. // `runSpec` auto-discovers each sibling that exports `cases`, requires it to // export exactly its function plus `cases`, and dispatches on case shape (a // `value` case is a derivation, otherwise a transition whose declared `effects` -// are also asserted). Todo's `State` lists are display-ordered, so the default -// (ordered, matcher-aware) comparison is correct — no options needed. +// are also asserted). Each case's `before`/`input` is a delta over `initial` +// (`State.create()`), so cases carry only what they change. Todo's `State` lists +// are display-ordered, so the default (ordered, matcher-aware) comparison is +// correct — no options needed. Conformance.runSpec( import.meta.glob>( ["./*.ts", "!./*.test.ts", "!./*.type-test.ts"], @@ -15,4 +18,5 @@ Conformance.runSpec( eager: true, }, ), + { initial: State.create() }, ); diff --git a/packages/data-lit-todo/src/features/main/data/state/toggle-complete.ts b/packages/data-lit-todo/src/features/main/data/state/toggle-complete.ts index 0443dae3..739fc1bb 100644 --- a/packages/data-lit-todo/src/features/main/data/state/toggle-complete.ts +++ b/packages/data-lit-todo/src/features/main/data/state/toggle-complete.ts @@ -3,23 +3,26 @@ import { AnalyticsService } from "../../services/analytics-service/analytics-ser import type { State } from "./state.js"; import { entity, type Conformance } from "./conformance-case.js"; import { Match } from "@adobe/data/testing"; -export const toggleComplete = >( - state: T, + +// Reads the todos, writes the todos — a `{ todos }` patch — flipping the +// addressed todo's `complete`; also logs `todoToggled`. +export const toggleComplete = ( + state: Pick, { id, analytics, }: { readonly id: number; readonly analytics: AnalyticsService }, -): T => { +): Pick => { analytics.todoToggled(); return { - ...state, todos: state.todos.map((todo) => todo.id === id ? { ...todo, complete: !todo.complete } : todo, ), }; }; -// Spec-owned cases, shared with the ecs `toggleComplete` transaction. Only the +// Spec-owned cases, shared with the ecs `toggleComplete` transaction. `before` is +// a delta over `State.create()`; `after` lists only the written todos. Only the // addressed todo's `complete` flips; an unknown id is a no-op. The transition // logs `todoToggled` unconditionally (as the action does). `before` ids address // the toggle; `after` ids are left open (`Match.anyNumber`). @@ -31,7 +34,6 @@ export const cases: Conformance = [ { id: 1, name: "a", complete: false }, { id: 2, name: "b", complete: false }, ], - displayCompleted: false, }, args: { id: entity(1), analytics: AnalyticsService.createFake() }, after: { @@ -39,7 +41,6 @@ export const cases: Conformance = [ { id: Match.anyNumber, name: "a", complete: true }, { id: Match.anyNumber, name: "b", complete: false }, ], - displayCompleted: false, }, effects: { analytics: [["todoToggled"]] }, }, @@ -58,7 +59,6 @@ export const cases: Conformance = [ { id: Match.anyNumber, name: "a", complete: false }, { id: Match.anyNumber, name: "b", complete: false }, ], - displayCompleted: true, }, effects: { analytics: [["todoToggled"]] }, }, @@ -66,12 +66,10 @@ export const cases: Conformance = [ name: "is a no-op for an unknown id but still logs the toggle", before: { todos: [{ id: 1, name: "a", complete: false }], - displayCompleted: false, }, args: { id: entity(99), analytics: AnalyticsService.createFake() }, after: { todos: [{ id: Match.anyNumber, name: "a", complete: false }], - displayCompleted: false, }, effects: { analytics: [["todoToggled"]] }, }, diff --git a/packages/data-lit-todo/src/features/main/data/state/toggle-display-completed.ts b/packages/data-lit-todo/src/features/main/data/state/toggle-display-completed.ts index 6157bcad..1c1f1031 100644 --- a/packages/data-lit-todo/src/features/main/data/state/toggle-display-completed.ts +++ b/packages/data-lit-todo/src/features/main/data/state/toggle-display-completed.ts @@ -3,25 +3,27 @@ import { AnalyticsService } from "../../services/analytics-service/analytics-ser import type { State } from "./state.js"; import type { Conformance } from "./conformance-case.js"; import { Match } from "@adobe/data/testing"; -export const toggleDisplayCompleted = < - T extends Pick, ->( - state: T, + +// Reads `displayCompleted`, writes `displayCompleted` — a `{ displayCompleted }` +// patch — flipping the flag; also logs `displayCompletedToggled`. +export const toggleDisplayCompleted = ( + state: Pick, { analytics }: { readonly analytics: AnalyticsService }, -): T => { +): Pick => { analytics.displayCompletedToggled(); - return { ...state, displayCompleted: !state.displayCompleted }; + return { displayCompleted: !state.displayCompleted }; }; // Spec-owned cases, shared with the ecs `toggleDisplayCompleted` transaction. -// Only the `displayCompleted` flag flips; the transition logs -// `displayCompletedToggled` (as the action does). +// `before` is a delta over `State.create()`; `after` lists only the written +// `displayCompleted`. Only the flag flips; todos are untouched; the transition +// logs `displayCompletedToggled` (as the action does). export const cases: Conformance = [ { name: "turns the completed view on", - before: { todos: [], displayCompleted: false }, + before: {}, args: { analytics: AnalyticsService.createFake() }, - after: { todos: [], displayCompleted: true }, + after: { displayCompleted: true }, effects: { analytics: [["displayCompletedToggled"]] }, }, { @@ -31,6 +33,9 @@ export const cases: Conformance = [ displayCompleted: true, }, args: { analytics: AnalyticsService.createFake() }, + // Only `displayCompleted` is written, but the carried-through todo holds an + // ecs-minted id, so it is restated with `Match.anyNumber` to bridge the + // seeded data id (1) and the id the ecs assigns on the round-trip. after: { todos: [{ id: Match.anyNumber, name: "a", complete: true }], displayCompleted: false, diff --git a/packages/data-lit-todo/src/features/main/services/main-service/conformance/actions.test.ts b/packages/data-lit-todo/src/features/main/services/main-service/conformance/actions.test.ts index f95a4524..71205583 100644 --- a/packages/data-lit-todo/src/features/main/services/main-service/conformance/actions.test.ts +++ b/packages/data-lit-todo/src/features/main/services/main-service/conformance/actions.test.ts @@ -2,6 +2,7 @@ /// import { Database } from "@adobe/data/ecs"; import { Conformance } from "@adobe/data/testing"; +import { State } from "../../../data/state/state.js"; import type { AnalyticsService } from "../../analytics-service/analytics-service.js"; import type { NameGeneratorService } from "../../name-generator-service/name-generator-service.js"; import { MainService } from "../main-service.js"; @@ -35,4 +36,5 @@ Conformance.runActions({ { eager: true }, ), actions, + initial: State.create(), }); diff --git a/packages/data-lit-todo/src/features/main/services/main-service/conformance/computeds.test.ts b/packages/data-lit-todo/src/features/main/services/main-service/conformance/computeds.test.ts index a827970d..572dda28 100644 --- a/packages/data-lit-todo/src/features/main/services/main-service/conformance/computeds.test.ts +++ b/packages/data-lit-todo/src/features/main/services/main-service/conformance/computeds.test.ts @@ -2,6 +2,7 @@ /// import { Database } from "@adobe/data/ecs"; import { Conformance } from "@adobe/data/testing"; +import { State } from "../../../data/state/state.js"; import { ComputedDatabase } from "../computed-database/computed-database.js"; import * as computeds from "../computed-database/computed/index.js"; import { fromState } from "./from-state.js"; @@ -27,4 +28,5 @@ Conformance.runComputeds({ ), computeds, hydrate: ["visibleTodos"], + initial: State.create(), }); diff --git a/packages/data-lit-todo/src/features/main/services/main-service/conformance/transactions.test.ts b/packages/data-lit-todo/src/features/main/services/main-service/conformance/transactions.test.ts index 55f4cd2d..8715f6d6 100644 --- a/packages/data-lit-todo/src/features/main/services/main-service/conformance/transactions.test.ts +++ b/packages/data-lit-todo/src/features/main/services/main-service/conformance/transactions.test.ts @@ -1,6 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. /// import { Conformance } from "@adobe/data/testing"; +import { State } from "../../../data/state/state.js"; import * as transactions from "../transaction-database/transactions/index.js"; import { createStore } from "./create-store.js"; import { fromState } from "./from-state.js"; @@ -24,4 +25,5 @@ Conformance.runTransactions({ { eager: true }, ), transactions, + initial: State.create(), }); From f5805b5c3f5e8ff1d92ead607028c53702d92dbf Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 7 Aug 2026 11:49:12 -0700 Subject: [PATCH 29/37] test(tictactoe): sibling unit tests for every data/ helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings data-lit-tictactoe into compliance with the data/index.md rule ("each helper has a sibling *.test.ts"). Adds 18 direct unit tests (board-state ×10, player-mark ×4, board-cell/game-status/play-move-args/winning-line ×1 each) so each pure helper fails at its own source rather than only indirectly through conformance. 100 tests total; no helper changed, no bugs found. Co-Authored-By: Claude Sonnet 4.6 --- .../main/data/board-cell/blank.test.ts | 9 +++++ .../board-state/create-initial-board.test.ts | 10 +++++ .../data/board-state/current-player.test.ts | 20 ++++++++++ .../data/board-state/derive-status.test.ts | 21 ++++++++++ .../main/data/board-state/from-marks.test.ts | 19 ++++++++++ .../data/board-state/get-move-count.test.ts | 14 +++++++ .../main/data/board-state/get-winner.test.ts | 33 ++++++++++++++++ .../data/board-state/get-winning-line.test.ts | 26 +++++++++++++ .../data/board-state/is-board-full.test.ts | 17 +++++++++ .../data/board-state/is-game-over.test.ts | 21 ++++++++++ .../data/board-state/set-board-cell.test.ts | 23 +++++++++++ .../main/data/game-status/is-active.test.ts | 15 ++++++++ .../data/play-move-args/can-play-move.test.ts | 38 +++++++++++++++++++ .../features/main/data/player-mark/is.test.ts | 17 +++++++++ .../main/data/player-mark/mark-color.test.ts | 11 ++++++ .../main/data/player-mark/opponent.test.ts | 10 +++++ .../main/data/player-mark/values.test.ts | 9 +++++ .../main/data/winning-line/lines.test.ts | 26 +++++++++++++ 18 files changed, 339 insertions(+) create mode 100644 packages/data-lit-tictactoe/src/features/main/data/board-cell/blank.test.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/data/board-state/create-initial-board.test.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/data/board-state/current-player.test.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/data/board-state/derive-status.test.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/data/board-state/from-marks.test.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/data/board-state/get-move-count.test.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/data/board-state/get-winner.test.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/data/board-state/get-winning-line.test.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/data/board-state/is-board-full.test.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/data/board-state/is-game-over.test.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/data/board-state/set-board-cell.test.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/data/game-status/is-active.test.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/data/play-move-args/can-play-move.test.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/data/player-mark/is.test.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/data/player-mark/mark-color.test.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/data/player-mark/opponent.test.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/data/player-mark/values.test.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/data/winning-line/lines.test.ts diff --git a/packages/data-lit-tictactoe/src/features/main/data/board-cell/blank.test.ts b/packages/data-lit-tictactoe/src/features/main/data/board-cell/blank.test.ts new file mode 100644 index 00000000..af1d3414 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/board-cell/blank.test.ts @@ -0,0 +1,9 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it, expect } from "vitest"; +import { blank } from "./blank.js"; + +describe("blank", () => { + it("is the single-space unplayed-cell character", () => { + expect(blank).toBe(" "); + }); +}); diff --git a/packages/data-lit-tictactoe/src/features/main/data/board-state/create-initial-board.test.ts b/packages/data-lit-tictactoe/src/features/main/data/board-state/create-initial-board.test.ts new file mode 100644 index 00000000..3144596b --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/board-state/create-initial-board.test.ts @@ -0,0 +1,10 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it, expect } from "vitest"; +import { createInitialBoard } from "./create-initial-board.js"; + +describe("createInitialBoard", () => { + it("is nine blank cells", () => { + expect(createInitialBoard()).toBe(" "); + expect(createInitialBoard()).toHaveLength(9); + }); +}); diff --git a/packages/data-lit-tictactoe/src/features/main/data/board-state/current-player.test.ts b/packages/data-lit-tictactoe/src/features/main/data/board-state/current-player.test.ts new file mode 100644 index 00000000..c3aaa8dc --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/board-state/current-player.test.ts @@ -0,0 +1,20 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it, expect } from "vitest"; +import { currentPlayer } from "./current-player.js"; + +describe("currentPlayer", () => { + it("is the first player on an empty board", () => { + expect(currentPlayer(" ", "X")).toBe("X"); + }); + + it("alternates after each move (first player X)", () => { + expect(currentPlayer("X ", "X")).toBe("O"); + expect(currentPlayer("XO ", "X")).toBe("X"); + }); + + it("honors a non-default first player", () => { + expect(currentPlayer(" ", "O")).toBe("O"); + expect(currentPlayer("O ", "O")).toBe("X"); + expect(currentPlayer("OX ", "O")).toBe("O"); + }); +}); diff --git a/packages/data-lit-tictactoe/src/features/main/data/board-state/derive-status.test.ts b/packages/data-lit-tictactoe/src/features/main/data/board-state/derive-status.test.ts new file mode 100644 index 00000000..f029431b --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/board-state/derive-status.test.ts @@ -0,0 +1,21 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it, expect } from "vitest"; +import { deriveStatus } from "./derive-status.js"; + +describe("deriveStatus", () => { + it("is idle on an empty board", () => { + expect(deriveStatus(" ")).toBe("idle"); + }); + + it("is in_progress once a mark is placed with no winner", () => { + expect(deriveStatus("X ")).toBe("in_progress"); + }); + + it("is won when a line is complete", () => { + expect(deriveStatus("XXXOO ")).toBe("won"); + }); + + it("is draw when the board is full with no winner", () => { + expect(deriveStatus("XOXXOOOXX")).toBe("draw"); + }); +}); diff --git a/packages/data-lit-tictactoe/src/features/main/data/board-state/from-marks.test.ts b/packages/data-lit-tictactoe/src/features/main/data/board-state/from-marks.test.ts new file mode 100644 index 00000000..27cae201 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/board-state/from-marks.test.ts @@ -0,0 +1,19 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it, expect } from "vitest"; +import { fromMarks } from "./from-marks.js"; + +describe("fromMarks", () => { + it("is a blank board for no marks", () => { + expect(fromMarks([])).toBe(" "); + }); + + it("projects marks into their index-addressed cells", () => { + expect( + fromMarks([ + { mark: "X", index: 0 }, + { mark: "O", index: 4 }, + { mark: "X", index: 8 }, + ]), + ).toBe("X O X"); + }); +}); diff --git a/packages/data-lit-tictactoe/src/features/main/data/board-state/get-move-count.test.ts b/packages/data-lit-tictactoe/src/features/main/data/board-state/get-move-count.test.ts new file mode 100644 index 00000000..46450a7c --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/board-state/get-move-count.test.ts @@ -0,0 +1,14 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it, expect } from "vitest"; +import { getMoveCount } from "./get-move-count.js"; + +describe("getMoveCount", () => { + it("is zero for an empty board", () => { + expect(getMoveCount(" ")).toBe(0); + }); + + it("counts both players' marks", () => { + expect(getMoveCount("XO ")).toBe(2); + expect(getMoveCount("XOXXOOOXX")).toBe(9); + }); +}); diff --git a/packages/data-lit-tictactoe/src/features/main/data/board-state/get-winner.test.ts b/packages/data-lit-tictactoe/src/features/main/data/board-state/get-winner.test.ts new file mode 100644 index 00000000..034f055e --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/board-state/get-winner.test.ts @@ -0,0 +1,33 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it, expect } from "vitest"; +import { getWinner } from "./get-winner.js"; + +describe("getWinner", () => { + it("is null on an empty board", () => { + expect(getWinner(" ")).toBeNull(); + }); + + it("is null while the game is still in progress", () => { + expect(getWinner("XO ")).toBeNull(); + }); + + it("returns the mark that completes a row", () => { + expect(getWinner("XXXOO ")).toBe("X"); + }); + + it("returns the mark that completes a column", () => { + expect(getWinner("XO XO X ")).toBe("X"); + }); + + it("returns the mark that completes a diagonal", () => { + expect(getWinner("XO OX X")).toBe("X"); + }); + + it("returns O when O wins", () => { + expect(getWinner("OOOXX ")).toBe("O"); + }); + + it("is 'cat' when the board is full with no winner", () => { + expect(getWinner("XOXXOOOXX")).toBe("cat"); + }); +}); diff --git a/packages/data-lit-tictactoe/src/features/main/data/board-state/get-winning-line.test.ts b/packages/data-lit-tictactoe/src/features/main/data/board-state/get-winning-line.test.ts new file mode 100644 index 00000000..9f40dee7 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/board-state/get-winning-line.test.ts @@ -0,0 +1,26 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it, expect } from "vitest"; +import { getWinningLine } from "./get-winning-line.js"; + +describe("getWinningLine", () => { + it("is null when there is no completed line", () => { + expect(getWinningLine(" ")).toBeNull(); + expect(getWinningLine("XO ")).toBeNull(); + }); + + it("finds a winning row", () => { + expect(getWinningLine("XXXOO ")).toEqual([0, 1, 2]); + }); + + it("finds a winning column", () => { + expect(getWinningLine("XO XO X ")).toEqual([0, 3, 6]); + }); + + it("finds a top-left-to-bottom-right diagonal", () => { + expect(getWinningLine("XO OX X")).toEqual([0, 4, 8]); + }); + + it("finds a top-right-to-bottom-left diagonal", () => { + expect(getWinningLine("OOXXX X ")).toEqual([2, 4, 6]); + }); +}); diff --git a/packages/data-lit-tictactoe/src/features/main/data/board-state/is-board-full.test.ts b/packages/data-lit-tictactoe/src/features/main/data/board-state/is-board-full.test.ts new file mode 100644 index 00000000..de5c19ad --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/board-state/is-board-full.test.ts @@ -0,0 +1,17 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it, expect } from "vitest"; +import { isBoardFull } from "./is-board-full.js"; + +describe("isBoardFull", () => { + it("is false for an empty board", () => { + expect(isBoardFull(" ")).toBe(false); + }); + + it("is false while any cell is blank", () => { + expect(isBoardFull("XOXXOOOX ")).toBe(false); + }); + + it("is true when every cell is filled", () => { + expect(isBoardFull("XOXXOOOXX")).toBe(true); + }); +}); diff --git a/packages/data-lit-tictactoe/src/features/main/data/board-state/is-game-over.test.ts b/packages/data-lit-tictactoe/src/features/main/data/board-state/is-game-over.test.ts new file mode 100644 index 00000000..e3a2b855 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/board-state/is-game-over.test.ts @@ -0,0 +1,21 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it, expect } from "vitest"; +import { isGameOver } from "./is-game-over.js"; + +describe("isGameOver", () => { + it("is false on an empty board", () => { + expect(isGameOver(" ")).toBe(false); + }); + + it("is false while the game is in progress", () => { + expect(isGameOver("XO ")).toBe(false); + }); + + it("is true when there is a winning line", () => { + expect(isGameOver("XXXOO ")).toBe(true); + }); + + it("is true when the board is full (draw)", () => { + expect(isGameOver("XOXXOOOXX")).toBe(true); + }); +}); diff --git a/packages/data-lit-tictactoe/src/features/main/data/board-state/set-board-cell.test.ts b/packages/data-lit-tictactoe/src/features/main/data/board-state/set-board-cell.test.ts new file mode 100644 index 00000000..b4ade5a4 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/board-state/set-board-cell.test.ts @@ -0,0 +1,23 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it, expect } from "vitest"; +import { setBoardCell } from "./set-board-cell.js"; + +describe("setBoardCell", () => { + it("places a mark at the first cell", () => { + expect(setBoardCell({ board: " ", index: 0, mark: "X" })).toBe( + "X ", + ); + }); + + it("places a mark at the last cell", () => { + expect(setBoardCell({ board: " ", index: 8, mark: "O" })).toBe( + " O", + ); + }); + + it("leaves the other cells untouched", () => { + expect(setBoardCell({ board: "X O ", index: 4, mark: "X" })).toBe( + "X X ", + ); + }); +}); diff --git a/packages/data-lit-tictactoe/src/features/main/data/game-status/is-active.test.ts b/packages/data-lit-tictactoe/src/features/main/data/game-status/is-active.test.ts new file mode 100644 index 00000000..1c432c26 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/game-status/is-active.test.ts @@ -0,0 +1,15 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it, expect } from "vitest"; +import { isActive } from "./is-active.js"; + +describe("isActive", () => { + it("is true while the game still accepts moves", () => { + expect(isActive("idle")).toBe(true); + expect(isActive("in_progress")).toBe(true); + }); + + it("is false once the game has ended", () => { + expect(isActive("won")).toBe(false); + expect(isActive("draw")).toBe(false); + }); +}); diff --git a/packages/data-lit-tictactoe/src/features/main/data/play-move-args/can-play-move.test.ts b/packages/data-lit-tictactoe/src/features/main/data/play-move-args/can-play-move.test.ts new file mode 100644 index 00000000..2f7047df --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/play-move-args/can-play-move.test.ts @@ -0,0 +1,38 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it, expect } from "vitest"; +import { canPlayMove } from "./can-play-move.js"; + +describe("canPlayMove", () => { + it("allows a move into a blank cell of an in-progress game", () => { + expect(canPlayMove({ board: " ", index: 4 })).toEqual({ ok: true }); + }); + + it("rejects a non-integer, negative, or too-large index", () => { + expect(canPlayMove({ board: " ", index: 1.5 })).toEqual({ + ok: false, + reason: "index_out_of_bounds", + }); + expect(canPlayMove({ board: " ", index: -1 })).toEqual({ + ok: false, + reason: "index_out_of_bounds", + }); + expect(canPlayMove({ board: " ", index: 9 })).toEqual({ + ok: false, + reason: "index_out_of_bounds", + }); + }); + + it("rejects a move once the game is over", () => { + expect(canPlayMove({ board: "XXXOO ", index: 5 })).toEqual({ + ok: false, + reason: "game_over", + }); + }); + + it("rejects a move into an occupied cell", () => { + expect(canPlayMove({ board: "X ", index: 0 })).toEqual({ + ok: false, + reason: "cell_occupied", + }); + }); +}); diff --git a/packages/data-lit-tictactoe/src/features/main/data/player-mark/is.test.ts b/packages/data-lit-tictactoe/src/features/main/data/player-mark/is.test.ts new file mode 100644 index 00000000..ec165e28 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/player-mark/is.test.ts @@ -0,0 +1,17 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it, expect } from "vitest"; +import { is } from "./is.js"; + +describe("is", () => { + it("accepts the two player marks", () => { + expect(is("X")).toBe(true); + expect(is("O")).toBe(true); + }); + + it("rejects anything else", () => { + expect(is(" ")).toBe(false); + expect(is("x")).toBe(false); + expect(is(1)).toBe(false); + expect(is(null)).toBe(false); + }); +}); diff --git a/packages/data-lit-tictactoe/src/features/main/data/player-mark/mark-color.test.ts b/packages/data-lit-tictactoe/src/features/main/data/player-mark/mark-color.test.ts new file mode 100644 index 00000000..6e557cac --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/player-mark/mark-color.test.ts @@ -0,0 +1,11 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it, expect } from "vitest"; +import { markColor } from "./mark-color.js"; + +describe("markColor", () => { + it("has a distinct colour string for each mark", () => { + expect(typeof markColor.X).toBe("string"); + expect(typeof markColor.O).toBe("string"); + expect(markColor.X).not.toBe(markColor.O); + }); +}); diff --git a/packages/data-lit-tictactoe/src/features/main/data/player-mark/opponent.test.ts b/packages/data-lit-tictactoe/src/features/main/data/player-mark/opponent.test.ts new file mode 100644 index 00000000..28841748 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/player-mark/opponent.test.ts @@ -0,0 +1,10 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it, expect } from "vitest"; +import { opponent } from "./opponent.js"; + +describe("opponent", () => { + it("maps each mark to the other", () => { + expect(opponent.X).toBe("O"); + expect(opponent.O).toBe("X"); + }); +}); diff --git a/packages/data-lit-tictactoe/src/features/main/data/player-mark/values.test.ts b/packages/data-lit-tictactoe/src/features/main/data/player-mark/values.test.ts new file mode 100644 index 00000000..12219fb2 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/player-mark/values.test.ts @@ -0,0 +1,9 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it, expect } from "vitest"; +import { values } from "./values.js"; + +describe("values", () => { + it("is the two player marks", () => { + expect(values).toEqual(["X", "O"]); + }); +}); diff --git a/packages/data-lit-tictactoe/src/features/main/data/winning-line/lines.test.ts b/packages/data-lit-tictactoe/src/features/main/data/winning-line/lines.test.ts new file mode 100644 index 00000000..b1007fe9 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/winning-line/lines.test.ts @@ -0,0 +1,26 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it, expect } from "vitest"; +import { lines } from "./lines.js"; + +describe("lines", () => { + it("is the eight winning triples of board indices", () => { + expect(lines).toEqual([ + [0, 1, 2], + [3, 4, 5], + [6, 7, 8], + [0, 3, 6], + [1, 4, 7], + [2, 5, 8], + [0, 4, 8], + [2, 4, 6], + ]); + }); + + it("references only valid cell indices, three per line", () => { + for (const line of lines) { + expect(line).toHaveLength(3); + for (const i of line) expect(i).toBeGreaterThanOrEqual(0); + for (const i of line) expect(i).toBeLessThanOrEqual(8); + } + }); +}); From 9e1b73d88f3676df370ec4cd01a6c42e9f916686 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 7 Aug 2026 12:27:25 -0700 Subject: [PATCH 30/37] =?UTF-8?q?feat(data):=20Conformance.runFeature=20?= =?UTF-8?q?=E2=80=94=20one-call=20feature=20conformance;=20tictactoe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit runFeature pulls the ops off the plugin's registered facets (plugin.transactions / plugin.actions / computedPlugin.computed) and constructs the stores/dbs itself, so a feature supplies only: its State namespace (create() + samples), the data/state glob, the plugin(s), and a single aggregated `projection` ({fromState,toState, toData}). It runs transaction + action + computed conformance plus the projection round-trip over State.samples. Escape-hatch `ops` overrides discovery when an op isn't registered in the facet; user-scoped features keep using the lower-level runners directly. tictactoe conformance collapses from 8 files to 2 (conformance.test.ts + a single projection.ts); add State.samples. 99 tests. Co-Authored-By: Claude Sonnet 4.6 --- .../src/features/main/data/state/public.ts | 1 + .../src/features/main/data/state/samples.ts | 9 ++ .../main-service/conformance/actions.test.ts | 28 ----- .../conformance/computeds.test.ts | 31 ----- .../conformance/conformance.test.ts | 24 ++++ .../main-service/conformance/create-store.ts | 13 -- .../main-service/conformance/from-state.ts | 33 ----- .../conformance/projection.test.ts | 56 --------- .../main-service/conformance/projection.ts | 51 ++++++++ .../main-service/conformance/to-data.ts | 17 --- .../main-service/conformance/to-state.ts | 24 ---- .../conformance/transactions.test.ts | 29 ----- .../data/src/testing/conformance/public.ts | 1 + .../src/testing/conformance/run-feature.ts | 117 ++++++++++++++++++ 14 files changed, 203 insertions(+), 231 deletions(-) create mode 100644 packages/data-lit-tictactoe/src/features/main/data/state/samples.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/actions.test.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/computeds.test.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/conformance.test.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/create-store.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/from-state.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/projection.test.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/projection.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/to-data.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/to-state.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/transactions.test.ts create mode 100644 packages/data/src/testing/conformance/run-feature.ts diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/public.ts b/packages/data-lit-tictactoe/src/features/main/data/state/public.ts index cdecee7b..74cd0885 100644 --- a/packages/data-lit-tictactoe/src/features/main/data/state/public.ts +++ b/packages/data-lit-tictactoe/src/features/main/data/state/public.ts @@ -1,5 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. export { create } from "./create.js"; +export { samples } from "./samples.js"; export { playMove } from "./play-move.js"; export { restartGame } from "./restart-game.js"; export { currentPlayer } from "./current-player.js"; diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/samples.ts b/packages/data-lit-tictactoe/src/features/main/data/state/samples.ts new file mode 100644 index 00000000..497177af --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/state/samples.ts @@ -0,0 +1,9 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { State } from "./state.js"; + +// Representative full states for the projection round-trip (toState ∘ fromState ≡ +// identity). Varied boards + non-default counters exercise the whole ecs↔State map. +export const samples: readonly State[] = [ + { board: "XOXXOOOXX", firstPlayer: "O", xWins: 3, oWins: 2, draws: 1 }, + { board: "X O X ", firstPlayer: "O", xWins: 1, oWins: 0, draws: 0 }, +]; diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/actions.test.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/actions.test.ts deleted file mode 100644 index 8a529dba..00000000 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/actions.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -/// -import { Database } from "@adobe/data/ecs"; -import { Conformance } from "@adobe/data/testing"; -import { State } from "../../../data/state/state.js"; -import { MainService } from "../main-service.js"; -import * as actions from "../action-database/actions/index.js"; -import { fromState } from "./from-state.js"; -import { toState } from "./to-state.js"; - -// Every ecs action, conformed by name against its transition. No tictactoe -// transition injects a service, so `makeDb` needs no recording service overrides. -Conformance.runActions({ - makeDb: () => Database.toSystemDatabase(Database.create(MainService.plugin)), - store: (db) => db.store, - fromState, - toState, - transitions: import.meta.glob( - [ - "../../../data/state/*.ts", - "!../../../data/state/*.test.ts", - "!../../../data/state/*.type-test.ts", - ], - { eager: true }, - ), - actions, - initial: State.create(), -}); diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/computeds.test.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/computeds.test.ts deleted file mode 100644 index 0fb002a1..00000000 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/computeds.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -/// -import { Database } from "@adobe/data/ecs"; -import { Conformance } from "@adobe/data/testing"; -import { State } from "../../../data/state/state.js"; -import { ComputedDatabase } from "../computed-database/computed-database.js"; -import * as computeds from "../computed-database/computed/index.js"; -import { fromState } from "./from-state.js"; -import { toData } from "./to-data.js"; - -// Every ecs computed backing a `data/state` derivation, conformed by name. Only -// `currentPlayer` is a derivation (composes board + firstPlayer); the single-type -// board computeds (winner/status/…) have no derivation and are covered by their -// `data/board-state` helper tests. Built from the ComputedDatabase layer. -Conformance.runComputeds({ - makeDb: () => - Database.toSystemDatabase(Database.create(ComputedDatabase.plugin)), - store: (db) => db.store, - fromState, - toData, - derivations: import.meta.glob( - [ - "../../../data/state/*.ts", - "!../../../data/state/*.test.ts", - "!../../../data/state/*.type-test.ts", - ], - { eager: true }, - ), - computeds, - initial: State.create(), -}); diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/conformance.test.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/conformance.test.ts new file mode 100644 index 00000000..b12e724a --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/conformance.test.ts @@ -0,0 +1,24 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +/// +import { Conformance } from "@adobe/data/testing"; +import { State } from "../../../data/state/state.js"; +import { MainService } from "../main-service.js"; +import { ComputedDatabase } from "../computed-database/computed-database.js"; +import { projection } from "./projection.js"; + +// The whole ecs conformance for this feature in one call: `runFeature` pulls the +// transactions/actions off `MainService.plugin`, the computeds off the +// `ComputedDatabase` layer, seeds each case's `before` (a delta) over +// `State.create()`, and round-trips `State.samples` through the projection. +Conformance.runFeature({ + state: State, + transitions: import.meta.glob( + ["../../../data/state/*.ts", "!**/*.test.ts", "!**/*.type-test.ts"], + { + eager: true, + }, + ), + plugin: MainService.plugin, + computedPlugin: ComputedDatabase.plugin, + projection, +}); diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/create-store.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/create-store.ts deleted file mode 100644 index b864f388..00000000 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/create-store.ts +++ /dev/null @@ -1,13 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Store } from "@adobe/data/ecs"; -import type { CoreDatabase } from "../core-database/core-database.js"; -import { IndexDatabase } from "../index-database/index-database.js"; - -// A fresh writable store carrying the feature's whole schema. `IndexDatabase` is -// the lowest layer that declares it all (components / resources / archetypes + -// indexes) — the behaviour layers above add none — and `Store.create` reads a -// plugin's schema facets directly. Typed as `CoreDatabase.Store`: the surface the -// projection (`fromState` / `toState`) and the raw transaction functions use. -// Test-only. -export const createStore = (): CoreDatabase.Store => - Store.create(IndexDatabase.plugin); diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/from-state.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/from-state.ts deleted file mode 100644 index 43461520..00000000 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/from-state.ts +++ /dev/null @@ -1,33 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { State } from "../../../data/state/state.js"; -import { PlayerMark } from "../../../data/player-mark/player-mark.js"; -import type { CoreDatabase } from "../core-database/core-database.js"; - -// Seed a store to exactly match a `data/` `State`: clear every placed mark, set -// the scalar resources, then insert one PlacedMark entity per occupied board -// cell. The inverse of `toState`. Test-only — the bridge that lets an ecs -// mutation be checked against the pure transform it stands for (see -// `expect-conforms.ts`). -// -// Clearing iterates tail→head so each delete is from the tail (no hole-fill -// shift). The board string carries the marks (tictactoe stores each mark as an -// entity), so `PlayerMark.is` narrows each cell and skips the blanks. -export const fromState = (store: CoreDatabase.Store, state: State): void => { - for (const arch of store.queryArchetypes( - store.archetypes.PlacedMark.components, - )) { - for (let row = arch.rowCount - 1; row >= 0; row--) { - store.delete(arch.columns.id.get(row)); - } - } - store.resources.firstPlayer = state.firstPlayer; - store.resources.xWins = state.xWins; - store.resources.oWins = state.oWins; - store.resources.draws = state.draws; - for (let index = 0; index < state.board.length; index++) { - const cell = state.board[index]; - if (PlayerMark.is(cell)) { - store.archetypes.PlacedMark.insert({ mark: cell, index }); - } - } -}; diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/projection.test.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/projection.test.ts deleted file mode 100644 index dbd7de0a..00000000 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/projection.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -// -// Guards the projection itself. `fromState` and `toState` are the bridge every -// transaction conformance test trusts; a symmetric bug in the pair would cancel -// out and mask a real ecs defect. This identity test — `toState(fromState(s)) ≡ s` -// over representative states — proves the projection round-trips on its own. -import { describe, it } from "vitest"; -import { Match } from "@adobe/data/testing"; -import type { State } from "../../../data/state/state.js"; -import { createStore } from "./create-store.js"; -import { fromState } from "./from-state.js"; -import { toState } from "./to-state.js"; - -const states: readonly { readonly name: string; readonly state: State }[] = [ - { - name: "a full board with non-zero counters", - state: { - board: "XOXXOOOXX", - firstPlayer: "O", - xWins: 3, - oWins: 2, - draws: 1, - }, - }, - { - name: "an empty board (just the resources)", - state: { - board: " ", - firstPlayer: "X", - xWins: 0, - oWins: 0, - draws: 0, - }, - }, - { - name: "a game in progress", - state: { - board: "X O X ", - firstPlayer: "O", - xWins: 1, - oWins: 0, - draws: 0, - }, - }, -]; - -describe("ecs/conformance projection round-trips (toState ∘ fromState ≡ identity)", () => { - for (const { name, state } of states) { - it(name, () => { - const store = createStore(); - fromState(store, state); - // Tic-tac-toe's board/counters carry no ecs-minted ids, so it compares equal. - Match.assert(toState(store), state); - }); - } -}); diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/projection.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/projection.ts new file mode 100644 index 00000000..c630babf --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/projection.ts @@ -0,0 +1,51 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Entity } from "@adobe/data/ecs"; +import { BoardState } from "../../../data/board-state/board-state.js"; +import { PlayerMark } from "../../../data/player-mark/player-mark.js"; +import type { PlacedMark } from "../../../data/placed-mark/placed-mark.js"; +import type { State } from "../../../data/state/state.js"; +import type { CoreDatabase } from "../core-database/core-database.js"; + +// Read one placed-mark entity back into its `data/` value — the per-entity mapping +// `toState` folds over. +const toData = (store: CoreDatabase.Store, entity: Entity): PlacedMark => { + const row = store.read(entity, store.archetypes.PlacedMark); + if (row === null) + throw new Error("conformance projection: expected a placed-mark entity"); + return { mark: row.mark, index: row.index }; +}; + +// The test-only ecs↔`State` projection, passed to `Conformance.runFeature`. +// `fromState` seeds a store to a `State` (tictactoe is index-addressed, so it +// returns no id map); `toState` reads it back; `toData` reads one entity. +export const projection = { + fromState: (store: CoreDatabase.Store, state: State): void => { + for (const arch of store.queryArchetypes( + store.archetypes.PlacedMark.components, + )) { + for (let row = arch.rowCount - 1; row >= 0; row--) + store.delete(arch.columns.id.get(row)); + } + store.resources.firstPlayer = state.firstPlayer; + store.resources.xWins = state.xWins; + store.resources.oWins = state.oWins; + store.resources.draws = state.draws; + for (let index = 0; index < state.board.length; index++) { + const cell = state.board[index]; + if (PlayerMark.is(cell)) + store.archetypes.PlacedMark.insert({ mark: cell, index }); + } + }, + toState: (store: CoreDatabase.Store): State => ({ + board: BoardState.fromMarks( + [...store.select(store.archetypes.PlacedMark.components)].map((entity) => + toData(store, entity), + ), + ), + firstPlayer: store.resources.firstPlayer, + xWins: store.resources.xWins, + oWins: store.resources.oWins, + draws: store.resources.draws, + }), + toData, +}; diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/to-data.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/to-data.ts deleted file mode 100644 index fe2cd0fd..00000000 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/to-data.ts +++ /dev/null @@ -1,17 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Entity } from "@adobe/data/ecs"; -import type { PlacedMark } from "../../../data/placed-mark/placed-mark.js"; -import type { CoreDatabase } from "../core-database/core-database.js"; - -// Read one entity back into its `data/` value — the per-entity projection -// `toState` is built on, and the single place the ecs↔data mapping for a placed -// mark lives. Test-only. -export const toData = ( - store: CoreDatabase.Store, - entity: Entity, -): PlacedMark => { - const row = store.read(entity, store.archetypes.PlacedMark); - if (row === null) - throw new Error("conformance projection: expected a placed-mark entity"); - return { mark: row.mark, index: row.index }; -}; diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/to-state.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/to-state.ts deleted file mode 100644 index 1c7f3f78..00000000 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/to-state.ts +++ /dev/null @@ -1,24 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { State } from "../../../data/state/state.js"; -import { BoardState } from "../../../data/board-state/board-state.js"; -import type { CoreDatabase } from "../core-database/core-database.js"; -import { toData } from "./to-data.js"; - -// Read a store back into a `data/` `State` — the inverse of `fromState`. Each -// placed-mark entity is read through the per-entity `toData` projection, folded -// into the compact board string, then joined with the scalar resources. -// Test-only. -const readBoard = (store: CoreDatabase.Store): BoardState => - BoardState.fromMarks( - [...store.select(store.archetypes.PlacedMark.components)].map((entity) => - toData(store, entity), - ), - ); - -export const toState = (store: CoreDatabase.Store): State => ({ - board: readBoard(store), - firstPlayer: store.resources.firstPlayer, - xWins: store.resources.xWins, - oWins: store.resources.oWins, - draws: store.resources.draws, -}); diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/transactions.test.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/transactions.test.ts deleted file mode 100644 index bbc5f770..00000000 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/transactions.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -/// -import { Conformance } from "@adobe/data/testing"; -import { State } from "../../../data/state/state.js"; -import * as transactions from "../transaction-database/transactions/index.js"; -import { createStore } from "./create-store.js"; -import { fromState } from "./from-state.js"; -import { toState } from "./to-state.js"; - -// Every ecs transaction, conformed by name against its `data/state` transition — -// no per-item wiring. `runTransactions` discovers the transitions (the glob), -// pairs each registered transaction to the same-named one, seeds `fromState`, -// applies, and compares `toState`. Moves are addressed by board index (plain -// data), so no `entity()` markers are needed. -Conformance.runTransactions({ - createStore, - fromState, - toState, - transitions: import.meta.glob( - [ - "../../../data/state/*.ts", - "!../../../data/state/*.test.ts", - "!../../../data/state/*.type-test.ts", - ], - { eager: true }, - ), - transactions, - initial: State.create(), -}); diff --git a/packages/data/src/testing/conformance/public.ts b/packages/data/src/testing/conformance/public.ts index 7769220e..7d055259 100644 --- a/packages/data/src/testing/conformance/public.ts +++ b/packages/data/src/testing/conformance/public.ts @@ -5,3 +5,4 @@ export { runSpec, type SpecOptions } from "./run-spec.js"; export { runTransactions, type TransactionRunConfig } from "./run-transactions.js"; export { runActions, type ActionRunConfig } from "./run-actions.js"; export { runComputeds, type ComputedRunConfig } from "./run-computeds.js"; +export { runFeature, type FeatureRunConfig, type Projection } from "./run-feature.js"; diff --git a/packages/data/src/testing/conformance/run-feature.ts b/packages/data/src/testing/conformance/run-feature.ts new file mode 100644 index 00000000..2bbf15b7 --- /dev/null +++ b/packages/data/src/testing/conformance/run-feature.ts @@ -0,0 +1,117 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it } from "vitest"; +import { Database, Store } from "../../ecs/index.js"; +import type { Entity } from "../../ecs/entity/entity.js"; +import { assert } from "../match/assert.js"; +import type { MatchOptions } from "../match/match.js"; +import { runTransactions } from "./run-transactions.js"; +import { runActions } from "./run-actions.js"; +import { runComputeds } from "./run-computeds.js"; + +// The feature's ecs↔`State` projection — the one genuinely feature-specific piece. +export interface Projection { + readonly fromState: (store: Store, state: State) => ReadonlyMap | void; + readonly toState: (store: Store) => State; + readonly toData?: (store: Store, entity: Entity) => unknown; +} + +// One call conforms a whole feature. The runner pulls the ops off the plugin's +// registered facets (`plugin.transactions` / `plugin.actions` / +// `computedPlugin.computed`) and constructs the stores/dbs itself, so a feature +// supplies only its `State` namespace (default + representative samples), the +// `data/state` glob (the `{ fn, cases }` source), the plugin(s), and its +// projection. It runs the transaction, action, and computed conformance plus a +// projection round-trip (`toState ∘ fromState ≡ identity`) over `State.samples`. +// +// A feature whose ops aren't registered in the facet (a conformance-only action), +// or that needs ambient per-case context (a user-scoped `userId`), uses the +// lower-level `runTransactions`/`runActions`/`runComputeds` directly instead. +export interface FeatureRunConfig { + // The `State` namespace: `create()` is the default seed each case's `before` + // deltas over; `samples` (optional) are representative full states for the + // projection round-trip. + readonly state: { create(): State; readonly samples?: readonly State[] }; + // `import.meta.glob(["../../../data/state/*.ts", "!**/*.test.ts", "!**/*.type-test.ts"], { eager: true })`. + readonly transitions: Record>; + // The assembled feature plugin (`MainService.plugin`) — its `.transactions` and + // `.actions` facets are the ops, and it builds the transaction store + action db. + readonly plugin: Database.Plugin; + // The `ComputedDatabase` layer plugin — its `.computed` facet is the ops, built + // from this layer for seed-freshness. Omit when the feature has no derivations. + readonly computedPlugin?: Database.Plugin; + readonly projection: Projection; + // Names of computeds that emit an entity-id list (hydrated through `toData`). + readonly hydrate?: readonly string[]; + readonly match?: MatchOptions; + // Override the ops discovered from the plugin when they aren't registered in a + // facet (e.g. per-transition actions kept out of the plugin to bound its type). + readonly ops?: { + readonly transactions?: Record; + readonly actions?: Record; + readonly computeds?: Record; + }; +} + +// Runtime invariant: a plugin object carries its registered facet maps (see +// `create-plugin.ts`), so this reads the ops directly off it. +type PluginFacets = { transactions: Record; actions: Record; computed: Record }; + +export function runFeature( + config: FeatureRunConfig, +): void { + const initial = config.state.create(); + const { fromState, toState, toData } = config.projection; + const facets = config.plugin as unknown as PluginFacets; + // A plugin carries the schema facets, so `Store.create` / `Database.create` + // accept it; the resulting store/db is the projection's `StoreT`/`Db`. + const makeStore = (): StoreT => Store.create(config.plugin as never) as StoreT; + + runTransactions({ + createStore: makeStore, + fromState, + toState, + initial, + transitions: config.transitions, + transactions: config.ops?.transactions ?? facets.transactions, + match: config.match, + }); + + runActions({ + makeDb: (services) => Database.toSystemDatabase(Database.create(config.plugin as never, { services })) as unknown as Db, + store: (db) => db.store, + fromState, + toState, + initial, + transitions: config.transitions, + actions: config.ops?.actions ?? facets.actions, + match: config.match, + }); + + if (config.computedPlugin) { + const computedFacets = config.computedPlugin as unknown as PluginFacets; + runComputeds({ + makeDb: () => Database.toSystemDatabase(Database.create(config.computedPlugin as never)) as unknown as Db, + store: (db) => db.store, + fromState, + toData, + initial, + derivations: config.transitions, + computeds: config.ops?.computeds ?? computedFacets.computed, + hydrate: config.hydrate, + match: config.match, + }); + } + + const samples = config.state.samples ?? []; + if (samples.length > 0) { + describe("projection round-trips (toState ∘ fromState ≡ identity)", () => { + samples.forEach((sample, index) => { + it(`sample ${index}`, () => { + const store = makeStore(); + fromState(store, sample); + assert(toState(store), sample, config.match); + }); + }); + }); + } +} From 743a6cced014e212149360f483efcad847c1347e Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 7 Aug 2026 12:35:40 -0700 Subject: [PATCH 31/37] feat(data): roll Conformance.runFeature across all samples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each feature's ECS conformance collapses to one runFeature call + a single aggregated projection.ts (fromState/toState[/toData]); every feature gains State.samples for the projection round-trip. todo/tictactoe/pixie use the entity markers + hydrate; space-rock passes match.unordered and keeps its systems tests (rewired to the aggregated projection); negotiation uses the ops.actions glob override for its unregistered per-transition actions. p2p presence stays on the lower-level runTransactions/runActions directly — its per-surface userId seeding is the documented exception runFeature doesn't cover. todo 102 · tictactoe 99 · solid 32 · pixie 42 · p2p 53 · space-rock 135; @adobe/data 2935. Co-Authored-By: Claude Sonnet 4.6 --- .../src/features/main/data/state/public.ts | 1 + .../src/features/main/data/state/samples.ts | 46 ++++++++ .../src/features/main/data/state/spec.test.ts | 6 +- .../main-service/conformance/actions.test.ts | 38 ------- .../conformance/conformance.test.ts | 25 ++++ .../main-service/conformance/create-store.ts | 13 --- .../main-service/conformance/from-state.ts | 31 ----- .../conformance/projection.test.ts | 74 ------------ .../main-service/conformance/projection.ts | 107 ++++++++++++++++++ .../main-service/conformance/to-data.ts | 42 ------- .../main-service/conformance/to-state.ts | 39 ------- .../conformance/transactions.test.ts | 33 ------ .../collision-detection.test.ts | 7 +- .../system-database/tick-loop.test.ts | 11 +- .../src/features/main/data/state/public.ts | 1 + .../src/features/main/data/state/samples.ts | 27 +++++ .../main-service/conformance/actions.test.ts | 40 ------- .../conformance/computeds.test.ts | 32 ------ .../conformance/conformance.test.ts | 25 ++++ .../main-service/conformance/create-store.ts | 13 --- .../main-service/conformance/from-state.ts | 42 ------- .../conformance/projection.test.ts | 57 ---------- .../main-service/conformance/projection.ts | 66 +++++++++++ .../main-service/conformance/to-data.ts | 16 --- .../main-service/conformance/to-state.ts | 25 ---- .../conformance/transactions.test.ts | 29 ----- .../features/negotiation/data/state/public.ts | 1 + .../negotiation/data/state/samples.ts | 29 +++++ .../negotiation/data/state/spec.test.ts | 8 +- .../main-service/conformance/actions.test.ts | 34 ------ .../conformance/conformance.test.ts | 30 +++++ .../main-service/conformance/create-store.ts | 8 -- .../main-service/conformance/from-state.ts | 26 ----- .../conformance/projection.test.ts | 46 -------- .../main-service/conformance/projection.ts | 35 ++++++ .../main-service/conformance/to-state.ts | 19 ---- .../conformance/transactions.test.ts | 25 ---- .../src/features/main/data/state/create.ts | 7 ++ .../src/features/main/data/state/public.ts | 2 + .../src/features/main/data/state/samples.ts | 64 +++++++++++ .../src/features/main/data/state/spec.test.ts | 2 + .../main-service/conformance/actions.test.ts | 32 ------ .../conformance/conformance.test.ts | 21 ++++ .../main-service/conformance/create-store.ts | 9 -- .../main-service/conformance/from-state.ts | 40 ------- .../conformance/projection.test.ts | 94 --------------- .../main-service/conformance/projection.ts | 62 ++++++++++ .../main-service/conformance/to-data.ts | 21 ---- .../main-service/conformance/to-state.ts | 18 --- .../conformance/transactions.test.ts | 27 ----- .../src/features/main/data/state/public.ts | 1 + .../src/features/main/data/state/samples.ts | 14 +++ .../src/features/main/data/state/spec.test.ts | 2 + .../main-service/conformance/actions.test.ts | 31 ----- .../conformance/conformance.test.ts | 20 ++++ .../main-service/conformance/create-store.ts | 8 -- .../main-service/conformance/from-state.ts | 22 ---- .../conformance/projection.test.ts | 39 ------- .../main-service/conformance/projection.ts | 25 ++++ .../main-service/conformance/to-state.ts | 11 -- .../conformance/transactions.test.ts | 27 ----- 61 files changed, 632 insertions(+), 1074 deletions(-) create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/samples.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/actions.test.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/conformance.test.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/create-store.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/from-state.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/projection.test.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/projection.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/to-data.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/to-state.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/transactions.test.ts create mode 100644 packages/data-lit-todo/src/features/main/data/state/samples.ts delete mode 100644 packages/data-lit-todo/src/features/main/services/main-service/conformance/actions.test.ts delete mode 100644 packages/data-lit-todo/src/features/main/services/main-service/conformance/computeds.test.ts create mode 100644 packages/data-lit-todo/src/features/main/services/main-service/conformance/conformance.test.ts delete mode 100644 packages/data-lit-todo/src/features/main/services/main-service/conformance/create-store.ts delete mode 100644 packages/data-lit-todo/src/features/main/services/main-service/conformance/from-state.ts delete mode 100644 packages/data-lit-todo/src/features/main/services/main-service/conformance/projection.test.ts create mode 100644 packages/data-lit-todo/src/features/main/services/main-service/conformance/projection.ts delete mode 100644 packages/data-lit-todo/src/features/main/services/main-service/conformance/to-data.ts delete mode 100644 packages/data-lit-todo/src/features/main/services/main-service/conformance/to-state.ts delete mode 100644 packages/data-lit-todo/src/features/main/services/main-service/conformance/transactions.test.ts create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/data/state/samples.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/actions.test.ts create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/conformance.test.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/create-store.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/from-state.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/projection.test.ts create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/projection.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/to-state.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/transactions.test.ts create mode 100644 packages/data-react-pixie/src/features/main/data/state/create.ts create mode 100644 packages/data-react-pixie/src/features/main/data/state/samples.ts delete mode 100644 packages/data-react-pixie/src/features/main/services/main-service/conformance/actions.test.ts create mode 100644 packages/data-react-pixie/src/features/main/services/main-service/conformance/conformance.test.ts delete mode 100644 packages/data-react-pixie/src/features/main/services/main-service/conformance/create-store.ts delete mode 100644 packages/data-react-pixie/src/features/main/services/main-service/conformance/from-state.ts delete mode 100644 packages/data-react-pixie/src/features/main/services/main-service/conformance/projection.test.ts create mode 100644 packages/data-react-pixie/src/features/main/services/main-service/conformance/projection.ts delete mode 100644 packages/data-react-pixie/src/features/main/services/main-service/conformance/to-data.ts delete mode 100644 packages/data-react-pixie/src/features/main/services/main-service/conformance/to-state.ts delete mode 100644 packages/data-react-pixie/src/features/main/services/main-service/conformance/transactions.test.ts create mode 100644 packages/data-solid-dashboard/src/features/main/data/state/samples.ts delete mode 100644 packages/data-solid-dashboard/src/features/main/services/main-service/conformance/actions.test.ts create mode 100644 packages/data-solid-dashboard/src/features/main/services/main-service/conformance/conformance.test.ts delete mode 100644 packages/data-solid-dashboard/src/features/main/services/main-service/conformance/create-store.ts delete mode 100644 packages/data-solid-dashboard/src/features/main/services/main-service/conformance/from-state.ts delete mode 100644 packages/data-solid-dashboard/src/features/main/services/main-service/conformance/projection.test.ts create mode 100644 packages/data-solid-dashboard/src/features/main/services/main-service/conformance/projection.ts delete mode 100644 packages/data-solid-dashboard/src/features/main/services/main-service/conformance/to-state.ts delete mode 100644 packages/data-solid-dashboard/src/features/main/services/main-service/conformance/transactions.test.ts diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/public.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/public.ts index 4ff1e2c5..86f77fe1 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/public.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/public.ts @@ -1,6 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. export { create } from "./create.js"; +export { samples } from "./samples.js"; export { createInitial } from "./create-initial.js"; export { stepShip } from "./step-ship.js"; export { fireBullet } from "./fire-bullet.js"; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/samples.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/samples.ts new file mode 100644 index 00000000..203bd44c --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/samples.ts @@ -0,0 +1,46 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { State } from "./state.js"; + +// Representative full states for the projection round-trip (toState ∘ fromState ≡ +// identity). Varied ships / bullets / asteroids + non-default counters exercise the +// whole ecs↔State map, including a multiset case (identical same-size asteroids). +export const samples: readonly State[] = [ + { + bounds: [800, 600], + ship: { position: [400, 300], velocity: [12, -7], rotation: 1.25 }, + bullets: [ + { position: [100, 100], velocity: [400, 0], age: 0.1 }, + { position: [220, 340], velocity: [-100, 200], age: 0.9 }, + ], + asteroids: [ + { position: [50, 60], velocity: [10, 20], size: "large" }, + { position: [700, 80], velocity: [-30, 5], size: "medium" }, + { position: [640, 540], velocity: [0, -15], size: "small" }, + ], + score: 240, + lives: 2, + wave: 5, + }, + { + bounds: [320, 240], + ship: { position: [160, 120], velocity: [0, 0], rotation: -Math.PI / 2 }, + bullets: [], + asteroids: [], + score: 0, + lives: 3, + wave: 0, + }, + { + bounds: [500, 500], + ship: { position: [250, 250], velocity: [0, 0], rotation: 0 }, + bullets: [], + asteroids: [ + { position: [250, 250], velocity: [0, 0], size: "medium" }, + { position: [250, 250], velocity: [0, 0], size: "medium" }, + { position: [250, 250], velocity: [0, 0], size: "medium" }, + ], + score: 90, + lives: 1, + wave: 3, + }, +]; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/spec.test.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/spec.test.ts index 9b061739..bf7aa9b7 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/spec.test.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/spec.test.ts @@ -1,6 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. /// import { Conformance } from "@adobe/data/testing"; +import { State } from "./state.js"; // The single pure-spec test for every transform AND derivation in this folder. // `runSpec` auto-discovers each sibling exporting `cases` and dispatches on shape. @@ -13,5 +14,8 @@ Conformance.runSpec( eager: true, }, ), - { match: { unordered: new Set(["bullets", "asteroids"]) } }, + { + initial: State.create(), + match: { unordered: new Set(["bullets", "asteroids"]) }, + }, ); diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/actions.test.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/actions.test.ts deleted file mode 100644 index fdca407b..00000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/actions.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -/// -import { Database } from "@adobe/data/ecs"; -import { Conformance } from "@adobe/data/testing"; -import type { RandomService } from "../../random-service/random-service.js"; -import { MainService } from "../main-service.js"; -import * as actions from "../action-database/actions/index.js"; -import { fromState } from "./from-state.js"; -import { toState } from "./to-state.js"; - -// Every ecs action, conformed by name against its transition. `runActions` -// discovers transitions, turns each case's injected services into recording -// overrides via `makeDb`, runs the action, and asserts state + declared effects. -// Auto-pairs `fireBullet` and `spawnRandomWave`. Entity bags compare as -// multisets via the `match` option. -Conformance.runActions({ - // Runtime invariant: the recording wrappers preserve the service's shape, so - // they are a valid factory override. - makeDb: (services) => - Database.toSystemDatabase( - Database.create(MainService.plugin, { - services: services as { random?: RandomService }, - }), - ), - store: (db) => db.store, - fromState, - toState, - transitions: import.meta.glob( - [ - "../../../data/state/*.ts", - "!../../../data/state/*.test.ts", - "!../../../data/state/*.type-test.ts", - ], - { eager: true }, - ), - actions, - match: { unordered: new Set(["bullets", "asteroids"]) }, -}); diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/conformance.test.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/conformance.test.ts new file mode 100644 index 00000000..46b98b69 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/conformance.test.ts @@ -0,0 +1,25 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +/// +import { Conformance } from "@adobe/data/testing"; +import { State } from "../../../data/state/state.js"; +import { MainService } from "../main-service.js"; +import { projection } from "./projection.js"; + +// The whole ecs conformance for this feature in one call: `runFeature` pulls the +// transactions/actions off `MainService.plugin`, seeds each case's `before` (a +// delta) over `State.create()`, and round-trips `State.samples` through the +// projection. The entity bags the ecs materialises in nondeterministic row order +// (`bullets`, `asteroids`) compare as multisets via `match.unordered`. There is no +// `computedPlugin` — space-rock has no `state/` derivations. +Conformance.runFeature({ + state: State, + transitions: import.meta.glob( + ["../../../data/state/*.ts", "!**/*.test.ts", "!**/*.type-test.ts"], + { + eager: true, + }, + ), + plugin: MainService.plugin, + projection, + match: { unordered: new Set(["bullets", "asteroids"]) }, +}); diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/create-store.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/create-store.ts deleted file mode 100644 index 2b2ba403..00000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/create-store.ts +++ /dev/null @@ -1,13 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Store } from "@adobe/data/ecs"; -import type { CoreDatabase } from "../core-database/core-database.js"; -import { IndexDatabase } from "../index-database/index-database.js"; - -// A fresh writable store carrying the feature's whole schema. `IndexDatabase` is -// the lowest layer that declares it all (components / resources / archetypes + -// indexes) — the behaviour layers above (transactions / computed / systems) add -// none — and `Store.create` reads a plugin's schema facets directly. Typed as -// `CoreDatabase.Store`: the surface the projection (`fromState` / `toState`) and -// the raw transaction functions use. Test-only. -export const createStore = (): CoreDatabase.Store => - Store.create(IndexDatabase.plugin); diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/from-state.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/from-state.ts deleted file mode 100644 index 9745e1c6..00000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/from-state.ts +++ /dev/null @@ -1,31 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { State } from "../../../data/state/state.js"; -import type { CoreDatabase } from "../core-database/core-database.js"; - -// Seed a store to exactly match a `data/` `State`: clear every entity, set the -// scalar resources, then insert the ship, bullets, and asteroids. The inverse -// of `toState`. Test-only — the bridge that lets an ecs mutation be checked -// against the pure transform it stands for (see `expect-conforms.ts`). -// -// Clearing iterates tail→head so each delete is from the tail (no hole-fill -// shift). Every entity carries `position`, so one query covers all three -// archetypes. Row shapes equal their `data/` types (no stored broad-phase -// column), so each value inserts directly. -export const fromState = (store: CoreDatabase.Store, state: State): void => { - for (const arch of store.queryArchetypes(["position"])) { - for (let row = arch.rowCount - 1; row >= 0; row--) { - store.delete(arch.columns.id.get(row)); - } - } - store.resources.bounds = state.bounds; - store.resources.score = state.score; - store.resources.lives = state.lives; - store.resources.wave = state.wave; - store.archetypes.Ship.insert(state.ship); - for (const bullet of state.bullets) { - store.archetypes.Bullet.insert(bullet); - } - for (const asteroid of state.asteroids) { - store.archetypes.Asteroid.insert(asteroid); - } -}; diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/projection.test.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/projection.test.ts deleted file mode 100644 index 3724dc44..00000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/projection.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -// -// Guards the projection itself. `fromState` and `toState` are the bridge every -// transaction / system conformance test trusts; a symmetric bug in the pair would -// cancel out and mask a real ecs defect. This identity test — `toState(fromState(s)) -// ≡ s` over representative states — proves the projection round-trips on its own. -import { describe, it } from "vitest"; -import { Match } from "@adobe/data/testing"; -import type { State } from "../../../data/state/state.js"; -import { createStore } from "./create-store.js"; -import { fromState } from "./from-state.js"; -import { toState } from "./to-state.js"; - -const unordered = { unordered: new Set(["bullets", "asteroids"]) }; - -const states: readonly { readonly name: string; readonly state: State }[] = [ - { - name: "a full game: ship + bullets + asteroids of every size, non-zero counters", - state: { - bounds: [800, 600], - ship: { position: [400, 300], velocity: [12, -7], rotation: 1.25 }, - bullets: [ - { position: [100, 100], velocity: [400, 0], age: 0.1 }, - { position: [220, 340], velocity: [-100, 200], age: 0.9 }, - ], - asteroids: [ - { position: [50, 60], velocity: [10, 20], size: "large" }, - { position: [700, 80], velocity: [-30, 5], size: "medium" }, - { position: [640, 540], velocity: [0, -15], size: "small" }, - ], - score: 240, - lives: 2, - wave: 5, - }, - }, - { - name: "no bullets and no asteroids (just the ship)", - state: { - bounds: [320, 240], - ship: { position: [160, 120], velocity: [0, 0], rotation: -Math.PI / 2 }, - bullets: [], - asteroids: [], - score: 0, - lives: 3, - wave: 0, - }, - }, - { - name: "many same-size asteroids at the same point (multiset round-trip)", - state: { - bounds: [500, 500], - ship: { position: [250, 250], velocity: [0, 0], rotation: 0 }, - bullets: [], - asteroids: [ - { position: [250, 250], velocity: [0, 0], size: "medium" }, - { position: [250, 250], velocity: [0, 0], size: "medium" }, - { position: [250, 250], velocity: [0, 0], size: "medium" }, - ], - score: 90, - lives: 1, - wave: 3, - }, - }, -]; - -describe("ecs/conformance projection round-trips (toState ∘ fromState ≡ identity)", () => { - for (const { name, state } of states) { - it(name, () => { - const store = createStore(); - fromState(store, state); - Match.assert(toState(store), state, unordered); - }); - } -}); diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/projection.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/projection.ts new file mode 100644 index 00000000..81a281d5 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/projection.ts @@ -0,0 +1,107 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Entity } from "@adobe/data/ecs"; +import type { State } from "../../../data/state/state.js"; +import type { Ship } from "../../../data/ship/ship.js"; +import type { Bullet } from "../../../data/bullet/bullet.js"; +import type { Asteroid } from "../../../data/asteroid/asteroid.js"; +import type { CoreDatabase } from "../core-database/core-database.js"; + +// Read one entity back into its `data/` value — the per-entity projection +// `toState` is built on, and the single place the ecs↔data mapping for the three +// entity kinds lives. Unlike a single-archetype feature (todo), an entity here is +// one of Ship / Bullet / Asteroid, so `toData` probes each named archetype (their +// component sets are distinct — only Ship has `rotation`, only Bullet `age`, only +// Asteroid `size`) and projects the first that matches. Test-only. +const toData = ( + store: CoreDatabase.Store, + entity: Entity, +): Ship | Bullet | Asteroid => { + const ship = store.read(entity, store.archetypes.Ship); + if (ship !== null) + return { + position: ship.position, + velocity: ship.velocity, + rotation: ship.rotation, + }; + const bullet = store.read(entity, store.archetypes.Bullet); + if (bullet !== null) + return { + position: bullet.position, + velocity: bullet.velocity, + age: bullet.age, + }; + const asteroid = store.read(entity, store.archetypes.Asteroid); + if (asteroid !== null) + return { + position: asteroid.position, + velocity: asteroid.velocity, + size: asteroid.size, + }; + throw new Error( + "conformance projection: entity is not a ship, bullet, or asteroid", + ); +}; + +// The test-only ecs↔`State` projection, passed to `Conformance.runFeature`. +// `fromState` seeds a store to a `State` (space-rock is not id-addressed, so it +// returns no id map); `toState` reads it back through `toData`; `toData` reads one +// entity. These are STRICTLY for conformance tests and MUST NEVER run in +// production code. +export const projection = { + // Seed a store to exactly match a `data/` `State`: clear every entity, set the + // scalar resources, then insert the ship, bullets, and asteroids. The inverse + // of `toState`. Clearing iterates tail→head so each delete is from the tail (no + // hole-fill shift). Every entity carries `position`, so one query covers all + // three archetypes. Row shapes equal their `data/` types (no stored broad-phase + // column), so each value inserts directly. + fromState: (store: CoreDatabase.Store, state: State): void => { + for (const arch of store.queryArchetypes(["position"])) { + for (let row = arch.rowCount - 1; row >= 0; row--) { + store.delete(arch.columns.id.get(row)); + } + } + store.resources.bounds = state.bounds; + store.resources.score = state.score; + store.resources.lives = state.lives; + store.resources.wave = state.wave; + store.archetypes.Ship.insert(state.ship); + for (const bullet of state.bullets) { + store.archetypes.Bullet.insert(bullet); + } + for (const asteroid of state.asteroids) { + store.archetypes.Asteroid.insert(asteroid); + } + }, + // Read a store back into a `data/` `State` — the inverse of `fromState`, built on + // the per-entity `toData` projection. Every entity carries `position`, so one + // query covers all three archetypes; each entity is projected through `toData` + // and sorted into the ship, bullets, or asteroids slot by its distinguishing + // member (`rotation` → ship, `age` → bullet, `size` → asteroid). Row order across + // archetypes is arbitrary, but the entity collections compare as multisets, so it + // need not be stable. + toState: (store: CoreDatabase.Store): State => { + let ship: Ship | undefined; + const bullets: Bullet[] = []; + const asteroids: Asteroid[] = []; + for (const arch of store.queryArchetypes(["position"])) { + for (let row = 0; row < arch.rowCount; row++) { + const value = toData(store, arch.columns.id.get(row)); + if ("rotation" in value) ship = value; + else if ("age" in value) bullets.push(value); + else asteroids.push(value); + } + } + if (ship === undefined) + throw new Error("conformance projection: expected a ship entity"); + return { + bounds: store.resources.bounds, + ship, + bullets, + asteroids, + score: store.resources.score, + lives: store.resources.lives, + wave: store.resources.wave, + }; + }, + toData, +}; diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/to-data.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/to-data.ts deleted file mode 100644 index 57b1c40e..00000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/to-data.ts +++ /dev/null @@ -1,42 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Entity } from "@adobe/data/ecs"; -import type { Ship } from "../../../data/ship/ship.js"; -import type { Bullet } from "../../../data/bullet/bullet.js"; -import type { Asteroid } from "../../../data/asteroid/asteroid.js"; -import type { CoreDatabase } from "../core-database/core-database.js"; - -// Read one entity back into its `data/` value — the per-entity projection -// `toState` is built on, and the single place the ecs↔data mapping for the three -// entity kinds lives. Unlike a single-archetype feature (todo), an entity here is -// one of Ship / Bullet / Asteroid, so `toData` probes each named archetype (their -// component sets are distinct — only Ship has `rotation`, only Bullet `age`, only -// Asteroid `size`) and projects the first that matches. Test-only. -export const toData = ( - store: CoreDatabase.Store, - entity: Entity, -): Ship | Bullet | Asteroid => { - const ship = store.read(entity, store.archetypes.Ship); - if (ship !== null) - return { - position: ship.position, - velocity: ship.velocity, - rotation: ship.rotation, - }; - const bullet = store.read(entity, store.archetypes.Bullet); - if (bullet !== null) - return { - position: bullet.position, - velocity: bullet.velocity, - age: bullet.age, - }; - const asteroid = store.read(entity, store.archetypes.Asteroid); - if (asteroid !== null) - return { - position: asteroid.position, - velocity: asteroid.velocity, - size: asteroid.size, - }; - throw new Error( - "conformance projection: entity is not a ship, bullet, or asteroid", - ); -}; diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/to-state.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/to-state.ts deleted file mode 100644 index bb763e41..00000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/to-state.ts +++ /dev/null @@ -1,39 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { State } from "../../../data/state/state.js"; -import type { Ship } from "../../../data/ship/ship.js"; -import type { Bullet } from "../../../data/bullet/bullet.js"; -import type { Asteroid } from "../../../data/asteroid/asteroid.js"; -import type { CoreDatabase } from "../core-database/core-database.js"; -import { toData } from "./to-data.js"; - -// Read a store back into a `data/` `State` — the inverse of `fromState`, built on -// the per-entity `toData` projection. Every entity carries `position`, so one -// query covers all three archetypes; each entity is projected through `toData` and -// sorted into the ship, bullets, or asteroids slot by its distinguishing member -// (`rotation` → ship, `age` → bullet, `size` → asteroid). Row order across -// archetypes is arbitrary, but the entity collections compare as multisets -// (`expectStateMatches`), so it need not be stable. Test-only. -export const toState = (store: CoreDatabase.Store): State => { - let ship: Ship | undefined; - const bullets: Bullet[] = []; - const asteroids: Asteroid[] = []; - for (const arch of store.queryArchetypes(["position"])) { - for (let row = 0; row < arch.rowCount; row++) { - const value = toData(store, arch.columns.id.get(row)); - if ("rotation" in value) ship = value; - else if ("age" in value) bullets.push(value); - else asteroids.push(value); - } - } - if (ship === undefined) - throw new Error("conformance projection: expected a ship entity"); - return { - bounds: store.resources.bounds, - ship, - bullets, - asteroids, - score: store.resources.score, - lives: store.resources.lives, - wave: store.resources.wave, - }; -}; diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/transactions.test.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/transactions.test.ts deleted file mode 100644 index 193a6eb8..00000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/transactions.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -/// -import { Conformance } from "@adobe/data/testing"; -import * as transactions from "../transaction-database/transactions/index.js"; -import { createStore } from "./create-store.js"; -import { fromState } from "./from-state.js"; -import { toState } from "./to-state.js"; - -// Every ecs transaction, conformed by name against its `data/state` transition — -// no per-item wiring. `runTransactions` discovers the transitions (the glob), -// pairs each registered transaction to the same-named one, seeds `fromState`, -// applies, and compares `toState`. Auto-pairs `createInitial`, `spawnRandomWave`, -// and `fireBullet`; `newGame`/`setInput`/`setBounds` (infra — no `data/` -// transform) and `hitAsteroid`/`loseLife` (system-dispatched — the collision -// system's behavior is covered by `system-database/collision-detection.test.ts` -// and the `resolveBulletHits`/`resolveShipHits` transitions by -// `data/state/spec.test.ts`) have no same-named transition and are skipped. -// Entity bags compare as multisets via the `match` option. -Conformance.runTransactions({ - createStore, - fromState, - toState, - transitions: import.meta.glob( - [ - "../../../data/state/*.ts", - "!../../../data/state/*.test.ts", - "!../../../data/state/*.type-test.ts", - ], - { eager: true }, - ), - transactions, - match: { unordered: new Set(["bullets", "asteroids"]) }, -}); diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/system-database/collision-detection.test.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/system-database/collision-detection.test.ts index d70a5a6a..b632ced2 100644 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/system-database/collision-detection.test.ts +++ b/packages/data-lit-space-rock-game/src/features/main/services/main-service/system-database/collision-detection.test.ts @@ -16,8 +16,7 @@ import { Ship } from "../../../data/ship/ship.js"; import { Input } from "../../../data/input/input.js"; import { Size } from "../../../data/size/size.js"; import { createSystemDatabase } from "../conformance/create-system-database.js"; -import { fromState } from "../conformance/from-state.js"; -import { toState } from "../conformance/to-state.js"; +import { projection } from "../conformance/projection.js"; import { driveFrame } from "../conformance/drive-frame.js"; const base = (overrides: Partial): State => ({ @@ -34,11 +33,11 @@ const base = (overrides: Partial): State => ({ // Seed the geometry, run exactly one detection-only frame (dt 0), project back. const detect = (state: State): State => { const db = createSystemDatabase(); - fromState(db.store, state); + projection.fromState(db.store, state); db.store.resources.frameDelta = 0; db.transactions.setInput(Input.none); driveFrame(db); - return toState(db.store); + return projection.toState(db.store); }; describe("collision detection — bullet ↔ asteroid selection", () => { diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/system-database/tick-loop.test.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/system-database/tick-loop.test.ts index dd9a8d87..3963514c 100644 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/system-database/tick-loop.test.ts +++ b/packages/data-lit-space-rock-game/src/features/main/services/main-service/system-database/tick-loop.test.ts @@ -28,8 +28,7 @@ import { Input } from "../../../data/input/input.js"; import { cases } from "../../../data/state/step.js"; import { Match } from "@adobe/data/testing"; import { createSystemDatabase } from "../conformance/create-system-database.js"; -import { fromState } from "../conformance/from-state.js"; -import { toState } from "../conformance/to-state.js"; +import { projection } from "../conformance/projection.js"; import { driveFrame } from "../conformance/drive-frame.js"; describe("ECS system tick loop conforms to State.step (one frame = one step)", () => { @@ -49,11 +48,11 @@ describe("ECS system tick loop conforms to State.step (one frame = one step)", ( ); const db = createSystemDatabase(); - fromState(db.store, before); + projection.fromState(db.store, before); db.store.resources.frameDelta = dt; db.transactions.setInput(input); driveFrame(db); - Match.assert(toState(db.store), testCase.after, unordered); + Match.assert(projection.toState(db.store), testCase.after, unordered); }); } @@ -64,7 +63,7 @@ describe("ECS system tick loop conforms to State.step (one frame = one step)", ( // is random), so assert those and the count, not the velocities. it("waves system refills a cleared field with the large-asteroid ring", () => { const db = createSystemDatabase(); - fromState(db.store, { + projection.fromState(db.store, { ...State.create(), bounds: [200, 200], ship: Ship.spawn([100, 100]), @@ -75,7 +74,7 @@ describe("ECS system tick loop conforms to State.step (one frame = one step)", ( db.transactions.setInput(Input.none); driveFrame(db); - const after = toState(db.store); + const after = projection.toState(db.store); expect(after.wave).toBe(1); expect(after.asteroids).toHaveLength(4); expect(after.asteroids.every((a) => a.size === "large")).toBe(true); diff --git a/packages/data-lit-todo/src/features/main/data/state/public.ts b/packages/data-lit-todo/src/features/main/data/state/public.ts index 8f4d00bb..2b941cbe 100644 --- a/packages/data-lit-todo/src/features/main/data/state/public.ts +++ b/packages/data-lit-todo/src/features/main/data/state/public.ts @@ -1,5 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. export { create } from "./create.js"; +export { samples } from "./samples.js"; export { createTodo } from "./create-todo.js"; export { createRandomTodo } from "./create-random-todo.js"; export { createBulkTodos } from "./create-bulk-todos.js"; diff --git a/packages/data-lit-todo/src/features/main/data/state/samples.ts b/packages/data-lit-todo/src/features/main/data/state/samples.ts new file mode 100644 index 00000000..e2c6ffd9 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/data/state/samples.ts @@ -0,0 +1,27 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Match } from "@adobe/data/testing"; +import type { State } from "./state.js"; + +// Representative full states for the projection round-trip (toState ∘ fromState ≡ +// identity). Todo ids are authored as `anyNumber`: the ecs reassigns ids from its +// own id-space, so the round-trip leaves them open. Varied lists (mixed +// complete/incomplete, empty, duplicate names) exercise the whole ecs↔State map. +export const samples: readonly State[] = [ + { + todos: [ + { id: Match.anyNumber, name: "buy milk", complete: false }, + { id: Match.anyNumber, name: "walk dog", complete: true }, + { id: Match.anyNumber, name: "write tests", complete: false }, + ], + displayCompleted: true, + }, + { todos: [], displayCompleted: false }, + { + todos: [ + { id: Match.anyNumber, name: "task", complete: false }, + { id: Match.anyNumber, name: "task", complete: false }, + { id: Match.anyNumber, name: "task", complete: true }, + ], + displayCompleted: false, + }, +]; diff --git a/packages/data-lit-todo/src/features/main/services/main-service/conformance/actions.test.ts b/packages/data-lit-todo/src/features/main/services/main-service/conformance/actions.test.ts deleted file mode 100644 index 71205583..00000000 --- a/packages/data-lit-todo/src/features/main/services/main-service/conformance/actions.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -/// -import { Database } from "@adobe/data/ecs"; -import { Conformance } from "@adobe/data/testing"; -import { State } from "../../../data/state/state.js"; -import type { AnalyticsService } from "../../analytics-service/analytics-service.js"; -import type { NameGeneratorService } from "../../name-generator-service/name-generator-service.js"; -import { MainService } from "../main-service.js"; -import * as actions from "../action-database/actions/index.js"; -import { fromState } from "./from-state.js"; -import { toState } from "./to-state.js"; - -// Every ecs action, conformed by name against its transition. The case's injected -// services become recording overrides via `makeDb`; the runner splits them out, -// resolves `entity()` arg markers, runs the action, and asserts state + effects. -Conformance.runActions({ - // Runtime invariant: the recording wrappers preserve each service's shape. - makeDb: (services) => - Database.toSystemDatabase( - Database.create(MainService.plugin, { - services: services as { - analytics?: AnalyticsService; - nameGenerator?: NameGeneratorService; - }, - }), - ), - store: (db) => db.store, - fromState, - toState, - transitions: import.meta.glob( - [ - "../../../data/state/*.ts", - "!../../../data/state/*.test.ts", - "!../../../data/state/*.type-test.ts", - ], - { eager: true }, - ), - actions, - initial: State.create(), -}); diff --git a/packages/data-lit-todo/src/features/main/services/main-service/conformance/computeds.test.ts b/packages/data-lit-todo/src/features/main/services/main-service/conformance/computeds.test.ts deleted file mode 100644 index 572dda28..00000000 --- a/packages/data-lit-todo/src/features/main/services/main-service/conformance/computeds.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -/// -import { Database } from "@adobe/data/ecs"; -import { Conformance } from "@adobe/data/testing"; -import { State } from "../../../data/state/state.js"; -import { ComputedDatabase } from "../computed-database/computed-database.js"; -import * as computeds from "../computed-database/computed/index.js"; -import { fromState } from "./from-state.js"; -import { toData } from "./to-data.js"; - -// Every ecs computed backing a `data/state` derivation, conformed by name, built -// from the ComputedDatabase layer. `visibleTodos` emits entity ids, so it is named -// in `hydrate` to project each through `toData` into the `Todo[]` the derivation -// yields; scalar/value computeds compare directly. -Conformance.runComputeds({ - makeDb: () => - Database.toSystemDatabase(Database.create(ComputedDatabase.plugin)), - store: (db) => db.store, - fromState, - toData, - derivations: import.meta.glob( - [ - "../../../data/state/*.ts", - "!../../../data/state/*.test.ts", - "!../../../data/state/*.type-test.ts", - ], - { eager: true }, - ), - computeds, - hydrate: ["visibleTodos"], - initial: State.create(), -}); diff --git a/packages/data-lit-todo/src/features/main/services/main-service/conformance/conformance.test.ts b/packages/data-lit-todo/src/features/main/services/main-service/conformance/conformance.test.ts new file mode 100644 index 00000000..c39b83d5 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/services/main-service/conformance/conformance.test.ts @@ -0,0 +1,25 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +/// +import { Conformance } from "@adobe/data/testing"; +import { State } from "../../../data/state/state.js"; +import { MainService } from "../main-service.js"; +import { ComputedDatabase } from "../computed-database/computed-database.js"; +import { projection } from "./projection.js"; + +// The whole ecs conformance for this feature in one call: `runFeature` pulls the +// transactions/actions off `MainService.plugin`, the computeds off the +// `ComputedDatabase` layer, seeds each case's `before` (a delta) over +// `State.create()`, and round-trips `State.samples` through the projection. +// `visibleTodos` emits entity ids, so it is named in `hydrate` to project each +// through `toData` into the `Todo[]` the derivation yields. +Conformance.runFeature({ + state: State, + transitions: import.meta.glob( + ["../../../data/state/*.ts", "!**/*.test.ts", "!**/*.type-test.ts"], + { eager: true }, + ), + plugin: MainService.plugin, + computedPlugin: ComputedDatabase.plugin, + projection, + hydrate: ["visibleTodos"], +}); diff --git a/packages/data-lit-todo/src/features/main/services/main-service/conformance/create-store.ts b/packages/data-lit-todo/src/features/main/services/main-service/conformance/create-store.ts deleted file mode 100644 index b864f388..00000000 --- a/packages/data-lit-todo/src/features/main/services/main-service/conformance/create-store.ts +++ /dev/null @@ -1,13 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Store } from "@adobe/data/ecs"; -import type { CoreDatabase } from "../core-database/core-database.js"; -import { IndexDatabase } from "../index-database/index-database.js"; - -// A fresh writable store carrying the feature's whole schema. `IndexDatabase` is -// the lowest layer that declares it all (components / resources / archetypes + -// indexes) — the behaviour layers above add none — and `Store.create` reads a -// plugin's schema facets directly. Typed as `CoreDatabase.Store`: the surface the -// projection (`fromState` / `toState`) and the raw transaction functions use. -// Test-only. -export const createStore = (): CoreDatabase.Store => - Store.create(IndexDatabase.plugin); diff --git a/packages/data-lit-todo/src/features/main/services/main-service/conformance/from-state.ts b/packages/data-lit-todo/src/features/main/services/main-service/conformance/from-state.ts deleted file mode 100644 index f2e4fc0b..00000000 --- a/packages/data-lit-todo/src/features/main/services/main-service/conformance/from-state.ts +++ /dev/null @@ -1,42 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Entity } from "@adobe/data/ecs"; -import type { State } from "../../../data/state/state.js"; -import type { CoreDatabase } from "../core-database/core-database.js"; - -// Seed a store to exactly match a `data/` `State`: clear every todo, set the -// `displayCompleted` resource, then insert the todos in display order. The -// inverse of `toState`. Test-only — the bridge that lets an ecs mutation be -// checked against the pure transform it stands for (see `expect-conforms.ts`). -// -// Clearing iterates tail→head so each delete is from the tail (no hole-fill -// shift). Todos are inserted in array (display) order with `order` = the index; -// the implementation-only slots (`dragPosition`, `assignees`) are seeded empty. -// -// The ecs assigns entity ids from its own quadrant-encoded id-space, unrelated -// to the spec's domain `id`. This returns the `spec id → seeded entity` map so the -// conformance runners resolve id-addressed operations generically -// (`Conformance.resolver`); nothing here assumes the two id-spaces coincide. -export const fromState = ( - store: CoreDatabase.Store, - state: State, -): ReadonlyMap => { - for (const arch of store.queryArchetypes(store.archetypes.Todo.components)) { - for (let row = arch.rowCount - 1; row >= 0; row--) { - store.delete(arch.columns.id.get(row)); - } - } - store.resources.displayCompleted = state.displayCompleted; - return new Map( - state.todos.map((todo, index) => [ - todo.id, - store.archetypes.Todo.insert({ - todo: true, - name: todo.name, - complete: todo.complete, - order: index, - dragPosition: null, - assignees: [], - }), - ]), - ); -}; diff --git a/packages/data-lit-todo/src/features/main/services/main-service/conformance/projection.test.ts b/packages/data-lit-todo/src/features/main/services/main-service/conformance/projection.test.ts deleted file mode 100644 index bb25701d..00000000 --- a/packages/data-lit-todo/src/features/main/services/main-service/conformance/projection.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -// -// Guards the projection itself. `fromState` and `toState` are the bridge every -// transaction conformance test trusts; a symmetric bug in the pair (e.g. both -// dropping the same field) would cancel out and mask a real ecs defect. This -// identity test — `toState(fromState(s)) ≡ s` over representative states — -// proves the projection round-trips faithfully on its own. -import { describe, it } from "vitest"; -import { Match } from "@adobe/data/testing"; -import type { State } from "../../../data/state/state.js"; -import { createStore } from "./create-store.js"; -import { fromState } from "./from-state.js"; -import { toState } from "./to-state.js"; - -const states: readonly { readonly name: string; readonly state: State }[] = [ - { - name: "a mixed list of complete and incomplete todos, completed view on", - state: { - todos: [ - { id: 1, name: "buy milk", complete: false }, - { id: 2, name: "walk dog", complete: true }, - { id: 3, name: "write tests", complete: false }, - ], - displayCompleted: true, - }, - }, - { - name: "an empty list with the completed view off", - state: { todos: [], displayCompleted: false }, - }, - { - name: "todos sharing a name (multiset round-trip), completed view off", - state: { - todos: [ - { id: 1, name: "task", complete: false }, - { id: 2, name: "task", complete: false }, - { id: 3, name: "task", complete: true }, - ], - displayCompleted: false, - }, - }, -]; - -describe("ecs/conformance projection round-trips (toState ∘ fromState ≡ identity)", () => { - for (const { name, state } of states) { - it(name, () => { - const store = createStore(); - fromState(store, state); - // The ecs reassigns ids from its own id-space, so compare against the same - // state with ids left open. - Match.assert(toState(store), { - ...state, - todos: state.todos.map((todo) => ({ ...todo, id: Match.anyNumber })), - }); - }); - } -}); diff --git a/packages/data-lit-todo/src/features/main/services/main-service/conformance/projection.ts b/packages/data-lit-todo/src/features/main/services/main-service/conformance/projection.ts new file mode 100644 index 00000000..0d333054 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/services/main-service/conformance/projection.ts @@ -0,0 +1,66 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Entity } from "@adobe/data/ecs"; +import type { State } from "../../../data/state/state.js"; +import type { Todo } from "../../../data/todo/todo.js"; +import type { CoreDatabase } from "../core-database/core-database.js"; + +// Read one entity back into its `data/` value — the per-entity projection +// `toState` is built on, and the single place the ecs↔data mapping for a todo +// lives. Reused by the computed conformance to hydrate id-based computed outputs +// (e.g. `visibleTodos` returns entity ids) into the value shape a derivation +// yields, so those computeds need no bespoke projection. Test-only. +const toData = (store: CoreDatabase.Store, entity: Entity): Todo => { + const row = store.read(entity, store.archetypes.Todo); + if (row === null) + throw new Error("conformance projection: expected a todo entity"); + return { id: row.id, name: row.name, complete: row.complete }; +}; + +// The test-only ecs↔`State` projection, passed to `Conformance.runFeature`. +// `fromState` seeds a store to a `State` (clearing every todo, setting the +// `displayCompleted` resource, then inserting the todos in display order with +// `order` = the index; the implementation-only slots (`dragPosition`, +// `assignees`) are seeded empty). Clearing iterates tail→head so each delete is +// from the tail (no hole-fill shift). The ecs assigns entity ids from its own +// id-space, unrelated to the spec's domain `id`; `fromState` returns the `spec id +// → seeded entity` map so the runners resolve id-addressed operations generically. +// `toState` reads it back (todos in ascending `order`, each through its full +// archetype so the row shape never aliases; only the spec fields are projected); +// `toData` reads one entity. +export const projection = { + fromState: ( + store: CoreDatabase.Store, + state: State, + ): ReadonlyMap => { + for (const arch of store.queryArchetypes( + store.archetypes.Todo.components, + )) { + for (let row = arch.rowCount - 1; row >= 0; row--) { + store.delete(arch.columns.id.get(row)); + } + } + store.resources.displayCompleted = state.displayCompleted; + return new Map( + state.todos.map((todo, index) => [ + todo.id, + store.archetypes.Todo.insert({ + todo: true, + name: todo.name, + complete: todo.complete, + order: index, + dragPosition: null, + assignees: [], + }), + ]), + ); + }, + toState: (store: CoreDatabase.Store): State => ({ + todos: [ + ...store.select(store.archetypes.Todo.components, { + order: { order: true }, + }), + ].map((entity) => toData(store, entity)), + displayCompleted: store.resources.displayCompleted, + }), + toData, +}; diff --git a/packages/data-lit-todo/src/features/main/services/main-service/conformance/to-data.ts b/packages/data-lit-todo/src/features/main/services/main-service/conformance/to-data.ts deleted file mode 100644 index 392de3a2..00000000 --- a/packages/data-lit-todo/src/features/main/services/main-service/conformance/to-data.ts +++ /dev/null @@ -1,16 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Entity } from "@adobe/data/ecs"; -import type { Todo } from "../../../data/todo/todo.js"; -import type { CoreDatabase } from "../core-database/core-database.js"; - -// Read one entity back into its `data/` value — the per-entity projection -// `toState` is built on, and the single place the ecs↔data mapping for a todo -// lives. Reused by the computed conformance to hydrate id-based computed outputs -// (e.g. `visibleTodos` returns entity ids) into the value shape a derivation -// yields, so those computeds need no bespoke projection. Test-only. -export const toData = (store: CoreDatabase.Store, entity: Entity): Todo => { - const row = store.read(entity, store.archetypes.Todo); - if (row === null) - throw new Error("conformance projection: expected a todo entity"); - return { id: row.id, name: row.name, complete: row.complete }; -}; diff --git a/packages/data-lit-todo/src/features/main/services/main-service/conformance/to-state.ts b/packages/data-lit-todo/src/features/main/services/main-service/conformance/to-state.ts deleted file mode 100644 index 952e80a8..00000000 --- a/packages/data-lit-todo/src/features/main/services/main-service/conformance/to-state.ts +++ /dev/null @@ -1,25 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { State } from "../../../data/state/state.js"; -import type { Todo } from "../../../data/todo/todo.js"; -import type { CoreDatabase } from "../core-database/core-database.js"; -import { toData } from "./to-data.js"; - -// Read a store back into a `data/` `State` — the inverse of `fromState`. Todos -// are read in ascending `order` (the ecs materialisation of display order), -// each through its full `Todo` archetype so the row shape never aliases; only -// the spec fields (`id`, `name`, `complete`) are projected — the ecs-only -// `order` / `dragPosition` / `assignees` slots stay behind. The projected `id` -// is the entity id (the ecs's own id-space, not the spec's domain id), so -// cases author `after` ids as `anyNumber`, so the comparison leaves them open. -// Test-only. -const readTodos = (store: CoreDatabase.Store): Todo[] => - [ - ...store.select(store.archetypes.Todo.components, { - order: { order: true }, - }), - ].map((entity) => toData(store, entity)); - -export const toState = (store: CoreDatabase.Store): State => ({ - todos: readTodos(store), - displayCompleted: store.resources.displayCompleted, -}); diff --git a/packages/data-lit-todo/src/features/main/services/main-service/conformance/transactions.test.ts b/packages/data-lit-todo/src/features/main/services/main-service/conformance/transactions.test.ts deleted file mode 100644 index 8715f6d6..00000000 --- a/packages/data-lit-todo/src/features/main/services/main-service/conformance/transactions.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -/// -import { Conformance } from "@adobe/data/testing"; -import { State } from "../../../data/state/state.js"; -import * as transactions from "../transaction-database/transactions/index.js"; -import { createStore } from "./create-store.js"; -import { fromState } from "./from-state.js"; -import { toState } from "./to-state.js"; - -// Every ecs transaction, conformed by name against its `data/state` transition — -// no per-item wiring. Entity-addressed transitions carry a `Conformance.entity` -// marker in their case args, which the runner resolves via the `fromState` id map. -// `dragTodo` has no same-named transition (the drag UI transaction) and is skipped; -// `reorderTodo` is conformed through its action. -Conformance.runTransactions({ - createStore, - fromState, - toState, - transitions: import.meta.glob( - [ - "../../../data/state/*.ts", - "!../../../data/state/*.test.ts", - "!../../../data/state/*.type-test.ts", - ], - { eager: true }, - ), - transactions, - initial: State.create(), -}); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/public.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/public.ts index 8f09457c..c8049674 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/public.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/public.ts @@ -1,5 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. export { create } from "./create.js"; +export { samples } from "./samples.js"; export { startHostSignaling } from "./start-host-signaling.js"; export { startJoinSignaling } from "./start-join-signaling.js"; export { setOfferCode } from "./set-offer-code.js"; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/samples.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/samples.ts new file mode 100644 index 00000000..4be3c505 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/samples.ts @@ -0,0 +1,29 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { create } from "./create.js"; +import type { State } from "./state.js"; + +// Representative full negotiation states for the projection round-trip +// (toState ∘ fromState ≡ identity). Varied phase / role / connection / code / +// banner fields exercise the whole store↔State resource map. +export const samples: readonly State[] = [ + create(), + { + ...create(), + phase: "host-signaling", + role: "host", + connection: "connecting", + offerCode: "OFFER-123", + hostAnswerInput: "partial", + bannerText: "Waiting for a joiner…", + }, + { + ...create(), + phase: "game", + role: "joiner", + connection: "connected", + sessionId: "sess-9", + answerCode: "ANSWER-9", + bannerText: "Connection failed", + bannerError: true, + }, +]; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/spec.test.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/spec.test.ts index 7a202fc1..ee3f4bf9 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/spec.test.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/spec.test.ts @@ -1,13 +1,16 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. /// import { Conformance } from "@adobe/data/testing"; +import { State } from "./state.js"; // The single pure-spec test for every transform AND derivation in this folder. // `runSpec` auto-discovers each sibling that exports `cases`, requires it to // export exactly its function plus `cases`, and dispatches on case shape (a // `value` case is a derivation, otherwise a transition whose declared `effects` -// are also asserted). Negotiation's `State` is scalars/strings/enums, so the -// default (ordered, matcher-aware) comparison is correct — no options needed. +// are also asserted). Each case's `before` is a delta over `initial` (the default +// state), so cases carry only what they change. Negotiation's `State` is +// scalars/strings/enums, so the default (ordered, matcher-aware) comparison is +// correct — no `match` options needed. Conformance.runSpec( import.meta.glob>( ["./*.ts", "!./*.test.ts", "!./*.type-test.ts"], @@ -15,4 +18,5 @@ Conformance.runSpec( eager: true, }, ), + { initial: State.create() }, ); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/actions.test.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/actions.test.ts deleted file mode 100644 index b0025ae7..00000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/actions.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -/// -import { Database } from "@adobe/data/ecs"; -import { Conformance } from "@adobe/data/testing"; -import { MainService } from "../main-service.js"; -import { fromState } from "./from-state.js"; -import { toState } from "./to-state.js"; - -// Every ecs action, conformed by name against its transition. The per-transition -// actions live beside the barrel but aren't registered in the facet (that would -// grow the plugin type past tsc's budget), so we discover them by globbing the -// actions directory; the capability-orchestration verbs (configure/startHost/…) -// have no transition and are skipped. Negotiation injects no services. -Conformance.runActions({ - makeDb: (services) => - Database.toSystemDatabase( - Database.create(MainService.plugin, { services }), - ), - store: (db) => db.store, - fromState, - toState, - transitions: import.meta.glob( - [ - "../../../data/state/*.ts", - "!../../../data/state/*.test.ts", - "!../../../data/state/*.type-test.ts", - ], - { eager: true }, - ), - actions: import.meta.glob( - ["../action-database/actions/*.ts", "!../action-database/actions/index.ts"], - { eager: true }, - ), -}); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/conformance.test.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/conformance.test.ts new file mode 100644 index 00000000..2ea87888 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/conformance.test.ts @@ -0,0 +1,30 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +/// +import { Conformance } from "@adobe/data/testing"; +import { State } from "../../../data/state/state.js"; +import { MainService } from "../main-service.js"; +import { projection } from "./projection.js"; + +// The whole ecs conformance for this feature in one call: `runFeature` pulls the +// transactions off `MainService.plugin`, seeds each case's `before` (a delta) over +// `State.create()`, and round-trips `State.samples` through the projection. +// Negotiation's per-transition actions (set-offer-code, enter-game, …) are +// deliberately kept out of the plugin's `actions` facet (that would grow the plugin +// type past tsc's budget), so they are discovered via the actions-directory glob +// (`ops.actions`) instead of off `plugin.actions`. Transactions ARE registered, so +// no transactions override; there are no derivations, so no `computedPlugin`. +Conformance.runFeature({ + state: State, + transitions: import.meta.glob( + ["../../../data/state/*.ts", "!**/*.test.ts", "!**/*.type-test.ts"], + { eager: true }, + ), + plugin: MainService.plugin, + projection, + ops: { + actions: import.meta.glob( + ["../action-database/actions/*.ts", "!**/index.ts"], + { eager: true }, + ), + }, +}); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/create-store.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/create-store.ts deleted file mode 100644 index a0d4904d..00000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/create-store.ts +++ /dev/null @@ -1,8 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Store } from "@adobe/data/ecs"; -import { CoreDatabase } from "../core-database/core-database.js"; - -// A fresh writable store carrying the negotiation schema. `CoreDatabase` is the -// lowest (and only) schema layer — the behaviour layers above add no schema — and -// `Store.create` reads a plugin's schema facets directly. Test-only. -export const createStore = (): CoreDatabase.Store => Store.create(CoreDatabase.plugin); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/from-state.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/from-state.ts deleted file mode 100644 index 62f636c9..00000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/from-state.ts +++ /dev/null @@ -1,26 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Entity } from "@adobe/data/ecs"; -import type { State } from "../../../data/state/state.js"; -import type { CoreDatabase } from "../core-database/core-database.js"; - -// Seed a store to exactly match a `data/` negotiation `State` — the scalar -// resources. The non-serializable `gameDb` resource is left at its default; it is -// invisible to the spec. Negotiation has no entity collections, so the returned -// `spec id → seeded entity` map is empty and the conformance runners' `resolve` is -// never used. The inverse of `toState`. Test-only. -export const fromState = ( - store: CoreDatabase.Store, - state: State, -): ReadonlyMap => { - store.resources.phase = state.phase; - store.resources.connection = state.connection; - store.resources.role = state.role; - store.resources.sessionId = state.sessionId; - store.resources.offerCode = state.offerCode; - store.resources.answerCode = state.answerCode; - store.resources.bannerText = state.bannerText; - store.resources.bannerError = state.bannerError; - store.resources.hostAnswerInput = state.hostAnswerInput; - store.resources.joinerOfferInput = state.joinerOfferInput; - return new Map(); -}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/projection.test.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/projection.test.ts deleted file mode 100644 index bdf8c06d..00000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/projection.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -// -// Guards the projection itself: `toState(fromState(s)) ≡ s` over representative -// states, so a symmetric bug in the pair can't mask a real ecs defect. -import { describe, it } from "vitest"; -import { Match } from "@adobe/data/testing"; -import { State } from "../../../data/state/state.js"; -import { createStore } from "./create-store.js"; -import { fromState } from "./from-state.js"; -import { toState } from "./to-state.js"; - -const states: readonly { readonly name: string; readonly state: State }[] = [ - { name: "the initial idle state", state: State.create() }, - { - name: "a host mid-signaling with an offer code", - state: { - ...State.create(), - phase: "host-signaling", - role: "host", - connection: "connecting", - offerCode: "OFFER-123", - hostAnswerInput: "partial", - }, - }, - { - name: "a connected game session", - state: { - ...State.create(), - phase: "game", - role: "joiner", - connection: "connected", - sessionId: "sess-9", - answerCode: "ANSWER-9", - }, - }, -]; - -describe("negotiation conformance projection round-trips (toState ∘ fromState ≡ identity)", () => { - for (const { name, state } of states) { - it(name, () => { - const store = createStore(); - fromState(store, state); - Match.assert(toState(store), state); - }); - } -}); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/projection.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/projection.ts new file mode 100644 index 00000000..5b70e5a8 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/projection.ts @@ -0,0 +1,35 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { State } from "../../../data/state/state.js"; +import type { CoreDatabase } from "../core-database/core-database.js"; + +// The test-only store↔`State` projection, passed to `Conformance.runFeature`. +// Negotiation is resource-only (no entity collections), so `fromState` seeds the +// scalar resources and returns nothing — ids resolve to `Entity.none` — and there +// is no per-entity `toData`. The non-serializable `gameDb` resource is deliberately +// left at its default: it is session-only ECS state, invisible to the spec. +export const projection = { + fromState: (store: CoreDatabase.Store, state: State): void => { + store.resources.phase = state.phase; + store.resources.connection = state.connection; + store.resources.role = state.role; + store.resources.sessionId = state.sessionId; + store.resources.offerCode = state.offerCode; + store.resources.answerCode = state.answerCode; + store.resources.bannerText = state.bannerText; + store.resources.bannerError = state.bannerError; + store.resources.hostAnswerInput = state.hostAnswerInput; + store.resources.joinerOfferInput = state.joinerOfferInput; + }, + toState: (store: CoreDatabase.Store): State => ({ + phase: store.resources.phase, + connection: store.resources.connection, + role: store.resources.role, + sessionId: store.resources.sessionId, + offerCode: store.resources.offerCode, + answerCode: store.resources.answerCode, + bannerText: store.resources.bannerText, + bannerError: store.resources.bannerError, + hostAnswerInput: store.resources.hostAnswerInput, + joinerOfferInput: store.resources.joinerOfferInput, + }), +}; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/to-state.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/to-state.ts deleted file mode 100644 index 1bde35e1..00000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/to-state.ts +++ /dev/null @@ -1,19 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { State } from "../../../data/state/state.js"; -import type { CoreDatabase } from "../core-database/core-database.js"; - -// Read a store back into a `data/` negotiation `State` — the inverse of -// `fromState`. The non-serializable `gameDb` resource is deliberately excluded. -// Test-only. -export const toState = (store: CoreDatabase.Store): State => ({ - phase: store.resources.phase, - connection: store.resources.connection, - role: store.resources.role, - sessionId: store.resources.sessionId, - offerCode: store.resources.offerCode, - answerCode: store.resources.answerCode, - bannerText: store.resources.bannerText, - bannerError: store.resources.bannerError, - hostAnswerInput: store.resources.hostAnswerInput, - joinerOfferInput: store.resources.joinerOfferInput, -}); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/transactions.test.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/transactions.test.ts deleted file mode 100644 index e570cbd2..00000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/transactions.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -/// -import { Conformance } from "@adobe/data/testing"; -import * as transactions from "../transaction-database/transactions/index.js"; -import { createStore } from "./create-store.js"; -import { fromState } from "./from-state.js"; -import { toState } from "./to-state.js"; - -// Every ecs transaction, conformed by name against its `data/state` transition. -// `setGameDb` has no transition (infra) and is skipped; `enterGame` has no -// transaction and is conformed through its action. -Conformance.runTransactions({ - createStore, - fromState, - toState, - transitions: import.meta.glob( - [ - "../../../data/state/*.ts", - "!../../../data/state/*.test.ts", - "!../../../data/state/*.type-test.ts", - ], - { eager: true }, - ), - transactions, -}); diff --git a/packages/data-react-pixie/src/features/main/data/state/create.ts b/packages/data-react-pixie/src/features/main/data/state/create.ts new file mode 100644 index 00000000..bf661de4 --- /dev/null +++ b/packages/data-react-pixie/src/features/main/data/state/create.ts @@ -0,0 +1,7 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { State } from "./state.js"; + +// The default scene state: no sprites, no filter. It is the baseline the +// conformance cases author their `before` as deltas over, and the state a fresh +// scene starts in. +export const create = (): State => ({ sprites: [], filter: "none" }); diff --git a/packages/data-react-pixie/src/features/main/data/state/public.ts b/packages/data-react-pixie/src/features/main/data/state/public.ts index 7c2e717a..b53ae5d7 100644 --- a/packages/data-react-pixie/src/features/main/data/state/public.ts +++ b/packages/data-react-pixie/src/features/main/data/state/public.ts @@ -1,4 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +export { create } from "./create.js"; +export { samples } from "./samples.js"; export { createSprite } from "./create-sprite.js"; export { setSpriteHovered } from "./set-sprite-hovered.js"; export { setSpriteActive } from "./set-sprite-active.js"; diff --git a/packages/data-react-pixie/src/features/main/data/state/samples.ts b/packages/data-react-pixie/src/features/main/data/state/samples.ts new file mode 100644 index 00000000..596c968c --- /dev/null +++ b/packages/data-react-pixie/src/features/main/data/state/samples.ts @@ -0,0 +1,64 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Match } from "@adobe/data/testing"; +import type { State } from "./state.js"; + +// Representative full states for the projection round-trip (toState ∘ fromState ≡ +// identity). Sprite ids are authored as `anyNumber`: the ecs reassigns ids from +// its own id-space, so the round-trip leaves them open. Varied sprite collections +// + scene filters exercise the whole ecs↔State map. +export const samples: readonly State[] = [ + { + sprites: [ + { + id: Match.anyNumber, + position: [100, 100], + rotation: 0, + kind: "bunny", + hovered: false, + active: false, + }, + { + id: Match.anyNumber, + position: [300, 200], + rotation: 1, + kind: "fox", + hovered: true, + active: false, + }, + { + id: Match.anyNumber, + position: [150, 250], + rotation: 0.5, + kind: "bunny", + hovered: false, + active: true, + }, + ], + filter: "sepia", + }, + { + sprites: [], + filter: "none", + }, + { + sprites: [ + { + id: Match.anyNumber, + position: [10, 10], + rotation: 0, + kind: "fox", + hovered: false, + active: false, + }, + { + id: Match.anyNumber, + position: [20, 20], + rotation: 0, + kind: "fox", + hovered: false, + active: false, + }, + ], + filter: "blur", + }, +]; diff --git a/packages/data-react-pixie/src/features/main/data/state/spec.test.ts b/packages/data-react-pixie/src/features/main/data/state/spec.test.ts index ebdddf05..a1d81b29 100644 --- a/packages/data-react-pixie/src/features/main/data/state/spec.test.ts +++ b/packages/data-react-pixie/src/features/main/data/state/spec.test.ts @@ -1,6 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. /// import { Conformance } from "@adobe/data/testing"; +import { State } from "./state.js"; // The single pure-spec test for every transform AND derivation in this folder. // `runSpec` auto-discovers each sibling that exports `cases`, requires it to @@ -16,4 +17,5 @@ Conformance.runSpec( eager: true, }, ), + { initial: State.create() }, ); diff --git a/packages/data-react-pixie/src/features/main/services/main-service/conformance/actions.test.ts b/packages/data-react-pixie/src/features/main/services/main-service/conformance/actions.test.ts deleted file mode 100644 index 0a22629e..00000000 --- a/packages/data-react-pixie/src/features/main/services/main-service/conformance/actions.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -/// -import { Database } from "@adobe/data/ecs"; -import { Conformance } from "@adobe/data/testing"; -import { MainService } from "../main-service.js"; -import * as actions from "../action-database/actions/index.js"; -import { fromState } from "./from-state.js"; -import { toState } from "./to-state.js"; - -// Every ecs action, conformed by name against its transition. The runner splits -// each case's injected services into recording overrides via `makeDb`, resolves -// `entity()` arg markers to the seeded entity, runs the action, and asserts state -// + declared effects. This feature injects no services, so the override is always -// empty (no cast). -Conformance.runActions({ - makeDb: (services) => - Database.toSystemDatabase( - Database.create(MainService.plugin, { services }), - ), - store: (db) => db.store, - fromState, - toState, - transitions: import.meta.glob( - [ - "../../../data/state/*.ts", - "!../../../data/state/*.test.ts", - "!../../../data/state/*.type-test.ts", - ], - { eager: true }, - ), - actions, -}); diff --git a/packages/data-react-pixie/src/features/main/services/main-service/conformance/conformance.test.ts b/packages/data-react-pixie/src/features/main/services/main-service/conformance/conformance.test.ts new file mode 100644 index 00000000..7db542c1 --- /dev/null +++ b/packages/data-react-pixie/src/features/main/services/main-service/conformance/conformance.test.ts @@ -0,0 +1,21 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +/// +import { Conformance } from "@adobe/data/testing"; +import { State } from "../../../data/state/state.js"; +import { MainService } from "../main-service.js"; +import { projection } from "./projection.js"; + +// The whole ecs conformance for this feature in one call: `runFeature` pulls the +// transactions/actions off `MainService.plugin`, seeds each case's `before` (a +// delta) over `State.create()`, resolves each entity-addressed case's `entity()` +// markers through the `fromState` id map, and round-trips `State.samples` through +// the projection. This feature has no derivations, so no `computedPlugin`. +Conformance.runFeature({ + state: State, + transitions: import.meta.glob( + ["../../../data/state/*.ts", "!**/*.test.ts", "!**/*.type-test.ts"], + { eager: true }, + ), + plugin: MainService.plugin, + projection, +}); diff --git a/packages/data-react-pixie/src/features/main/services/main-service/conformance/create-store.ts b/packages/data-react-pixie/src/features/main/services/main-service/conformance/create-store.ts deleted file mode 100644 index fef74895..00000000 --- a/packages/data-react-pixie/src/features/main/services/main-service/conformance/create-store.ts +++ /dev/null @@ -1,9 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Store } from "@adobe/data/ecs"; -import { CoreDatabase } from "../core-database/core-database.js"; - -// A fresh writable store carrying the feature's whole schema. The feature has no -// index / behaviour layers that add schema, so `CoreDatabase` is the lowest (and -// only) schema layer. Typed as `CoreDatabase.Store`: the surface the projection -// (`fromState` / `toState`) and the raw transaction functions use. Test-only. -export const createStore = (): CoreDatabase.Store => Store.create(CoreDatabase.plugin); diff --git a/packages/data-react-pixie/src/features/main/services/main-service/conformance/from-state.ts b/packages/data-react-pixie/src/features/main/services/main-service/conformance/from-state.ts deleted file mode 100644 index 078de980..00000000 --- a/packages/data-react-pixie/src/features/main/services/main-service/conformance/from-state.ts +++ /dev/null @@ -1,40 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Entity } from "@adobe/data/ecs"; -import type { State } from "../../../data/state/state.js"; -import type { CoreDatabase } from "../core-database/core-database.js"; - -// Seed a store to exactly match a `data/` `State`: clear every sprite, set the -// `filter` resource, then insert the sprites. The inverse of `toState`. -// Test-only — the bridge that lets an ecs mutation be checked against the pure -// transform it stands for. -// -// Clearing iterates tail→head so each delete is from the tail (no hole-fill -// shift). The ecs assigns entity ids from its own id-space, unrelated to the -// spec's domain `id`. This returns the `spec id → seeded entity` map so the -// conformance runners resolve id-addressed operations generically -// (`Conformance.resolver`); nothing here assumes the two id-spaces coincide. -export const fromState = ( - store: CoreDatabase.Store, - state: State, -): ReadonlyMap => { - for (const arch of store.queryArchetypes( - store.archetypes.Sprite.components, - )) { - for (let row = arch.rowCount - 1; row >= 0; row--) { - store.delete(arch.columns.id.get(row)); - } - } - store.resources.filter = state.filter; - return new Map( - state.sprites.map((sprite) => [ - sprite.id, - store.archetypes.Sprite.insert({ - position: sprite.position, - rotation: sprite.rotation, - kind: sprite.kind, - hovered: sprite.hovered, - active: sprite.active, - }), - ]), - ); -}; diff --git a/packages/data-react-pixie/src/features/main/services/main-service/conformance/projection.test.ts b/packages/data-react-pixie/src/features/main/services/main-service/conformance/projection.test.ts deleted file mode 100644 index a8235aee..00000000 --- a/packages/data-react-pixie/src/features/main/services/main-service/conformance/projection.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -// -// Guards the projection itself. `fromState` and `toState` are the bridge every -// transaction/action conformance test trusts; a symmetric bug in the pair (e.g. -// both dropping the same field) would cancel out and mask a real ecs defect. This -// identity test — `toState(fromState(s)) ≡ s` over representative states — proves -// the projection round-trips faithfully on its own. -import { describe, it } from "vitest"; -import { Match } from "@adobe/data/testing"; -import type { State } from "../../../data/state/state.js"; -import { createStore } from "./create-store.js"; -import { fromState } from "./from-state.js"; -import { toState } from "./to-state.js"; - -const states: readonly { readonly name: string; readonly state: State }[] = [ - { - name: "a mix of sprites with a scene filter", - state: { - sprites: [ - { - id: 1, - position: [100, 100], - rotation: 0, - kind: "bunny", - hovered: false, - active: false, - }, - { - id: 2, - position: [300, 200], - rotation: 1, - kind: "fox", - hovered: true, - active: false, - }, - { - id: 3, - position: [150, 250], - rotation: 0.5, - kind: "bunny", - hovered: false, - active: true, - }, - ], - filter: "sepia", - }, - }, - { - name: "an empty scene with no filter", - state: { sprites: [], filter: "none" }, - }, - { - name: "sprites sharing a kind, blur filter", - state: { - sprites: [ - { - id: 1, - position: [10, 10], - rotation: 0, - kind: "fox", - hovered: false, - active: false, - }, - { - id: 2, - position: [20, 20], - rotation: 0, - kind: "fox", - hovered: false, - active: false, - }, - ], - filter: "blur", - }, - }, -]; - -describe("ecs/conformance projection round-trips (toState ∘ fromState ≡ identity)", () => { - for (const { name, state } of states) { - it(name, () => { - const store = createStore(); - fromState(store, state); - // The ecs reassigns ids from its own id-space, so compare against the same - // state with ids left open. - Match.assert(toState(store), { - ...state, - sprites: state.sprites.map((sprite) => ({ - ...sprite, - id: Match.anyNumber, - })), - }); - }); - } -}); diff --git a/packages/data-react-pixie/src/features/main/services/main-service/conformance/projection.ts b/packages/data-react-pixie/src/features/main/services/main-service/conformance/projection.ts new file mode 100644 index 00000000..47ec85ef --- /dev/null +++ b/packages/data-react-pixie/src/features/main/services/main-service/conformance/projection.ts @@ -0,0 +1,62 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Entity } from "@adobe/data/ecs"; +import type { Sprite } from "../../../data/sprite/sprite.js"; +import type { State } from "../../../data/state/state.js"; +import type { CoreDatabase } from "../core-database/core-database.js"; + +// Read one entity back into its `data/` value — the per-entity projection +// `toState` folds over, and the single place the ecs↔data mapping for a sprite +// lives. The projected `id` is the entity id (the ecs's own id-space). +const toData = (store: CoreDatabase.Store, entity: Entity): Sprite => { + const row = store.read(entity, store.archetypes.Sprite); + if (row === null) + throw new Error("conformance projection: expected a sprite entity"); + return { + id: row.id, + position: row.position, + rotation: row.rotation, + kind: row.kind, + hovered: row.hovered, + active: row.active, + }; +}; + +// The test-only ecs↔`State` projection, passed to `Conformance.runFeature`. +// `fromState` seeds a store to a `State` (clear every sprite, set `filter`, then +// insert the sprites) and returns the `spec id → seeded entity` map so the +// runners resolve id-addressed operations generically; `toState` reads it back; +// `toData` reads one entity. +export const projection = { + fromState: ( + store: CoreDatabase.Store, + state: State, + ): ReadonlyMap => { + for (const arch of store.queryArchetypes( + store.archetypes.Sprite.components, + )) { + for (let row = arch.rowCount - 1; row >= 0; row--) { + store.delete(arch.columns.id.get(row)); + } + } + store.resources.filter = state.filter; + return new Map( + state.sprites.map((sprite) => [ + sprite.id, + store.archetypes.Sprite.insert({ + position: sprite.position, + rotation: sprite.rotation, + kind: sprite.kind, + hovered: sprite.hovered, + active: sprite.active, + }), + ]), + ); + }, + toState: (store: CoreDatabase.Store): State => ({ + sprites: [...store.select(store.archetypes.Sprite.components)].map( + (entity) => toData(store, entity), + ), + filter: store.resources.filter, + }), + toData, +}; diff --git a/packages/data-react-pixie/src/features/main/services/main-service/conformance/to-data.ts b/packages/data-react-pixie/src/features/main/services/main-service/conformance/to-data.ts deleted file mode 100644 index 04e3220a..00000000 --- a/packages/data-react-pixie/src/features/main/services/main-service/conformance/to-data.ts +++ /dev/null @@ -1,21 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Entity } from "@adobe/data/ecs"; -import type { Sprite } from "../../../data/sprite/sprite.js"; -import type { CoreDatabase } from "../core-database/core-database.js"; - -// Read one entity back into its `data/` value — the per-entity projection -// `toState` is built on, and the single place the ecs↔data mapping for a sprite -// lives. The projected `id` is the entity id (the ecs's own id-space, not the -// spec's domain id), so cases author `after` ids as `anyNumber`. Test-only. -export const toData = (store: CoreDatabase.Store, entity: Entity): Sprite => { - const row = store.read(entity, store.archetypes.Sprite); - if (row === null) throw new Error("conformance projection: expected a sprite entity"); - return { - id: row.id, - position: row.position, - rotation: row.rotation, - kind: row.kind, - hovered: row.hovered, - active: row.active, - }; -}; diff --git a/packages/data-react-pixie/src/features/main/services/main-service/conformance/to-state.ts b/packages/data-react-pixie/src/features/main/services/main-service/conformance/to-state.ts deleted file mode 100644 index 1056a31b..00000000 --- a/packages/data-react-pixie/src/features/main/services/main-service/conformance/to-state.ts +++ /dev/null @@ -1,18 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { State } from "../../../data/state/state.js"; -import type { Sprite } from "../../../data/sprite/sprite.js"; -import type { CoreDatabase } from "../core-database/core-database.js"; -import { toData } from "./to-data.js"; - -// Read a store back into a `data/` `State` — the inverse of `fromState`. Sprites -// are read in archetype/insertion order (there is no ordering column) through the -// per-entity `toData` projection; the projected `id` is the entity id (the ecs's -// own id-space, not the spec's domain id), so conformance comparisons leave it -// open (`anyNumber`). Test-only. -const readSprites = (store: CoreDatabase.Store): Sprite[] => - [...store.select(store.archetypes.Sprite.components)].map((entity) => toData(store, entity)); - -export const toState = (store: CoreDatabase.Store): State => ({ - sprites: readSprites(store), - filter: store.resources.filter, -}); diff --git a/packages/data-react-pixie/src/features/main/services/main-service/conformance/transactions.test.ts b/packages/data-react-pixie/src/features/main/services/main-service/conformance/transactions.test.ts deleted file mode 100644 index 9de85e1a..00000000 --- a/packages/data-react-pixie/src/features/main/services/main-service/conformance/transactions.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -/// -import { Conformance } from "@adobe/data/testing"; -import * as transactions from "../transaction-database/transactions/index.js"; -import { createStore } from "./create-store.js"; -import { fromState } from "./from-state.js"; -import { toState } from "./to-state.js"; - -// Every ecs transaction, conformed by name against its `data/state` transition — -// no per-item wiring. Entity-addressed transitions (setSpriteActive / -// setSpriteHovered / toggleSpriteActive) carry a `Conformance.entity` marker in -// their case args, which the runner resolves to the seeded entity via the -// `fromState` id map. createSprite / setFilter / tick are plain-data addressed. -Conformance.runTransactions({ - createStore, - fromState, - toState, - transitions: import.meta.glob( - [ - "../../../data/state/*.ts", - "!../../../data/state/*.test.ts", - "!../../../data/state/*.type-test.ts", - ], - { eager: true }, - ), - transactions, -}); diff --git a/packages/data-solid-dashboard/src/features/main/data/state/public.ts b/packages/data-solid-dashboard/src/features/main/data/state/public.ts index 6a845a91..1b91e948 100644 --- a/packages/data-solid-dashboard/src/features/main/data/state/public.ts +++ b/packages/data-solid-dashboard/src/features/main/data/state/public.ts @@ -1,5 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. export { create } from "./create.js"; +export { samples } from "./samples.js"; export { increment } from "./increment.js"; export { decrement } from "./decrement.js"; export { reset } from "./reset.js"; diff --git a/packages/data-solid-dashboard/src/features/main/data/state/samples.ts b/packages/data-solid-dashboard/src/features/main/data/state/samples.ts new file mode 100644 index 00000000..06542151 --- /dev/null +++ b/packages/data-solid-dashboard/src/features/main/data/state/samples.ts @@ -0,0 +1,14 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { State } from "./state.js"; + +// Representative full states for the projection round-trip (toState ∘ fromState ≡ +// identity). A populated dashboard and the initial defaults exercise the whole +// ecs↔State map. The state is entirely scalar resources, so the compare is exact. +export const samples: readonly State[] = [ + { + count: 3, + log: ["Incremented to 1", "Name changed to Ada"], + userName: "Ada", + }, + { count: 0, log: [], userName: "Guest" }, +]; diff --git a/packages/data-solid-dashboard/src/features/main/data/state/spec.test.ts b/packages/data-solid-dashboard/src/features/main/data/state/spec.test.ts index 136134bd..5244781e 100644 --- a/packages/data-solid-dashboard/src/features/main/data/state/spec.test.ts +++ b/packages/data-solid-dashboard/src/features/main/data/state/spec.test.ts @@ -1,6 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. /// import { Conformance } from "@adobe/data/testing"; +import { State } from "./state.js"; // The single pure-spec test for every transform AND derivation in this folder. // `runSpec` auto-discovers each sibling that exports `cases`, requires it to @@ -15,4 +16,5 @@ Conformance.runSpec( eager: true, }, ), + { initial: State.create() }, ); diff --git a/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/actions.test.ts b/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/actions.test.ts deleted file mode 100644 index 58e540c1..00000000 --- a/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/actions.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -/// -import { Database } from "@adobe/data/ecs"; -import { Conformance } from "@adobe/data/testing"; -import { MainService } from "../main-service.js"; -import * as actions from "../action-database/actions/index.js"; -import { fromState } from "./from-state.js"; -import { toState } from "./to-state.js"; - -// Every ecs action, conformed by name against its transition. `runActions` -// discovers transitions, turns each case's injected services into recording -// overrides via `makeDb`, runs the action, and asserts state + declared effects. -// This feature injects no services, so the override is always empty (no cast). -Conformance.runActions({ - makeDb: (services) => - Database.toSystemDatabase( - Database.create(MainService.plugin, { services }), - ), - store: (db) => db.store, - fromState, - toState, - transitions: import.meta.glob( - [ - "../../../data/state/*.ts", - "!../../../data/state/*.test.ts", - "!../../../data/state/*.type-test.ts", - ], - { eager: true }, - ), - actions, -}); diff --git a/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/conformance.test.ts b/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/conformance.test.ts new file mode 100644 index 00000000..07606cd3 --- /dev/null +++ b/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/conformance.test.ts @@ -0,0 +1,20 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +/// +import { Conformance } from "@adobe/data/testing"; +import { State } from "../../../data/state/state.js"; +import { MainService } from "../main-service.js"; +import { projection } from "./projection.js"; + +// The whole ecs conformance for this feature in one call: `runFeature` pulls the +// transactions/actions off `MainService.plugin`, seeds each case's `before` (a +// delta) over `State.create()`, and round-trips `State.samples` through the +// projection. This feature has no derivations, so no `computedPlugin`. +Conformance.runFeature({ + state: State, + transitions: import.meta.glob( + ["../../../data/state/*.ts", "!**/*.test.ts", "!**/*.type-test.ts"], + { eager: true }, + ), + plugin: MainService.plugin, + projection, +}); diff --git a/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/create-store.ts b/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/create-store.ts deleted file mode 100644 index 129cdc24..00000000 --- a/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/create-store.ts +++ /dev/null @@ -1,8 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Store } from "@adobe/data/ecs"; -import { CoreDatabase } from "../core-database/core-database.js"; - -// A fresh writable store carrying the feature's whole schema. This feature has -// no indexes, so `CoreDatabase` is the lowest (and only) layer that declares all -// the schema; `Store.create` reads a plugin's schema facets directly. Test-only. -export const createStore = (): CoreDatabase.Store => Store.create(CoreDatabase.plugin); diff --git a/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/from-state.ts b/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/from-state.ts deleted file mode 100644 index c3a506a8..00000000 --- a/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/from-state.ts +++ /dev/null @@ -1,22 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Entity } from "@adobe/data/ecs"; -import type { State } from "../../../data/state/state.js"; -import type { CoreDatabase } from "../core-database/core-database.js"; - -// Seed a store to exactly match a `data/` `State`. The whole state is scalar -// resources, so seeding is three assignments — the inverse of `toState`. -// Test-only bridge that lets an ecs mutation be checked against the pure -// transform it stands for. -// -// The conformance runners resolve id-addressed operations generically through the -// `spec id → seeded entity` map this returns. This feature has no entities (only -// scalar resources), so nothing is id-addressed and the map is always empty. -export const fromState = ( - store: CoreDatabase.Store, - state: State, -): ReadonlyMap => { - store.resources.count = state.count; - store.resources.log = state.log; - store.resources.userName = state.userName; - return new Map(); -}; diff --git a/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/projection.test.ts b/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/projection.test.ts deleted file mode 100644 index 396c0231..00000000 --- a/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/projection.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -// -// Guards the projection itself. `fromState` and `toState` are the bridge every -// transaction/action conformance test trusts; a symmetric bug in the pair (e.g. -// both dropping the same field) would cancel out and mask a real ecs defect. This -// identity test — `toState(fromState(s)) ≡ s` over representative states — proves -// the projection round-trips faithfully on its own. The state is entirely scalar -// resources (no ecs-minted ids), so the compare is exact. -import { describe, it } from "vitest"; -import { Match } from "@adobe/data/testing"; -import type { State } from "../../../data/state/state.js"; -import { createStore } from "./create-store.js"; -import { fromState } from "./from-state.js"; -import { toState } from "./to-state.js"; - -const states: readonly { readonly name: string; readonly state: State }[] = [ - { - name: "a populated dashboard: positive count, multi-entry log, named user", - state: { - count: 3, - log: ["Incremented to 1", "Name changed to Ada"], - userName: "Ada", - }, - }, - { - name: "the initial defaults: zero count, empty log, guest user", - state: { count: 0, log: [], userName: "Guest" }, - }, -]; - -describe("ecs conformance projection round-trips (toState ∘ fromState ≡ identity)", () => { - for (const { name, state } of states) { - it(name, () => { - const store = createStore(); - fromState(store, state); - Match.assert(toState(store), state); - }); - } -}); diff --git a/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/projection.ts b/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/projection.ts new file mode 100644 index 00000000..a8b8980d --- /dev/null +++ b/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/projection.ts @@ -0,0 +1,25 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Entity } from "@adobe/data/ecs"; +import type { State } from "../../../data/state/state.js"; +import type { CoreDatabase } from "../core-database/core-database.js"; + +// The test-only ecs↔`State` projection, passed to `Conformance.runFeature`. +// `fromState` seeds a store to a `State`; the whole state is scalar resources, so +// seeding is three assignments, it mints no ids and returns an empty map. +// `toState` reads it back — the inverse. No entities, so no `toData`. +export const projection = { + fromState: ( + store: CoreDatabase.Store, + state: State, + ): ReadonlyMap => { + store.resources.count = state.count; + store.resources.log = state.log; + store.resources.userName = state.userName; + return new Map(); + }, + toState: (store: CoreDatabase.Store): State => ({ + count: store.resources.count, + log: store.resources.log, + userName: store.resources.userName, + }), +}; diff --git a/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/to-state.ts b/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/to-state.ts deleted file mode 100644 index ebddb673..00000000 --- a/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/to-state.ts +++ /dev/null @@ -1,11 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { State } from "../../../data/state/state.js"; -import type { CoreDatabase } from "../core-database/core-database.js"; - -// Read a store back into a `data/` `State` — the inverse of `fromState`. -// Test-only. -export const toState = (store: CoreDatabase.Store): State => ({ - count: store.resources.count, - log: store.resources.log, - userName: store.resources.userName, -}); diff --git a/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/transactions.test.ts b/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/transactions.test.ts deleted file mode 100644 index aff810c6..00000000 --- a/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/transactions.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -/// -import { Conformance } from "@adobe/data/testing"; -import * as transactions from "../transaction-database/transactions/index.js"; -import { createStore } from "./create-store.js"; -import { fromState } from "./from-state.js"; -import { toState } from "./to-state.js"; - -// Every ecs transaction, conformed by name against its `data/state` transition — -// no per-item wiring. `runTransactions` discovers the transitions (the glob), -// pairs each registered transaction to the same-named one, seeds `fromState`, -// applies, and compares `toState`. This feature holds only scalar resources, so -// nothing is id-addressed and no `entity()` markers are needed. -Conformance.runTransactions({ - createStore, - fromState, - toState, - transitions: import.meta.glob( - [ - "../../../data/state/*.ts", - "!../../../data/state/*.test.ts", - "!../../../data/state/*.type-test.ts", - ], - { eager: true }, - ), - transactions, -}); From 4175000c84c7288b4a0bac2469a052391a92aa8d Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 7 Aug 2026 12:43:22 -0700 Subject: [PATCH 32/37] docs(rules): teach Conformance.runFeature (one-call feature conformance) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite the conformance guidance for the runFeature model: a feature's ECS conformance is one runFeature call (ops pulled off the plugin facets, stores/dbs built by the runner) + a single aggregated projection.ts + State.samples; cases are deltas over State.create(); transitions are read→write patches. Documents the ops override (unregistered per-transition actions), computedPlugin freshness, and the p2p-presence lower-level-runner exception. state/transactions/actions/computed/ index.md updated to match; create-store + the four per-surface test files are gone. Co-Authored-By: Claude Sonnet 4.6 --- .../.claude/rules/features/data/state.md | 76 ++++-- .../data-ai/.claude/rules/features/index.md | 7 +- .../features/services/main-service/actions.md | 27 +- .../services/main-service/computed.md | 25 +- .../services/main-service/conformance.md | 233 ++++++++++-------- .../services/main-service/transactions.md | 13 +- 6 files changed, 221 insertions(+), 160 deletions(-) diff --git a/packages/data-ai/.claude/rules/features/data/state.md b/packages/data-ai/.claude/rules/features/data/state.md index 9703c6fc..dbc8e1d9 100644 --- a/packages/data-ai/.claude/rules/features/data/state.md +++ b/packages/data-ai/.claude/rules/features/data/state.md @@ -6,11 +6,11 @@ paths: # data/state/ — the State specification `State` is the whole feature as **one immutable object** — the pure, fully-tested -source of truth. Each transform is a small function of state; each derivation a -pure selector. Reference: `data-lit-todo`'s `data/state/`. +source of truth. Each transition is a read→write **patch** over state; each +derivation a pure selector. Reference: `data-lit-todo`'s `data/state/`. ```ts -// state.ts — the aggregate + the transform/derivation namespace. +// state.ts — the aggregate + the transition/derivation namespace. export type State = { readonly todos: readonly Todo[]; readonly displayCompleted: boolean }; export * as State from "./public.js"; ``` @@ -18,6 +18,20 @@ export * as State from "./public.js"; Every feature with ECS resources/transactions owns a `State` (a scalar `{ playing: boolean }`, or `{}` when there is none). +**`State` has a standard shape.** Two exports are conventional and drive +conformance: + +- **`create(): State`** (`data/state/create.ts`) — the default state. Every + conformance case's `before` is a **delta over `State.create()`**, and both the + pure `spec.test.ts` and the ecs `runFeature` seed from it. +- **`samples: readonly State[]`** (`data/state/samples.ts`) — representative + **full** states `runFeature` round-trips through the projection + (`toState ∘ fromState ≡ identity`, see `conformance.md`). + +Both are re-exported through `public.js`, so `State.create()` / `State.samples` +are namespace members. (A `cases` literal must still not touch `public.js` at +load — import `create` from `./create.js` directly there; see below.) + ## One file per transform: the function **and** its cases A transform file exports **exactly two things** — the function and its @@ -27,8 +41,9 @@ conformance runner reuses; co-locating them removes the per-transform `.cases.ts and `.test.ts`. **Why co-locate `cases` (not a sibling `*.test.ts`)?** They are spec-owned -fixtures **four runners reuse** (spec / transaction / action / computed) — the -transform's contract expressed as data, not a per-file test — so they belong +fixtures the pure `spec.test.ts` **and** the ecs `runFeature` call both reuse +(driving transaction / action / computed conformance) — the transform's contract +expressed as data, not a per-file test — so they belong beside the thing they specify, and `Conformance` binds them to the signature so they can't drift. Kept inert (no `describe`; one aggregator runs them) they also sidestep the double execution vitest triggers when a single file @@ -59,28 +74,37 @@ export const entity = ConformanceApi.entity; // create-todo.ts import { Match } from "@adobe/data/testing"; import type { Conformance } from "./conformance-case.js"; // the thin per-feature alias above -export const createTodo = >( - state: T, +export const createTodo = ( + state: Pick, { name, complete, analytics }: { name: string; complete?: boolean; analytics: AnalyticsService }, -): T => { analytics.todoCreated({ name }); return appendTodo(state, { name, complete }); }; +): Pick => { + analytics.todoCreated({ name }); + return { todos: [...state.todos, { name, complete: complete ?? false }] }; // writes patch only +}; export const cases: Conformance = [ { name: "appends the first todo", - before: { todos: [], displayCompleted: false }, + before: {}, // empty delta — the default State.create() args: { name: "a", analytics: AnalyticsService.createFake() }, - after: { todos: [{ id: Match.anyNumber, name: "a", complete: false }], displayCompleted: false }, + after: { todos: [{ id: Match.anyNumber, name: "a", complete: false }] }, // only the changed field effects: { analytics: [["todoCreated", { name: "a" }]] } }, ]; ``` -- **Signature** `(state, args) => state`. Narrow-in/same-shape-out — generic over - the smallest `Pick` slice so it lifts to full-state. **All non-state - inputs go in the single `args` object** (`Conformance` reads - `Parameters[1]`) — bundle a `dt`, an injected service, etc. into it, never as a - third positional. Args may be narrowed/omitted; a transform that takes **none** - omits `args` from each case entirely (the shared `Case` type makes `args` - optional exactly then). **Guard no-ops by returning `state` unchanged**, never - throw. +- **Signature** `(state: Pick, args) => Pick` — a + **read→write patch**. The parameter is the smallest `Pick` the + transition **reads**; the return is *only the fields it **writes***. **No + ` => T` generic, no `...state` spread** in the return — return the patch and + let the runner merge it. **All non-state inputs go in the single `args` object** + (`Conformance` reads `Parameters[1]`) — bundle a `dt`, an injected + service, etc. into it, never as a third positional. A transition that takes + **no** args omits `args` from each case entirely (the shared `Case` type makes + `args` optional exactly then). **Guard no-ops by returning an empty patch `{}`** + (or the unchanged slice), never throw. +- **A composer merges sub-patches explicitly.** A transition built from smaller + ones spreads them — `return { ...s, ...sub(s) }` — so each sub-patch's writes + layer in; a transition that merely **delegates** to one sub-transition returns + that delegate's patch directly (no spread needed). - **Co-located `cases` must not touch the feature's `public.js` barrel at module load** — that barrel re-exports this very file, so calling `State.create()` (or any barrel member) in a top-level `cases` literal dead-locks the import cycle. @@ -88,7 +112,13 @@ export const cases: Conformance = [ inline full-`State` literals. - **`Conformance`** (the alias above) derives the case `args` type from the function's own signature — author it once, and cases can't drift from what - the function accepts. `before`/`after` are full `State`. + the function accepts. **`before` is a delta over `State.create()`** — list only + the fields this case sets differently from the default; **`after` is the writes + patch** — only the fields the transition changes. The runner seeds + `{ ...State.create(), ...before }` and compares against + `{ ...State.create(), ...before, ...after }`, so any field a case doesn't mention + is the default and stays unchanged. (A full `before`/`after` still works — it just + overrides the default wholesale.) - **`after` leaves minted values open** with the shared matchers `Match.anyNumber` / `Match.anyString`, imported from `@adobe/data/testing` — there is **no** per-feature `matchers.ts` anymore. An id the ECS assigns from its own id-space is @@ -108,11 +138,13 @@ export const cases: Conformance = [ own arg type. `runSpec` unwraps it to the plain data-id for the pure side; the ECS runners resolve it to the seeded entity (see `conformance.md`). - No per-transform test. The single **`spec.test.ts`** is one call — - `Conformance.runSpec(import.meta.glob(["./*.ts", "!./*.test.ts"], { eager: true }))` + `Conformance.runSpec(import.meta.glob(["./*.ts", "!./*.test.ts", "!./*.type-test.ts"], { eager: true }), { initial: State.create() })` — that auto-discovers every sibling exporting `cases`, enforces the two-exports rule, and dispatches on case shape (a `value` case → derivation; otherwise a - transition whose declared `effects` are also asserted). Pass `{ match }` only when - the feature needs float tolerance or unordered collections (see `conformance.md`). + transition whose declared `effects` are also asserted). The `{ initial: + State.create() }` is what makes each case's `before`/`input` a delta over the + default. Add `match` alongside it only when the feature needs float tolerance or + unordered collections (see `conformance.md`). There is no per-feature `expect-state-matches.ts`, `record-effects.ts`, `expect-conforms.ts`, or `conformance-case.type-test.ts` — those are gone; the shared driver owns comparison, effect recording, and name-based auto-pairing, and diff --git a/packages/data-ai/.claude/rules/features/index.md b/packages/data-ai/.claude/rules/features/index.md index 00f36bb9..47f6de9c 100644 --- a/packages/data-ai/.claude/rules/features/index.md +++ b/packages/data-ai/.claude/rules/features/index.md @@ -102,9 +102,10 @@ The tie between `data/` (spec) and `main-service` (implementation) is `toState(apply(fromState(before), args)) ≡ transform(before, args)`: each main-service mutation, seeded and read back through a test-only store↔`State` projection, equals the pure `data/` transform it stands for. The per-feature -projection lives in `services/main-service/conformance/` and is replayed by the -shared `@adobe/data/testing` runners, which pair each ECS op to its same-named -transition automatically (see `services/main-service/conformance.md`); +projection lives in `services/main-service/conformance/`, and a **single +`Conformance.runFeature({...})` call** replays the shared cases against the ecs — +pairing each ECS op to its same-named transition automatically and round-tripping +the projection (see `services/main-service/conformance.md`); the shared `{ before, args, after }` cases are spec-owned — co-located in each `data/state/.ts`, which exports its function plus `cases` — so conforming the implementation is "substitute the implementation, reuse the diff --git a/packages/data-ai/.claude/rules/features/services/main-service/actions.md b/packages/data-ai/.claude/rules/features/services/main-service/actions.md index 2543351a..3499d14f 100644 --- a/packages/data-ai/.claude/rules/features/services/main-service/actions.md +++ b/packages/data-ai/.claude/rules/features/services/main-service/actions.md @@ -19,8 +19,8 @@ there need not be a same-named transaction; transactions are the looser layer. **Per-frame / system transitions are exempt.** In a real-time feature the `step*` / physics / collision transitions are realized by the **systems** tick loop, not -by an action, and are conformed by the tick-loop test (`systems.md`), not -`actions.test.ts`. Give an action only to transitions a user/UI invokes directly +by an action, and are conformed by the tick-loop test (`systems.md`), not the +action surface of `runFeature`. Give an action only to transitions a user/UI invokes directly (and skip it too when the realization needs more than one transaction — e.g. a `newGame` that both sets bounds and resets is conformed via its transaction). @@ -48,20 +48,19 @@ export const addRandomTodo = async (service: ServiceDatabase) => { Reactive computeds refresh only on a committed transaction, so an imperative read of one can hand back a stale shared cache (and it's the UI's layer, not the action's). This also keeps the action correct under the conformance seed. -- **Conformance** (`conformance/actions.test.ts`) is a single, auto-paired - `Conformance.runActions({ makeDb, store, fromState, toState, transitions, actions, - match?, seedContext? })` call: it discovers the `data/state` transitions and pairs - each `actions` entry to the **same-named** transition. **The action is the primary - seam** — it reads injected services from `db.services`, so the case's service args - become recording overrides via `makeDb(services)` (built as - `Database.toSystemDatabase(Database.create(MainService.plugin, { services }))`); the - driver splits the case `args` into services and plain input, runs the action, then +- **Conformance** is the action surface of the feature's single + `conformance/conformance.test.ts` `Conformance.runFeature({...})` call. It pulls + actions off **`plugin.actions`** and pairs each to the **same-named** `data/state` + transition. **The action is the primary seam** — it reads injected services from + `db.services`, so the case's service args become recording overrides (the runner + builds `Database.toSystemDatabase(Database.create(plugin, { services }))`); it + splits the case `args` into services and plain input, runs the action, then `Match.assert`s `toState ≡ after` **and** checks the recorded calls against the case's `effects`. There is no `define`/`conforms` and no coverage guard. A thin **same-named** action gives a transaction-only or renamed transition something to pair with (todo's `reorderTodo`). A streaming/capability action with no transition - is skipped — if it isn't in the facet barrel (p2p's `movePresence`, streamed via - `trackPresence`), point `actions:` at a directory glob - (`import.meta.glob([".../actions/*.ts", "!.../actions/index.ts"], { eager: true })`) - so it is still discovered (see `conformance.md`). + is skipped. **A per-transition action kept out of the facet** (to bound the + plugin's type) is discovered via `runFeature`'s `ops.actions` glob — + `ops: { actions: import.meta.glob([".../actions/*.ts", "!.../actions/index.ts"], { eager: true }) }` + (p2p negotiation). That is the *only* reason to pass `ops` (see `conformance.md`). - An `index.ts` barrel feeds the `actions` plugin facet. diff --git a/packages/data-ai/.claude/rules/features/services/main-service/computed.md b/packages/data-ai/.claude/rules/features/services/main-service/computed.md index 30d9ce04..92534aa2 100644 --- a/packages/data-ai/.claude/rules/features/services/main-service/computed.md +++ b/packages/data-ai/.claude/rules/features/services/main-service/computed.md @@ -47,17 +47,20 @@ reads. An `index.ts` barrel re-exports every computed; `computed-database.ts` registers it under the `computed` facet. **Conform a computed to its `data/state` derivation** whenever one exists. The -derivation co-locates `{ input, value }` cases (`Derivation`), and -`conformance/computeds.test.ts` — one auto-paired `Conformance.runComputeds({ makeDb, -store, fromState, toData, derivations, computeds, hydrate?, match? })` call — pairs -each computed to its **same-named** derivation, seeds the store from `input`, reads -the computed's synchronous emission, and `Match.assert`s it against `value` (see -`conformance.md`). There is no `define`/`conforms` wiring. Build `makeDb` from the -**`ComputedDatabase`** layer. Comparison is identity by default; a computed that -emits an **entity-id list** names itself in `hydrate: [...]` (todo's `visibleTodos`) -so the runner projects each id through `toData`. A computed with **no `state/` -derivation is skipped** — single-`data/` math is covered by that helper's own -test. +derivation co-locates `{ input, value }` cases (`Derivation`), and the +feature's single `conformance/conformance.test.ts` `Conformance.runFeature({...})` +call conforms computeds: it pulls them off **`computedPlugin.computed`**, pairs each +to its **same-named** derivation, seeds the store from `input`, reads the computed's +synchronous emission, and `Match.assert`s it against `value` (see `conformance.md`). +There is no `define`/`conforms` wiring. **`computedPlugin` is the `ComputedDatabase` +layer** — the runner builds computed conformance from that layer, not the assembled +`MainService`, so a `withCache` above can't hand back a stale pre-seed value (a +direct `fromState` seed emits no transaction to invalidate it). Comparison is +identity by default; a computed that emits an **entity-id list** names itself in +`hydrate: [...]` (todo's `visibleTodos`) so the runner projects each id through +`toData`. A computed with **no `state/` derivation is skipped** — +single-`data/` math is covered by that helper's own test. A feature with no +`state/` derivation omits `computedPlugin` entirely. **What needs conformance is proportional to wiring logic.** A computed that composes/branches over the aggregate *is* a `state/` derivation (composes ≥2 diff --git a/packages/data-ai/.claude/rules/features/services/main-service/conformance.md b/packages/data-ai/.claude/rules/features/services/main-service/conformance.md index 1bab064b..056b3d01 100644 --- a/packages/data-ai/.claude/rules/features/services/main-service/conformance.md +++ b/packages/data-ai/.claude/rules/features/services/main-service/conformance.md @@ -6,13 +6,14 @@ paths: # services/main-service/conformance/ — keeping the ECS honest against the spec Test-only (imported only by `*.test.ts`, in no facet barrel). The `data/state` -cases are the shared truth; the shared **`@adobe/data/testing`** drivers replay +cases are the shared truth; the shared **`@adobe/data/testing`** runner replays them against the ECS. This folder holds only the *feature-specific projection* -plus one thin runner test per surface. Reference: `data-lit-todo`'s -`conformance/` + its `data/state/spec.test.ts` (and `data-lit-space-rock-game` -for the entity-bag / real-time variants). +(`projection.ts`) plus one `conformance.test.ts` that makes a **single** +`Conformance.runFeature({...})` call. Reference: `data-lit-tictactoe`'s +`conformance/` (the zero-config example) and `data-lit-todo` (entity markers + +`hydrate`); `data-lit-space-rock-game` for the entity-bag / real-time variant. -**The `conformance/` projection helpers — `fromState`, `toState`, and the +**The projection helpers — `fromState`, `toState`, and the `toData(store, entity)` reader — are strictly for conformance tests and MUST NEVER run in production code, ever.** `fromState`/`toState` rewrite the whole store out-of-band; runtime code reads through observables/indexes and writes @@ -37,100 +38,145 @@ here): it honors any asymmetric matcher, so vitest's `expect.any(...)` interops. - **`Conformance`** — the case types (`Case`, `Cases`, `DerivationCase`, `DerivationCases`, `Effects`, `ServiceCall`), the `entity(specId)` identity - marker, the id `resolver(map)`, and the four runner drivers `runSpec` / - `runTransactions` / `runActions` / `runComputeds`. Auto-pairing (transition ⇄ op - by name), effect recording, and id resolution are built **into** the drivers — - no per-feature helper writes them. The `Effects` type-test also lives here (once), + marker, the id `resolver(map)`, the whole-feature driver **`runFeature`**, the + pure-spec driver **`runSpec`**, and the lower-level per-surface drivers + `runTransactions` / `runActions` / `runComputeds` (internals of `runFeature`, + exported for the escape hatch below). Auto-pairing (transition ⇄ op by name), + effect recording, and id resolution are built **into** the drivers — no + per-feature helper writes them. The `Effects` type-test also lives here (once), so there is no per-feature `conformance-case.type-test.ts` to author. ## Projection (store ⇄ State) — the one per-feature piece -Only these files are feature-specific; each is small and mechanical: +`projection.ts` is the only feature-specific file, and it is a **single +aggregated export** — the three test-only helpers in one file, one `export`: -- `create-store.ts` — a fresh writable store carrying the whole schema, built - cast-free from the lowest schema layer (`Store.create(IndexDatabase.plugin)`). -- `from-state.ts` — `fromState(store, state)` seeds a store to a `State` (clear - tail→head, insert entities, set resources). It **returns the `id → entity` - map** (`ReadonlyMap`) it built while seeding — the ECS assigns ids - from its own id-space, so the drivers turn this map into a `resolve` via +```ts +// conformance/projection.ts +export const projection = { fromState, toState, toData }; +``` + +- `fromState(store, state)` seeds a store to a `State` (clear tail→head, insert + entities, set resources). It **returns the `id → entity` map** + (`ReadonlyMap`) it built while seeding — the ECS assigns ids from + its own id-space, so the runner turns this map into a `resolve` via `Conformance.resolver`; **no feature writes id resolution by hand**. A feature whose transactions are index-addressed or singleton returns `void`, and any id then resolves to `Entity.none`. -- `to-data.ts` — **`toData(store, entity)`**: read one entity as its `data/` - value. The single place the ECS↔data mapping lives; the computed runner reuses - it to hydrate id-list computed outputs. -- `to-state.ts` — `toState(store)` reads the whole store back, built on `toData`. - -## The four runner test files — one `Conformance.run*` call each - -Each surface is a **single driver call, with no per-item wiring** — no `define` -callback, no `conforms(...)` adapters, no `registered`/`covers` coverage guards. -Each ECS runner takes the `data/state` **transitions** (or **derivations**) glob -and the ECS **ops** (a facet barrel `import * as x`, OR a directory glob -`import.meta.glob([".../ops/*.ts", "!.../index.ts"], { eager: true })` when an op -isn't registered in the facet), and **pairs them by name**: each ECS op is -conformed against the same-named transition/derivation. The driver owns the fresh -store, the `fromState` seed, `resolve` (built from the returned id map), the -`toState` compare, and effect recording. Auto-pairing can't forget an item, so -**there is no coverage guard**: an op with **no same-named transition** is -infrastructure or system-dispatched and is simply **skipped** (a streaming action -with no transition is skipped too). - -- **`data/state/spec.test.ts`** (in `data/state/`, not here) — the pure suite: - `Conformance.runSpec(import.meta.glob([...], { eager: true }), { match? })`. - Discovers every file exporting `cases`, enforces the two-exports rule, and - dispatches on case shape (transition → state + effects, derivation → - `fn(input) ≡ value`). Unwraps any `entity(specId)` arg marker to its plain - data-id for the pure side. -- **`transactions.test.ts`** — `Conformance.runTransactions({ createStore, - fromState, toState, transitions, transactions, match?, seedContext? })`. - `transitions` is the `data/state` glob; `transactions` is the facet barrel. Each - transaction pairs to its same-named transition and is conformed **state-only** - (seed `fromState(before)`, apply, `Match.assert` `toState ≡ after`) — service - effects are asserted through the action. A transaction with no same-named - transition is skipped: `dragTodo` (the drag UI op), `setInput` / `setBounds` / - `newGame` (infra, no `data/` transform), `hitAsteroid` / `loseLife` - (system-dispatched). tictactoe is the zero-config example (moves are board-index - addressed — no `entity()` markers). -- **`actions.test.ts`** — `Conformance.runActions({ makeDb, store, fromState, - toState, transitions, actions, match?, seedContext? })`. **The action is the - primary, app-facing seam**: it reads injected services from `db.services`, so the - case's service args become **recording overrides** via `makeDb(services)` — - `Database.toSystemDatabase(Database.create(MainService.plugin, { services }))`. - The driver splits the case `args` into services (wrapped for recording) and plain - input, runs the (async) action, then asserts **state and the declared - `effects`**. A same-named thin action gives a transaction-only or renamed - transition something to pair with; `actions` may be the facet barrel OR a - directory glob when the op isn't in the barrel (p2p's `movePresence` — the UI - streams via `trackPresence` — is discovered via the actions glob). A - streaming/capability action with no transition is skipped. -- **`computeds.test.ts`** — `Conformance.runComputeds({ makeDb, store, fromState, - toData, derivations, computeds, hydrate?, match? })`. Pairs each computed to its - same-named `data/state` derivation, seeds from the case `input`, reads the - computed's synchronous emission, and matches the derivation's `value`. Comparison - is **identity by default**; a computed that emits an entity-id list names itself - in **`hydrate: [...]`** (todo's `visibleTodos`) so the runner projects each id - through `toData` into the value shape the derivation yields. **Build `makeDb` from - the `ComputedDatabase` layer** - (`Database.toSystemDatabase(Database.create(ComputedDatabase.plugin))`), not the - assembled `MainService`: a behaviour layer above may `withCache` a pre-seed value - that a direct `fromState` seed emits no transaction to invalidate — the computed - layer keeps the seed authoritative. A computed with **no `state/` derivation is - skipped** — single-`data/` math (tictactoe's `winner` / `status`) is covered - by that helper's own test. A feature with no `state/` derivation ships **no - `computeds.test.ts` at all** (a test file that registers zero tests fails vitest). +- `toState(store)` reads the whole store back, built on `toData`. +- `toData(store, entity)` reads one entity as its `data/` value — the single + place the ECS↔data mapping lives; the runner reuses it to hydrate id-list + computed outputs. **Present only when the feature has entities** (omit for a + scalar / resource-only feature). + +This replaces the old separate `create-store.ts` / `from-state.ts` / +`to-state.ts` / `to-data.ts`. **`create-store` is gone** — `runFeature` builds +every store/db itself with `Store.create(plugin)` / `Database.create(plugin)`. + +## ECS conformance is ONE call — `Conformance.runFeature` + +`conformance/conformance.test.ts` is a single call that conforms the whole +feature — transaction + action + computed conformance **plus** the projection +round-trip. No per-surface test file, no `define` callback, no `conforms(...)` +adapters, no coverage guard. + +```ts +Conformance.runFeature({ + state: State, + transitions: import.meta.glob( + ["../../../data/state/*.ts", "!**/*.test.ts", "!**/*.type-test.ts"], + { eager: true }, + ), + plugin: MainService.plugin, + computedPlugin: ComputedDatabase.plugin, // omit if no state/ derivations + projection, + hydrate: ["visibleTodos"], // entity-id-list computeds; omit if none + match: { unordered: new Set(["bullets", "asteroids"]) }, // entity bags / tolerance; omit if none + ops: { actions: import.meta.glob([...]) }, // ONLY when ops aren't in the plugin facet +}); +``` + +- **`state`** is the `State` namespace itself — the runner calls `State.create()` + for the default each case's `before` deltas over, and round-trips `State.samples` + through the projection. +- **`transitions`** is the `data/state` glob (the `{ fn, cases }` source). +- **Ops come off the plugin facets.** The runner reads + `plugin.transactions` / `plugin.actions` / `computedPlugin.computed` and builds + the stores/dbs itself (`Store.create(plugin)` for transactions, + `Database.create(plugin)` for actions, `Database.create(computedPlugin)` for + computeds). Each op pairs to its **same-named** transition/derivation; auto-pairing + can't forget an item, so there is **no coverage guard** — an op with **no + same-named transition** is infrastructure or system-dispatched and is simply + **skipped** (a streaming action with no transition is skipped too). +- **`ops`** overrides the facet-discovered ops per surface — use it **only** when + an op isn't registered in the plugin facet: a per-transition action kept out of + the facet (to bound the plugin's type) is discovered via an + `ops.actions: import.meta.glob([".../actions/*.ts", "!.../actions/index.ts"], { eager: true })` + glob (p2p negotiation). Ordinary features omit `ops` entirely. +- **`computedPlugin`** is the `ComputedDatabase` **layer** plugin — build computed + conformance from this layer, **not** the assembled `MainService`: a behaviour + layer above may `withCache` a pre-seed value that a direct `fromState` seed emits + no transaction to invalidate; the computed layer keeps the seed authoritative. + Omit it when the feature has no `state/` derivations (a computed with no + derivation is skipped anyway, and single-`data/` math is covered by that + helper's own test). +- **`hydrate`** names the computeds that emit an **entity-id list** (todo's + `visibleTodos`) so the runner maps each id through `toData` into the value shape + the derivation yields. Comparison is identity otherwise. Omit if none. +- **`match`** threads `MatchOptions` through every comparison (see below). Omit if + none. +- **Projection round-trip.** When `State.samples` is non-empty, the runner adds a + `toState ∘ fromState ≡ identity` test per sample — proving the pair round-trips + faithfully so a symmetric bug in `fromState`/`toState` can't cancel out and mask + a real ECS defect. No separate `projection.test.ts`. + +`data-lit-tictactoe` is the zero-config call (no `computedPlugin`, no `hydrate`, +no `match`, no `ops` — moves are board-index addressed, so no `entity()` +markers). `data-lit-todo` adds `hydrate: ["visibleTodos"]` and `entity()` markers. +`data-lit-space-rock-game` adds `match: { unordered: … }` for its entity bags +(its per-frame transitions are conformed by the systems tick loop, not here — see +`systems.md`). + +## The pure spec — `data/state/spec.test.ts` + +Lives in `data/state/`, not here. One call: + +```ts +Conformance.runSpec( + import.meta.glob(["./*.ts", "!./*.test.ts", "!./*.type-test.ts"], { eager: true }), + { initial: State.create() }, +); +``` + +It discovers every file exporting `cases`, enforces the two-exports rule, and +dispatches on case shape (transition → state + effects, derivation → +`fn(input) ≡ value`), seeding each case's `before`/`input` as a delta over +`State.create()`. Unwraps any `entity(specId)` arg marker to its plain data-id for +the pure side. + +## The exception — a per-surface `userId`, via the lower-level runners + +A feature that needs **ambient per-case context differing per surface** — a +user-scoped `userId` that must be seeded before the raw transaction *and* +independently before the action dispatch (**p2p presence**) — does **not** use +`runFeature`. It calls `Conformance.runTransactions` / `runActions` (still +exported) directly, each with its own `seedContext` (and any per-surface +concurrency), because the seam differs between the transaction store and the +action db. `runFeature` has no place to thread two different `seedContext`s, so +this feature drops to the lower level. This is the escape hatch — ordinary +features never touch these drivers directly. ## Identity — the `entity(specId)` marker An entity-addressed transition writes its addressed id as `args: { id: entity(2) }` — import `entity`, re-exported from the feature's `data/state/conformance-case.ts`. -`runSpec` unwraps it to the plain data-id; the ECS runners resolve it to the +`runSpec` unwraps it to the plain data-id; the ECS runner resolves it to the **seeded entity** via the id→entity map `fromState` returns (turned into a `resolve` by `Conformance.resolver` — no feature writes `resolve` by hand). Two conventions make the wiring vanish: the ECS op takes the entity **under the transition's own arg key** (`{ id }`, same-shape args, no reshape), and `fromState` returns the `ReadonlyMap` id→entity map (or `void` for an index-addressed / singleton -feature, whose ids then resolve to `Entity.none`). todo is the reference. +feature, whose ids then resolve to `Entity.none`). `data-lit-todo` is the reference. ## Name-parity — add a same-named op, never a per-item adapter @@ -140,16 +186,9 @@ op is infra, and a thin **same-named** op (todo's `reorderTodo` action, space-ro `createInitial` transaction) gives the transition something to pair with. Do **not** reintroduce a per-item adapter to bridge a name mismatch — add the same-named op. -## The residual seam — `seedContext` - -The one thing not derivable from cases is ambient, user-scoped context. Pass -`seedContext?: (store|db, before, args) => void` — it runs after `fromState`, before -the op — only when a feature needs it (p2p seeds the acting peer's `userId` from the -case's `mark`). Ordinary features omit it entirely. +## Ordering, tolerance, `ref` — all via `match` -## Ordering, tolerance, `ref` — all via `Match` options - -The drivers thread a `match?: MatchOptions` through to every comparison, so +`runFeature` threads `match?: MatchOptions` through to every comparison, so per-feature tuning is data, not code: - **Ordered by default.** `toState` reads a display-ordered collection in order — @@ -171,19 +210,7 @@ per-feature tuning is data, not code: Effect recording lives in the drivers: they enumerate a plain-object service's own methods and closure-wrap each to record `[method, ...args]` calls, then delegate (no `Proxy`, per the repo rule). `runSpec` records the case's injected service -`args`; `runActions` records the `db.services` overrides. A case's `effects` +`args`; the action surface records the `db.services` overrides. A case's `effects` asserts each **declared** service's calls exactly (`Array` = ordered, `Set` = any order); undeclared services (value-returning reads like `generateName`) are ignored. - -## Structural guard - -Guard the projection with one `fromState → toState` identity test on -representative states (`projection.test.ts`), comparing with `Match.assert` and -`Match.anyNumber` ids (and the same `unordered` option when the feature has entity -bags). This proves the pair round-trips faithfully so a symmetric bug in -`fromState`/`toState` can't cancel out and mask a real ECS defect. For a -real-time feature the whole-tick equivalent lives beside the system loop -(`system-database/tick-loop.test.ts`): drive one headless frame and -`Match.assert(toState(db.store), after, { unordered })` against the shared -`step` cases (see `systems.md`). diff --git a/packages/data-ai/.claude/rules/features/services/main-service/transactions.md b/packages/data-ai/.claude/rules/features/services/main-service/transactions.md index 1992b6ac..9a8de75e 100644 --- a/packages/data-ai/.claude/rules/features/services/main-service/transactions.md +++ b/packages/data-ai/.claude/rules/features/services/main-service/transactions.md @@ -32,15 +32,14 @@ export const playMove = (t: CoreDatabase.Store, { index }: PlayMoveArgs) => { result — read the touched slice, call the `data/` transform, write the diff. - Keep transaction files **single-export** (the `transactions/` barrel is `export *`-ed into the plugin facet, so a second export would pollute it). -- **Conformance is wired once, centrally, and auto-paired** — not per-file. - `conformance/transactions.test.ts` is a single `Conformance.runTransactions({ - createStore, fromState, toState, transitions, transactions, match?, seedContext? - })` call: it discovers the `data/state` transitions (the `transitions:` glob), - pairs each registered `transactions` barrel entry to the **same-named** transition, - and conforms it — seed `fromState(before)`, apply, `Match.assert` `toState ≡ after` +- **Conformance is wired once, centrally, and auto-paired** — not per-file. The + feature's single `conformance/conformance.test.ts` `Conformance.runFeature({...})` + call conforms transactions: it pulls them off **`plugin.transactions`** (the + registered facet), pairs each to the **same-named** `data/state` transition, and + conforms it — seed `fromState(before)`, apply, `Match.assert` `toState ≡ after` (state only; service effects are asserted through the action). There is **no** `define`/`conforms` adapter and **no** `covers` guard. A transaction taking - **entity ids** takes them under the transition's own arg key (`{ id }`); the driver + **entity ids** takes them under the transition's own arg key (`{ id }`); the runner resolves each `entity(specId)` marker via the id→entity map `fromState` returns. A transaction with **no same-named transition** is infrastructure (`setInput`, `setBounds`) or the drag UI op (`dragTodo`) or system-dispatched — it is simply From 331fd638f05b506ff189be1bebbef836ccc85f0a Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 7 Aug 2026 13:42:38 -0700 Subject: [PATCH 33/37] refactor(data): runSpec takes `state` (the State namespace), like runFeature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit runSpec's options now take `{ state: State }` and read `state.create()` for the delta baseline, instead of `{ initial: State.create() }` — so both runSpec and runFeature consume the same standard-shaped State namespace. Updated all spec.test.ts calls + the rules. Co-Authored-By: Claude Sonnet 4.6 --- packages/data-ai/.claude/rules/features/data/state.md | 10 +++++----- .../features/services/main-service/conformance.md | 2 +- .../src/features/main/data/state/spec.test.ts | 2 +- .../src/features/main/data/state/spec.test.ts | 2 +- .../src/features/main/data/state/spec.test.ts | 2 +- .../src/features/negotiation/data/state/spec.test.ts | 2 +- .../src/features/main/data/state/spec.test.ts | 2 +- .../src/features/main/data/state/spec.test.ts | 2 +- packages/data/src/testing/conformance/run-spec.ts | 11 ++++++----- 9 files changed, 18 insertions(+), 17 deletions(-) diff --git a/packages/data-ai/.claude/rules/features/data/state.md b/packages/data-ai/.claude/rules/features/data/state.md index dbc8e1d9..57da298d 100644 --- a/packages/data-ai/.claude/rules/features/data/state.md +++ b/packages/data-ai/.claude/rules/features/data/state.md @@ -138,13 +138,13 @@ export const cases: Conformance = [ own arg type. `runSpec` unwraps it to the plain data-id for the pure side; the ECS runners resolve it to the seeded entity (see `conformance.md`). - No per-transform test. The single **`spec.test.ts`** is one call — - `Conformance.runSpec(import.meta.glob(["./*.ts", "!./*.test.ts", "!./*.type-test.ts"], { eager: true }), { initial: State.create() })` + `Conformance.runSpec(import.meta.glob(["./*.ts", "!./*.test.ts", "!./*.type-test.ts"], { eager: true }), { state: State })` — that auto-discovers every sibling exporting `cases`, enforces the two-exports rule, and dispatches on case shape (a `value` case → derivation; otherwise a - transition whose declared `effects` are also asserted). The `{ initial: - State.create() }` is what makes each case's `before`/`input` a delta over the - default. Add `match` alongside it only when the feature needs float tolerance or - unordered collections (see `conformance.md`). + transition whose declared `effects` are also asserted). Passing `{ state: State }` + (the same `state` shape `runFeature` takes) is what makes each case's + `before`/`input` a delta over `State.create()`. Add `match` alongside it only when + the feature needs float tolerance or unordered collections (see `conformance.md`). There is no per-feature `expect-state-matches.ts`, `record-effects.ts`, `expect-conforms.ts`, or `conformance-case.type-test.ts` — those are gone; the shared driver owns comparison, effect recording, and name-based auto-pairing, and diff --git a/packages/data-ai/.claude/rules/features/services/main-service/conformance.md b/packages/data-ai/.claude/rules/features/services/main-service/conformance.md index 056b3d01..f55c550f 100644 --- a/packages/data-ai/.claude/rules/features/services/main-service/conformance.md +++ b/packages/data-ai/.claude/rules/features/services/main-service/conformance.md @@ -144,7 +144,7 @@ Lives in `data/state/`, not here. One call: ```ts Conformance.runSpec( import.meta.glob(["./*.ts", "!./*.test.ts", "!./*.type-test.ts"], { eager: true }), - { initial: State.create() }, + { state: State }, ); ``` diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/spec.test.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/spec.test.ts index bf7aa9b7..de58e711 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/spec.test.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/spec.test.ts @@ -15,7 +15,7 @@ Conformance.runSpec( }, ), { - initial: State.create(), + state: State, match: { unordered: new Set(["bullets", "asteroids"]) }, }, ); diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/spec.test.ts b/packages/data-lit-tictactoe/src/features/main/data/state/spec.test.ts index 3d24208a..8dcff23d 100644 --- a/packages/data-lit-tictactoe/src/features/main/data/state/spec.test.ts +++ b/packages/data-lit-tictactoe/src/features/main/data/state/spec.test.ts @@ -15,5 +15,5 @@ Conformance.runSpec( eager: true, }, ), - { initial: State.create() }, + { state: State }, ); diff --git a/packages/data-lit-todo/src/features/main/data/state/spec.test.ts b/packages/data-lit-todo/src/features/main/data/state/spec.test.ts index afa7f3dd..ac56e8bc 100644 --- a/packages/data-lit-todo/src/features/main/data/state/spec.test.ts +++ b/packages/data-lit-todo/src/features/main/data/state/spec.test.ts @@ -18,5 +18,5 @@ Conformance.runSpec( eager: true, }, ), - { initial: State.create() }, + { state: State }, ); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/spec.test.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/spec.test.ts index ee3f4bf9..f6dee1c4 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/spec.test.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/spec.test.ts @@ -18,5 +18,5 @@ Conformance.runSpec( eager: true, }, ), - { initial: State.create() }, + { state: State }, ); diff --git a/packages/data-react-pixie/src/features/main/data/state/spec.test.ts b/packages/data-react-pixie/src/features/main/data/state/spec.test.ts index a1d81b29..7dcf71c9 100644 --- a/packages/data-react-pixie/src/features/main/data/state/spec.test.ts +++ b/packages/data-react-pixie/src/features/main/data/state/spec.test.ts @@ -17,5 +17,5 @@ Conformance.runSpec( eager: true, }, ), - { initial: State.create() }, + { state: State }, ); diff --git a/packages/data-solid-dashboard/src/features/main/data/state/spec.test.ts b/packages/data-solid-dashboard/src/features/main/data/state/spec.test.ts index 5244781e..b72e12f5 100644 --- a/packages/data-solid-dashboard/src/features/main/data/state/spec.test.ts +++ b/packages/data-solid-dashboard/src/features/main/data/state/spec.test.ts @@ -16,5 +16,5 @@ Conformance.runSpec( eager: true, }, ), - { initial: State.create() }, + { state: State }, ); diff --git a/packages/data/src/testing/conformance/run-spec.ts b/packages/data/src/testing/conformance/run-spec.ts index c40abe70..f07586d0 100644 --- a/packages/data/src/testing/conformance/run-spec.ts +++ b/packages/data/src/testing/conformance/run-spec.ts @@ -10,10 +10,11 @@ const isDerivationCase = (c: unknown): c is DerivationCase => typeof c === "object" && c !== null && "value" in c; export interface SpecOptions { - // The feature's default `State`. Each case's `before` is merged over it, so a - // case names only the fields it sets differently from the default. Omit it and - // cases must carry a full `before`. - readonly initial?: object; + // The feature's `State` namespace (the same `state` shape `runFeature` takes). + // Its `create()` is the default each case's `before` deltas over, so a case + // names only the fields it sets differently. Omit it and cases must carry a + // full `before`. + readonly state?: { create(): object }; // Passed through to `matches` (float tolerance, unordered collections). readonly match?: MatchOptions; // Override the `describe` label per module (default `State.`). @@ -61,7 +62,7 @@ export const runSpec = (modules: Record>, option // wrap injected services so their calls are recorded. const { args, calls } = recordArgServices(adaptArgs(tc.args)); // Case `before` is a delta over the feature default; `after` a writes patch. - const before = { ...(options.initial ?? {}), ...(tc.before as Record) }; + const before = { ...(options.state?.create() ?? {}), ...(tc.before as Record) }; const result = (await fn(before, args)) as Record; assert({ ...before, ...result }, { ...before, ...(tc.after as Record) }, options.match); expectEffects(calls, tc.effects); From e98a975c193f8e21e0d2b6ccb7af5b95b9389da0 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 7 Aug 2026 13:53:22 -0700 Subject: [PATCH 34/37] refactor(data): runSpec takes a single config object, matching runFeature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit runSpec now takes `{ state?, transitions, match?, label? }` — the glob is the named `transitions` key (not a raw positional arg) and `state` is first, so runSpec and runFeature read the same shape. Updated all spec.test.ts calls (incl. presence, which passes no `state`) and the rules. Co-Authored-By: Claude Sonnet 4.6 --- .../.claude/rules/features/data/state.md | 2 +- .../services/main-service/conformance.md | 8 +-- .../src/features/main/data/state/spec.test.ts | 22 ++++---- .../src/features/main/data/state/spec.test.ts | 19 +++---- .../src/features/main/data/state/spec.test.ts | 22 +++----- .../negotiation/data/state/spec.test.ts | 22 +++----- .../features/presence/data/state/spec.test.ts | 15 +++--- .../src/features/main/data/state/spec.test.ts | 21 +++----- .../src/features/main/data/state/spec.test.ts | 20 +++---- .../data/src/testing/conformance/public.ts | 2 +- .../data/src/testing/conformance/run-spec.ts | 54 ++++++++++++------- 11 files changed, 97 insertions(+), 110 deletions(-) diff --git a/packages/data-ai/.claude/rules/features/data/state.md b/packages/data-ai/.claude/rules/features/data/state.md index 57da298d..92e6d8ad 100644 --- a/packages/data-ai/.claude/rules/features/data/state.md +++ b/packages/data-ai/.claude/rules/features/data/state.md @@ -138,7 +138,7 @@ export const cases: Conformance = [ own arg type. `runSpec` unwraps it to the plain data-id for the pure side; the ECS runners resolve it to the seeded entity (see `conformance.md`). - No per-transform test. The single **`spec.test.ts`** is one call — - `Conformance.runSpec(import.meta.glob(["./*.ts", "!./*.test.ts", "!./*.type-test.ts"], { eager: true }), { state: State })` + `Conformance.runSpec({ state: State, transitions: import.meta.glob(["./*.ts", "!./*.test.ts", "!./*.type-test.ts"], { eager: true }) })` — that auto-discovers every sibling exporting `cases`, enforces the two-exports rule, and dispatches on case shape (a `value` case → derivation; otherwise a transition whose declared `effects` are also asserted). Passing `{ state: State }` diff --git a/packages/data-ai/.claude/rules/features/services/main-service/conformance.md b/packages/data-ai/.claude/rules/features/services/main-service/conformance.md index f55c550f..dde0b1c2 100644 --- a/packages/data-ai/.claude/rules/features/services/main-service/conformance.md +++ b/packages/data-ai/.claude/rules/features/services/main-service/conformance.md @@ -142,10 +142,10 @@ markers). `data-lit-todo` adds `hydrate: ["visibleTodos"]` and `entity()` marker Lives in `data/state/`, not here. One call: ```ts -Conformance.runSpec( - import.meta.glob(["./*.ts", "!./*.test.ts", "!./*.type-test.ts"], { eager: true }), - { state: State }, -); +Conformance.runSpec({ + state: State, + transitions: import.meta.glob(["./*.ts", "!./*.test.ts", "!./*.type-test.ts"], { eager: true }), +}); ``` It discovers every file exporting `cases`, enforces the two-exports rule, and diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/spec.test.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/spec.test.ts index de58e711..4ecff81c 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/spec.test.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/spec.test.ts @@ -4,18 +4,14 @@ import { Conformance } from "@adobe/data/testing"; import { State } from "./state.js"; // The single pure-spec test for every transform AND derivation in this folder. -// `runSpec` auto-discovers each sibling exporting `cases` and dispatches on shape. -// The entity bags (`bullets`, `asteroids`) the ecs materialises in nondeterministic -// row order compare as multisets; ordered `Vec2`s and scalars compare in order. -Conformance.runSpec( - import.meta.glob>( +// `runSpec` auto-discovers each sibling exporting `cases`, requires it to export +// exactly its function plus `cases`, and dispatches on case shape. Each case's +// `before`/`input` is a delta over `State.create()`. +Conformance.runSpec({ + state: State, + transitions: import.meta.glob( ["./*.ts", "!./*.test.ts", "!./*.type-test.ts"], - { - eager: true, - }, + { eager: true }, ), - { - state: State, - match: { unordered: new Set(["bullets", "asteroids"]) }, - }, -); + match: { unordered: new Set(["bullets", "asteroids"]) }, +}); diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/spec.test.ts b/packages/data-lit-tictactoe/src/features/main/data/state/spec.test.ts index 8dcff23d..c85cde05 100644 --- a/packages/data-lit-tictactoe/src/features/main/data/state/spec.test.ts +++ b/packages/data-lit-tictactoe/src/features/main/data/state/spec.test.ts @@ -4,16 +4,13 @@ import { Conformance } from "@adobe/data/testing"; import { State } from "./state.js"; // The single pure-spec test for every transform AND derivation in this folder. -// `runSpec` auto-discovers each sibling that exports `cases`, requires exactly its -// function plus `cases`, and dispatches on case shape (a `value` case is a -// derivation, otherwise a transition). Each case's `before` is a delta over -// `initial` (the default state), so cases carry only what they change. -Conformance.runSpec( - import.meta.glob>( +// `runSpec` auto-discovers each sibling exporting `cases`, requires it to export +// exactly its function plus `cases`, and dispatches on case shape. Each case's +// `before`/`input` is a delta over `State.create()`. +Conformance.runSpec({ + state: State, + transitions: import.meta.glob( ["./*.ts", "!./*.test.ts", "!./*.type-test.ts"], - { - eager: true, - }, + { eager: true }, ), - { state: State }, -); +}); diff --git a/packages/data-lit-todo/src/features/main/data/state/spec.test.ts b/packages/data-lit-todo/src/features/main/data/state/spec.test.ts index ac56e8bc..c85cde05 100644 --- a/packages/data-lit-todo/src/features/main/data/state/spec.test.ts +++ b/packages/data-lit-todo/src/features/main/data/state/spec.test.ts @@ -4,19 +4,13 @@ import { Conformance } from "@adobe/data/testing"; import { State } from "./state.js"; // The single pure-spec test for every transform AND derivation in this folder. -// `runSpec` auto-discovers each sibling that exports `cases`, requires it to -// export exactly its function plus `cases`, and dispatches on case shape (a -// `value` case is a derivation, otherwise a transition whose declared `effects` -// are also asserted). Each case's `before`/`input` is a delta over `initial` -// (`State.create()`), so cases carry only what they change. Todo's `State` lists -// are display-ordered, so the default (ordered, matcher-aware) comparison is -// correct — no options needed. -Conformance.runSpec( - import.meta.glob>( +// `runSpec` auto-discovers each sibling exporting `cases`, requires it to export +// exactly its function plus `cases`, and dispatches on case shape. Each case's +// `before`/`input` is a delta over `State.create()`. +Conformance.runSpec({ + state: State, + transitions: import.meta.glob( ["./*.ts", "!./*.test.ts", "!./*.type-test.ts"], - { - eager: true, - }, + { eager: true }, ), - { state: State }, -); +}); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/spec.test.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/spec.test.ts index f6dee1c4..c85cde05 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/spec.test.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/spec.test.ts @@ -4,19 +4,13 @@ import { Conformance } from "@adobe/data/testing"; import { State } from "./state.js"; // The single pure-spec test for every transform AND derivation in this folder. -// `runSpec` auto-discovers each sibling that exports `cases`, requires it to -// export exactly its function plus `cases`, and dispatches on case shape (a -// `value` case is a derivation, otherwise a transition whose declared `effects` -// are also asserted). Each case's `before` is a delta over `initial` (the default -// state), so cases carry only what they change. Negotiation's `State` is -// scalars/strings/enums, so the default (ordered, matcher-aware) comparison is -// correct — no `match` options needed. -Conformance.runSpec( - import.meta.glob>( +// `runSpec` auto-discovers each sibling exporting `cases`, requires it to export +// exactly its function plus `cases`, and dispatches on case shape. Each case's +// `before`/`input` is a delta over `State.create()`. +Conformance.runSpec({ + state: State, + transitions: import.meta.glob( ["./*.ts", "!./*.test.ts", "!./*.type-test.ts"], - { - eager: true, - }, + { eager: true }, ), - { state: State }, -); +}); diff --git a/packages/data-p2p-tictactoe/src/features/presence/data/state/spec.test.ts b/packages/data-p2p-tictactoe/src/features/presence/data/state/spec.test.ts index c92aea50..5aea4d5d 100644 --- a/packages/data-p2p-tictactoe/src/features/presence/data/state/spec.test.ts +++ b/packages/data-p2p-tictactoe/src/features/presence/data/state/spec.test.ts @@ -3,16 +3,15 @@ import { Conformance } from "@adobe/data/testing"; // The single pure-spec test for every transform AND derivation in this folder. -// `runSpec` auto-discovers each sibling that exports `cases`, requires it to -// export exactly its function plus `cases`, and dispatches on case shape (a -// `value` case is a derivation, otherwise a transition whose declared `effects` -// are also asserted). Cursor positions are `Vec2` tuples compared in order and the -// `cursors` map compares by key set, so the default comparison is correct. -Conformance.runSpec( - import.meta.glob>( +// `runSpec` auto-discovers each sibling exporting `cases`, requires it to export +// exactly its function plus `cases`, and dispatches on case shape. Presence cases +// carry a full `before`, so no `state` default is passed. Cursor positions are +// `Vec2` tuples compared in order; the `cursors` map compares by key set. +Conformance.runSpec({ + transitions: import.meta.glob>( ["./*.ts", "!./*.test.ts", "!./*.type-test.ts"], { eager: true, }, ), -); +}); diff --git a/packages/data-react-pixie/src/features/main/data/state/spec.test.ts b/packages/data-react-pixie/src/features/main/data/state/spec.test.ts index 7dcf71c9..c85cde05 100644 --- a/packages/data-react-pixie/src/features/main/data/state/spec.test.ts +++ b/packages/data-react-pixie/src/features/main/data/state/spec.test.ts @@ -4,18 +4,13 @@ import { Conformance } from "@adobe/data/testing"; import { State } from "./state.js"; // The single pure-spec test for every transform AND derivation in this folder. -// `runSpec` auto-discovers each sibling that exports `cases`, requires it to -// export exactly its function plus `cases`, and dispatches on case shape (a -// `value` case is a derivation, otherwise a transition whose declared `effects` -// are also asserted). `toState` reads sprites in insertion order matching each -// case's authored order, so the default (ordered, matcher-aware) comparison is -// correct — no options needed. -Conformance.runSpec( - import.meta.glob>( +// `runSpec` auto-discovers each sibling exporting `cases`, requires it to export +// exactly its function plus `cases`, and dispatches on case shape. Each case's +// `before`/`input` is a delta over `State.create()`. +Conformance.runSpec({ + state: State, + transitions: import.meta.glob( ["./*.ts", "!./*.test.ts", "!./*.type-test.ts"], - { - eager: true, - }, + { eager: true }, ), - { state: State }, -); +}); diff --git a/packages/data-solid-dashboard/src/features/main/data/state/spec.test.ts b/packages/data-solid-dashboard/src/features/main/data/state/spec.test.ts index b72e12f5..c85cde05 100644 --- a/packages/data-solid-dashboard/src/features/main/data/state/spec.test.ts +++ b/packages/data-solid-dashboard/src/features/main/data/state/spec.test.ts @@ -4,17 +4,13 @@ import { Conformance } from "@adobe/data/testing"; import { State } from "./state.js"; // The single pure-spec test for every transform AND derivation in this folder. -// `runSpec` auto-discovers each sibling that exports `cases`, requires it to -// export exactly its function plus `cases`, and dispatches on case shape (a -// `value` case is a derivation, otherwise a transition whose declared `effects` -// are also asserted). This feature's `State` is entirely scalar (the `log` trail -// is chronological), so the default ordered, matcher-aware comparison is correct. -Conformance.runSpec( - import.meta.glob>( +// `runSpec` auto-discovers each sibling exporting `cases`, requires it to export +// exactly its function plus `cases`, and dispatches on case shape. Each case's +// `before`/`input` is a delta over `State.create()`. +Conformance.runSpec({ + state: State, + transitions: import.meta.glob( ["./*.ts", "!./*.test.ts", "!./*.type-test.ts"], - { - eager: true, - }, + { eager: true }, ), - { state: State }, -); +}); diff --git a/packages/data/src/testing/conformance/public.ts b/packages/data/src/testing/conformance/public.ts index 7d055259..bf546e41 100644 --- a/packages/data/src/testing/conformance/public.ts +++ b/packages/data/src/testing/conformance/public.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. export type { Case, Cases, DerivationCase, DerivationCases, Effects, ServiceCall } from "./types.js"; export { entity } from "./entity-ref.js"; -export { runSpec, type SpecOptions } from "./run-spec.js"; +export { runSpec, type SpecRunConfig } from "./run-spec.js"; export { runTransactions, type TransactionRunConfig } from "./run-transactions.js"; export { runActions, type ActionRunConfig } from "./run-actions.js"; export { runComputeds, type ComputedRunConfig } from "./run-computeds.js"; diff --git a/packages/data/src/testing/conformance/run-spec.ts b/packages/data/src/testing/conformance/run-spec.ts index f07586d0..a3fe0a53 100644 --- a/packages/data/src/testing/conformance/run-spec.ts +++ b/packages/data/src/testing/conformance/run-spec.ts @@ -9,12 +9,14 @@ import type { DerivationCase, Effects } from "./types.js"; const isDerivationCase = (c: unknown): c is DerivationCase => typeof c === "object" && c !== null && "value" in c; -export interface SpecOptions { - // The feature's `State` namespace (the same `state` shape `runFeature` takes). - // Its `create()` is the default each case's `before` deltas over, so a case - // names only the fields it sets differently. Omit it and cases must carry a - // full `before`. +export interface SpecRunConfig { + // The feature's `State` namespace (the same `state` `runFeature` takes). Its + // `create()` is the default each case's `before` deltas over, so a case names + // only the fields it sets differently. Omit it and cases must carry a full `before`. readonly state?: { create(): object }; + // `import.meta.glob(["./*.ts", "!./*.test.ts", "!./*.type-test.ts"], { eager: true })` + // — the same `transitions` glob `runFeature` takes. + readonly transitions: Record>; // Passed through to `matches` (float tolerance, unordered collections). readonly match?: MatchOptions; // Override the `describe` label per module (default `State.`). @@ -22,23 +24,28 @@ export interface SpecOptions { } // The single pure-spec test for every transform AND derivation in a `data/state/` -// folder. Pass `import.meta.glob(["./*.ts", "!./*.test.ts"], { eager: true })`; it -// auto-discovers each file that exports `cases`, requires that file to export -// exactly its function plus `cases`, and dispatches on case shape: a `value` case -// checks a derivation `(state) => value`; otherwise a transition `(state, args) => -// state`, whose declared `effects` on injected services are also asserted. A -// service-injected transition is async, so results are awaited uniformly. -export const runSpec = (modules: Record>, options: SpecOptions = {}): void => { - for (const [path, module] of Object.entries(modules)) { +// folder. It auto-discovers each file that exports `cases`, requires that file to +// export exactly its function plus `cases`, and dispatches on case shape: a `value` +// case checks a derivation `(state) => value`; otherwise a transition `(state, +// args) => state`, whose declared `effects` on injected services are also asserted. +// A service-injected transition is async, so results are awaited uniformly. +export const runSpec = (config: SpecRunConfig): void => { + for (const [path, module] of Object.entries(config.transitions)) { const exportNames = Object.keys(module); if (!exportNames.includes("cases")) continue; - const functionNames = exportNames.filter((key) => typeof module[key] === "function"); + const functionNames = exportNames.filter( + (key) => typeof module[key] === "function", + ); const fnName = functionNames.length === 1 ? functionNames[0] : undefined; - const label = options.label ? options.label(path, fnName) : `State.${fnName ?? path}`; + const label = config.label + ? config.label(path, fnName) + : `State.${fnName ?? path}`; describe(label, () => { if (exportNames.length !== 2 || functionNames.length !== 1) { it("exports exactly its function and `cases`", () => { - throw new Error(`${path} exports [${exportNames.join(", ")}] — expected one function + cases`); + throw new Error( + `${path} exports [${exportNames.join(", ")}] — expected one function + cases`, + ); }); return; } @@ -47,7 +54,9 @@ export const runSpec = (modules: Record>, option const cases = module["cases"] as readonly unknown[]; for (const testCase of cases) { if (isDerivationCase(testCase)) { - it(testCase.name, () => assert(fn(testCase.input), testCase.value, options.match)); + it(testCase.name, () => + assert(fn(testCase.input), testCase.value, config.match), + ); continue; } const tc = testCase as { @@ -62,9 +71,16 @@ export const runSpec = (modules: Record>, option // wrap injected services so their calls are recorded. const { args, calls } = recordArgServices(adaptArgs(tc.args)); // Case `before` is a delta over the feature default; `after` a writes patch. - const before = { ...(options.state?.create() ?? {}), ...(tc.before as Record) }; + const before = { + ...(config.state?.create() ?? {}), + ...(tc.before as Record), + }; const result = (await fn(before, args)) as Record; - assert({ ...before, ...result }, { ...before, ...(tc.after as Record) }, options.match); + assert( + { ...before, ...result }, + { ...before, ...(tc.after as Record) }, + config.match, + ); expectEffects(calls, tc.effects); }); } From 7c01f84132fe38009aea6a19542dc3e29b08327e Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 7 Aug 2026 14:58:58 -0700 Subject: [PATCH 35/37] refactor(samples): extract per-feature transitions.ts; migrate data-gpu-hopper to @adobe/data/testing Extract the conformance transition glob into one test-only data/state/transitions.ts per feature, imported by both spec.test.ts and conformance.test.ts (kept off the State namespace so import.meta.glob + case fixtures never enter production imports). Bring presence to the standard shape (State.samples) and drop its inlined states. Migrate data-gpu-hopper off the pre-toolkit pattern (per-transform .cases.ts + .test.ts, local expect-state-matches/expect-conforms, create-store/from-state/ to-state) to @adobe/data/testing: co-located cases, single runSpec + runFeature calls, aggregate projection.ts, samples.ts. Update the data-ai rules to show the transitions.ts pattern. Co-Authored-By: Claude Opus 4.8 --- .../.claude/rules/features/data/state.md | 34 ++++- .../services/main-service/conformance.md | 22 +-- .../main/data/state/conformance-case.ts | 20 +-- .../main/data/state/expect-state-matches.ts | 35 ----- .../src/features/main/data/state/hop.cases.ts | 88 ------------ .../src/features/main/data/state/hop.test.ts | 13 -- .../src/features/main/data/state/hop.ts | 52 +++++++ .../main/data/state/lose-life.cases.ts | 37 ----- .../src/features/main/data/state/lose-life.ts | 25 ++++ .../main/data/state/new-game.cases.ts | 29 ---- .../src/features/main/data/state/new-game.ts | 35 +++++ .../src/features/main/data/state/public.ts | 2 + .../src/features/main/data/state/samples.ts | 41 ++++++ .../src/features/main/data/state/spec.test.ts | 15 ++ .../features/main/data/state/step.cases.ts | 136 ------------------ .../src/features/main/data/state/step.test.ts | 13 -- .../src/features/main/data/state/step.ts | 87 +++++++++++ .../features/main/data/state/transitions.ts | 11 ++ .../main/data/state/win-goal.cases.ts | 30 ---- .../src/features/main/data/state/win-goal.ts | 22 +++ .../conformance/conformance.test.ts | 22 +++ .../main-service/conformance/create-store.ts | 10 -- .../conformance/expect-conforms.ts | 42 ------ .../main-service/conformance/from-state.ts | 30 ---- .../conformance/projection.test.ts | 64 --------- .../main-service/conformance/projection.ts | 78 ++++++++++ .../main-service/conformance/to-state.ts | 45 ------ .../system-database/outcome-selection.test.ts | 7 +- .../system-database/tick-loop.test.ts | 24 ++-- .../transactions/hop.test.ts | 19 --- .../transactions/lose-life.test.ts | 17 --- .../transactions/new-game.test.ts | 18 --- .../transactions/win-goal.test.ts | 17 --- .../src/features/main/data/state/spec.test.ts | 13 +- .../features/main/data/state/transitions.ts | 11 ++ .../conformance/conformance.test.ts | 12 +- .../src/features/main/data/state/spec.test.ts | 14 +- .../features/main/data/state/transitions.ts | 11 ++ .../conformance/conformance.test.ts | 10 +- .../src/features/main/data/state/spec.test.ts | 16 +-- .../features/main/data/state/transitions.ts | 11 ++ .../conformance/conformance.test.ts | 8 +- .../negotiation/data/state/spec.test.ts | 16 +-- .../negotiation/data/state/transitions.ts | 11 ++ .../conformance/conformance.test.ts | 7 +- .../features/presence/data/state/public.ts | 1 + .../features/presence/data/state/samples.ts | 13 ++ .../features/presence/data/state/spec.test.ts | 19 +-- .../presence/data/state/transitions.ts | 11 ++ .../main-service/conformance/actions.test.ts | 10 +- .../conformance/projection.test.ts | 24 +--- .../conformance/transactions.test.ts | 11 +- .../src/features/main/data/state/spec.test.ts | 16 +-- .../features/main/data/state/transitions.ts | 11 ++ .../conformance/conformance.test.ts | 10 +- .../src/features/main/data/state/spec.test.ts | 16 +-- .../features/main/data/state/transitions.ts | 11 ++ .../conformance/conformance.test.ts | 10 +- 58 files changed, 614 insertions(+), 829 deletions(-) delete mode 100644 packages/data-gpu-hopper/src/features/main/data/state/expect-state-matches.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/data/state/hop.cases.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/data/state/hop.test.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/data/state/lose-life.cases.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/data/state/new-game.cases.ts create mode 100644 packages/data-gpu-hopper/src/features/main/data/state/new-game.ts create mode 100644 packages/data-gpu-hopper/src/features/main/data/state/samples.ts create mode 100644 packages/data-gpu-hopper/src/features/main/data/state/spec.test.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/data/state/step.cases.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/data/state/step.test.ts create mode 100644 packages/data-gpu-hopper/src/features/main/data/state/transitions.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/data/state/win-goal.cases.ts create mode 100644 packages/data-gpu-hopper/src/features/main/services/main-service/conformance/conformance.test.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/services/main-service/conformance/create-store.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/services/main-service/conformance/expect-conforms.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/services/main-service/conformance/from-state.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/services/main-service/conformance/projection.test.ts create mode 100644 packages/data-gpu-hopper/src/features/main/services/main-service/conformance/projection.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/services/main-service/conformance/to-state.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transactions/hop.test.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transactions/lose-life.test.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transactions/new-game.test.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transactions/win-goal.test.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/transitions.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/data/state/transitions.ts create mode 100644 packages/data-lit-todo/src/features/main/data/state/transitions.ts create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/data/state/transitions.ts create mode 100644 packages/data-p2p-tictactoe/src/features/presence/data/state/samples.ts create mode 100644 packages/data-p2p-tictactoe/src/features/presence/data/state/transitions.ts create mode 100644 packages/data-react-pixie/src/features/main/data/state/transitions.ts create mode 100644 packages/data-solid-dashboard/src/features/main/data/state/transitions.ts diff --git a/packages/data-ai/.claude/rules/features/data/state.md b/packages/data-ai/.claude/rules/features/data/state.md index 92e6d8ad..3ac88c94 100644 --- a/packages/data-ai/.claude/rules/features/data/state.md +++ b/packages/data-ai/.claude/rules/features/data/state.md @@ -32,6 +32,27 @@ Both are re-exported through `public.js`, so `State.create()` / `State.samples` are namespace members. (A `cases` literal must still not touch `public.js` at load — import `create` from `./create.js` directly there; see below.) +**The discovered transitions are a test-only sibling, NOT on the namespace.** +`data/state/transitions.ts` exports one `import.meta.glob` of the folder — the +`{ fn, cases }` modules — imported by *both* `spec.test.ts` and the ecs +`conformance.test.ts` so the glob is authored once per feature: + +```ts +// data/state/transitions.ts — test-only; both test entry points import it +export const transitions = import.meta.glob>( + ["./*.ts", "!./*.test.ts", "!./*.type-test.ts", "!./transitions.ts"], + { eager: true }, +); +``` + +It stays **out of `public.js`** deliberately: unlike `create`/`samples` (plain +prod-safe values), `transitions` is the test-case graph — hanging it on `State` +would drag `import.meta.glob` (a Vite-only construct) and every `cases` fixture +(fake services, matchers, the whole `@adobe/data/testing` module) into every +production import of `State`, none of which tree-shakes off a live namespace +re-export. So `create`/`samples` earn a namespace slot; `transitions` is a test +concern the tests import directly. + ## One file per transform: the function **and** its cases A transform file exports **exactly two things** — the function and its @@ -138,12 +159,13 @@ export const cases: Conformance = [ own arg type. `runSpec` unwraps it to the plain data-id for the pure side; the ECS runners resolve it to the seeded entity (see `conformance.md`). - No per-transform test. The single **`spec.test.ts`** is one call — - `Conformance.runSpec({ state: State, transitions: import.meta.glob(["./*.ts", "!./*.test.ts", "!./*.type-test.ts"], { eager: true }) })` - — that auto-discovers every sibling exporting `cases`, enforces the two-exports - rule, and dispatches on case shape (a `value` case → derivation; otherwise a - transition whose declared `effects` are also asserted). Passing `{ state: State }` - (the same `state` shape `runFeature` takes) is what makes each case's - `before`/`input` a delta over `State.create()`. Add `match` alongside it only when + `Conformance.runSpec({ state: State, transitions })` importing `transitions` + from the test-only `./transitions.js` (above) — that auto-discovers every module + exporting `cases`, enforces the two-exports rule, and dispatches on case shape (a + `value` case → derivation; otherwise a transition whose declared `effects` are + also asserted). Passing `{ state: State }` (the same `state` shape `runFeature` + takes) is what makes each case's `before`/`input` a delta over `State.create()`. + Add `match` alongside it only when the feature needs float tolerance or unordered collections (see `conformance.md`). There is no per-feature `expect-state-matches.ts`, `record-effects.ts`, `expect-conforms.ts`, or `conformance-case.type-test.ts` — those are gone; the diff --git a/packages/data-ai/.claude/rules/features/services/main-service/conformance.md b/packages/data-ai/.claude/rules/features/services/main-service/conformance.md index dde0b1c2..2a22b975 100644 --- a/packages/data-ai/.claude/rules/features/services/main-service/conformance.md +++ b/packages/data-ai/.claude/rules/features/services/main-service/conformance.md @@ -81,12 +81,12 @@ round-trip. No per-surface test file, no `define` callback, no `conforms(...)` adapters, no coverage guard. ```ts +import { State } from "../../../data/state/state.js"; +import { transitions } from "../../../data/state/transitions.js"; + Conformance.runFeature({ state: State, - transitions: import.meta.glob( - ["../../../data/state/*.ts", "!**/*.test.ts", "!**/*.type-test.ts"], - { eager: true }, - ), + transitions, plugin: MainService.plugin, computedPlugin: ComputedDatabase.plugin, // omit if no state/ derivations projection, @@ -99,7 +99,9 @@ Conformance.runFeature({ - **`state`** is the `State` namespace itself — the runner calls `State.create()` for the default each case's `before` deltas over, and round-trips `State.samples` through the projection. -- **`transitions`** is the `data/state` glob (the `{ fn, cases }` source). +- **`transitions`** is imported from the feature's test-only + `data/state/transitions.ts` (the single `import.meta.glob` of the `{ fn, cases }` + source, shared with `spec.test.ts` — see `data/state.md`). Never re-glob it here. - **Ops come off the plugin facets.** The runner reads `plugin.transactions` / `plugin.actions` / `computedPlugin.computed` and builds the stores/dbs itself (`Store.create(plugin)` for transactions, @@ -139,13 +141,13 @@ markers). `data-lit-todo` adds `hydrate: ["visibleTodos"]` and `entity()` marker ## The pure spec — `data/state/spec.test.ts` -Lives in `data/state/`, not here. One call: +Lives in `data/state/`, not here. One call, importing the same `transitions`: ```ts -Conformance.runSpec({ - state: State, - transitions: import.meta.glob(["./*.ts", "!./*.test.ts", "!./*.type-test.ts"], { eager: true }), -}); +import { State } from "./state.js"; +import { transitions } from "./transitions.js"; + +Conformance.runSpec({ state: State, transitions }); ``` It discovers every file exporting `cases`, enforces the two-exports rule, and diff --git a/packages/data-gpu-hopper/src/features/main/data/state/conformance-case.ts b/packages/data-gpu-hopper/src/features/main/data/state/conformance-case.ts index 44e5a8a5..4426eca4 100644 --- a/packages/data-gpu-hopper/src/features/main/data/state/conformance-case.ts +++ b/packages/data-gpu-hopper/src/features/main/data/state/conformance-case.ts @@ -1,13 +1,13 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance as ConformanceApi } from "@adobe/data/testing"; import type { State } from "./state.js"; -// One spec-owned conformance case: a `data/` transform's `{ before, args, after }` -// authored as full `State`. Lives in `.cases.ts` and is shared, -// unchanged, by the data transform test and the ecs conformance runner -// (see `ecs/conformance/`). -export type ConformanceCase = { - readonly name: string; - readonly before: State; - readonly args: Args; - readonly after: State; -}; +// The conformance case types for this feature — the shared `@adobe/data/testing` +// machinery with `State` bound once, so a transform authors `Conformance` +// and a derivation `Derivation` (args/input/value read from the +// function's own signature). This tiny file is the only per-feature conformance +// type declaration; everything else lives in `@adobe/data/testing`. +export type Conformance unknown> = + ConformanceApi.Cases; +export type Derivation unknown> = + ConformanceApi.DerivationCases; diff --git a/packages/data-gpu-hopper/src/features/main/data/state/expect-state-matches.ts b/packages/data-gpu-hopper/src/features/main/data/state/expect-state-matches.ts deleted file mode 100644 index 2575a2dd..00000000 --- a/packages/data-gpu-hopper/src/features/main/data/state/expect-state-matches.ts +++ /dev/null @@ -1,35 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { expect } from "vitest"; -import { equalsUnordered } from "@adobe/data"; -import type { State } from "./state.js"; - -// Spec-owned tolerant `State` equality, shared by the data/ transform tests and -// the ecs conformance runner. Two orthogonal concerns, kept separate: -// -// precision — normalise every number on both sides onto a shared grid, so a -// value that differs only by F32↔f64 storage rounding compares equal. -// `Math.fround` collapses the F32 rounding; rounding to 1e-2 collapses any -// residual epsilon. `+ 0` normalises `-0` to `0`. (The frog's continuous -// position is a float, so this matters once the ecs stores it as F32.) -// ordering — `equalsUnordered` compares arrays as MULTISETS (archetype -// hole-fills make row order nondeterministic) and is object key-order -// independent. -const quantize = (n: number): number => Math.round(Math.fround(n) * 100) / 100 + 0; - -const normalize = (value: unknown): unknown => { - if (typeof value === "number") return quantize(value); - if (Array.isArray(value)) return value.map(normalize); - if (value !== null && typeof value === "object") { - return Object.fromEntries(Object.entries(value).map(([key, v]) => [key, normalize(v)])); - } - return value; -}; - -export const expectStateMatches = (actual: State, expected: State): void => { - const a = normalize(actual); - const b = normalize(expected); - expect( - equalsUnordered(a, b), - `State mismatch:\n actual ${JSON.stringify(a)}\n expected ${JSON.stringify(b)}`, - ).toBe(true); -}; diff --git a/packages/data-gpu-hopper/src/features/main/data/state/hop.cases.ts b/packages/data-gpu-hopper/src/features/main/data/state/hop.cases.ts deleted file mode 100644 index b07b6d7c..00000000 --- a/packages/data-gpu-hopper/src/features/main/data/state/hop.cases.ts +++ /dev/null @@ -1,88 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Direction } from "../direction/direction.js"; -import type { State } from "./state.js"; -import type { ConformanceCase } from "./conformance-case.js"; - -// A bare 5-wide, 3-tall board. `hop` reads only frog / width / height / status, -// so the lanes and hazards are irrelevant and left empty here. -const base: Omit = { - width: 5, - height: 3, - lanes: [], - hazards: [], - lives: 3, - score: 0, - status: "playing", -}; - -// Spec-owned `{ before, args, after }` cases for `State.hop`, shared with the ecs -// hop transaction. Covers each direction, clamping at all four board edges, the -// grid-snap of a log-ridden fractional x, and the game-over no-op. -export const cases: readonly ConformanceCase[] = [ - { - name: "hops up toward the goal", - before: { ...base, frog: { x: 2, y: 0 } }, - args: "up", - after: { ...base, frog: { x: 2, y: 1 } }, - }, - { - name: "hops down toward the start", - before: { ...base, frog: { x: 2, y: 1 } }, - args: "down", - after: { ...base, frog: { x: 2, y: 0 } }, - }, - { - name: "hops left", - before: { ...base, frog: { x: 2, y: 1 } }, - args: "left", - after: { ...base, frog: { x: 1, y: 1 } }, - }, - { - name: "hops right", - before: { ...base, frog: { x: 2, y: 1 } }, - args: "right", - after: { ...base, frog: { x: 3, y: 1 } }, - }, - { - name: "clamps at the bottom row", - before: { ...base, frog: { x: 2, y: 0 } }, - args: "down", - after: { ...base, frog: { x: 2, y: 0 } }, - }, - { - name: "clamps at the top (goal) row", - before: { ...base, frog: { x: 2, y: 2 } }, - args: "up", - after: { ...base, frog: { x: 2, y: 2 } }, - }, - { - name: "clamps at the left edge", - before: { ...base, frog: { x: 0, y: 1 } }, - args: "left", - after: { ...base, frog: { x: 0, y: 1 } }, - }, - { - name: "clamps at the right edge", - before: { ...base, frog: { x: 4, y: 1 } }, - args: "right", - after: { ...base, frog: { x: 4, y: 1 } }, - }, - { - name: "snaps a log-ridden fractional x while hopping sideways", - before: { ...base, frog: { x: 2.4, y: 1 } }, - args: "right", - after: { ...base, frog: { x: 3, y: 1 } }, - }, - { - name: "snaps a log-ridden fractional x while hopping forward", - before: { ...base, frog: { x: 2.6, y: 1 } }, - args: "up", - after: { ...base, frog: { x: 3, y: 2 } }, - }, - { - name: "ignores input once the game is over", - before: { ...base, status: "gameOver", frog: { x: 2, y: 1 } }, - args: "up", - after: { ...base, status: "gameOver", frog: { x: 2, y: 1 } }, - }, -]; diff --git a/packages/data-gpu-hopper/src/features/main/data/state/hop.test.ts b/packages/data-gpu-hopper/src/features/main/data/state/hop.test.ts deleted file mode 100644 index aae5de25..00000000 --- a/packages/data-gpu-hopper/src/features/main/data/state/hop.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it } from "vitest"; -import { State } from "./state.js"; -import { cases } from "./hop.cases.js"; -import { expectStateMatches } from "./expect-state-matches.js"; - -describe("State.hop", () => { - for (const { name, before, args, after } of cases) { - it(name, () => { - expectStateMatches(State.hop(before, args), after); - }); - } -}); diff --git a/packages/data-gpu-hopper/src/features/main/data/state/hop.ts b/packages/data-gpu-hopper/src/features/main/data/state/hop.ts index abd43962..8068d241 100644 --- a/packages/data-gpu-hopper/src/features/main/data/state/hop.ts +++ b/packages/data-gpu-hopper/src/features/main/data/state/hop.ts @@ -2,6 +2,7 @@ import { Direction } from "../direction/direction.js"; import { GameStatus } from "../game-status/game-status.js"; import type { State } from "./state.js"; +import type { Conformance } from "./conformance-case.js"; const clamp = (value: number, max: number): number => Math.max(0, Math.min(max, value)); @@ -22,3 +23,54 @@ export const hop = = { + width: 5, + height: 3, + lanes: [], + hazards: [], + lives: 3, + score: 0, + status: "playing", +}; + +// Spec-owned cases, shared with the ecs `hop` transaction. Covers each direction, +// clamping at all four board edges, the grid-snap of a log-ridden fractional x, +// and the game-over no-op. +export const cases: Conformance = [ + { name: "hops up toward the goal", + before: { ...base, frog: { x: 2, y: 0 } }, args: "up", + after: { ...base, frog: { x: 2, y: 1 } } }, + { name: "hops down toward the start", + before: { ...base, frog: { x: 2, y: 1 } }, args: "down", + after: { ...base, frog: { x: 2, y: 0 } } }, + { name: "hops left", + before: { ...base, frog: { x: 2, y: 1 } }, args: "left", + after: { ...base, frog: { x: 1, y: 1 } } }, + { name: "hops right", + before: { ...base, frog: { x: 2, y: 1 } }, args: "right", + after: { ...base, frog: { x: 3, y: 1 } } }, + { name: "clamps at the bottom row", + before: { ...base, frog: { x: 2, y: 0 } }, args: "down", + after: { ...base, frog: { x: 2, y: 0 } } }, + { name: "clamps at the top (goal) row", + before: { ...base, frog: { x: 2, y: 2 } }, args: "up", + after: { ...base, frog: { x: 2, y: 2 } } }, + { name: "clamps at the left edge", + before: { ...base, frog: { x: 0, y: 1 } }, args: "left", + after: { ...base, frog: { x: 0, y: 1 } } }, + { name: "clamps at the right edge", + before: { ...base, frog: { x: 4, y: 1 } }, args: "right", + after: { ...base, frog: { x: 4, y: 1 } } }, + { name: "snaps a log-ridden fractional x while hopping sideways", + before: { ...base, frog: { x: 2.4, y: 1 } }, args: "right", + after: { ...base, frog: { x: 3, y: 1 } } }, + { name: "snaps a log-ridden fractional x while hopping forward", + before: { ...base, frog: { x: 2.6, y: 1 } }, args: "up", + after: { ...base, frog: { x: 3, y: 2 } } }, + { name: "ignores input once the game is over", + before: { ...base, status: "gameOver", frog: { x: 2, y: 1 } }, args: "up", + after: { ...base, status: "gameOver", frog: { x: 2, y: 1 } } }, +]; diff --git a/packages/data-gpu-hopper/src/features/main/data/state/lose-life.cases.ts b/packages/data-gpu-hopper/src/features/main/data/state/lose-life.cases.ts deleted file mode 100644 index 0b774538..00000000 --- a/packages/data-gpu-hopper/src/features/main/data/state/lose-life.cases.ts +++ /dev/null @@ -1,37 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { State } from "./state.js"; -import type { ConformanceCase } from "./conformance-case.js"; - -// A 5-wide board, so the respawn column is `floor((5-1)/2) = 2`. loseLife reads -// only lives / status / frog / width; the rest is inert here. -const base: Omit = { - width: 5, - height: 3, - lanes: [], - hazards: [], - score: 0, -}; - -// Spec-owned `{ before, args, after }` cases for `State.loseLife`, shared with the -// ecs `loseLife` transaction: a life lost + respawn, the last life ending the -// game (no respawn), and the finished-game no-op. -export const cases: readonly ConformanceCase[] = [ - { - name: "spends a life and respawns the frog at the start", - before: { ...base, lives: 3, status: "playing", frog: { x: 1, y: 1 } }, - args: undefined, - after: { ...base, lives: 2, status: "playing", frog: { x: 2, y: 0 } }, - }, - { - name: "the last life ends the game without respawning", - before: { ...base, lives: 1, status: "playing", frog: { x: 3, y: 2 } }, - args: undefined, - after: { ...base, lives: 0, status: "gameOver", frog: { x: 3, y: 2 } }, - }, - { - name: "ignores a finished game (no-op)", - before: { ...base, lives: 0, status: "gameOver", frog: { x: 3, y: 2 } }, - args: undefined, - after: { ...base, lives: 0, status: "gameOver", frog: { x: 3, y: 2 } }, - }, -]; diff --git a/packages/data-gpu-hopper/src/features/main/data/state/lose-life.ts b/packages/data-gpu-hopper/src/features/main/data/state/lose-life.ts index 33a98178..faa0a2b9 100644 --- a/packages/data-gpu-hopper/src/features/main/data/state/lose-life.ts +++ b/packages/data-gpu-hopper/src/features/main/data/state/lose-life.ts @@ -1,6 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { GameStatus } from "../game-status/game-status.js"; import type { State } from "./state.js"; +import type { Conformance } from "./conformance-case.js"; import { startPosition } from "./start-position.js"; // Spend one life: respawn the frog at the start, or end the game if that was the @@ -14,3 +15,27 @@ export const loseLife = = { + width: 5, + height: 3, + lanes: [], + hazards: [], + score: 0, +}; + +// Spec-owned cases, shared with the ecs `loseLife` transaction: a life lost + +// respawn, the last life ending the game (no respawn), and the finished-game no-op. +export const cases: Conformance = [ + { name: "spends a life and respawns the frog at the start", + before: { ...base, lives: 3, status: "playing", frog: { x: 1, y: 1 } }, + after: { ...base, lives: 2, status: "playing", frog: { x: 2, y: 0 } } }, + { name: "the last life ends the game without respawning", + before: { ...base, lives: 1, status: "playing", frog: { x: 3, y: 2 } }, + after: { ...base, lives: 0, status: "gameOver", frog: { x: 3, y: 2 } } }, + { name: "ignores a finished game (no-op)", + before: { ...base, lives: 0, status: "gameOver", frog: { x: 3, y: 2 } }, + after: { ...base, lives: 0, status: "gameOver", frog: { x: 3, y: 2 } } }, +]; diff --git a/packages/data-gpu-hopper/src/features/main/data/state/new-game.cases.ts b/packages/data-gpu-hopper/src/features/main/data/state/new-game.cases.ts deleted file mode 100644 index 7afbecbd..00000000 --- a/packages/data-gpu-hopper/src/features/main/data/state/new-game.cases.ts +++ /dev/null @@ -1,29 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { State } from "./state.js"; -import type { ConformanceCase } from "./conformance-case.js"; - -// `newGame` ignores the prior state and resets to `State.create()`. The `before` -// here differs in every field (dimensions, terrain, hazards, frog, lives, score, -// status) so the case proves the reset is total. Shared with the ecs `newGame` -// transaction, whose spec is `State.create`. -export const cases: readonly ConformanceCase[] = [ - { - name: "resets a mid-game store to the initial game", - before: { - width: 3, - height: 3, - lanes: [ - { row: 0, kind: "grass" }, - { row: 1, kind: "river" }, - { row: 2, kind: "goal" }, - ], - hazards: [{ kind: "log", lane: 1, x: 0, width: 2, velocity: 1 }], - frog: { x: 1, y: 2 }, - lives: 0, - score: 7, - status: "gameOver", - }, - args: undefined, - after: State.create(), - }, -]; diff --git a/packages/data-gpu-hopper/src/features/main/data/state/new-game.ts b/packages/data-gpu-hopper/src/features/main/data/state/new-game.ts new file mode 100644 index 00000000..19a60629 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/state/new-game.ts @@ -0,0 +1,35 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { State } from "./state.js"; +import type { Conformance } from "./conformance-case.js"; +import { create } from "./create.js"; + +// A "new game" TRANSITION: it deliberately **ignores** the prior `state` and +// produces the initial game (see `create`), which is exactly what the ecs +// `newGame` transaction it maps to does (it clears whatever was there). The prior +// state is still the first parameter so it fits the `(state, args) => state` shape +// the co-located conformance cases derive from. +export const newGame = (_state: State): State => create(); + +// Spec-owned cases, shared with the ecs `newGame` transaction. `before` is a +// fully-divergent mid-run state (dimensions, terrain, hazards, frog, lives, score, +// status all differ) so the reset is proven total. +export const cases: Conformance = [ + { + name: "resets a mid-game store to the initial game", + before: { + width: 3, + height: 3, + lanes: [ + { row: 0, kind: "grass" }, + { row: 1, kind: "river" }, + { row: 2, kind: "goal" }, + ], + hazards: [{ kind: "log", lane: 1, x: 0, width: 2, velocity: 1 }], + frog: { x: 1, y: 2 }, + lives: 0, + score: 7, + status: "gameOver", + }, + after: create(), + }, +]; diff --git a/packages/data-gpu-hopper/src/features/main/data/state/public.ts b/packages/data-gpu-hopper/src/features/main/data/state/public.ts index f517d7e0..091178c5 100644 --- a/packages/data-gpu-hopper/src/features/main/data/state/public.ts +++ b/packages/data-gpu-hopper/src/features/main/data/state/public.ts @@ -1,9 +1,11 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. export { create } from "./create.js"; +export { samples } from "./samples.js"; export { hop } from "./hop.js"; export { step } from "./step.js"; export { winGoal } from "./win-goal.js"; export { loseLife } from "./lose-life.js"; +export { newGame } from "./new-game.js"; export { startPosition } from "./start-position.js"; export { laneAt } from "./lane-at.js"; export { frogOutcome } from "./frog-outcome.js"; diff --git a/packages/data-gpu-hopper/src/features/main/data/state/samples.ts b/packages/data-gpu-hopper/src/features/main/data/state/samples.ts new file mode 100644 index 00000000..966ba282 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/state/samples.ts @@ -0,0 +1,41 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { State } from "./state.js"; +import { create } from "./create.js"; + +// Representative full states for the projection round-trip (toState ∘ fromState ≡ +// identity). The initial game, a mid-run state with a fractional log-carried frog +// and depleted lives, and a minimal empty board — together exercising the whole +// ecs↔State map (resources, the frog entity, and the hazard bag). +export const samples: readonly State[] = [ + create(), + { + width: 5, + height: 3, + lanes: [ + { row: 0, kind: "grass" }, + { row: 1, kind: "river" }, + { row: 2, kind: "goal" }, + ], + hazards: [ + { kind: "log", lane: 1, x: 1.5, width: 3, velocity: 1 }, + { kind: "log", lane: 1, x: 4, width: 2, velocity: 1 }, + ], + frog: { x: 2.5, y: 1 }, + lives: 1, + score: 4, + status: "playing", + }, + { + width: 4, + height: 2, + lanes: [ + { row: 0, kind: "grass" }, + { row: 1, kind: "goal" }, + ], + hazards: [], + frog: { x: 1, y: 0 }, + lives: 3, + score: 0, + status: "playing", + }, +]; diff --git a/packages/data-gpu-hopper/src/features/main/data/state/spec.test.ts b/packages/data-gpu-hopper/src/features/main/data/state/spec.test.ts new file mode 100644 index 00000000..284c732c --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/state/spec.test.ts @@ -0,0 +1,15 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Conformance } from "@adobe/data/testing"; +import { State } from "./state.js"; +import { transitions } from "./transitions.js"; + +// The single pure-spec test for every transform in this folder. `runSpec` +// auto-discovers each module in `transitions` that exports `cases`, requires it to +// export exactly its function plus `cases`, and dispatches on case shape. The +// hazard bag the ecs materialises in nondeterministic row order compares as a +// multiset via `match.unordered`. +Conformance.runSpec({ + state: State, + transitions, + match: { unordered: new Set(["hazards"]) }, +}); diff --git a/packages/data-gpu-hopper/src/features/main/data/state/step.cases.ts b/packages/data-gpu-hopper/src/features/main/data/state/step.cases.ts deleted file mode 100644 index a22c432e..00000000 --- a/packages/data-gpu-hopper/src/features/main/data/state/step.cases.ts +++ /dev/null @@ -1,136 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Lane } from "../lane/lane.js"; -import type { ConformanceCase } from "./conformance-case.js"; - -// Two 5-wide, 3-tall boards differing only in the middle lane's terrain. -const roadLanes: readonly Lane[] = [ - { row: 0, kind: "grass" }, - { row: 1, kind: "road" }, - { row: 2, kind: "goal" }, -]; -const riverLanes: readonly Lane[] = [ - { row: 0, kind: "grass" }, - { row: 1, kind: "river" }, - { row: 2, kind: "goal" }, -]; - -// Spec-owned `{ before, args, after }` cases for `State.step` (args is dt), shared -// with the ecs tick. Every step here uses dt = 1 so hazard/carry displacements are -// exact. Covers: hazards scrolling while the frog stays safe, a car hit (life lost -// + respawn), the final-life game over, drowning over open water, riding a log, -// being carried off the board edge (with a wrapping log), reaching the goal, and -// the game-over no-op. -export const cases: readonly ConformanceCase[] = [ - { - name: "scrolls hazards while the frog rests on grass", - before: { - width: 5, height: 3, lanes: roadLanes, - hazards: [{ kind:"car", lane: 1, x: 0, width: 1, velocity: 1 }], - frog: { x: 2, y: 0 }, lives: 3, score: 0, status: "playing", - }, - args: 1, - after: { - width: 5, height: 3, lanes: roadLanes, - hazards: [{ kind:"car", lane: 1, x: 1, width: 1, velocity: 1 }], - frog: { x: 2, y: 0 }, lives: 3, score: 0, status: "playing", - }, - }, - { - name: "a car reaching the frog costs a life and respawns it", - before: { - width: 5, height: 3, lanes: roadLanes, - hazards: [{ kind:"car", lane: 1, x: 0, width: 1, velocity: 1 }], - frog: { x: 1, y: 1 }, lives: 3, score: 0, status: "playing", - }, - args: 1, - after: { - width: 5, height: 3, lanes: roadLanes, - hazards: [{ kind:"car", lane: 1, x: 1, width: 1, velocity: 1 }], - frog: { x: 2, y: 0 }, lives: 2, score: 0, status: "playing", - }, - }, - { - name: "a car hit on the last life ends the game", - before: { - width: 5, height: 3, lanes: roadLanes, - hazards: [{ kind:"car", lane: 1, x: 0, width: 1, velocity: 1 }], - frog: { x: 1, y: 1 }, lives: 1, score: 0, status: "playing", - }, - args: 1, - after: { - width: 5, height: 3, lanes: roadLanes, - hazards: [{ kind:"car", lane: 1, x: 1, width: 1, velocity: 1 }], - frog: { x: 1, y: 1 }, lives: 0, score: 0, status: "gameOver", - }, - }, - { - name: "open water with no log under the frog drowns it", - before: { - width: 5, height: 3, lanes: riverLanes, - hazards: [{ kind:"log", lane: 1, x: 3, width: 1, velocity: 0 }], - frog: { x: 1, y: 1 }, lives: 3, score: 0, status: "playing", - }, - args: 1, - after: { - width: 5, height: 3, lanes: riverLanes, - hazards: [{ kind:"log", lane: 1, x: 3, width: 1, velocity: 0 }], - frog: { x: 2, y: 0 }, lives: 2, score: 0, status: "playing", - }, - }, - { - name: "a log carries the frog along and keeps it safe", - before: { - width: 5, height: 3, lanes: riverLanes, - hazards: [{ kind:"log", lane: 1, x: 0, width: 3, velocity: 1 }], - frog: { x: 1, y: 1 }, lives: 3, score: 0, status: "playing", - }, - args: 1, - after: { - width: 5, height: 3, lanes: riverLanes, - hazards: [{ kind:"log", lane: 1, x: 1, width: 3, velocity: 1 }], - frog: { x: 2, y: 1 }, lives: 3, score: 0, status: "playing", - }, - }, - { - name: "a log carrying the frog past the edge drowns it", - before: { - width: 5, height: 3, lanes: riverLanes, - hazards: [{ kind:"log", lane: 1, x: 3, width: 2, velocity: 2 }], - frog: { x: 4, y: 1 }, lives: 3, score: 0, status: "playing", - }, - args: 1, - after: { - width: 5, height: 3, lanes: riverLanes, - hazards: [{ kind:"log", lane: 1, x: 0, width: 2, velocity: 2 }], - frog: { x: 2, y: 0 }, lives: 2, score: 0, status: "playing", - }, - }, - { - name: "reaching the goal scores and wins", - before: { - width: 5, height: 3, lanes: roadLanes, - hazards: [{ kind:"car", lane: 1, x: 0, width: 1, velocity: 1 }], - frog: { x: 2, y: 2 }, lives: 3, score: 0, status: "playing", - }, - args: 1, - after: { - width: 5, height: 3, lanes: roadLanes, - hazards: [{ kind:"car", lane: 1, x: 1, width: 1, velocity: 1 }], - frog: { x: 2, y: 2 }, lives: 3, score: 1, status: "won", - }, - }, - { - name: "does nothing once the game is over", - before: { - width: 5, height: 3, lanes: roadLanes, - hazards: [{ kind:"car", lane: 1, x: 0, width: 1, velocity: 1 }], - frog: { x: 2, y: 0 }, lives: 0, score: 0, status: "gameOver", - }, - args: 1, - after: { - width: 5, height: 3, lanes: roadLanes, - hazards: [{ kind:"car", lane: 1, x: 0, width: 1, velocity: 1 }], - frog: { x: 2, y: 0 }, lives: 0, score: 0, status: "gameOver", - }, - }, -]; diff --git a/packages/data-gpu-hopper/src/features/main/data/state/step.test.ts b/packages/data-gpu-hopper/src/features/main/data/state/step.test.ts deleted file mode 100644 index 6b531ae1..00000000 --- a/packages/data-gpu-hopper/src/features/main/data/state/step.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it } from "vitest"; -import { State } from "./state.js"; -import { cases } from "./step.cases.js"; -import { expectStateMatches } from "./expect-state-matches.js"; - -describe("State.step", () => { - for (const { name, before, args, after } of cases) { - it(name, () => { - expectStateMatches(State.step(before, args), after); - }); - } -}); diff --git a/packages/data-gpu-hopper/src/features/main/data/state/step.ts b/packages/data-gpu-hopper/src/features/main/data/state/step.ts index 6db5f81b..d98c8af7 100644 --- a/packages/data-gpu-hopper/src/features/main/data/state/step.ts +++ b/packages/data-gpu-hopper/src/features/main/data/state/step.ts @@ -3,7 +3,9 @@ import { Hazard } from "../hazard/hazard.js"; import { LaneKind } from "../lane-kind/lane-kind.js"; import { Outcome } from "../outcome/outcome.js"; import { GameStatus } from "../game-status/game-status.js"; +import type { Lane } from "../lane/lane.js"; import type { State } from "./state.js"; +import type { Conformance } from "./conformance-case.js"; import { laneAt } from "./lane-at.js"; import { frogOutcome } from "./frog-outcome.js"; import { winGoal } from "./win-goal.js"; @@ -39,3 +41,88 @@ export const step = (state: State, dt: number): State => { if (Outcome.isFatal[outcome]) return loseLife(moved); return moved; }; + +// Two 5-wide, 3-tall boards differing only in the middle lane's terrain. +const roadLanes: readonly Lane[] = [ + { row: 0, kind: "grass" }, + { row: 1, kind: "road" }, + { row: 2, kind: "goal" }, +]; +const riverLanes: readonly Lane[] = [ + { row: 0, kind: "grass" }, + { row: 1, kind: "river" }, + { row: 2, kind: "goal" }, +]; + +// Spec-owned cases (args is dt), shared with the ecs tick (the system loop conforms +// to this — see systems.md). Every step here uses dt = 1 so hazard/carry +// displacements are exact. Covers: hazards scrolling while the frog stays safe, a +// car hit (life lost + respawn), the final-life game over, drowning over open +// water, riding a log, being carried off the board edge (with a wrapping log), +// reaching the goal, and the game-over no-op. +export const cases: Conformance = [ + { name: "scrolls hazards while the frog rests on grass", + before: { width: 5, height: 3, lanes: roadLanes, + hazards: [{ kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }], + frog: { x: 2, y: 0 }, lives: 3, score: 0, status: "playing" }, + args: 1, + after: { width: 5, height: 3, lanes: roadLanes, + hazards: [{ kind: "car", lane: 1, x: 1, width: 1, velocity: 1 }], + frog: { x: 2, y: 0 }, lives: 3, score: 0, status: "playing" } }, + { name: "a car reaching the frog costs a life and respawns it", + before: { width: 5, height: 3, lanes: roadLanes, + hazards: [{ kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }], + frog: { x: 1, y: 1 }, lives: 3, score: 0, status: "playing" }, + args: 1, + after: { width: 5, height: 3, lanes: roadLanes, + hazards: [{ kind: "car", lane: 1, x: 1, width: 1, velocity: 1 }], + frog: { x: 2, y: 0 }, lives: 2, score: 0, status: "playing" } }, + { name: "a car hit on the last life ends the game", + before: { width: 5, height: 3, lanes: roadLanes, + hazards: [{ kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }], + frog: { x: 1, y: 1 }, lives: 1, score: 0, status: "playing" }, + args: 1, + after: { width: 5, height: 3, lanes: roadLanes, + hazards: [{ kind: "car", lane: 1, x: 1, width: 1, velocity: 1 }], + frog: { x: 1, y: 1 }, lives: 0, score: 0, status: "gameOver" } }, + { name: "open water with no log under the frog drowns it", + before: { width: 5, height: 3, lanes: riverLanes, + hazards: [{ kind: "log", lane: 1, x: 3, width: 1, velocity: 0 }], + frog: { x: 1, y: 1 }, lives: 3, score: 0, status: "playing" }, + args: 1, + after: { width: 5, height: 3, lanes: riverLanes, + hazards: [{ kind: "log", lane: 1, x: 3, width: 1, velocity: 0 }], + frog: { x: 2, y: 0 }, lives: 2, score: 0, status: "playing" } }, + { name: "a log carries the frog along and keeps it safe", + before: { width: 5, height: 3, lanes: riverLanes, + hazards: [{ kind: "log", lane: 1, x: 0, width: 3, velocity: 1 }], + frog: { x: 1, y: 1 }, lives: 3, score: 0, status: "playing" }, + args: 1, + after: { width: 5, height: 3, lanes: riverLanes, + hazards: [{ kind: "log", lane: 1, x: 1, width: 3, velocity: 1 }], + frog: { x: 2, y: 1 }, lives: 3, score: 0, status: "playing" } }, + { name: "a log carrying the frog past the edge drowns it", + before: { width: 5, height: 3, lanes: riverLanes, + hazards: [{ kind: "log", lane: 1, x: 3, width: 2, velocity: 2 }], + frog: { x: 4, y: 1 }, lives: 3, score: 0, status: "playing" }, + args: 1, + after: { width: 5, height: 3, lanes: riverLanes, + hazards: [{ kind: "log", lane: 1, x: 0, width: 2, velocity: 2 }], + frog: { x: 2, y: 0 }, lives: 2, score: 0, status: "playing" } }, + { name: "reaching the goal scores and wins", + before: { width: 5, height: 3, lanes: roadLanes, + hazards: [{ kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }], + frog: { x: 2, y: 2 }, lives: 3, score: 0, status: "playing" }, + args: 1, + after: { width: 5, height: 3, lanes: roadLanes, + hazards: [{ kind: "car", lane: 1, x: 1, width: 1, velocity: 1 }], + frog: { x: 2, y: 2 }, lives: 3, score: 1, status: "won" } }, + { name: "does nothing once the game is over", + before: { width: 5, height: 3, lanes: roadLanes, + hazards: [{ kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }], + frog: { x: 2, y: 0 }, lives: 0, score: 0, status: "gameOver" }, + args: 1, + after: { width: 5, height: 3, lanes: roadLanes, + hazards: [{ kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }], + frog: { x: 2, y: 0 }, lives: 0, score: 0, status: "gameOver" } }, +]; diff --git a/packages/data-gpu-hopper/src/features/main/data/state/transitions.ts b/packages/data-gpu-hopper/src/features/main/data/state/transitions.ts new file mode 100644 index 00000000..1092fd91 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/state/transitions.ts @@ -0,0 +1,11 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +/// + +// The transform/derivation modules for conformance discovery — the `{ fn, cases }` +// source shared by `spec.test.ts` (pure) and the ecs `conformance.test.ts`. The +// glob must be authored where Vite can statically see it (an eager glob, excluding +// tests and this file itself); consumers import the resolved map. +export const transitions = import.meta.glob>( + ["./*.ts", "!./*.test.ts", "!./*.type-test.ts", "!./transitions.ts"], + { eager: true }, +); diff --git a/packages/data-gpu-hopper/src/features/main/data/state/win-goal.cases.ts b/packages/data-gpu-hopper/src/features/main/data/state/win-goal.cases.ts deleted file mode 100644 index b9b43f3d..00000000 --- a/packages/data-gpu-hopper/src/features/main/data/state/win-goal.cases.ts +++ /dev/null @@ -1,30 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { State } from "./state.js"; -import type { ConformanceCase } from "./conformance-case.js"; - -// winGoal reads only score / status; the rest is inert here. -const base: Omit = { - width: 5, - height: 3, - lanes: [], - hazards: [], - lives: 3, - frog: { x: 2, y: 2 }, -}; - -// Spec-owned `{ before, args, after }` cases for `State.winGoal`, shared with the -// ecs `winGoal` transaction: scoring the goal, and the finished-game no-op. -export const cases: readonly ConformanceCase[] = [ - { - name: "scores the goal and wins", - before: { ...base, score: 2, status: "playing" }, - args: undefined, - after: { ...base, score: 3, status: "won" }, - }, - { - name: "ignores a finished game (no-op)", - before: { ...base, score: 3, status: "won" }, - args: undefined, - after: { ...base, score: 3, status: "won" }, - }, -]; diff --git a/packages/data-gpu-hopper/src/features/main/data/state/win-goal.ts b/packages/data-gpu-hopper/src/features/main/data/state/win-goal.ts index 715b3d21..5f0218cb 100644 --- a/packages/data-gpu-hopper/src/features/main/data/state/win-goal.ts +++ b/packages/data-gpu-hopper/src/features/main/data/state/win-goal.ts @@ -1,6 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { GameStatus } from "../game-status/game-status.js"; import type { State } from "./state.js"; +import type { Conformance } from "./conformance-case.js"; // Score the reached goal and end the game as won. A no-op once the game has // finished, keeping it idempotent. @@ -8,3 +9,24 @@ export const winGoal = >(state: T): T if (!GameStatus.isPlaying(state.status)) return state; return { ...state, score: state.score + 1, status: "won" }; }; + +// winGoal reads only score / status; the rest is inert here. +const base: Omit = { + width: 5, + height: 3, + lanes: [], + hazards: [], + lives: 3, + frog: { x: 2, y: 2 }, +}; + +// Spec-owned cases, shared with the ecs `winGoal` transaction: scoring the goal, +// and the finished-game no-op. +export const cases: Conformance = [ + { name: "scores the goal and wins", + before: { ...base, score: 2, status: "playing" }, + after: { ...base, score: 3, status: "won" } }, + { name: "ignores a finished game (no-op)", + before: { ...base, score: 3, status: "won" }, + after: { ...base, score: 3, status: "won" } }, +]; diff --git a/packages/data-gpu-hopper/src/features/main/services/main-service/conformance/conformance.test.ts b/packages/data-gpu-hopper/src/features/main/services/main-service/conformance/conformance.test.ts new file mode 100644 index 00000000..ec8b591a --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/services/main-service/conformance/conformance.test.ts @@ -0,0 +1,22 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Conformance } from "@adobe/data/testing"; +import { State } from "../../../data/state/state.js"; +import { transitions } from "../../../data/state/transitions.js"; +import { MainService } from "../main-service.js"; +import { projection } from "./projection.js"; + +// The whole ecs conformance for this feature in one call: `runFeature` pairs each +// transaction on `MainService.plugin` (hop / winGoal / loseLife / newGame) with its +// same-named `data/state` transition, seeds each case's `before` over +// `State.create()`, and round-trips `State.samples` through the projection. The +// hazard bag the ecs materialises in nondeterministic row order compares as a +// multiset via `match.unordered`. `step` has no transaction — the per-frame system +// loop conforms it in `system-database/tick-loop.test.ts` (see systems.md) — so it +// is simply skipped here. Hopper has no derivations, so no `computedPlugin`. +Conformance.runFeature({ + state: State, + transitions, + plugin: MainService.plugin, + projection, + match: { unordered: new Set(["hazards"]) }, +}); diff --git a/packages/data-gpu-hopper/src/features/main/services/main-service/conformance/create-store.ts b/packages/data-gpu-hopper/src/features/main/services/main-service/conformance/create-store.ts deleted file mode 100644 index 640d619a..00000000 --- a/packages/data-gpu-hopper/src/features/main/services/main-service/conformance/create-store.ts +++ /dev/null @@ -1,10 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Store } from "@adobe/data/ecs"; -import { CoreDatabase } from "../core-database/core-database.js"; - -// A fresh writable store carrying the feature's whole schema. Hopper has no -// index layer, so `CoreDatabase` is the lowest (and only schema) layer, and -// `Store.create` reads its schema facets directly. Typed as `CoreDatabase.Store`: -// the surface the projection (`fromState` / `toState`) and the raw transaction -// functions use. Test-only. -export const createStore = (): CoreDatabase.Store => Store.create(CoreDatabase.plugin); diff --git a/packages/data-gpu-hopper/src/features/main/services/main-service/conformance/expect-conforms.ts b/packages/data-gpu-hopper/src/features/main/services/main-service/conformance/expect-conforms.ts deleted file mode 100644 index b21919ce..00000000 --- a/packages/data-gpu-hopper/src/features/main/services/main-service/conformance/expect-conforms.ts +++ /dev/null @@ -1,42 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { it } from "vitest"; -import type { State } from "../../../data/state/state.js"; -import type { ConformanceCase } from "../../../data/state/conformance-case.js"; -import { expectStateMatches } from "../../../data/state/expect-state-matches.js"; -import type { CoreDatabase } from "../core-database/core-database.js"; -import { createStore } from "./create-store.js"; -import { fromState } from "./from-state.js"; -import { toState } from "./to-state.js"; - -// The conformance runner, bound to THIS feature's projection (`fromState` / -// `toState`). For each case it proves the one conformance property -// -// toState(apply(fromState(before), args)) ≡ spec(before, args) -// -// in two asserted halves: -// 1. `spec(before, args) ≡ after` — keeps the shared case honest (a -// mis-authored `after` is caught here, independent of the ecs path). -// 2. seed `fromState(before)` → run the caller's `apply` → `toState ≡ after` -// — the ecs implementation reproduces the pure transform. -// -// `apply` receives the seeded writable store and calls the raw transaction -// function directly (a transaction is `(store, args) => void`, so no `Database` -// is involved). Hopper's transactions take plain data args (a direction, or -// nothing), so no entity resolution is needed in `apply`. Entity collections -// compare as multisets; scalars and resources exactly (see `expectStateMatches`). -export const expectConforms = (config: { - readonly cases: readonly ConformanceCase[]; - readonly spec: (before: State, args: Args) => State; - readonly apply: (store: CoreDatabase.Store, args: Args) => void; -}): void => { - for (const testCase of config.cases) { - it(testCase.name, () => { - expectStateMatches(config.spec(testCase.before, testCase.args), testCase.after); - - const store = createStore(); - fromState(store, testCase.before); - config.apply(store, testCase.args); - expectStateMatches(toState(store), testCase.after); - }); - } -}; diff --git a/packages/data-gpu-hopper/src/features/main/services/main-service/conformance/from-state.ts b/packages/data-gpu-hopper/src/features/main/services/main-service/conformance/from-state.ts deleted file mode 100644 index 2247f8dc..00000000 --- a/packages/data-gpu-hopper/src/features/main/services/main-service/conformance/from-state.ts +++ /dev/null @@ -1,30 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { State } from "../../../data/state/state.js"; -import type { CoreDatabase } from "../core-database/core-database.js"; - -// Seed a store to exactly match a `data/` `State`: clear the frog and every -// hazard, set the scalar resources and terrain, then insert one frog entity and -// one entity per hazard. The inverse of `toState`. Test-only — the bridge that -// lets an ecs mutation be checked against the pure transform it stands for (see -// `expect-conforms.ts`). Clearing iterates tail→head so each delete is from the -// tail (no hole-fill shift). -export const fromState = (store: CoreDatabase.Store, state: State): void => { - for (const arch of store.queryArchetypes(store.archetypes.Frog.components)) { - for (let row = arch.rowCount - 1; row >= 0; row--) store.delete(arch.columns.id.get(row)); - } - for (const arch of store.queryArchetypes(store.archetypes.Hazard.components)) { - for (let row = arch.rowCount - 1; row >= 0; row--) store.delete(arch.columns.id.get(row)); - } - - store.resources.width = state.width; - store.resources.height = state.height; - store.resources.lives = state.lives; - store.resources.score = state.score; - store.resources.status = state.status; - store.resources.lanes = state.lanes; - - store.archetypes.Frog.insert({ x: state.frog.x, y: state.frog.y }); - for (const hazard of state.hazards) { - store.archetypes.Hazard.insert(hazard); - } -}; diff --git a/packages/data-gpu-hopper/src/features/main/services/main-service/conformance/projection.test.ts b/packages/data-gpu-hopper/src/features/main/services/main-service/conformance/projection.test.ts deleted file mode 100644 index 756d6cc2..00000000 --- a/packages/data-gpu-hopper/src/features/main/services/main-service/conformance/projection.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -// -// Guards the projection itself. `fromState` and `toState` are the bridge every -// transaction conformance test trusts; a symmetric bug in the pair (e.g. both -// dropping the same field) would cancel out and mask a real ecs defect. This -// identity test — `toState(fromState(s)) ≡ s` over representative states — -// proves the projection round-trips faithfully on its own. -import { describe, it } from "vitest"; -import { State } from "../../../data/state/state.js"; -import type { State as StateType } from "../../../data/state/state.js"; -import { expectStateMatches } from "../../../data/state/expect-state-matches.js"; -import { createStore } from "./create-store.js"; -import { fromState } from "./from-state.js"; -import { toState } from "./to-state.js"; - -const states: readonly { readonly name: string; readonly state: StateType }[] = [ - { name: "the initial game", state: State.create() }, - { - name: "a mid-run state with a carried frog and depleted lives", - state: { - width: 5, - height: 3, - lanes: [ - { row: 0, kind: "grass" }, - { row: 1, kind: "river" }, - { row: 2, kind: "goal" }, - ], - hazards: [ - { kind: "log", lane: 1, x: 1.5, width: 3, velocity: 1 }, - { kind: "log", lane: 1, x: 4, width: 2, velocity: 1 }, - ], - frog: { x: 2.5, y: 1 }, - lives: 1, - score: 4, - status: "playing", - }, - }, - { - name: "an empty board with no hazards", - state: { - width: 4, - height: 2, - lanes: [ - { row: 0, kind: "grass" }, - { row: 1, kind: "goal" }, - ], - hazards: [], - frog: { x: 1, y: 0 }, - lives: 3, - score: 0, - status: "playing", - }, - }, -]; - -describe("ecs/conformance projection round-trips (toState ∘ fromState ≡ identity)", () => { - for (const { name, state } of states) { - it(name, () => { - const store = createStore(); - fromState(store, state); - expectStateMatches(toState(store), state); - }); - } -}); diff --git a/packages/data-gpu-hopper/src/features/main/services/main-service/conformance/projection.ts b/packages/data-gpu-hopper/src/features/main/services/main-service/conformance/projection.ts new file mode 100644 index 00000000..7a5dbed2 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/services/main-service/conformance/projection.ts @@ -0,0 +1,78 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { State } from "../../../data/state/state.js"; +import type { Frog } from "../../../data/frog/frog.js"; +import type { Hazard } from "../../../data/hazard/hazard.js"; +import type { CoreDatabase } from "../core-database/core-database.js"; + +// Read the single frog entity through the Frog archetype's full component set +// (`x`, `y`) — hazards lack `y`, so the shapes never alias. +const readFrog = (store: CoreDatabase.Store): Frog => { + for (const arch of store.queryArchetypes(store.archetypes.Frog.components)) { + for (let row = 0; row < arch.rowCount; row++) { + return { x: arch.columns.x.get(row), y: arch.columns.y.get(row) }; + } + } + throw new Error("frog entity missing from store"); +}; + +const readHazards = (store: CoreDatabase.Store): Hazard[] => { + const hazards: Hazard[] = []; + for (const arch of store.queryArchetypes(store.archetypes.Hazard.components)) { + for (let row = 0; row < arch.rowCount; row++) { + hazards.push({ + kind: arch.columns.kind.get(row), + lane: arch.columns.lane.get(row), + x: arch.columns.x.get(row), + width: arch.columns.width.get(row), + velocity: arch.columns.velocity.get(row), + }); + } + } + return hazards; +}; + +// The test-only ecs↔`State` projection, passed to `Conformance.runFeature` (and +// reused by the system tick-loop / outcome-selection tests). Hopper is not +// id-addressed (transactions take a direction, not an entity id), so `fromState` +// returns no id map and there are no id-list computeds — hence no `toData`. These +// are STRICTLY for conformance tests and MUST NEVER run in production code. +export const projection = { + // Seed a store to exactly match a `data/` `State`: clear the frog and every + // hazard, set the scalar resources and terrain, then insert one frog entity and + // one entity per hazard. The inverse of `toState`. Clearing iterates tail→head + // so each delete is from the tail (no hole-fill shift). The loop-plumbing + // resources (frameDelta / pendingDirection) have no `State` analogue and keep + // their defaults. + fromState: (store: CoreDatabase.Store, state: State): void => { + for (const arch of store.queryArchetypes(store.archetypes.Frog.components)) { + for (let row = arch.rowCount - 1; row >= 0; row--) store.delete(arch.columns.id.get(row)); + } + for (const arch of store.queryArchetypes(store.archetypes.Hazard.components)) { + for (let row = arch.rowCount - 1; row >= 0; row--) store.delete(arch.columns.id.get(row)); + } + + store.resources.width = state.width; + store.resources.height = state.height; + store.resources.lives = state.lives; + store.resources.score = state.score; + store.resources.status = state.status; + store.resources.lanes = state.lanes; + + store.archetypes.Frog.insert({ x: state.frog.x, y: state.frog.y }); + for (const hazard of state.hazards) { + store.archetypes.Hazard.insert(hazard); + } + }, + // Read a store back into a `data/` `State` — the inverse of `fromState`. The + // scalar resources join the frog and the hazard entities. + toState: (store: CoreDatabase.Store): State => ({ + width: store.resources.width, + height: store.resources.height, + lanes: store.resources.lanes, + hazards: readHazards(store), + frog: readFrog(store), + lives: store.resources.lives, + score: store.resources.score, + status: store.resources.status, + }), +}; diff --git a/packages/data-gpu-hopper/src/features/main/services/main-service/conformance/to-state.ts b/packages/data-gpu-hopper/src/features/main/services/main-service/conformance/to-state.ts deleted file mode 100644 index 6609af53..00000000 --- a/packages/data-gpu-hopper/src/features/main/services/main-service/conformance/to-state.ts +++ /dev/null @@ -1,45 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { State } from "../../../data/state/state.js"; -import type { Frog } from "../../../data/frog/frog.js"; -import type { Hazard } from "../../../data/hazard/hazard.js"; -import type { CoreDatabase } from "../core-database/core-database.js"; - -// Read the single frog entity through the Frog archetype's full component set -// (`x`, `y`) — hazards lack `y`, so the shapes never alias. -const readFrog = (store: CoreDatabase.Store): Frog => { - for (const arch of store.queryArchetypes(store.archetypes.Frog.components)) { - for (let row = 0; row < arch.rowCount; row++) { - return { x: arch.columns.x.get(row), y: arch.columns.y.get(row) }; - } - } - throw new Error("frog entity missing from store"); -}; - -const readHazards = (store: CoreDatabase.Store): Hazard[] => { - const hazards: Hazard[] = []; - for (const arch of store.queryArchetypes(store.archetypes.Hazard.components)) { - for (let row = 0; row < arch.rowCount; row++) { - hazards.push({ - kind: arch.columns.kind.get(row), - lane: arch.columns.lane.get(row), - x: arch.columns.x.get(row), - width: arch.columns.width.get(row), - velocity: arch.columns.velocity.get(row), - }); - } - } - return hazards; -}; - -// Read a store back into a `data/` `State` — the inverse of `fromState`. The -// scalar resources join the frog and the hazard entities. Test-only. -export const toState = (store: CoreDatabase.Store): State => ({ - width: store.resources.width, - height: store.resources.height, - lanes: store.resources.lanes, - hazards: readHazards(store), - frog: readFrog(store), - lives: store.resources.lives, - score: store.resources.score, - status: store.resources.status, -}); diff --git a/packages/data-gpu-hopper/src/features/main/services/main-service/system-database/outcome-selection.test.ts b/packages/data-gpu-hopper/src/features/main/services/main-service/system-database/outcome-selection.test.ts index 3a0d104b..67b2e999 100644 --- a/packages/data-gpu-hopper/src/features/main/services/main-service/system-database/outcome-selection.test.ts +++ b/packages/data-gpu-hopper/src/features/main/services/main-service/system-database/outcome-selection.test.ts @@ -11,8 +11,7 @@ import { describe, it, expect } from "vitest"; import type { State } from "../../../data/state/state.js"; import type { Lane } from "../../../data/lane/lane.js"; import { createSystemDatabase } from "../conformance/create-system-database.js"; -import { fromState } from "../conformance/from-state.js"; -import { toState } from "../conformance/to-state.js"; +import { projection } from "../conformance/projection.js"; import { driveFrame } from "../conformance/drive-frame.js"; const roadLanes: readonly Lane[] = [ @@ -41,10 +40,10 @@ const base = (overrides: Partial): State => ({ // Seed the geometry, run exactly one selection-only frame (dt 0), project back. const detect = (state: State): State => { const db = createSystemDatabase(); - fromState(db.store, state); + projection.fromState(db.store, state); db.store.resources.frameDelta = 0; driveFrame(db); - return toState(db.store); + return projection.toState(db.store); }; describe("outcome selection — road", () => { diff --git a/packages/data-gpu-hopper/src/features/main/services/main-service/system-database/tick-loop.test.ts b/packages/data-gpu-hopper/src/features/main/services/main-service/system-database/tick-loop.test.ts index 7e1c446e..328ea66a 100644 --- a/packages/data-gpu-hopper/src/features/main/services/main-service/system-database/tick-loop.test.ts +++ b/packages/data-gpu-hopper/src/features/main/services/main-service/system-database/tick-loop.test.ts @@ -12,29 +12,33 @@ // frame `frameDelta` (a resource with no `data/` analogue, written straight to the // store as the oracle is fed `dt`), and — crucially — seed NO pending input, so // the per-frame systems' combined effect equals `State.step(before, dt)` exactly -// (hop is covered by the transaction test). Then drive one headless frame and -// assert `toState ≡ after`. Each case also asserts `State.step ≡ after` first, -// keeping the shared case honest. +// (hop is covered by the transaction conformance). Then drive one headless frame +// and assert `toState ≡ after`. Each case also asserts `State.step ≡ after` first, +// keeping the shared case honest. The hazard bag compares as a multiset. import { describe, it } from "vitest"; +import { Match } from "@adobe/data/testing"; import { State } from "../../../data/state/state.js"; -import { cases } from "../../../data/state/step.cases.js"; -import { expectStateMatches } from "../../../data/state/expect-state-matches.js"; +import { cases } from "../../../data/state/step.js"; import { createSystemDatabase } from "../conformance/create-system-database.js"; -import { fromState } from "../conformance/from-state.js"; -import { toState } from "../conformance/to-state.js"; +import { projection } from "../conformance/projection.js"; import { driveFrame } from "../conformance/drive-frame.js"; +const unordered = { unordered: new Set(["hazards"]) }; + describe("ECS system tick loop conforms to State.step (one frame = one step)", () => { for (const testCase of cases) { it(testCase.name, () => { const dt = testCase.args; - expectStateMatches(State.step(testCase.before, dt), testCase.after); + // A case `before` is a delta over the feature default (`Case.before` is + // `Partial`), so materialise the full seed the same way the runners do. + const before = { ...State.create(), ...testCase.before }; + Match.assert(State.step(before, dt), testCase.after, unordered); const db = createSystemDatabase(); - fromState(db.store, testCase.before); + projection.fromState(db.store, before); db.store.resources.frameDelta = dt; driveFrame(db); - expectStateMatches(toState(db.store), testCase.after); + Match.assert(projection.toState(db.store), testCase.after, unordered); }); } }); diff --git a/packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transactions/hop.test.ts b/packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transactions/hop.test.ts deleted file mode 100644 index ea0fb355..00000000 --- a/packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transactions/hop.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -// -// `hop` conforms to `State.hop`. Both take a plain direction, so `apply` calls -// the raw transaction directly on the seeded store — no entity resolution -// needed. Every direction, edge clamp, the grid-snap, and the game-over no-op -// are covered by the shared spec cases. -import { describe } from "vitest"; -import { State } from "../../../../data/state/state.js"; -import { cases } from "../../../../data/state/hop.cases.js"; -import { expectConforms } from "../../conformance/expect-conforms.js"; -import { hop } from "./hop.js"; - -describe("hop transaction conforms to State.hop", () => { - expectConforms({ - cases, - spec: State.hop, - apply: hop, - }); -}); diff --git a/packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transactions/lose-life.test.ts b/packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transactions/lose-life.test.ts deleted file mode 100644 index 6daf989c..00000000 --- a/packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transactions/lose-life.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -// -// `loseLife` conforms to `State.loseLife` — spend a life and respawn, end the -// game on the last life, and no-op once finished. Shared spec cases. -import { describe } from "vitest"; -import { State } from "../../../../data/state/state.js"; -import { cases } from "../../../../data/state/lose-life.cases.js"; -import { expectConforms } from "../../conformance/expect-conforms.js"; -import { loseLife } from "./lose-life.js"; - -describe("loseLife transaction conforms to State.loseLife", () => { - expectConforms({ - cases, - spec: State.loseLife, - apply: loseLife, - }); -}); diff --git a/packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transactions/new-game.test.ts b/packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transactions/new-game.test.ts deleted file mode 100644 index 6bdef1b8..00000000 --- a/packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transactions/new-game.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -// -// `newGame` conforms to `State.create` — it discards whatever the store held and -// re-seeds the initial game. The shared case seeds a fully-divergent mid-run -// state so the reset is proven total. -import { describe } from "vitest"; -import { State } from "../../../../data/state/state.js"; -import { cases } from "../../../../data/state/new-game.cases.js"; -import { expectConforms } from "../../conformance/expect-conforms.js"; -import { newGame } from "./new-game.js"; - -describe("newGame transaction conforms to State.create", () => { - expectConforms({ - cases, - spec: State.create, - apply: newGame, - }); -}); diff --git a/packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transactions/win-goal.test.ts b/packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transactions/win-goal.test.ts deleted file mode 100644 index 66d3679a..00000000 --- a/packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transactions/win-goal.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -// -// `winGoal` conforms to `State.winGoal` — score + win, and no-op once finished. -// Shared spec cases. -import { describe } from "vitest"; -import { State } from "../../../../data/state/state.js"; -import { cases } from "../../../../data/state/win-goal.cases.js"; -import { expectConforms } from "../../conformance/expect-conforms.js"; -import { winGoal } from "./win-goal.js"; - -describe("winGoal transaction conforms to State.winGoal", () => { - expectConforms({ - cases, - spec: State.winGoal, - apply: winGoal, - }); -}); diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/spec.test.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/spec.test.ts index 4ecff81c..047b585d 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/spec.test.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/spec.test.ts @@ -1,17 +1,14 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -/// import { Conformance } from "@adobe/data/testing"; import { State } from "./state.js"; +import { transitions } from "./transitions.js"; // The single pure-spec test for every transform AND derivation in this folder. -// `runSpec` auto-discovers each sibling exporting `cases`, requires it to export -// exactly its function plus `cases`, and dispatches on case shape. Each case's -// `before`/`input` is a delta over `State.create()`. +// `runSpec` auto-discovers each module in `transitions` that exports `cases`, +// requires it to export exactly its function plus `cases`, and dispatches on case +// shape. Each case's `before`/`input` is a delta over `State.create()`. Conformance.runSpec({ state: State, - transitions: import.meta.glob( - ["./*.ts", "!./*.test.ts", "!./*.type-test.ts"], - { eager: true }, - ), + transitions, match: { unordered: new Set(["bullets", "asteroids"]) }, }); diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/transitions.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/transitions.ts new file mode 100644 index 00000000..1092fd91 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/transitions.ts @@ -0,0 +1,11 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +/// + +// The transform/derivation modules for conformance discovery — the `{ fn, cases }` +// source shared by `spec.test.ts` (pure) and the ecs `conformance.test.ts`. The +// glob must be authored where Vite can statically see it (an eager glob, excluding +// tests and this file itself); consumers import the resolved map. +export const transitions = import.meta.glob>( + ["./*.ts", "!./*.test.ts", "!./*.type-test.ts", "!./transitions.ts"], + { eager: true }, +); diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/conformance.test.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/conformance.test.ts index 46b98b69..b4ce014e 100644 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/conformance.test.ts +++ b/packages/data-lit-space-rock-game/src/features/main/services/main-service/conformance/conformance.test.ts @@ -1,24 +1,20 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -/// import { Conformance } from "@adobe/data/testing"; import { State } from "../../../data/state/state.js"; +import { transitions } from "../../../data/state/transitions.js"; import { MainService } from "../main-service.js"; import { projection } from "./projection.js"; // The whole ecs conformance for this feature in one call: `runFeature` pulls the // transactions/actions off `MainService.plugin`, seeds each case's `before` (a // delta) over `State.create()`, and round-trips `State.samples` through the -// projection. The entity bags the ecs materialises in nondeterministic row order +// projection. `transitions` (the discovered `{ fn, cases }` modules) is shared with +// spec.test. The entity bags the ecs materialises in nondeterministic row order // (`bullets`, `asteroids`) compare as multisets via `match.unordered`. There is no // `computedPlugin` — space-rock has no `state/` derivations. Conformance.runFeature({ state: State, - transitions: import.meta.glob( - ["../../../data/state/*.ts", "!**/*.test.ts", "!**/*.type-test.ts"], - { - eager: true, - }, - ), + transitions, plugin: MainService.plugin, projection, match: { unordered: new Set(["bullets", "asteroids"]) }, diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/spec.test.ts b/packages/data-lit-tictactoe/src/features/main/data/state/spec.test.ts index c85cde05..41791ede 100644 --- a/packages/data-lit-tictactoe/src/features/main/data/state/spec.test.ts +++ b/packages/data-lit-tictactoe/src/features/main/data/state/spec.test.ts @@ -1,16 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -/// import { Conformance } from "@adobe/data/testing"; import { State } from "./state.js"; +import { transitions } from "./transitions.js"; -// The single pure-spec test for every transform AND derivation in this folder. -// `runSpec` auto-discovers each sibling exporting `cases`, requires it to export -// exactly its function plus `cases`, and dispatches on case shape. Each case's +// The single pure-spec test for every transform AND derivation. Each case's // `before`/`input` is a delta over `State.create()`. -Conformance.runSpec({ - state: State, - transitions: import.meta.glob( - ["./*.ts", "!./*.test.ts", "!./*.type-test.ts"], - { eager: true }, - ), -}); +Conformance.runSpec({ state: State, transitions }); diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/transitions.ts b/packages/data-lit-tictactoe/src/features/main/data/state/transitions.ts new file mode 100644 index 00000000..1092fd91 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/state/transitions.ts @@ -0,0 +1,11 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +/// + +// The transform/derivation modules for conformance discovery — the `{ fn, cases }` +// source shared by `spec.test.ts` (pure) and the ecs `conformance.test.ts`. The +// glob must be authored where Vite can statically see it (an eager glob, excluding +// tests and this file itself); consumers import the resolved map. +export const transitions = import.meta.glob>( + ["./*.ts", "!./*.test.ts", "!./*.type-test.ts", "!./transitions.ts"], + { eager: true }, +); diff --git a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/conformance.test.ts b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/conformance.test.ts index b12e724a..d1de2870 100644 --- a/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/conformance.test.ts +++ b/packages/data-lit-tictactoe/src/features/main/services/main-service/conformance/conformance.test.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -/// import { Conformance } from "@adobe/data/testing"; import { State } from "../../../data/state/state.js"; +import { transitions } from "../../../data/state/transitions.js"; import { MainService } from "../main-service.js"; import { ComputedDatabase } from "../computed-database/computed-database.js"; import { projection } from "./projection.js"; @@ -10,14 +10,10 @@ import { projection } from "./projection.js"; // transactions/actions off `MainService.plugin`, the computeds off the // `ComputedDatabase` layer, seeds each case's `before` (a delta) over // `State.create()`, and round-trips `State.samples` through the projection. +// `transitions` (the discovered `{ fn, cases }` modules) is shared with spec.test. Conformance.runFeature({ state: State, - transitions: import.meta.glob( - ["../../../data/state/*.ts", "!**/*.test.ts", "!**/*.type-test.ts"], - { - eager: true, - }, - ), + transitions, plugin: MainService.plugin, computedPlugin: ComputedDatabase.plugin, projection, diff --git a/packages/data-lit-todo/src/features/main/data/state/spec.test.ts b/packages/data-lit-todo/src/features/main/data/state/spec.test.ts index c85cde05..9d5c4074 100644 --- a/packages/data-lit-todo/src/features/main/data/state/spec.test.ts +++ b/packages/data-lit-todo/src/features/main/data/state/spec.test.ts @@ -1,16 +1,10 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -/// import { Conformance } from "@adobe/data/testing"; import { State } from "./state.js"; +import { transitions } from "./transitions.js"; // The single pure-spec test for every transform AND derivation in this folder. -// `runSpec` auto-discovers each sibling exporting `cases`, requires it to export -// exactly its function plus `cases`, and dispatches on case shape. Each case's -// `before`/`input` is a delta over `State.create()`. -Conformance.runSpec({ - state: State, - transitions: import.meta.glob( - ["./*.ts", "!./*.test.ts", "!./*.type-test.ts"], - { eager: true }, - ), -}); +// `runSpec` auto-discovers each module in `transitions` that exports `cases`, +// requires it to export exactly its function plus `cases`, and dispatches on case +// shape. Each case's `before`/`input` is a delta over `State.create()`. +Conformance.runSpec({ state: State, transitions }); diff --git a/packages/data-lit-todo/src/features/main/data/state/transitions.ts b/packages/data-lit-todo/src/features/main/data/state/transitions.ts new file mode 100644 index 00000000..1092fd91 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/data/state/transitions.ts @@ -0,0 +1,11 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +/// + +// The transform/derivation modules for conformance discovery — the `{ fn, cases }` +// source shared by `spec.test.ts` (pure) and the ecs `conformance.test.ts`. The +// glob must be authored where Vite can statically see it (an eager glob, excluding +// tests and this file itself); consumers import the resolved map. +export const transitions = import.meta.glob>( + ["./*.ts", "!./*.test.ts", "!./*.type-test.ts", "!./transitions.ts"], + { eager: true }, +); diff --git a/packages/data-lit-todo/src/features/main/services/main-service/conformance/conformance.test.ts b/packages/data-lit-todo/src/features/main/services/main-service/conformance/conformance.test.ts index c39b83d5..fb951fc0 100644 --- a/packages/data-lit-todo/src/features/main/services/main-service/conformance/conformance.test.ts +++ b/packages/data-lit-todo/src/features/main/services/main-service/conformance/conformance.test.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -/// import { Conformance } from "@adobe/data/testing"; import { State } from "../../../data/state/state.js"; +import { transitions } from "../../../data/state/transitions.js"; import { MainService } from "../main-service.js"; import { ComputedDatabase } from "../computed-database/computed-database.js"; import { projection } from "./projection.js"; @@ -10,14 +10,12 @@ import { projection } from "./projection.js"; // transactions/actions off `MainService.plugin`, the computeds off the // `ComputedDatabase` layer, seeds each case's `before` (a delta) over // `State.create()`, and round-trips `State.samples` through the projection. +// `transitions` (the discovered `{ fn, cases }` modules) is shared with spec.test. // `visibleTodos` emits entity ids, so it is named in `hydrate` to project each // through `toData` into the `Todo[]` the derivation yields. Conformance.runFeature({ state: State, - transitions: import.meta.glob( - ["../../../data/state/*.ts", "!**/*.test.ts", "!**/*.type-test.ts"], - { eager: true }, - ), + transitions, plugin: MainService.plugin, computedPlugin: ComputedDatabase.plugin, projection, diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/spec.test.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/spec.test.ts index c85cde05..9d5c4074 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/spec.test.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/spec.test.ts @@ -1,16 +1,10 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -/// import { Conformance } from "@adobe/data/testing"; import { State } from "./state.js"; +import { transitions } from "./transitions.js"; // The single pure-spec test for every transform AND derivation in this folder. -// `runSpec` auto-discovers each sibling exporting `cases`, requires it to export -// exactly its function plus `cases`, and dispatches on case shape. Each case's -// `before`/`input` is a delta over `State.create()`. -Conformance.runSpec({ - state: State, - transitions: import.meta.glob( - ["./*.ts", "!./*.test.ts", "!./*.type-test.ts"], - { eager: true }, - ), -}); +// `runSpec` auto-discovers each module in `transitions` that exports `cases`, +// requires it to export exactly its function plus `cases`, and dispatches on case +// shape. Each case's `before`/`input` is a delta over `State.create()`. +Conformance.runSpec({ state: State, transitions }); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/transitions.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/transitions.ts new file mode 100644 index 00000000..1092fd91 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/transitions.ts @@ -0,0 +1,11 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +/// + +// The transform/derivation modules for conformance discovery — the `{ fn, cases }` +// source shared by `spec.test.ts` (pure) and the ecs `conformance.test.ts`. The +// glob must be authored where Vite can statically see it (an eager glob, excluding +// tests and this file itself); consumers import the resolved map. +export const transitions = import.meta.glob>( + ["./*.ts", "!./*.test.ts", "!./*.type-test.ts", "!./transitions.ts"], + { eager: true }, +); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/conformance.test.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/conformance.test.ts index 2ea87888..d4777088 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/conformance.test.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/conformance/conformance.test.ts @@ -2,12 +2,14 @@ /// import { Conformance } from "@adobe/data/testing"; import { State } from "../../../data/state/state.js"; +import { transitions } from "../../../data/state/transitions.js"; import { MainService } from "../main-service.js"; import { projection } from "./projection.js"; // The whole ecs conformance for this feature in one call: `runFeature` pulls the // transactions off `MainService.plugin`, seeds each case's `before` (a delta) over // `State.create()`, and round-trips `State.samples` through the projection. +// `transitions` (the discovered `{ fn, cases }` modules) is shared with spec.test. // Negotiation's per-transition actions (set-offer-code, enter-game, …) are // deliberately kept out of the plugin's `actions` facet (that would grow the plugin // type past tsc's budget), so they are discovered via the actions-directory glob @@ -15,10 +17,7 @@ import { projection } from "./projection.js"; // no transactions override; there are no derivations, so no `computedPlugin`. Conformance.runFeature({ state: State, - transitions: import.meta.glob( - ["../../../data/state/*.ts", "!**/*.test.ts", "!**/*.type-test.ts"], - { eager: true }, - ), + transitions, plugin: MainService.plugin, projection, ops: { diff --git a/packages/data-p2p-tictactoe/src/features/presence/data/state/public.ts b/packages/data-p2p-tictactoe/src/features/presence/data/state/public.ts index 3963f8ef..437afe36 100644 --- a/packages/data-p2p-tictactoe/src/features/presence/data/state/public.ts +++ b/packages/data-p2p-tictactoe/src/features/presence/data/state/public.ts @@ -1,3 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. export { create } from "./create.js"; +export { samples } from "./samples.js"; export { movePresence } from "./move-presence.js"; diff --git a/packages/data-p2p-tictactoe/src/features/presence/data/state/samples.ts b/packages/data-p2p-tictactoe/src/features/presence/data/state/samples.ts new file mode 100644 index 00000000..8e88e804 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/presence/data/state/samples.ts @@ -0,0 +1,13 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Vec2 } from "@adobe/data/math"; +import type { State } from "./state.js"; +import { create } from "./create.js"; + +const at = (x: number, y: number): Vec2 => [x, y] as Vec2; + +/** Representative presence states the conformance projection round-trips. */ +export const samples: readonly State[] = [ + create(), // no cursors reported yet + { cursors: { X: at(0.5, 0.25) } }, // one peer cursor + { cursors: { X: at(0.5, 0.25), O: at(0.75, 0.5) } }, // both peer cursors +]; diff --git a/packages/data-p2p-tictactoe/src/features/presence/data/state/spec.test.ts b/packages/data-p2p-tictactoe/src/features/presence/data/state/spec.test.ts index 5aea4d5d..fa0ada48 100644 --- a/packages/data-p2p-tictactoe/src/features/presence/data/state/spec.test.ts +++ b/packages/data-p2p-tictactoe/src/features/presence/data/state/spec.test.ts @@ -1,17 +1,10 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -/// import { Conformance } from "@adobe/data/testing"; +import { transitions } from "./transitions.js"; // The single pure-spec test for every transform AND derivation in this folder. -// `runSpec` auto-discovers each sibling exporting `cases`, requires it to export -// exactly its function plus `cases`, and dispatches on case shape. Presence cases -// carry a full `before`, so no `state` default is passed. Cursor positions are -// `Vec2` tuples compared in order; the `cursors` map compares by key set. -Conformance.runSpec({ - transitions: import.meta.glob>( - ["./*.ts", "!./*.test.ts", "!./*.type-test.ts"], - { - eager: true, - }, - ), -}); +// `runSpec` auto-discovers each module in `transitions` that exports `cases`, +// requires it to export exactly its function plus `cases`, and dispatches on case +// shape. Presence cases carry a full `before`, so no `state` default is passed. +// Cursor positions are `Vec2` tuples compared in order; `cursors` compares by key. +Conformance.runSpec({ transitions }); diff --git a/packages/data-p2p-tictactoe/src/features/presence/data/state/transitions.ts b/packages/data-p2p-tictactoe/src/features/presence/data/state/transitions.ts new file mode 100644 index 00000000..1092fd91 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/presence/data/state/transitions.ts @@ -0,0 +1,11 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +/// + +// The transform/derivation modules for conformance discovery — the `{ fn, cases }` +// source shared by `spec.test.ts` (pure) and the ecs `conformance.test.ts`. The +// glob must be authored where Vite can statically see it (an eager glob, excluding +// tests and this file itself); consumers import the resolved map. +export const transitions = import.meta.glob>( + ["./*.ts", "!./*.test.ts", "!./*.type-test.ts", "!./transitions.ts"], + { eager: true }, +); diff --git a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/actions.test.ts b/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/actions.test.ts index e2989767..2cefac9c 100644 --- a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/actions.test.ts +++ b/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/actions.test.ts @@ -3,6 +3,7 @@ import { Database } from "@adobe/data/ecs"; import type { ConcurrencyStrategyFactory } from "@adobe/data/ecs"; import { Conformance } from "@adobe/data/testing"; +import { transitions } from "../../../data/state/transitions.js"; import { MainService } from "../main-service.js"; import { fromState } from "./from-state.js"; import { toState } from "./to-state.js"; @@ -40,14 +41,7 @@ Conformance.runActions({ store: (db) => db.store, fromState, toState, - transitions: import.meta.glob( - [ - "../../../data/state/*.ts", - "!../../../data/state/*.test.ts", - "!../../../data/state/*.type-test.ts", - ], - { eager: true }, - ), + transitions, actions: import.meta.glob( ["../action-database/actions/*.ts", "!../action-database/actions/index.ts"], { eager: true }, diff --git a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/projection.test.ts b/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/projection.test.ts index 452488d6..d66ef3af 100644 --- a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/projection.test.ts +++ b/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/projection.test.ts @@ -1,32 +1,22 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. // -// Guards the presence projection: `toState(fromState(s)) ≡ s` over representative -// states. +// Guards the presence projection: `toState(fromState(s)) ≡ s` over `State.samples`. +// Presence uses the lower-level runners (see conformance.md), so — unlike a +// `runFeature` feature, which folds this round-trip into the one call — its +// projection round-trip is a standalone test over the same shared samples. import { describe, it } from "vitest"; -import type { Vec2 } from "@adobe/data/math"; import { Match } from "@adobe/data/testing"; import { State } from "../../../data/state/state.js"; import { createStore } from "./create-store.js"; import { fromState } from "./from-state.js"; import { toState } from "./to-state.js"; -const at = (x: number, y: number): Vec2 => [x, y] as Vec2; - -const states: readonly { readonly name: string; readonly state: State }[] = [ - { name: "no cursors reported yet", state: State.create() }, - { name: "one peer cursor", state: { cursors: { X: at(0.5, 0.25) } } }, - { - name: "both peer cursors", - state: { cursors: { X: at(0.5, 0.25), O: at(0.75, 0.5) } }, - }, -]; - describe("presence conformance projection round-trips (toState ∘ fromState ≡ identity)", () => { - for (const { name, state } of states) { - it(name, () => { + State.samples.forEach((state, i) => { + it(`sample ${i}`, () => { const store = createStore(); fromState(store, state); Match.assert(toState(store), state); }); - } + }); }); diff --git a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/transactions.test.ts b/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/transactions.test.ts index 57adeee7..76d48e76 100644 --- a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/transactions.test.ts +++ b/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/transactions.test.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -/// import { Conformance } from "@adobe/data/testing"; +import { transitions } from "../../../data/state/transitions.js"; import * as transactions from "../transaction-database/transactions/index.js"; import { createStore } from "./create-store.js"; import { seedUserId } from "./seed-user-id.js"; @@ -14,14 +14,7 @@ Conformance.runTransactions({ createStore, fromState, toState, - transitions: import.meta.glob( - [ - "../../../data/state/*.ts", - "!../../../data/state/*.test.ts", - "!../../../data/state/*.type-test.ts", - ], - { eager: true }, - ), + transitions, transactions, seedContext: (store, _before, args) => seedUserId(store, (args as { mark: string }).mark), diff --git a/packages/data-react-pixie/src/features/main/data/state/spec.test.ts b/packages/data-react-pixie/src/features/main/data/state/spec.test.ts index c85cde05..9d5c4074 100644 --- a/packages/data-react-pixie/src/features/main/data/state/spec.test.ts +++ b/packages/data-react-pixie/src/features/main/data/state/spec.test.ts @@ -1,16 +1,10 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -/// import { Conformance } from "@adobe/data/testing"; import { State } from "./state.js"; +import { transitions } from "./transitions.js"; // The single pure-spec test for every transform AND derivation in this folder. -// `runSpec` auto-discovers each sibling exporting `cases`, requires it to export -// exactly its function plus `cases`, and dispatches on case shape. Each case's -// `before`/`input` is a delta over `State.create()`. -Conformance.runSpec({ - state: State, - transitions: import.meta.glob( - ["./*.ts", "!./*.test.ts", "!./*.type-test.ts"], - { eager: true }, - ), -}); +// `runSpec` auto-discovers each module in `transitions` that exports `cases`, +// requires it to export exactly its function plus `cases`, and dispatches on case +// shape. Each case's `before`/`input` is a delta over `State.create()`. +Conformance.runSpec({ state: State, transitions }); diff --git a/packages/data-react-pixie/src/features/main/data/state/transitions.ts b/packages/data-react-pixie/src/features/main/data/state/transitions.ts new file mode 100644 index 00000000..1092fd91 --- /dev/null +++ b/packages/data-react-pixie/src/features/main/data/state/transitions.ts @@ -0,0 +1,11 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +/// + +// The transform/derivation modules for conformance discovery — the `{ fn, cases }` +// source shared by `spec.test.ts` (pure) and the ecs `conformance.test.ts`. The +// glob must be authored where Vite can statically see it (an eager glob, excluding +// tests and this file itself); consumers import the resolved map. +export const transitions = import.meta.glob>( + ["./*.ts", "!./*.test.ts", "!./*.type-test.ts", "!./transitions.ts"], + { eager: true }, +); diff --git a/packages/data-react-pixie/src/features/main/services/main-service/conformance/conformance.test.ts b/packages/data-react-pixie/src/features/main/services/main-service/conformance/conformance.test.ts index 7db542c1..2e7d551d 100644 --- a/packages/data-react-pixie/src/features/main/services/main-service/conformance/conformance.test.ts +++ b/packages/data-react-pixie/src/features/main/services/main-service/conformance/conformance.test.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -/// import { Conformance } from "@adobe/data/testing"; import { State } from "../../../data/state/state.js"; +import { transitions } from "../../../data/state/transitions.js"; import { MainService } from "../main-service.js"; import { projection } from "./projection.js"; @@ -9,13 +9,11 @@ import { projection } from "./projection.js"; // transactions/actions off `MainService.plugin`, seeds each case's `before` (a // delta) over `State.create()`, resolves each entity-addressed case's `entity()` // markers through the `fromState` id map, and round-trips `State.samples` through -// the projection. This feature has no derivations, so no `computedPlugin`. +// the projection. `transitions` (the discovered `{ fn, cases }` modules) is shared +// with spec.test. This feature has no derivations, so no `computedPlugin`. Conformance.runFeature({ state: State, - transitions: import.meta.glob( - ["../../../data/state/*.ts", "!**/*.test.ts", "!**/*.type-test.ts"], - { eager: true }, - ), + transitions, plugin: MainService.plugin, projection, }); diff --git a/packages/data-solid-dashboard/src/features/main/data/state/spec.test.ts b/packages/data-solid-dashboard/src/features/main/data/state/spec.test.ts index c85cde05..9d5c4074 100644 --- a/packages/data-solid-dashboard/src/features/main/data/state/spec.test.ts +++ b/packages/data-solid-dashboard/src/features/main/data/state/spec.test.ts @@ -1,16 +1,10 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -/// import { Conformance } from "@adobe/data/testing"; import { State } from "./state.js"; +import { transitions } from "./transitions.js"; // The single pure-spec test for every transform AND derivation in this folder. -// `runSpec` auto-discovers each sibling exporting `cases`, requires it to export -// exactly its function plus `cases`, and dispatches on case shape. Each case's -// `before`/`input` is a delta over `State.create()`. -Conformance.runSpec({ - state: State, - transitions: import.meta.glob( - ["./*.ts", "!./*.test.ts", "!./*.type-test.ts"], - { eager: true }, - ), -}); +// `runSpec` auto-discovers each module in `transitions` that exports `cases`, +// requires it to export exactly its function plus `cases`, and dispatches on case +// shape. Each case's `before`/`input` is a delta over `State.create()`. +Conformance.runSpec({ state: State, transitions }); diff --git a/packages/data-solid-dashboard/src/features/main/data/state/transitions.ts b/packages/data-solid-dashboard/src/features/main/data/state/transitions.ts new file mode 100644 index 00000000..1092fd91 --- /dev/null +++ b/packages/data-solid-dashboard/src/features/main/data/state/transitions.ts @@ -0,0 +1,11 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +/// + +// The transform/derivation modules for conformance discovery — the `{ fn, cases }` +// source shared by `spec.test.ts` (pure) and the ecs `conformance.test.ts`. The +// glob must be authored where Vite can statically see it (an eager glob, excluding +// tests and this file itself); consumers import the resolved map. +export const transitions = import.meta.glob>( + ["./*.ts", "!./*.test.ts", "!./*.type-test.ts", "!./transitions.ts"], + { eager: true }, +); diff --git a/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/conformance.test.ts b/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/conformance.test.ts index 07606cd3..17d95570 100644 --- a/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/conformance.test.ts +++ b/packages/data-solid-dashboard/src/features/main/services/main-service/conformance/conformance.test.ts @@ -1,20 +1,18 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -/// import { Conformance } from "@adobe/data/testing"; import { State } from "../../../data/state/state.js"; +import { transitions } from "../../../data/state/transitions.js"; import { MainService } from "../main-service.js"; import { projection } from "./projection.js"; // The whole ecs conformance for this feature in one call: `runFeature` pulls the // transactions/actions off `MainService.plugin`, seeds each case's `before` (a // delta) over `State.create()`, and round-trips `State.samples` through the -// projection. This feature has no derivations, so no `computedPlugin`. +// projection. `transitions` (the discovered `{ fn, cases }` modules) is shared with +// spec.test. This feature has no derivations, so no `computedPlugin`. Conformance.runFeature({ state: State, - transitions: import.meta.glob( - ["../../../data/state/*.ts", "!**/*.test.ts", "!**/*.type-test.ts"], - { eager: true }, - ), + transitions, plugin: MainService.plugin, projection, }); From 0e3968e0d0cdd3fbe5f41fdb3db05502898654d1 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 7 Aug 2026 15:41:23 -0700 Subject: [PATCH 36/37] refactor(samples): finish option-B patch-shaped transitions across all samples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert the remaining generic `> => T` transitions in space-rock, hopper, pixie, and solid to the patch shape `(state: Pick, args) => Pick` — returning only the fields each writes, no `...state` spread. Composers (space-rock `step`, hopper `step`, `createInitial`) merge sub-patches over the running state; the space-rock `spawnRandomWave` transaction now guards field-clear instead of the reference-identity no-op trick a patch return breaks. Value-returning selectors (frogOutcome, laneAt, startPosition) keep their generic — option B is about state transitions, not derivations. Co-Authored-By: Claude Opus 4.8 --- .../src/features/main/data/state/hop.ts | 48 +++++-------- .../src/features/main/data/state/lose-life.ts | 25 +++---- .../src/features/main/data/state/step.ts | 70 ++++++++++++------- .../src/features/main/data/state/win-goal.ts | 19 +++-- .../system-database/tick-loop.test.ts | 10 +-- .../main/data/state/create-initial.ts | 9 ++- .../features/main/data/state/fire-bullet.ts | 8 +-- .../main/data/state/resolve-bullet-hits.ts | 9 +-- .../main/data/state/resolve-ship-hits.ts | 11 ++- .../main/data/state/spawn-random-wave.ts | 12 ++-- .../features/main/data/state/spawn-wave.ts | 12 ++-- .../main/data/state/step-asteroids.ts | 8 +-- .../features/main/data/state/step-bullets.ts | 8 +-- .../src/features/main/data/state/step-ship.ts | 8 +-- .../src/features/main/data/state/step.ts | 16 +++-- .../transactions/spawn-random-wave.ts | 15 ++-- .../features/main/data/state/create-sprite.ts | 14 ++-- .../features/main/data/state/set-filter.ts | 18 ++--- .../main/data/state/set-sprite-active.ts | 33 +++------ .../main/data/state/set-sprite-hovered.ts | 33 +++------ .../src/features/main/data/state/tick.ts | 27 +++---- .../main/data/state/toggle-sprite-active.ts | 36 +++------- .../src/features/main/data/state/clear-log.ts | 26 +++---- .../src/features/main/data/state/decrement.ts | 30 ++++---- .../src/features/main/data/state/increment.ts | 31 ++++---- .../src/features/main/data/state/reset.ts | 23 +++--- .../features/main/data/state/set-user-name.ts | 23 +++--- 27 files changed, 254 insertions(+), 328 deletions(-) diff --git a/packages/data-gpu-hopper/src/features/main/data/state/hop.ts b/packages/data-gpu-hopper/src/features/main/data/state/hop.ts index 8068d241..a4f456f1 100644 --- a/packages/data-gpu-hopper/src/features/main/data/state/hop.ts +++ b/packages/data-gpu-hopper/src/features/main/data/state/hop.ts @@ -8,15 +8,14 @@ const clamp = (value: number, max: number): number => Math.max(0, Math.min(max, // Snap the frog one cell in `direction`, re-aligning a log-ridden fractional `x` // back onto the grid and clamping to the board. A no-op unless the game is in -// play, keeping it idempotent under repeated application. -export const hop = >( - state: T, +// play, keeping it idempotent. Writes only `frog`. +export const hop = ( + state: Pick, direction: Direction, -): T => { - if (!GameStatus.isPlaying(state.status)) return state; +): Pick => { + if (!GameStatus.isPlaying(state.status)) return { frog: state.frog }; const { dx, dy } = Direction.delta[direction]; return { - ...state, frog: { x: clamp(Math.round(state.frog.x) + dx, state.width - 1), y: clamp(state.frog.y + dy, state.height - 1), @@ -36,41 +35,30 @@ const base: Omit = { status: "playing", }; -// Spec-owned cases, shared with the ecs `hop` transaction. Covers each direction, -// clamping at all four board edges, the grid-snap of a log-ridden fractional x, -// and the game-over no-op. +// Spec-owned cases, shared with the ecs `hop` transaction. `before` overrides the +// default with a small test board; `after` is the writes patch (`frog` only). export const cases: Conformance = [ { name: "hops up toward the goal", - before: { ...base, frog: { x: 2, y: 0 } }, args: "up", - after: { ...base, frog: { x: 2, y: 1 } } }, + before: { ...base, frog: { x: 2, y: 0 } }, args: "up", after: { frog: { x: 2, y: 1 } } }, { name: "hops down toward the start", - before: { ...base, frog: { x: 2, y: 1 } }, args: "down", - after: { ...base, frog: { x: 2, y: 0 } } }, + before: { ...base, frog: { x: 2, y: 1 } }, args: "down", after: { frog: { x: 2, y: 0 } } }, { name: "hops left", - before: { ...base, frog: { x: 2, y: 1 } }, args: "left", - after: { ...base, frog: { x: 1, y: 1 } } }, + before: { ...base, frog: { x: 2, y: 1 } }, args: "left", after: { frog: { x: 1, y: 1 } } }, { name: "hops right", - before: { ...base, frog: { x: 2, y: 1 } }, args: "right", - after: { ...base, frog: { x: 3, y: 1 } } }, + before: { ...base, frog: { x: 2, y: 1 } }, args: "right", after: { frog: { x: 3, y: 1 } } }, { name: "clamps at the bottom row", - before: { ...base, frog: { x: 2, y: 0 } }, args: "down", - after: { ...base, frog: { x: 2, y: 0 } } }, + before: { ...base, frog: { x: 2, y: 0 } }, args: "down", after: { frog: { x: 2, y: 0 } } }, { name: "clamps at the top (goal) row", - before: { ...base, frog: { x: 2, y: 2 } }, args: "up", - after: { ...base, frog: { x: 2, y: 2 } } }, + before: { ...base, frog: { x: 2, y: 2 } }, args: "up", after: { frog: { x: 2, y: 2 } } }, { name: "clamps at the left edge", - before: { ...base, frog: { x: 0, y: 1 } }, args: "left", - after: { ...base, frog: { x: 0, y: 1 } } }, + before: { ...base, frog: { x: 0, y: 1 } }, args: "left", after: { frog: { x: 0, y: 1 } } }, { name: "clamps at the right edge", - before: { ...base, frog: { x: 4, y: 1 } }, args: "right", - after: { ...base, frog: { x: 4, y: 1 } } }, + before: { ...base, frog: { x: 4, y: 1 } }, args: "right", after: { frog: { x: 4, y: 1 } } }, { name: "snaps a log-ridden fractional x while hopping sideways", - before: { ...base, frog: { x: 2.4, y: 1 } }, args: "right", - after: { ...base, frog: { x: 3, y: 1 } } }, + before: { ...base, frog: { x: 2.4, y: 1 } }, args: "right", after: { frog: { x: 3, y: 1 } } }, { name: "snaps a log-ridden fractional x while hopping forward", - before: { ...base, frog: { x: 2.6, y: 1 } }, args: "up", - after: { ...base, frog: { x: 3, y: 2 } } }, + before: { ...base, frog: { x: 2.6, y: 1 } }, args: "up", after: { frog: { x: 3, y: 2 } } }, { name: "ignores input once the game is over", before: { ...base, status: "gameOver", frog: { x: 2, y: 1 } }, args: "up", - after: { ...base, status: "gameOver", frog: { x: 2, y: 1 } } }, + after: { frog: { x: 2, y: 1 } } }, ]; diff --git a/packages/data-gpu-hopper/src/features/main/data/state/lose-life.ts b/packages/data-gpu-hopper/src/features/main/data/state/lose-life.ts index faa0a2b9..8064830c 100644 --- a/packages/data-gpu-hopper/src/features/main/data/state/lose-life.ts +++ b/packages/data-gpu-hopper/src/features/main/data/state/lose-life.ts @@ -5,15 +5,17 @@ import type { Conformance } from "./conformance-case.js"; import { startPosition } from "./start-position.js"; // Spend one life: respawn the frog at the start, or end the game if that was the -// last life. A no-op once the game has finished, keeping it idempotent. -export const loseLife = >( - state: T, -): T => { - if (!GameStatus.isPlaying(state.status)) return state; +// last life. A no-op once the game has finished. Writes only `lives` + `status` +// + `frog` (each branch supplies all three; some are unchanged). +export const loseLife = ( + state: Pick, +): Pick => { + if (!GameStatus.isPlaying(state.status)) + return { lives: state.lives, status: state.status, frog: state.frog }; const lives = state.lives - 1; return lives <= 0 - ? { ...state, lives: 0, status: "gameOver" } - : { ...state, lives, frog: startPosition(state) }; + ? { lives: 0, status: "gameOver", frog: state.frog } + : { lives, status: state.status, frog: startPosition(state) }; }; // A 5-wide board, so the respawn column is `floor((5-1)/2) = 2`. loseLife reads @@ -26,16 +28,15 @@ const base: Omit = { score: 0, }; -// Spec-owned cases, shared with the ecs `loseLife` transaction: a life lost + -// respawn, the last life ending the game (no respawn), and the finished-game no-op. +// Spec-owned cases, shared with the ecs `loseLife` transaction. export const cases: Conformance = [ { name: "spends a life and respawns the frog at the start", before: { ...base, lives: 3, status: "playing", frog: { x: 1, y: 1 } }, - after: { ...base, lives: 2, status: "playing", frog: { x: 2, y: 0 } } }, + after: { lives: 2, status: "playing", frog: { x: 2, y: 0 } } }, { name: "the last life ends the game without respawning", before: { ...base, lives: 1, status: "playing", frog: { x: 3, y: 2 } }, - after: { ...base, lives: 0, status: "gameOver", frog: { x: 3, y: 2 } } }, + after: { lives: 0, status: "gameOver", frog: { x: 3, y: 2 } } }, { name: "ignores a finished game (no-op)", before: { ...base, lives: 0, status: "gameOver", frog: { x: 3, y: 2 } }, - after: { ...base, lives: 0, status: "gameOver", frog: { x: 3, y: 2 } } }, + after: { lives: 0, status: "gameOver", frog: { x: 3, y: 2 } } }, ]; diff --git a/packages/data-gpu-hopper/src/features/main/data/state/step.ts b/packages/data-gpu-hopper/src/features/main/data/state/step.ts index d98c8af7..802eeb77 100644 --- a/packages/data-gpu-hopper/src/features/main/data/state/step.ts +++ b/packages/data-gpu-hopper/src/features/main/data/state/step.ts @@ -11,12 +11,27 @@ import { frogOutcome } from "./frog-outcome.js"; import { winGoal } from "./win-goal.js"; import { loseLife } from "./lose-life.js"; +// The slice `step` writes. Every branch supplies all five keys; a composer layers +// the outcome sub-patch (`winGoal` / `loseLife`) over the movement fields. +type StepPatch = Pick; + // Advance the simulation by `dt` seconds: scroll the hazards, carry the frog if // it is riding a log, then resolve its fate — score a win, or on a fatal outcome // spend a life and respawn (or end the game once the last life is gone). A no-op // once the game has ended, keeping it idempotent. -export const step = (state: State, dt: number): State => { - if (!GameStatus.isPlaying(state.status)) return state; +export const step = ( + state: Pick, + dt: number, +): StepPatch => { + if (!GameStatus.isPlaying(state.status)) { + return { + hazards: state.hazards, + frog: state.frog, + score: state.score, + status: state.status, + lives: state.lives, + }; + } const hazards = state.hazards.map((hazard) => Hazard.advance(hazard, dt, state.width)); const lane = laneAt(state, state.frog.y); @@ -34,12 +49,22 @@ export const step = (state: State, dt: number): State => { ? { x: state.frog.x + carrier.velocity * dt, y: state.frog.y } : state.frog; - const moved: State = { ...state, hazards, frog }; + // The moved world (full read slice + updated hazards/frog), fed to the outcome + // derivation and the winGoal / loseLife sub-transitions. + const moved = { ...state, hazards, frog }; + // The movement-only patch the outcome sub-patch layers over. + const movedPatch: StepPatch = { + hazards, + frog, + score: state.score, + status: state.status, + lives: state.lives, + }; const outcome = frogOutcome(moved); - if (outcome === "win") return winGoal(moved); - if (Outcome.isFatal[outcome]) return loseLife(moved); - return moved; + if (outcome === "win") return { ...movedPatch, ...winGoal(moved) }; + if (Outcome.isFatal[outcome]) return { ...movedPatch, ...loseLife(moved) }; + return movedPatch; }; // Two 5-wide, 3-tall boards differing only in the middle lane's terrain. @@ -56,73 +81,66 @@ const riverLanes: readonly Lane[] = [ // Spec-owned cases (args is dt), shared with the ecs tick (the system loop conforms // to this — see systems.md). Every step here uses dt = 1 so hazard/carry -// displacements are exact. Covers: hazards scrolling while the frog stays safe, a -// car hit (life lost + respawn), the final-life game over, drowning over open -// water, riding a log, being carried off the board edge (with a wrapping log), -// reaching the goal, and the game-over no-op. +// displacements are exact. `before` overrides the default with a small test board; +// `after` is the writes patch (hazards / frog / score / status / lives). Covers: +// hazards scrolling while the frog stays safe, a car hit (life lost + respawn), the +// final-life game over, drowning over open water, riding a log, being carried off +// the board edge (with a wrapping log), reaching the goal, and the game-over no-op. export const cases: Conformance = [ { name: "scrolls hazards while the frog rests on grass", before: { width: 5, height: 3, lanes: roadLanes, hazards: [{ kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }], frog: { x: 2, y: 0 }, lives: 3, score: 0, status: "playing" }, args: 1, - after: { width: 5, height: 3, lanes: roadLanes, - hazards: [{ kind: "car", lane: 1, x: 1, width: 1, velocity: 1 }], + after: { hazards: [{ kind: "car", lane: 1, x: 1, width: 1, velocity: 1 }], frog: { x: 2, y: 0 }, lives: 3, score: 0, status: "playing" } }, { name: "a car reaching the frog costs a life and respawns it", before: { width: 5, height: 3, lanes: roadLanes, hazards: [{ kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }], frog: { x: 1, y: 1 }, lives: 3, score: 0, status: "playing" }, args: 1, - after: { width: 5, height: 3, lanes: roadLanes, - hazards: [{ kind: "car", lane: 1, x: 1, width: 1, velocity: 1 }], + after: { hazards: [{ kind: "car", lane: 1, x: 1, width: 1, velocity: 1 }], frog: { x: 2, y: 0 }, lives: 2, score: 0, status: "playing" } }, { name: "a car hit on the last life ends the game", before: { width: 5, height: 3, lanes: roadLanes, hazards: [{ kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }], frog: { x: 1, y: 1 }, lives: 1, score: 0, status: "playing" }, args: 1, - after: { width: 5, height: 3, lanes: roadLanes, - hazards: [{ kind: "car", lane: 1, x: 1, width: 1, velocity: 1 }], + after: { hazards: [{ kind: "car", lane: 1, x: 1, width: 1, velocity: 1 }], frog: { x: 1, y: 1 }, lives: 0, score: 0, status: "gameOver" } }, { name: "open water with no log under the frog drowns it", before: { width: 5, height: 3, lanes: riverLanes, hazards: [{ kind: "log", lane: 1, x: 3, width: 1, velocity: 0 }], frog: { x: 1, y: 1 }, lives: 3, score: 0, status: "playing" }, args: 1, - after: { width: 5, height: 3, lanes: riverLanes, - hazards: [{ kind: "log", lane: 1, x: 3, width: 1, velocity: 0 }], + after: { hazards: [{ kind: "log", lane: 1, x: 3, width: 1, velocity: 0 }], frog: { x: 2, y: 0 }, lives: 2, score: 0, status: "playing" } }, { name: "a log carries the frog along and keeps it safe", before: { width: 5, height: 3, lanes: riverLanes, hazards: [{ kind: "log", lane: 1, x: 0, width: 3, velocity: 1 }], frog: { x: 1, y: 1 }, lives: 3, score: 0, status: "playing" }, args: 1, - after: { width: 5, height: 3, lanes: riverLanes, - hazards: [{ kind: "log", lane: 1, x: 1, width: 3, velocity: 1 }], + after: { hazards: [{ kind: "log", lane: 1, x: 1, width: 3, velocity: 1 }], frog: { x: 2, y: 1 }, lives: 3, score: 0, status: "playing" } }, { name: "a log carrying the frog past the edge drowns it", before: { width: 5, height: 3, lanes: riverLanes, hazards: [{ kind: "log", lane: 1, x: 3, width: 2, velocity: 2 }], frog: { x: 4, y: 1 }, lives: 3, score: 0, status: "playing" }, args: 1, - after: { width: 5, height: 3, lanes: riverLanes, - hazards: [{ kind: "log", lane: 1, x: 0, width: 2, velocity: 2 }], + after: { hazards: [{ kind: "log", lane: 1, x: 0, width: 2, velocity: 2 }], frog: { x: 2, y: 0 }, lives: 2, score: 0, status: "playing" } }, { name: "reaching the goal scores and wins", before: { width: 5, height: 3, lanes: roadLanes, hazards: [{ kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }], frog: { x: 2, y: 2 }, lives: 3, score: 0, status: "playing" }, args: 1, - after: { width: 5, height: 3, lanes: roadLanes, - hazards: [{ kind: "car", lane: 1, x: 1, width: 1, velocity: 1 }], + after: { hazards: [{ kind: "car", lane: 1, x: 1, width: 1, velocity: 1 }], frog: { x: 2, y: 2 }, lives: 3, score: 1, status: "won" } }, { name: "does nothing once the game is over", before: { width: 5, height: 3, lanes: roadLanes, hazards: [{ kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }], frog: { x: 2, y: 0 }, lives: 0, score: 0, status: "gameOver" }, args: 1, - after: { width: 5, height: 3, lanes: roadLanes, - hazards: [{ kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }], + after: { hazards: [{ kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }], frog: { x: 2, y: 0 }, lives: 0, score: 0, status: "gameOver" } }, ]; diff --git a/packages/data-gpu-hopper/src/features/main/data/state/win-goal.ts b/packages/data-gpu-hopper/src/features/main/data/state/win-goal.ts index 5f0218cb..4addcc6c 100644 --- a/packages/data-gpu-hopper/src/features/main/data/state/win-goal.ts +++ b/packages/data-gpu-hopper/src/features/main/data/state/win-goal.ts @@ -4,10 +4,12 @@ import type { State } from "./state.js"; import type { Conformance } from "./conformance-case.js"; // Score the reached goal and end the game as won. A no-op once the game has -// finished, keeping it idempotent. -export const winGoal = >(state: T): T => { - if (!GameStatus.isPlaying(state.status)) return state; - return { ...state, score: state.score + 1, status: "won" }; +// finished. Writes only `score` + `status`. +export const winGoal = ( + state: Pick, +): Pick => { + if (!GameStatus.isPlaying(state.status)) return { score: state.score, status: state.status }; + return { score: state.score + 1, status: "won" }; }; // winGoal reads only score / status; the rest is inert here. @@ -20,13 +22,10 @@ const base: Omit = { frog: { x: 2, y: 2 }, }; -// Spec-owned cases, shared with the ecs `winGoal` transaction: scoring the goal, -// and the finished-game no-op. +// Spec-owned cases, shared with the ecs `winGoal` transaction. export const cases: Conformance = [ { name: "scores the goal and wins", - before: { ...base, score: 2, status: "playing" }, - after: { ...base, score: 3, status: "won" } }, + before: { ...base, score: 2, status: "playing" }, after: { score: 3, status: "won" } }, { name: "ignores a finished game (no-op)", - before: { ...base, score: 3, status: "won" }, - after: { ...base, score: 3, status: "won" } }, + before: { ...base, score: 3, status: "won" }, after: { score: 3, status: "won" } }, ]; diff --git a/packages/data-gpu-hopper/src/features/main/services/main-service/system-database/tick-loop.test.ts b/packages/data-gpu-hopper/src/features/main/services/main-service/system-database/tick-loop.test.ts index 328ea66a..8777f6d5 100644 --- a/packages/data-gpu-hopper/src/features/main/services/main-service/system-database/tick-loop.test.ts +++ b/packages/data-gpu-hopper/src/features/main/services/main-service/system-database/tick-loop.test.ts @@ -29,16 +29,18 @@ describe("ECS system tick loop conforms to State.step (one frame = one step)", ( for (const testCase of cases) { it(testCase.name, () => { const dt = testCase.args; - // A case `before` is a delta over the feature default (`Case.before` is - // `Partial`), so materialise the full seed the same way the runners do. + // A case `before` is a delta over the feature default and `after` a writes + // patch (`Case.before`/`after` are `Partial`), so materialise the full + // seed and the full expected state the same way the runners do. const before = { ...State.create(), ...testCase.before }; - Match.assert(State.step(before, dt), testCase.after, unordered); + const expected = { ...before, ...testCase.after }; + Match.assert({ ...before, ...State.step(before, dt) }, expected, unordered); const db = createSystemDatabase(); projection.fromState(db.store, before); db.store.resources.frameDelta = dt; driveFrame(db); - Match.assert(projection.toState(db.store), testCase.after, unordered); + Match.assert(projection.toState(db.store), expected, unordered); }); } }); diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/create-initial.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/create-initial.ts index 3a8c48ea..d91e46d7 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/create-initial.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/create-initial.ts @@ -15,8 +15,8 @@ import { spawnWave } from "./spawn-wave.js"; export const createInitial = ( _state: State, { bounds }: { readonly bounds: Vec2 }, -): State => - spawnWave({ +): State => { + const fresh: State = { bounds, ship: Ship.spawn(Vec2.scale(bounds, 0.5)), bullets: [], @@ -24,7 +24,10 @@ export const createInitial = ( score: 0, lives: 3, wave: 0, - }); + }; + // spawnWave returns only { asteroids, wave }; layer it over the fresh game. + return { ...fresh, ...spawnWave(fresh) }; +}; // Spec-owned cases, shared with the ecs `newGame` transaction. `createInitial` // ignores `before` entirely — it produces a fresh game from the bounds alone — so diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/fire-bullet.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/fire-bullet.ts index cf2264e4..67ee98d0 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/fire-bullet.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/fire-bullet.ts @@ -7,12 +7,12 @@ import { Ship } from "../ship/ship.js"; // Fire one bullet from the ship's nose, inheriting its momentum. Composes the // ship's muzzle kinematics with the bullet's own speed constant. -export const fireBullet = >( - state: T, -): T => { +export const fireBullet = ( + state: Pick, +): Pick => { const { position, velocity } = Ship.muzzle(state.ship, Bullet.speed); const bullet: Bullet = { position, velocity, age: 0 }; - return { ...state, bullets: [...state.bullets, bullet] }; + return { bullets: [...state.bullets, bullet] }; }; // Spec-owned cases, shared with the ecs `fireBullet` transaction. A bullet leaves diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/resolve-bullet-hits.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/resolve-bullet-hits.ts index f0fcabed..74c5e794 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/resolve-bullet-hits.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/resolve-bullet-hits.ts @@ -17,12 +17,10 @@ import { Collision } from "../collision/collision.js"; // prev = position - velocity*dt — and test that whole segment against each // asteroid. Asteroids are treated as stationary at their current position: they // drift ~1px/frame, negligible against the bullet's sweep. -export const resolveBulletHits = < - T extends Pick, ->( - state: T, +export const resolveBulletHits = ( + state: Pick, dt: number, -): T => { +): Pick => { const asteroids: Asteroid[] = [...state.asteroids]; // Children spawned this pass are collected separately and appended only after // every bullet has resolved — a bullet may hit an asteroid that existed at the @@ -52,7 +50,6 @@ export const resolveBulletHits = < spawned.push(...Asteroid.split(asteroid)); } return { - ...state, bullets: survivors, asteroids: [...asteroids, ...spawned], score, diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/resolve-ship-hits.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/resolve-ship-hits.ts index 1ac4d83a..4d97185b 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/resolve-ship-hits.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/resolve-ship-hits.ts @@ -9,11 +9,9 @@ import { Collision } from "../collision/collision.js"; // If any asteroid is touching the ship, it costs a life and the ship respawns // at the centre. No collision leaves the state untouched (idempotent). -export const resolveShipHits = < - T extends Pick, ->( - state: T, -): T => { +export const resolveShipHits = ( + state: Pick, +): Pick => { const struck = state.asteroids.some((a) => Collision.circlesOverlap( state.ship.position, @@ -23,10 +21,9 @@ export const resolveShipHits = < ), ); if (!struck) { - return state; + return { ship: state.ship, lives: state.lives }; } return { - ...state, lives: Math.max(0, state.lives - 1), ship: Ship.spawn(Vec2.scale(state.bounds, 0.5)), }; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-random-wave.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-random-wave.ts index 42758101..b2ec52dd 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-random-wave.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-random-wave.ts @@ -26,14 +26,12 @@ const asteroidsFor = (wave: number): number => 3 + wave; * `[0.5×, 1.5×)` — drawing one value per asteroid in ring order. A no-op while * asteroids remain (draws nothing, returns the same reference). */ -export const spawnRandomWave = < - T extends Pick, ->( - state: T, +export const spawnRandomWave = ( + state: Pick, { random }: { random: RandomService }, -): T => { +): Pick => { if (state.asteroids.length > 0) { - return state; + return { asteroids: state.asteroids, wave: state.wave }; } const wave = state.wave + 1; const count = asteroidsFor(wave); @@ -51,7 +49,7 @@ export const spawnRandomWave = < size: Size.largest, }); } - return { ...state, wave, asteroids }; + return { wave, asteroids }; }; // Spec-owned cases, shared with the ecs `spawnRandomWave` transaction. Each case diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-wave.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-wave.ts index 8a7f7c92..4ed2a018 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-wave.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-wave.ts @@ -18,13 +18,11 @@ const asteroidsFor = (wave: number): number => 3 + wave; // (`createInitial`, so a fresh game always starts from the same fair layout). // The randomized sibling `spawnRandomWave` injects a `random` service for the // varied refill waves the tick loop spawns. -export const spawnWave = < - T extends Pick, ->( - state: T, -): T => { +export const spawnWave = ( + state: Pick, +): Pick => { if (state.asteroids.length > 0) { - return state; + return { asteroids: state.asteroids, wave: state.wave }; } const wave = state.wave + 1; const count = asteroidsFor(wave); @@ -41,7 +39,7 @@ export const spawnWave = < size: Size.largest, }); } - return { ...state, wave, asteroids }; + return { wave, asteroids }; }; // Spec-owned cases for the deterministic `spawnWave` (no args) — the fixed FIRST diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/step-asteroids.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/step-asteroids.ts index e14fe5ec..a0ea1e60 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/step-asteroids.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/step-asteroids.ts @@ -6,10 +6,10 @@ import { Motion } from "../motion/motion.js"; import { Size } from "../size/size.js"; // Drift every asteroid one tick by its constant velocity, wrapping at edges. -export const stepAsteroids = >( - state: T, +export const stepAsteroids = ( + state: Pick, dt: number, -): T => { +): Pick => { const asteroids = state.asteroids.map((a) => ({ ...a, position: Motion.wrap( @@ -17,7 +17,7 @@ export const stepAsteroids = >( state.bounds, ), })); - return { ...state, asteroids }; + return { asteroids }; }; // Spec-owned cases, shared with the ecs system conformance (the asteroid half of diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/step-bullets.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/step-bullets.ts index 2c4a81f6..6f33fd82 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/step-bullets.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/step-bullets.ts @@ -7,10 +7,10 @@ import { Motion } from "../motion/motion.js"; // Advance every bullet one tick: drop the ones that expire this tick, and move // + age + wrap the survivors. -export const stepBullets = >( - state: T, +export const stepBullets = ( + state: Pick, dt: number, -): T => { +): Pick => { const bullets = state.bullets .filter((b) => !Bullet.isExpired(b.age, dt)) .map((b) => ({ @@ -21,7 +21,7 @@ export const stepBullets = >( ), age: b.age + dt, })); - return { ...state, bullets }; + return { bullets }; }; // Spec-owned cases, shared with the ecs system conformance (the `lifetime` diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/step-ship.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/step-ship.ts index c2edd28e..1a2ab98a 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/step-ship.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/step-ship.ts @@ -10,10 +10,10 @@ import { Motion } from "../motion/motion.js"; // and wrap at the screen edges. `dt` and `input` are bundled into one args object // (second parameter) so the co-located conformance cases derive their `args` type // straight from this signature (`Conformance`). -export const stepShip = >( - state: T, +export const stepShip = ( + state: Pick, { dt, input }: { readonly dt: number; readonly input: Input }, -): T => { +): Pick => { const { ship } = state; const rotation = Ship.turn(ship.rotation, input.turn, dt); const velocity = input.thrust @@ -23,7 +23,7 @@ export const stepShip = >( Motion.advance(ship.position, velocity, dt), state.bounds, ); - return { ...state, ship: { position, velocity, rotation } }; + return { ship: { position, velocity, rotation } }; }; // Spec-owned cases, shared with the ecs system conformance (the `control` + ship diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/step.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/step.ts index 28763ae8..0a4cbc06 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/step.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/step.ts @@ -42,15 +42,17 @@ export const step = ( if (isGameOver(state)) { return state; } - let next = stepShip(state, { dt, input }); + // Each sub-transition returns only the fields it writes; layer each patch over + // the running full state so the whole tick composes into one State. + let next: State = { ...state, ...stepShip(state, { dt, input }) }; if (input.fire) { - next = fireBullet(next); + next = { ...next, ...fireBullet(next) }; } - next = stepBullets(next, dt); - next = stepAsteroids(next, dt); - next = resolveBulletHits(next, dt); - next = resolveShipHits(next); - next = spawnRandomWave(next, { random }); + next = { ...next, ...stepBullets(next, dt) }; + next = { ...next, ...stepAsteroids(next, dt) }; + next = { ...next, ...resolveBulletHits(next, dt) }; + next = { ...next, ...resolveShipHits(next) }; + next = { ...next, ...spawnRandomWave(next, { random }) }; return next; }; diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/spawn-random-wave.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/spawn-random-wave.ts index 517794c9..24252c21 100644 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/spawn-random-wave.ts +++ b/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/spawn-random-wave.ts @@ -15,13 +15,14 @@ export const spawnRandomWave = ( t: CoreDatabase.Store, { random }: { random: RandomService }, ): void => { - const before = { - asteroids: readAsteroids(t), - wave: t.resources.wave, - bounds: t.resources.bounds, - }; - const after = State.spawnRandomWave(before, { random }); - if (after === before) return; + // spawnRandomWave is a no-op while the field still has asteroids; guard here so + // a mid-wave dispatch inserts nothing (the patch return is a fresh object, so it + // can no longer be reference-compared to detect the no-op). + if (readAsteroids(t).length > 0) return; + const after = State.spawnRandomWave( + { asteroids: [], wave: t.resources.wave, bounds: t.resources.bounds }, + { random }, + ); t.resources.wave = after.wave; for (const asteroid of after.asteroids) { t.archetypes.Asteroid.insert(asteroid); diff --git a/packages/data-react-pixie/src/features/main/data/state/create-sprite.ts b/packages/data-react-pixie/src/features/main/data/state/create-sprite.ts index deb30c7e..7790920d 100644 --- a/packages/data-react-pixie/src/features/main/data/state/create-sprite.ts +++ b/packages/data-react-pixie/src/features/main/data/state/create-sprite.ts @@ -8,15 +8,15 @@ import { Match } from "@adobe/data/testing"; const nextSpriteId = (state: Pick): number => state.sprites.reduce((max, sprite) => Math.max(max, sprite.id), 0) + 1; -export const createSprite = >( - state: T, +// Append a sprite to the scene. Returns only the field it writes (`sprites`). +export const createSprite = ( + state: Pick, input: { readonly position: Vec2; readonly rotation?: number; readonly kind: SpriteKind; }, -): T => ({ - ...state, +): Pick => ({ sprites: [ ...state.sprites, { @@ -33,11 +33,11 @@ export const createSprite = >( // Spec-owned cases, shared with the ecs `createSprite` transaction. A sprite is // appended (minted id left open as `anyNumber` — the ecs assigns its own) with // rotation defaulting to 0 and hovered/active to false; existing sprites are -// untouched. +// untouched. `before` is a delta over `State.create()`; `after` is the writes patch. export const cases: Conformance = [ { name: "appends the first sprite to an empty scene", - before: { sprites: [], filter: "none" }, + before: {}, args: { position: [100, 100], kind: "bunny" }, after: { sprites: [ @@ -50,7 +50,6 @@ export const cases: Conformance = [ active: false, }, ], - filter: "none", }, }, { @@ -88,7 +87,6 @@ export const cases: Conformance = [ active: false, }, ], - filter: "sepia", }, }, ]; diff --git a/packages/data-react-pixie/src/features/main/data/state/set-filter.ts b/packages/data-react-pixie/src/features/main/data/state/set-filter.ts index fb9f8845..122db4a8 100644 --- a/packages/data-react-pixie/src/features/main/data/state/set-filter.ts +++ b/packages/data-react-pixie/src/features/main/data/state/set-filter.ts @@ -3,24 +3,24 @@ import type { FilterKind } from "../filter-kind/filter-kind.js"; import type { State } from "./state.js"; import type { Conformance } from "./conformance-case.js"; -export const setFilter = >( - state: T, +// Replace the scene-wide filter. Writes only `filter`; sprites are untouched. +export const setFilter = ( + _state: Pick, input: { readonly filter: FilterKind }, -): T => ({ ...state, filter: input.filter }); +): Pick => ({ filter: input.filter }); -// Spec-owned cases, shared with the ecs `setFilter` transaction. Replaces the -// scene-wide filter; sprites are untouched. +// Spec-owned cases, shared with the ecs `setFilter` transaction. export const cases: Conformance = [ { name: "sets the filter from none to sepia", - before: { sprites: [], filter: "none" }, + before: {}, args: { filter: "sepia" }, - after: { sprites: [], filter: "sepia" }, + after: { filter: "sepia" }, }, { name: "replaces an existing filter", - before: { sprites: [], filter: "blur" }, + before: { filter: "blur" }, args: { filter: "night" }, - after: { sprites: [], filter: "night" }, + after: { filter: "night" }, }, ]; diff --git a/packages/data-react-pixie/src/features/main/data/state/set-sprite-active.ts b/packages/data-react-pixie/src/features/main/data/state/set-sprite-active.ts index efb92f94..7d4547b6 100644 --- a/packages/data-react-pixie/src/features/main/data/state/set-sprite-active.ts +++ b/packages/data-react-pixie/src/features/main/data/state/set-sprite-active.ts @@ -4,59 +4,46 @@ import type { State } from "./state.js"; import { entity, type Conformance } from "./conformance-case.js"; import { Match } from "@adobe/data/testing"; -export const setSpriteActive = >( - state: T, +// Set the addressed sprite's `active` flag. Writes only `sprites`. +export const setSpriteActive = ( + state: Pick, input: { readonly id: number; readonly active: boolean }, -): T => ({ - ...state, +): Pick => ({ sprites: state.sprites.map((sprite) => sprite.id === input.id ? { ...sprite, active: input.active } : sprite, ), }); const bunny: Sprite = { - id: 1, - position: [100, 100], - rotation: 0, - kind: "bunny", - hovered: false, - active: false, + id: 1, position: [100, 100], rotation: 0, kind: "bunny", hovered: false, active: false, }; const fox: Sprite = { - id: 2, - position: [300, 200], - rotation: 1, - kind: "fox", - hovered: false, - active: false, + id: 2, position: [300, 200], rotation: 1, kind: "fox", hovered: false, active: false, }; -// Spec-owned cases, shared with the ecs `setSpriteActive` transaction. Sets the -// addressed sprite's `active` flag; a no-op for an unknown id. `before` ids -// address the sprite; `after` ids are left open (`anyNumber`). +// Spec-owned cases, shared with the ecs `setSpriteActive` transaction. `before` +// ids address the sprite (`entity(2)`); `after` ids are left open (`anyNumber`). export const cases: Conformance = [ { name: "sets active true on the addressed sprite only", - before: { sprites: [bunny, fox], filter: "none" }, + before: { sprites: [bunny, fox] }, args: { id: entity(2), active: true }, after: { sprites: [ { ...bunny, id: Match.anyNumber }, { ...fox, id: Match.anyNumber, active: true }, ], - filter: "none", }, }, { name: "is a no-op for an unknown id", - before: { sprites: [bunny, fox], filter: "none" }, + before: { sprites: [bunny, fox] }, args: { id: entity(99), active: true }, after: { sprites: [ { ...bunny, id: Match.anyNumber }, { ...fox, id: Match.anyNumber }, ], - filter: "none", }, }, ]; diff --git a/packages/data-react-pixie/src/features/main/data/state/set-sprite-hovered.ts b/packages/data-react-pixie/src/features/main/data/state/set-sprite-hovered.ts index 2aa615a2..2bebae98 100644 --- a/packages/data-react-pixie/src/features/main/data/state/set-sprite-hovered.ts +++ b/packages/data-react-pixie/src/features/main/data/state/set-sprite-hovered.ts @@ -4,59 +4,46 @@ import type { State } from "./state.js"; import { entity, type Conformance } from "./conformance-case.js"; import { Match } from "@adobe/data/testing"; -export const setSpriteHovered = >( - state: T, +// Set the addressed sprite's `hovered` flag. Writes only `sprites`. +export const setSpriteHovered = ( + state: Pick, input: { readonly id: number; readonly hovered: boolean }, -): T => ({ - ...state, +): Pick => ({ sprites: state.sprites.map((sprite) => sprite.id === input.id ? { ...sprite, hovered: input.hovered } : sprite, ), }); const bunny: Sprite = { - id: 1, - position: [100, 100], - rotation: 0, - kind: "bunny", - hovered: false, - active: false, + id: 1, position: [100, 100], rotation: 0, kind: "bunny", hovered: false, active: false, }; const fox: Sprite = { - id: 2, - position: [300, 200], - rotation: 1, - kind: "fox", - hovered: false, - active: false, + id: 2, position: [300, 200], rotation: 1, kind: "fox", hovered: false, active: false, }; -// Spec-owned cases, shared with the ecs `setSpriteHovered` transaction. Sets the -// addressed sprite's `hovered` flag; a no-op for an unknown id. `before` ids -// address the sprite; `after` ids are left open (`anyNumber`). +// Spec-owned cases, shared with the ecs `setSpriteHovered` transaction. `before` +// ids address the sprite (`entity(1)`); `after` ids are left open (`anyNumber`). export const cases: Conformance = [ { name: "sets hovered true on the addressed sprite only", - before: { sprites: [bunny, fox], filter: "none" }, + before: { sprites: [bunny, fox] }, args: { id: entity(1), hovered: true }, after: { sprites: [ { ...bunny, id: Match.anyNumber, hovered: true }, { ...fox, id: Match.anyNumber }, ], - filter: "none", }, }, { name: "is a no-op for an unknown id", - before: { sprites: [bunny, fox], filter: "none" }, + before: { sprites: [bunny, fox] }, args: { id: entity(99), hovered: true }, after: { sprites: [ { ...bunny, id: Match.anyNumber }, { ...fox, id: Match.anyNumber }, ], - filter: "none", }, }, ]; diff --git a/packages/data-react-pixie/src/features/main/data/state/tick.ts b/packages/data-react-pixie/src/features/main/data/state/tick.ts index 52b23574..2c283c44 100644 --- a/packages/data-react-pixie/src/features/main/data/state/tick.ts +++ b/packages/data-react-pixie/src/features/main/data/state/tick.ts @@ -6,11 +6,11 @@ import { Match } from "@adobe/data/testing"; // Advance one animation frame: every sprite rotates by `delta * 0.1` radians. // `delta` is the frame time step, supplied by the caller (the render loop). -export const tick = >( - state: T, +// Writes only `sprites`. +export const tick = ( + state: Pick, input: { readonly delta: number }, -): T => ({ - ...state, +): Pick => ({ sprites: state.sprites.map((sprite) => ({ ...sprite, rotation: sprite.rotation + input.delta * 0.1, @@ -18,20 +18,10 @@ export const tick = >( }); const bunny: Sprite = { - id: 1, - position: [100, 100], - rotation: 0, - kind: "bunny", - hovered: false, - active: false, + id: 1, position: [100, 100], rotation: 0, kind: "bunny", hovered: false, active: false, }; const fox: Sprite = { - id: 2, - position: [300, 200], - rotation: 1, - kind: "fox", - hovered: false, - active: false, + id: 2, position: [300, 200], rotation: 1, kind: "fox", hovered: false, active: false, }; // Spec-owned cases, shared with the ecs `tick` transaction. Every sprite's @@ -39,20 +29,19 @@ const fox: Sprite = { export const cases: Conformance = [ { name: "advances every sprite's rotation by delta * 0.1", - before: { sprites: [bunny, fox], filter: "none" }, + before: { sprites: [bunny, fox] }, args: { delta: 10 }, after: { sprites: [ { ...bunny, id: Match.anyNumber, rotation: 1 }, { ...fox, id: Match.anyNumber, rotation: 2 }, ], - filter: "none", }, }, { name: "is a no-op on an empty scene", before: { sprites: [], filter: "blur" }, args: { delta: 5 }, - after: { sprites: [], filter: "blur" }, + after: { sprites: [] }, }, ]; diff --git a/packages/data-react-pixie/src/features/main/data/state/toggle-sprite-active.ts b/packages/data-react-pixie/src/features/main/data/state/toggle-sprite-active.ts index 3bf8d6de..b2d6b609 100644 --- a/packages/data-react-pixie/src/features/main/data/state/toggle-sprite-active.ts +++ b/packages/data-react-pixie/src/features/main/data/state/toggle-sprite-active.ts @@ -4,71 +4,57 @@ import type { State } from "./state.js"; import { entity, type Conformance } from "./conformance-case.js"; import { Match } from "@adobe/data/testing"; -export const toggleSpriteActive = >( - state: T, +// Flip the addressed sprite's `active` flag. Writes only `sprites`. +export const toggleSpriteActive = ( + state: Pick, input: { readonly id: number }, -): T => ({ - ...state, +): Pick => ({ sprites: state.sprites.map((sprite) => sprite.id === input.id ? { ...sprite, active: !sprite.active } : sprite, ), }); const bunny: Sprite = { - id: 1, - position: [100, 100], - rotation: 0, - kind: "bunny", - hovered: false, - active: false, + id: 1, position: [100, 100], rotation: 0, kind: "bunny", hovered: false, active: false, }; const activeFox: Sprite = { - id: 2, - position: [300, 200], - rotation: 1, - kind: "fox", - hovered: false, - active: true, + id: 2, position: [300, 200], rotation: 1, kind: "fox", hovered: false, active: true, }; -// Spec-owned cases, shared with the ecs `toggleSpriteActive` transaction. Flips -// the addressed sprite's `active` flag; a no-op for an unknown id. `before` ids -// address the sprite; `after` ids are left open (`anyNumber`). +// Spec-owned cases, shared with the ecs `toggleSpriteActive` transaction. `before` +// ids address the sprite; `after` ids are left open (`anyNumber`). export const cases: Conformance = [ { name: "toggles a sprite from inactive to active", - before: { sprites: [bunny, activeFox], filter: "none" }, + before: { sprites: [bunny, activeFox] }, args: { id: entity(1) }, after: { sprites: [ { ...bunny, id: Match.anyNumber, active: true }, { ...activeFox, id: Match.anyNumber }, ], - filter: "none", }, }, { name: "toggles a sprite from active to inactive", - before: { sprites: [bunny, activeFox], filter: "none" }, + before: { sprites: [bunny, activeFox] }, args: { id: entity(2) }, after: { sprites: [ { ...bunny, id: Match.anyNumber }, { ...activeFox, id: Match.anyNumber, active: false }, ], - filter: "none", }, }, { name: "is a no-op for an unknown id", - before: { sprites: [bunny, activeFox], filter: "none" }, + before: { sprites: [bunny, activeFox] }, args: { id: entity(99) }, after: { sprites: [ { ...bunny, id: Match.anyNumber }, { ...activeFox, id: Match.anyNumber }, ], - filter: "none", }, }, ]; diff --git a/packages/data-solid-dashboard/src/features/main/data/state/clear-log.ts b/packages/data-solid-dashboard/src/features/main/data/state/clear-log.ts index efadeba2..2176c28d 100644 --- a/packages/data-solid-dashboard/src/features/main/data/state/clear-log.ts +++ b/packages/data-solid-dashboard/src/features/main/data/state/clear-log.ts @@ -2,24 +2,18 @@ import type { State } from "./state.js"; import type { Conformance } from "./conformance-case.js"; -// Empty the activity log. The counter and user name are left untouched. -export const clearLog = >(state: T): T => ({ - ...state, - log: [], -}); +// Empty the activity log. The counter and user name are left untouched — the +// runner keeps them, since this writes only `log`. +export const clearLog = ( + _state: Pick, +): Pick => ({ log: [] }); // Spec-owned cases, shared with the ecs `clearLog` transaction and action. export const cases: Conformance = [ - { - name: "empties a populated log, leaving count and name intact", + { name: "empties a populated log, leaving count and name intact", before: { count: 2, log: ["a", "b"], userName: "Ada" }, - args: undefined, - after: { count: 2, log: [], userName: "Ada" }, - }, - { - name: "is a no-op on an already empty log", - before: { count: 0, log: [], userName: "Guest" }, - args: undefined, - after: { count: 0, log: [], userName: "Guest" }, - }, + after: { log: [] } }, + { name: "is a no-op on an already empty log", + before: {}, + after: {} }, ]; diff --git a/packages/data-solid-dashboard/src/features/main/data/state/decrement.ts b/packages/data-solid-dashboard/src/features/main/data/state/decrement.ts index 88a8e6c8..6fdcd606 100644 --- a/packages/data-solid-dashboard/src/features/main/data/state/decrement.ts +++ b/packages/data-solid-dashboard/src/features/main/data/state/decrement.ts @@ -2,26 +2,22 @@ import type { State } from "./state.js"; import type { Conformance } from "./conformance-case.js"; -// Lower the counter by one, never below zero. A no-op at zero returns `state` -// unchanged (no log entry), keeping the transform idempotent at the floor. -export const decrement = >(state: T): T => { - if (state.count <= 0) return state; +// Lower the counter by one, never below zero. At the floor it writes the slice +// back unchanged (no log entry), keeping the transform idempotent at zero. +export const decrement = ( + state: Pick, +): Pick => { + if (state.count <= 0) return { count: state.count, log: state.log }; const count = state.count - 1; - return { ...state, count, log: [...state.log, `Decremented to ${count}`] }; + return { count, log: [...state.log, `Decremented to ${count}`] }; }; // Spec-owned cases, shared with the ecs `decrement` transaction and action. export const cases: Conformance = [ - { - name: "decrements a positive count and logs the new value", - before: { count: 3, log: ["earlier"], userName: "Guest" }, - args: undefined, - after: { count: 2, log: ["earlier", "Decremented to 2"], userName: "Guest" }, - }, - { - name: "is a no-op at zero, leaving state untouched", - before: { count: 0, log: [], userName: "Guest" }, - args: undefined, - after: { count: 0, log: [], userName: "Guest" }, - }, + { name: "decrements a positive count and logs the new value", + before: { count: 3, log: ["earlier"] }, + after: { count: 2, log: ["earlier", "Decremented to 2"] } }, + { name: "is a no-op at zero, leaving state untouched", + before: {}, + after: {} }, ]; diff --git a/packages/data-solid-dashboard/src/features/main/data/state/increment.ts b/packages/data-solid-dashboard/src/features/main/data/state/increment.ts index 7bf276c0..3df45dda 100644 --- a/packages/data-solid-dashboard/src/features/main/data/state/increment.ts +++ b/packages/data-solid-dashboard/src/features/main/data/state/increment.ts @@ -2,27 +2,22 @@ import type { State } from "./state.js"; import type { Conformance } from "./conformance-case.js"; -// Raise the counter by one and record it in the activity log. -export const increment = >(state: T): T => { +// Raise the counter by one and record it in the activity log. Returns only the +// fields it writes (count + log); the runner merges the patch over the rest. +export const increment = ( + state: Pick, +): Pick => { const count = state.count + 1; - return { ...state, count, log: [...state.log, `Incremented to ${count}`] }; + return { count, log: [...state.log, `Incremented to ${count}`] }; }; // Spec-owned cases, shared with the ecs `increment` transaction and action. -// `before`/`after` are authored as full `State` literals (a value-level import of -// the `State` namespace here would form an eager `state → public → increment` -// cycle, so the defaults are inlined). +// `before` is a delta over `State.create()`; `after` is the writes patch. export const cases: Conformance = [ - { - name: "increments from zero and logs the new value", - before: { count: 0, log: [], userName: "Guest" }, - args: undefined, - after: { count: 1, log: ["Incremented to 1"], userName: "Guest" }, - }, - { - name: "increments an existing count, preserving prior log entries", - before: { count: 4, log: ["earlier"], userName: "Guest" }, - args: undefined, - after: { count: 5, log: ["earlier", "Incremented to 5"], userName: "Guest" }, - }, + { name: "increments from zero and logs the new value", + before: {}, + after: { count: 1, log: ["Incremented to 1"] } }, + { name: "increments an existing count, preserving prior log entries", + before: { count: 4, log: ["earlier"] }, + after: { count: 5, log: ["earlier", "Incremented to 5"] } }, ]; diff --git a/packages/data-solid-dashboard/src/features/main/data/state/reset.ts b/packages/data-solid-dashboard/src/features/main/data/state/reset.ts index 564d171f..9532afb4 100644 --- a/packages/data-solid-dashboard/src/features/main/data/state/reset.ts +++ b/packages/data-solid-dashboard/src/features/main/data/state/reset.ts @@ -3,24 +3,19 @@ import type { State } from "./state.js"; import type { Conformance } from "./conformance-case.js"; // Return the counter to zero and record the reset in the activity log. -export const reset = >(state: T): T => ({ - ...state, +export const reset = ( + state: Pick, +): Pick => ({ count: 0, log: [...state.log, "Reset to 0"], }); // Spec-owned cases, shared with the ecs `reset` transaction and action. export const cases: Conformance = [ - { - name: "resets a positive count and logs the reset", - before: { count: 7, log: ["earlier"], userName: "Guest" }, - args: undefined, - after: { count: 0, log: ["earlier", "Reset to 0"], userName: "Guest" }, - }, - { - name: "logs the reset even when already at zero", - before: { count: 0, log: [], userName: "Guest" }, - args: undefined, - after: { count: 0, log: ["Reset to 0"], userName: "Guest" }, - }, + { name: "resets a positive count and logs the reset", + before: { count: 7, log: ["earlier"] }, + after: { count: 0, log: ["earlier", "Reset to 0"] } }, + { name: "logs the reset even when already at zero", + before: {}, + after: { count: 0, log: ["Reset to 0"] } }, ]; diff --git a/packages/data-solid-dashboard/src/features/main/data/state/set-user-name.ts b/packages/data-solid-dashboard/src/features/main/data/state/set-user-name.ts index 39cb43e3..b7b2fe50 100644 --- a/packages/data-solid-dashboard/src/features/main/data/state/set-user-name.ts +++ b/packages/data-solid-dashboard/src/features/main/data/state/set-user-name.ts @@ -3,27 +3,22 @@ import type { State } from "./state.js"; import type { Conformance } from "./conformance-case.js"; // Change the active user's name and record the change in the activity log. -export const setUserName = >( - state: T, +export const setUserName = ( + state: Pick, { name }: { name: string }, -): T => ({ - ...state, +): Pick => ({ userName: name, log: [...state.log, `Name changed to ${name}`], }); // Spec-owned cases, shared with the ecs `setUserName` transaction and action. export const cases: Conformance = [ - { - name: "sets the name and logs the change", - before: { count: 0, log: [], userName: "Guest" }, + { name: "sets the name and logs the change", + before: {}, args: { name: "Ada" }, - after: { count: 0, log: ["Name changed to Ada"], userName: "Ada" }, - }, - { - name: "replaces an existing name, preserving prior log entries", - before: { count: 0, log: ["earlier"], userName: "Ada" }, + after: { userName: "Ada", log: ["Name changed to Ada"] } }, + { name: "replaces an existing name, preserving prior log entries", + before: { log: ["earlier"], userName: "Ada" }, args: { name: "Grace" }, - after: { count: 0, log: ["earlier", "Name changed to Grace"], userName: "Grace" }, - }, + after: { userName: "Grace", log: ["earlier", "Name changed to Grace"] } }, ]; From f3d0125adbe1031bcca7acd0bd7d0998c95a77a1 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 7 Aug 2026 15:59:43 -0700 Subject: [PATCH 37/37] =?UTF-8?q?docs(data-ai):=20single-command=20install?= =?UTF-8?q?=20=E2=80=94=20npx=20@adobe/data-ai@latest=20install?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The install command a human types is now one line with @latest (no version lookup); README leads with the 3-step quick start (install → restart agent → /build-application). The @latest bootstrap is a one-time human-initiated fetch; `install` still pins the exact resolved version into the consumer's devDependencies + postinstall, so every repeated/CI install runs a pinned, reviewed version — the regenerated bootstrap rule stays pinned to @VERSION. Co-Authored-By: Claude Opus 4.8 --- packages/data-ai/README.md | 67 +++++++++++++++++++++++------------- packages/data-ai/bin/cli.mjs | 4 +-- 2 files changed, 46 insertions(+), 25 deletions(-) diff --git a/packages/data-ai/README.md b/packages/data-ai/README.md index bd00a86e..d2ed97f4 100644 --- a/packages/data-ai/README.md +++ b/packages/data-ai/README.md @@ -15,25 +15,41 @@ The **rules** are the part that evolves often, so `install` pins the package and re-copies them automatically on every install: bump the version and they refresh — no manual step, no committed diff, no hand-editing. -> **For the installing agent:** run the one command below from the project root -> (plus the Claude-Code plugin step if the host is Claude Code), then stop. -> Everything the package writes lives in namespaced folders it wipes-and-recopies, -> so it never touches files you authored. +## Quick start -## Install (any host) +From your project folder root: -From the project root, one command: +**1. Install** — one command: ``` -npx @adobe/data-ai@ install # = the latest published version (pin it, never @latest) +npx @adobe/data-ai@latest install ``` +*(Claude Code only:* also add the skills plugin once — see +[Claude Code](#claude-code--add-the-skills-plugin-one-time). Cursor/Codex need +nothing extra; `install` copies their skills.*)* + +**2. Restart your AI agent** so it loads the newly installed skills and rules. + +**3. Create an application** — ask your agent: + +``` +/build-application +``` + +That's the whole flow. Everything below explains what `install` does and the +other build skills available. + +## What `install` does + `install` does two things: it **copies the bundle now** (rules → `.claude/rules/adobe-data-ai/`, and for Cursor/Codex skills → `.agents/skills/adobe-data-ai/`), and it **wires the repo so future installs self-update**, editing your `package.json` and `.gitignore` to: -1. pin `@adobe/data-ai` in `devDependencies` at the exact ``; +1. pin `@adobe/data-ai` in `devDependencies` at the **exact version it just + resolved** (never a range, so a later install can't silently pull an + unreviewed release); 2. add `data-ai install` to **your own** `postinstall` script (chaining if one exists) — it must be *your* script: pnpm does not run a dependency's lifecycle scripts, so a `postinstall` shipped inside the package would silently not fire; @@ -47,14 +63,21 @@ After that first run, sync your lockfile with a normal install (`pnpm install`) the pinned dev-dependency is recorded; from then on every install refreshes the bundle via the postinstall. +**Why `@latest` for the first command but a pinned version afterward?** The one +command you type is a one-time, human-initiated bootstrap, so `@latest` is fine +(the same posture as `npm create …@latest`). The part that *repeats* — every +teammate's and CI's install — runs from the **exact version `install` pinned into +your `devDependencies`**, never a moving tag, so no unreviewed release ever +auto-executes. Pinning happens where it matters, automatically. + **Update:** bump the `@adobe/data-ai` version in `package.json` and install again. That's it. ### Claude Code — add the skills plugin (one time) Claude Code loads skills from a marketplace plugin, not from `.claude/skills/`, so -its skills are a separate one-time install (the `init` step above already handles -the rules for Claude Code): +its skills are a separate one-time install (the `install` step above already +handles the rules for Claude Code): ``` claude plugin marketplace add adobe/data --scope project @@ -74,9 +97,9 @@ or you'd rather commit the bundle and update it on your own schedule — run delete the `postinstall`/dev-dependency lines it added. Re-running `install` is always a clean wipe-and-recopy; you then update by re-running it yourself. -## Use +## Build skills -Ask your agent to run a build command — skills are available by name: +Step 3 above uses `build-application`; the skills are available by name: - **`build-application`** — build a whole app: a base feature that hosts lazily-loaded peer features. @@ -92,18 +115,16 @@ by name when you want a single phase or a specialized flow: `build-data`, in subagents), `review` (audit output against the rules), and `structure` (reason about layout). -## One-liner: install + build an app - -Paste one of these into a shell, replacing the quoted text with your own app -description. It installs the bundle, then runs `build-application` headlessly on -your prompt. +## One-liner: install + build an app (headless) -This presumes you are already running the agent within the directory you want to use as your project root. +Paste this into an agent already running in your project root, replacing the +quoted text with your own app description. It installs the bundle, then runs +`build-application` on your prompt: ``` -Find the latest @adobe/data-ai version on npmjs, then set it up in the current -directory: run `npx @adobe/data-ai@ install` (this copies the bundle and -pins it so rules auto-update on every install). On Claude Code also add the skills -plugin per the package README. Then use its /build-application skill to build - +Set up @adobe/data-ai in the current directory: run +`npx @adobe/data-ai@latest install` (this copies the bundle and pins the exact +version so rules auto-update on every install). On Claude Code also add the +skills plugin per the package README. Then use its /build-application skill to +build ``` diff --git a/packages/data-ai/bin/cli.mjs b/packages/data-ai/bin/cli.mjs index 7b01bc40..96640618 100755 --- a/packages/data-ai/bin/cli.mjs +++ b/packages/data-ai/bin/cli.mjs @@ -249,8 +249,8 @@ Install the architecture skills + rules for Cursor, Codex, and other agents. same way for every agent — see README.) Usage: - npx ${PKG_NAME}@ install # copy the bundle AND wire auto-updates (default) - npx ${PKG_NAME}@ list + npx ${PKG_NAME}@latest install # copy the bundle AND wire auto-updates (default) + npx ${PKG_NAME}@latest list Commands: install Copy the bundle (skills → .agents/skills/${BUNDLE}/, rules →