From af9cd6c79de49c97bead7d96f735bc86f6592a7b Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Sun, 27 Sep 2026 09:03:20 -0700 Subject: [PATCH] feat(conformance): manifest-based feature-conformance standard; eliminate old co-located/glob API Author each feature's conformance as an explicit spec.ts manifest (Conformance.feature) consumed by checkSpec (pure) and checkFeature (ecs). Transforms become single-export pure functions with zero test-tier imports; cases move to inert sibling *.cases.ts with service returns scheduled as data (responses) and calls asserted as effects; service doubles move to *.fake.ts out of the production barrel. A per-package composite tsconfig wall isolates the test tier so a runtime import of it is a compile error. The manifest enforces at compile time: cases coverage, exact hydrate, service completeness, and computedPlugin presence. Removes casesBuilder/derivationsBuilder, the glob transitions discovery, and the old co-located case types. Migrates all seven sample apps and updates the data-ai feature rules to match. Bump to 0.10.20. Co-Authored-By: Claude Opus 4.8 --- package.json | 2 +- packages/data-ai/.claude-plugin/plugin.json | 2 +- .../.claude/rules/features/data/state.md | 499 ++++++------------ .../data-ai/.claude/rules/features/index.md | 26 +- .../.claude/rules/features/services/index.md | 45 +- .../features/services/main-service/actions.md | 18 +- .../services/main-service/computed.md | 21 +- .../services/main-service/conformance.md | 293 +++------- .../services/main-service/transactions.md | 2 +- packages/data-ai/package.json | 2 +- packages/data-gpu-hopper/.gitignore | 1 + packages/data-gpu-hopper/package.json | 2 +- .../main/data/state/conformance-case.ts | 10 - .../src/features/main/data/state/hop.cases.ts | 47 ++ .../src/features/main/data/state/hop.ts | 43 +- .../main/data/state/lose-life.cases.ts | 30 ++ .../src/features/main/data/state/lose-life.ts | 24 - .../main/data/state/new-game.cases.ts | 34 ++ .../src/features/main/data/state/new-game.ts | 27 - .../src/features/main/data/state/spec.test.ts | 16 +- .../src/features/main/data/state/spec.ts | 36 ++ .../features/main/data/state/step.cases.ts | 86 +++ .../src/features/main/data/state/step.ts | 82 +-- .../features/main/data/state/transforms.ts | 16 + .../features/main/data/state/transitions.ts | 11 - .../main/data/state/win-goal.cases.ts | 25 + .../src/features/main/data/state/win-goal.ts | 19 - .../conformance/conformance.test.ts | 18 +- .../main-service/conformance/projection.ts | 2 +- .../system-database/system-database.ts | 2 +- .../system-database/tick-loop.test.ts | 10 +- .../transaction-database/transactions/hop.ts | 4 +- packages/data-gpu-hopper/tsconfig.build.json | 29 + packages/data-gpu-hopper/tsconfig.test.json | 26 + packages/data-gpu/package.json | 2 +- packages/data-lit-space-rock-game/.gitignore | 1 + .../data-lit-space-rock-game/package.json | 2 +- .../main/data/state/conformance-case.ts | 10 - .../main/data/state/create-initial.cases.ts | 64 +++ .../main/data/state/create-initial.ts | 58 -- .../main/data/state/fire-bullet.cases.ts | 79 +++ .../features/main/data/state/fire-bullet.ts | 76 --- .../data/state/resolve-bullet-hits.cases.ts | 201 +++++++ .../main/data/state/resolve-bullet-hits.ts | 197 +------ .../data/state/resolve-ship-hits.cases.ts | 97 ++++ .../main/data/state/resolve-ship-hits.ts | 110 ---- .../data/state/spawn-random-wave.cases.ts | 56 ++ .../main/data/state/spawn-random-wave.ts | 53 -- .../main/data/state/spawn-wave.cases.ts | 51 ++ .../features/main/data/state/spawn-wave.ts | 46 -- .../src/features/main/data/state/spec.test.ts | 16 +- .../src/features/main/data/state/spec.ts | 54 ++ .../main/data/state/step-asteroids.cases.ts | 59 +++ .../main/data/state/step-asteroids.ts | 89 +--- .../main/data/state/step-bullets.cases.ts | 63 +++ .../features/main/data/state/step-bullets.ts | 92 +--- .../main/data/state/step-ship.cases.ts | 63 +++ .../src/features/main/data/state/step-ship.ts | 99 +--- .../features/main/data/state/step.cases.ts | 138 +++++ .../src/features/main/data/state/step.ts | 154 +----- .../features/main/data/state/transforms.ts | 26 + .../features/main/data/state/transitions.ts | 11 - .../actions/spawn-random-wave.ts | 10 +- .../conformance/conformance.test.ts | 31 +- .../main-service/conformance/projection.ts | 2 +- .../system-database/system-database.ts | 2 +- .../system-database/tick-loop.test.ts | 16 +- .../transactions/index.ts | 2 +- .../transactions/refill-wave.ts | 38 ++ .../transactions/spawn-random-wave.ts | 32 -- .../services/random-service/create-fake.ts | 23 - .../main/services/random-service/public.ts | 1 - .../services/random-service/random.fake.ts | 17 + .../tsconfig.build.json | 29 + .../tsconfig.test.json | 26 + packages/data-lit-tictactoe/.gitignore | 1 + packages/data-lit-tictactoe/package.json | 2 +- .../main/data/state/conformance-case.ts | 11 - .../main/data/state/current-player.cases.ts | 43 ++ .../main/data/state/current-player.ts | 38 -- .../main/data/state/play-move.cases.ts | 52 ++ .../src/features/main/data/state/play-move.ts | 45 -- .../main/data/state/restart-game.cases.ts | 58 ++ .../features/main/data/state/restart-game.ts | 56 -- .../src/features/main/data/state/spec.test.ts | 10 +- .../src/features/main/data/state/spec.ts | 33 ++ .../features/main/data/state/transforms.ts | 12 + .../features/main/data/state/transitions.ts | 11 - .../conformance/conformance.test.ts | 24 +- .../main-service/conformance/projection.ts | 2 +- .../data-lit-tictactoe/tsconfig.build.json | 29 + .../data-lit-tictactoe/tsconfig.test.json | 26 + packages/data-lit-todo/.gitignore | 1 + packages/data-lit-todo/package.json | 2 +- .../main/data/state/conformance-case.ts | 11 - .../data/state/create-bulk-todos.cases.ts | 63 +++ .../main/data/state/create-bulk-todos.ts | 64 --- .../data/state/create-random-todo.cases.ts | 50 ++ .../main/data/state/create-random-todo.ts | 58 -- .../main/data/state/create-todo.cases.ts | 57 ++ .../features/main/data/state/create-todo.ts | 60 --- .../main/data/state/delete-all-todos.cases.ts | 30 ++ .../main/data/state/delete-all-todos.ts | 30 -- .../main/data/state/delete-todo.cases.ts | 71 +++ .../features/main/data/state/delete-todo.ts | 55 -- .../main/data/state/reorder-todo.cases.ts | 93 ++++ .../features/main/data/state/reorder-todo.ts | 66 --- .../main/data/state/select-todo.cases.ts | 40 ++ .../features/main/data/state/select-todo.ts | 30 +- .../src/features/main/data/state/spec.test.ts | 17 +- .../src/features/main/data/state/spec.ts | 51 ++ .../main/data/state/toggle-complete.cases.ts | 65 +++ .../main/data/state/toggle-complete.ts | 60 --- .../state/toggle-display-completed.cases.ts | 29 + .../data/state/toggle-display-completed.ts | 33 -- .../features/main/data/state/transforms.ts | 19 + .../features/main/data/state/transitions.ts | 11 - .../main/data/state/visible-todos.cases.ts | 41 ++ .../features/main/data/state/visible-todos.ts | 37 -- .../analytics-service/analytics.fake.ts | 23 + .../services/analytics-service/create-fake.ts | 22 - .../main/services/analytics-service/public.ts | 1 - .../conformance/conformance.test.ts | 28 +- .../main-service/conformance/projection.ts | 2 +- .../name-generator-service/create-fake.ts | 24 - .../name-generator.fake.ts | 14 + .../services/name-generator-service/public.ts | 1 - packages/data-lit-todo/tsconfig.build.json | 29 + packages/data-lit-todo/tsconfig.test.json | 26 + packages/data-lit/package.json | 2 +- packages/data-p2p-tictactoe/.gitignore | 1 + packages/data-p2p-tictactoe/package.json | 2 +- .../data/state/conformance-case.ts | 13 - .../data/state/enter-game.cases.ts | 17 + .../negotiation/data/state/enter-game.ts | 21 - .../data/state/set-answer-code.cases.ts | 17 + .../negotiation/data/state/set-answer-code.ts | 19 - .../data/state/set-banner.cases.ts | 23 + .../negotiation/data/state/set-banner.ts | 33 -- .../data/state/set-connection.cases.ts | 23 + .../negotiation/data/state/set-connection.ts | 33 -- .../data/state/set-host-answer-input.cases.ts | 17 + .../data/state/set-host-answer-input.ts | 19 - .../state/set-joiner-offer-input.cases.ts | 17 + .../data/state/set-joiner-offer-input.ts | 19 - .../data/state/set-offer-code.cases.ts | 17 + .../negotiation/data/state/set-offer-code.ts | 19 - .../negotiation/data/state/spec.test.ts | 12 +- .../features/negotiation/data/state/spec.ts | 52 ++ .../data/state/start-host-signaling.cases.ts | 23 + .../data/state/start-host-signaling.ts | 20 - .../data/state/start-join-signaling.cases.ts | 23 + .../data/state/start-join-signaling.ts | 20 - .../negotiation/data/state/transforms.ts | 16 + .../negotiation/data/state/transitions.ts | 11 - .../conformance/conformance.test.ts | 33 +- .../main-service/conformance/projection.ts | 2 +- .../create-connection-service.test.ts | 6 +- .../services/create-connection-service.ts | 4 +- .../services/signaling-service/create-fake.ts | 25 - .../services/signaling-service/public.ts | 1 - .../signaling-service/signaling.fake.ts | 20 + .../presence/data/state/conformance-case.ts | 13 - .../data/state/move-presence.cases.ts | 28 + .../presence/data/state/move-presence.ts | 33 +- .../features/presence/data/state/spec.test.ts | 12 +- .../src/features/presence/data/state/spec.ts | 20 + .../presence/data/state/transforms.ts | 4 + .../presence/data/state/transitions.ts | 11 - .../main-service/conformance/actions.test.ts | 33 +- .../main-service/conformance/from-state.ts | 16 - .../conformance/projection.test.ts | 13 +- .../main-service/conformance/projection.ts | 19 + .../main-service/conformance/to-state.ts | 9 - .../conformance/transactions.test.ts | 20 +- .../data-p2p-tictactoe/tsconfig.build.json | 29 + .../data-p2p-tictactoe/tsconfig.test.json | 26 + packages/data-persistence/package.json | 2 +- packages/data-react-pixie/.gitignore | 1 + packages/data-react-pixie/package.json | 2 +- .../main/data/state/conformance-case.ts | 10 - .../main/data/state/create-sprite.cases.ts | 76 +++ .../features/main/data/state/create-sprite.ts | 71 --- .../main/data/state/set-filter.cases.ts | 23 + .../features/main/data/state/set-filter.ts | 17 - .../data/state/set-sprite-active.cases.ts | 45 ++ .../main/data/state/set-sprite-active.ts | 38 -- .../data/state/set-sprite-hovered.cases.ts | 45 ++ .../main/data/state/set-sprite-hovered.ts | 38 -- .../src/features/main/data/state/spec.test.ts | 12 +- .../src/features/main/data/state/spec.ts | 36 ++ .../features/main/data/state/tick.cases.ts | 37 ++ .../src/features/main/data/state/tick.ts | 30 -- .../data/state/toggle-sprite-active.cases.ts | 56 ++ .../main/data/state/toggle-sprite-active.ts | 49 -- .../features/main/data/state/transforms.ts | 15 + .../features/main/data/state/transitions.ts | 11 - .../conformance/conformance.test.ts | 23 +- .../main-service/conformance/projection.ts | 2 +- packages/data-react-pixie/tsconfig.build.json | 29 + packages/data-react-pixie/tsconfig.test.json | 26 + packages/data-react/package.json | 2 +- packages/data-rpc/package.json | 2 +- packages/data-solid-dashboard/.gitignore | 1 + packages/data-solid-dashboard/package.json | 2 +- .../main/data/state/clear-log.cases.ts | 21 + .../src/features/main/data/state/clear-log.ts | 11 - .../main/data/state/conformance-case.ts | 13 - .../main/data/state/decrement.cases.ts | 21 + .../src/features/main/data/state/decrement.ts | 11 - .../main/data/state/increment.cases.ts | 22 + .../src/features/main/data/state/increment.ts | 12 - .../features/main/data/state/reset.cases.ts | 21 + .../src/features/main/data/state/reset.ts | 11 - .../main/data/state/set-user-name.cases.ts | 23 + .../features/main/data/state/set-user-name.ts | 13 - .../src/features/main/data/state/spec.test.ts | 11 +- .../src/features/main/data/state/spec.ts | 34 ++ .../features/main/data/state/transforms.ts | 12 + .../features/main/data/state/transitions.ts | 11 - .../conformance/conformance.test.ts | 22 +- .../main-service/conformance/projection.ts | 2 +- .../data-solid-dashboard/tsconfig.build.json | 29 + .../data-solid-dashboard/tsconfig.test.json | 26 + packages/data-solid/package.json | 2 +- packages/data-sync/package.json | 2 +- packages/data-testing/package.json | 2 +- .../src/conformance/adapt-spec.ts | 62 +++ .../src/conformance/build-double.ts | 40 ++ .../data-testing/src/conformance/cases.ts | 93 ---- .../src/conformance/cases.type-test.ts | 54 -- .../src/conformance/feature-spec.ts | 124 +++++ .../src/conformance/feature-spec.type-test.ts | 174 ++++++ .../src/conformance/feature-types.ts | 196 +++++++ .../data-testing/src/conformance/public.ts | 37 +- .../conformance/run-spec.empty-cases.test.ts | 4 +- .../src/conformance/run-spec.empty.test.ts | 4 +- .../src/conformance/run-spec.test.ts | 4 +- .../data-testing/src/conformance/types.ts | 44 +- packages/data-testing/src/index.ts | 41 +- packages/data/package.json | 2 +- 241 files changed, 4484 insertions(+), 3767 deletions(-) create mode 100644 packages/data-gpu-hopper/.gitignore delete mode 100644 packages/data-gpu-hopper/src/features/main/data/state/conformance-case.ts create mode 100644 packages/data-gpu-hopper/src/features/main/data/state/hop.cases.ts create mode 100644 packages/data-gpu-hopper/src/features/main/data/state/lose-life.cases.ts create 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/spec.ts create mode 100644 packages/data-gpu-hopper/src/features/main/data/state/step.cases.ts create mode 100644 packages/data-gpu-hopper/src/features/main/data/state/transforms.ts delete mode 100644 packages/data-gpu-hopper/src/features/main/data/state/transitions.ts create mode 100644 packages/data-gpu-hopper/src/features/main/data/state/win-goal.cases.ts create mode 100644 packages/data-gpu-hopper/tsconfig.build.json create mode 100644 packages/data-gpu-hopper/tsconfig.test.json create mode 100644 packages/data-lit-space-rock-game/.gitignore delete mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/conformance-case.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/create-initial.cases.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/fire-bullet.cases.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/resolve-bullet-hits.cases.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/resolve-ship-hits.cases.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/spawn-random-wave.cases.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/spawn-wave.cases.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/spec.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/step-asteroids.cases.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/step-bullets.cases.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/step-ship.cases.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/step.cases.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/transforms.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/data/state/transitions.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/refill-wave.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/spawn-random-wave.ts delete mode 100644 packages/data-lit-space-rock-game/src/features/main/services/random-service/create-fake.ts create mode 100644 packages/data-lit-space-rock-game/src/features/main/services/random-service/random.fake.ts create mode 100644 packages/data-lit-space-rock-game/tsconfig.build.json create mode 100644 packages/data-lit-space-rock-game/tsconfig.test.json create mode 100644 packages/data-lit-tictactoe/.gitignore delete mode 100644 packages/data-lit-tictactoe/src/features/main/data/state/conformance-case.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/data/state/current-player.cases.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/data/state/play-move.cases.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/data/state/restart-game.cases.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/data/state/spec.ts create mode 100644 packages/data-lit-tictactoe/src/features/main/data/state/transforms.ts delete mode 100644 packages/data-lit-tictactoe/src/features/main/data/state/transitions.ts create mode 100644 packages/data-lit-tictactoe/tsconfig.build.json create mode 100644 packages/data-lit-tictactoe/tsconfig.test.json create mode 100644 packages/data-lit-todo/.gitignore delete mode 100644 packages/data-lit-todo/src/features/main/data/state/conformance-case.ts create mode 100644 packages/data-lit-todo/src/features/main/data/state/create-bulk-todos.cases.ts create mode 100644 packages/data-lit-todo/src/features/main/data/state/create-random-todo.cases.ts create mode 100644 packages/data-lit-todo/src/features/main/data/state/create-todo.cases.ts create mode 100644 packages/data-lit-todo/src/features/main/data/state/delete-all-todos.cases.ts create mode 100644 packages/data-lit-todo/src/features/main/data/state/delete-todo.cases.ts create mode 100644 packages/data-lit-todo/src/features/main/data/state/reorder-todo.cases.ts create mode 100644 packages/data-lit-todo/src/features/main/data/state/select-todo.cases.ts create mode 100644 packages/data-lit-todo/src/features/main/data/state/spec.ts create mode 100644 packages/data-lit-todo/src/features/main/data/state/toggle-complete.cases.ts create mode 100644 packages/data-lit-todo/src/features/main/data/state/toggle-display-completed.cases.ts create mode 100644 packages/data-lit-todo/src/features/main/data/state/transforms.ts delete mode 100644 packages/data-lit-todo/src/features/main/data/state/transitions.ts create mode 100644 packages/data-lit-todo/src/features/main/data/state/visible-todos.cases.ts create mode 100644 packages/data-lit-todo/src/features/main/services/analytics-service/analytics.fake.ts delete mode 100644 packages/data-lit-todo/src/features/main/services/analytics-service/create-fake.ts delete mode 100644 packages/data-lit-todo/src/features/main/services/name-generator-service/create-fake.ts create mode 100644 packages/data-lit-todo/src/features/main/services/name-generator-service/name-generator.fake.ts create mode 100644 packages/data-lit-todo/tsconfig.build.json create mode 100644 packages/data-lit-todo/tsconfig.test.json create mode 100644 packages/data-p2p-tictactoe/.gitignore delete mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/data/state/conformance-case.ts create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/data/state/enter-game.cases.ts create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-answer-code.cases.ts create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-banner.cases.ts create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-connection.cases.ts create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-host-answer-input.cases.ts create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-joiner-offer-input.cases.ts create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-offer-code.cases.ts create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/data/state/spec.ts create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/data/state/start-host-signaling.cases.ts create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/data/state/start-join-signaling.cases.ts create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/data/state/transforms.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/data/state/transitions.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/services/signaling-service/create-fake.ts create mode 100644 packages/data-p2p-tictactoe/src/features/negotiation/services/signaling-service/signaling.fake.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/presence/data/state/conformance-case.ts create mode 100644 packages/data-p2p-tictactoe/src/features/presence/data/state/move-presence.cases.ts create mode 100644 packages/data-p2p-tictactoe/src/features/presence/data/state/spec.ts create mode 100644 packages/data-p2p-tictactoe/src/features/presence/data/state/transforms.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/presence/data/state/transitions.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/from-state.ts create mode 100644 packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/projection.ts delete mode 100644 packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/to-state.ts create mode 100644 packages/data-p2p-tictactoe/tsconfig.build.json create mode 100644 packages/data-p2p-tictactoe/tsconfig.test.json create mode 100644 packages/data-react-pixie/.gitignore delete mode 100644 packages/data-react-pixie/src/features/main/data/state/conformance-case.ts create mode 100644 packages/data-react-pixie/src/features/main/data/state/create-sprite.cases.ts create mode 100644 packages/data-react-pixie/src/features/main/data/state/set-filter.cases.ts create mode 100644 packages/data-react-pixie/src/features/main/data/state/set-sprite-active.cases.ts create mode 100644 packages/data-react-pixie/src/features/main/data/state/set-sprite-hovered.cases.ts create mode 100644 packages/data-react-pixie/src/features/main/data/state/spec.ts create mode 100644 packages/data-react-pixie/src/features/main/data/state/tick.cases.ts create mode 100644 packages/data-react-pixie/src/features/main/data/state/toggle-sprite-active.cases.ts create mode 100644 packages/data-react-pixie/src/features/main/data/state/transforms.ts delete mode 100644 packages/data-react-pixie/src/features/main/data/state/transitions.ts create mode 100644 packages/data-react-pixie/tsconfig.build.json create mode 100644 packages/data-react-pixie/tsconfig.test.json create mode 100644 packages/data-solid-dashboard/.gitignore create mode 100644 packages/data-solid-dashboard/src/features/main/data/state/clear-log.cases.ts delete mode 100644 packages/data-solid-dashboard/src/features/main/data/state/conformance-case.ts create mode 100644 packages/data-solid-dashboard/src/features/main/data/state/decrement.cases.ts create mode 100644 packages/data-solid-dashboard/src/features/main/data/state/increment.cases.ts create mode 100644 packages/data-solid-dashboard/src/features/main/data/state/reset.cases.ts create mode 100644 packages/data-solid-dashboard/src/features/main/data/state/set-user-name.cases.ts create mode 100644 packages/data-solid-dashboard/src/features/main/data/state/spec.ts create mode 100644 packages/data-solid-dashboard/src/features/main/data/state/transforms.ts delete mode 100644 packages/data-solid-dashboard/src/features/main/data/state/transitions.ts create mode 100644 packages/data-solid-dashboard/tsconfig.build.json create mode 100644 packages/data-solid-dashboard/tsconfig.test.json create mode 100644 packages/data-testing/src/conformance/adapt-spec.ts create mode 100644 packages/data-testing/src/conformance/build-double.ts delete mode 100644 packages/data-testing/src/conformance/cases.ts delete mode 100644 packages/data-testing/src/conformance/cases.type-test.ts create mode 100644 packages/data-testing/src/conformance/feature-spec.ts create mode 100644 packages/data-testing/src/conformance/feature-spec.type-test.ts create mode 100644 packages/data-testing/src/conformance/feature-types.ts diff --git a/package.json b/package.json index 9e34379c3..d062b9049 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "data-monorepo", - "version": "0.10.19", + "version": "0.10.20", "private": true, "engines": { "node": ">=24" diff --git a/packages/data-ai/.claude-plugin/plugin.json b/packages/data-ai/.claude-plugin/plugin.json index 08e133e31..c0411c422 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.10.19", + "version": "0.10.20", "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/.claude/rules/features/data/state.md b/packages/data-ai/.claude/rules/features/data/state.md index 7b24f789b..abbfd1173 100644 --- a/packages/data-ai/.claude/rules/features/data/state.md +++ b/packages/data-ai/.claude/rules/features/data/state.md @@ -7,380 +7,203 @@ paths: `State` is the whole feature as **one immutable object** — the pure, fully-tested source of truth. Each transition is a read→write **patch** over state; each -derivation a pure selector. Reference: `data-lit-todo`'s `data/state/`. +derivation a pure selector. The ECS implementation is proven equivalent to it by +conformance (`../services/main-service/conformance.md`). Reference: +`data-lit-todo`'s `data/state/`. -The presence of this folder makes the feature **state-based** — its Functional -State Specification is authoritative and the ECS conforms to it. A feature without -`data/state/` is **ECS-based** (the ECS is the source of truth, no conformance) and -this rule does not apply — see `../index.md`, Two modes. +The presence of this folder makes the feature **state-based**. A feature without +`data/state/` is **ECS-based** (no spec, no conformance) — see `../index.md`. -```ts -// data/todo/todo.ts — an entity value type: plain readonly data, NO id. -export type Todo = { readonly name: string; readonly complete: boolean; readonly order: number }; -export * as Todo from "./public.js"; - -// data/todo/is.ts — a structural type guard, re-exported so it reads as `Todo.is`. -// `"k" in v` narrows `v` so each field reads without a cast. -export const is = (v: unknown): v is Todo => - typeof v === "object" && v !== null && - "name" in v && typeof v.name === "string" && - "complete" in v && typeof v.complete === "boolean" && - "order" in v && typeof v.order === "number"; +## The `State` shape -// data/state/state.ts — singletons + one identity-keyed entity map. +```ts +// data/state/state.ts export type State = { readonly displayCompleted: boolean; // a SINGLETON → an ECS resource of the same name readonly entities: ReadonlyMap; // ALL entities, keyed by id; the value has no id + readonly selectedTodo: Entity; // a reference singleton (Entity is a branded number) }; export * as State from "./public.js"; ``` -Every **state-based** feature owns a `State`. An ECS-based feature has none. - -## The standard `State` shape +- **Singletons** — every non-`entities` field → an ECS **resource** of the same name. +- **`entities`** — one `ReadonlyMap` holding every entity, keyed + by a numeric id; the value type is the union of the feature's entity value types and + carries **no `id`** (identity is the key). Omit `entities` for a feature with no + entities (all singletons). -A `State` has two kinds of field: +`State` may reuse plain ECS value types (the `Entity` id type, a `{ parent, order }` +bundle) but never ECS machinery (a store, a transaction, an index). Entity value types +are their own `data//` namespaces with a structural `is` guard (`../index.md`). -- **Singletons** — every non-`entities` field (`displayCompleted`, a `score`, a - `board`). Each becomes an ECS **resource** of the same name (see - `../services/main-service/resources.md`). -- **`entities`** — a single `ReadonlyMap` holding *every* entity, - keyed by a numeric id, where `EntityValue` is the union of the feature's entity value - types (`Todo`, or `Bullet | Asteroid`). Omit `entities` entirely for a feature with - no entities (`tictactoe`, `dashboard`) — it is then all singletons. +Two conventional exports drive conformance, re-exported through `public.js`: +- **`create(): State`** (`create.ts`) — the default; every case's `before`/`input` is a + delta over it. +- **`samples: readonly State[]`** (`samples.ts`) — representative full states the ecs + projection round-trips (`toState ∘ fromState ≡ identity`). -This maps 1:1 to the ECS. `State` must not depend on ECS *machinery* (a store, a -transaction, an index) — but it **may reuse ECS-related value types**: the key can be -the `Entity` type (it is just a branded `number`), and a reference/hierarchy bundle can -reuse a shared value type like `CoeditingRelations.OrderedChild` (`{ parent, order }`). -Those are plain serializable data, not ECS behaviour, and reusing them keeps the spec -and the ECS in lockstep — the same `Entity.schema` that types an id here is what -conformance walks to compare the two up to an id-bijection (see `conformance.md`). -Singletons ↔ resources; each `entities` value ↔ one entity whose **component set is the -value's own keys**, so `fromState` inserts a value into the archetype named by -`Object.keys(value)` and `toState` reads every entity back into the map. Identity lives -in the key, never a field — see `../../data-modelling.md` (Entities are keyed, never -id-bearing values). +## Files: the pure function and its cases are SEPARATE -### Entity value types — structural, id-less, one `is` guard each +Each conformed function is two files: -Each entity type is its own `data//` namespace (`data/index.md`): a plain -readonly type with **no `id`** plus a structural `is` guard (`is.ts`, re-exported so it -reads `Todo.is`). A **sub-archetype** is an intersection — `Bar = Foo & { baz }` — and -its guard **composes the base guard**: `Bar.is` calls `Foo.is(v)` first, then checks the -added fields (`"baz" in v && typeof v.baz === "boolean"`). So `Bar.is ⟹ Foo.is` by -construction. +- **`.ts`** — the pure function, a **single export**, with **zero test-tier + imports** (no `@adobe/data-testing`, no fakes). It depends only on `@adobe/data`, + other `data/` types, and — for a transition — the service interfaces it injects. +- **`.cases.ts`** — the spec-owned cases as **inert data**, imported by the + manifest. It imports the case type with `import type` and never constructs a live + service. -**Model structurally, like the ECS — no tags by default.** ECS systems match purely on -structure (a mover runs on anything with `position` + `velocity`, regardless of -"kind"), and `State` mirrors that: +This split is enforced by the package's project-reference wall (`tsconfig.build.json` +excludes `*.cases.ts`): a runtime file importing test-tier code is a **compile error**. -- **A property name means one thing feature-wide.** No two entity types may declare the - same property name with a different type/semantics — that is what lets every property - collapse to exactly one ECS component and keeps structural matching sound. -- **No marker / `kind` / tag field unless a genuine modelling need demands it.** Prefer - discriminating on the presence of real components; add a tag only when structure alone - cannot express the distinction. - -### Querying entities — `State.getXEntities`, returning ids - -Entity queries are `state/` derivations that return entity **ids** (look the value up -with `state.entities.get(id)`), mirroring the ECS `select`. Perf is irrelevant here -(full-map scan + guard); the ECS realises them as archetype queries. - -- **Unordered** → `ReadonlySet`: keys whose value passes the guard. It is a - **superset** match — `getFooEntities` (via `Foo.is`) also includes `Bar` entities, - just as `queryArchetypes(["…Foo's cols"])` returns the `Foo` *and* `Bar` archetypes. -- **Ordered** → `ReadonlyArray`: the matching ids sorted by an explicit `order` - component, exactly like an ECS `select(cols, { order })`. Order is **never** carried - by the `entities` map (it is identity-keyed / unordered); a meaningful order is a - component on the value and the derivation returns the sorted ids. +### The transform — `.ts` ```ts -// data/state/get-todos.ts — an ordered membership derivation returning ids -export const getTodos = (state: State): readonly number[] => - [...state.entities] - .filter(([, v]) => Todo.is(v)) - .sort(([, a], [, b]) => a.order - b.order) - .map(([id]) => id); -``` - -**`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.) - -**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: +// toggle-complete.ts +import type { Services } from "../../services/services.js"; +import type { State } from "./state.js"; -```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 }, -); +// Reads entities, writes entities — an `{ entities }` patch — and logs `todoToggled`. +export const toggleComplete = ( + state: Pick, + { id, analytics }: { readonly id: number } & Pick, +): Pick => { + analytics.todoToggled(); + const target = state.entities.get(id); + if (target === undefined) return { entities: state.entities }; + return { entities: new Map(state.entities).set(id, { ...target, complete: !target.complete }) }; +}; ``` -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 -`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`. - -**Why co-locate `cases` (not a sibling `*.test.ts`)?** They are spec-owned -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.cases(fn, …)` 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 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 separate -**`@adobe/data-testing`** package (two namespaces, `Match` and `Conformance`) — -kept out of `@adobe/data` itself so installing `@adobe/data` never pulls in a -`vitest` peer dependency; add `@adobe/data-testing` as a devDependency alongside -`vitest`, its optional peer dependency. 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: +- **Transition signature** `(state: Pick, args) => Pick` — + a read→write patch. The parameter is the smallest `Pick` it reads; the return + is only the fields it writes. No ` => T` generic, no `...state` spread — return the + patch and let the runner merge it. All non-state inputs go in the single `args` object; + a transition that takes neither args nor services takes only `(state)`. +- **Inject services with `Pick`** — never an inline `{ analytics: … }`. Data + args sit alongside via intersection: `{ readonly count: number } & Pick`. +- **Guard no-ops by returning an empty/unchanged patch**, never by throwing. Reserve + `throw` for a genuine precondition violation a case names with `throws`. +- **Derivation signature** `(state) => value` — a pure selector over **two or more** + `State` fields. An entity query returns entity **ids** (`readonly number[]` / + `ReadonlySet`); look values up in `entities`. Value math over a **single** + `data/` lives on that type's namespace, not here. + +### The cases — `.cases.ts` ```ts -// data/state/conformance-case.ts — the only per-feature conformance declaration -import { Conformance as ConformanceApi } from "@adobe/data-testing"; +// toggle-complete.cases.ts +import { Entity } from "@adobe/data/ecs"; +import type { Conformance } from "@adobe/data-testing"; import type { State } from "./state.js"; -// `Conformance.cases(fn, [options,] ...cases)` declares a transform's cases — the -// case types (args, before/after) come from `fn`. A derivation authors `Derivation`. -export const Conformance = { cases: ConformanceApi.casesBuilder() }; -export type Derivation unknown> = ConformanceApi.DerivationCases; -export type Effects = ConformanceApi.Effects; +import type { toggleComplete } from "./toggle-complete.js"; + +export const cases: Conformance.SpecCases = { + args: { type: "object", properties: { id: Entity.schema } }, // marks entity-reference args + cases: [ + { name: "marks an incomplete todo complete", + before: { entities: new Map([[1, { name: "a", complete: false, order: 0 }]]) }, + args: { id: 1 }, // DATA only — services are never here + after: { entities: new Map([[1, { name: "a", complete: true, order: 0 }]]) }, + effects: { analytics: [["todoToggled"]] } }, // service calls to assert + ], +}; ``` -```ts -// create-todo.ts -import { Conformance } from "./conformance-case.js"; // the thin per-feature alias above -import type { Services } from "../../services/services.js"; // the feature's service map -export const createTodo = ( - state: Pick, - { name, analytics }: { name: string } & Pick, -): Pick => { - analytics.todoCreated({ name }); - // The spec mints the id (the map key) and the order; the value carries neither an id - // nor anything the ECS allocates — identity is the key. - const id = Math.max(0, ...state.entities.keys()) + 1; - const order = state.entities.size; - return { entities: new Map(state.entities).set(id, { name, complete: false, order }) }; -}; +- Annotate `Conformance.SpecCases` for a transition, + `Conformance.SpecDerivations` for a derivation. That annotation is the + transition/derivation discriminator — nothing is inferred from the signature. +- **`before` is a delta over `State.create()`**; **`after` is the writes patch** (only + the fields the transition changes). A derivation case is `{ name, input, value }`. +- **`args` is data only.** Injected services are stripped from the case's `args` type; + the runner synthesizes each service's recording double from the manifest's `services` + templates. A transition with only services takes no `args`. +- **Service returns are scheduled as data in `responses`**, keyed by service then method; + only value-returning methods take a schedule (a per-call FIFO — exhausting it throws). + **Service calls to assert go in `effects`** as `[method, ...args]` tuples (`Array` = + ordered, `Set` = any order); a value-returning read you don't assert is simply omitted. + The case thus **owns both the input and the expectation** — the double invents nothing. + ```ts + // create-random-todo.cases.ts (a value-returning service) + responses: { nameGenerator: { generateName: ["random task"] }, + analytics: { randomTodoRequested: [{ startedAt: 0 }] } }, + effects: { analytics: [["randomTodoRequested"], + ["randomTodoAdded", { timing: { startedAt: 0 }, name: "random task" }]] }, + ``` +- **Entity ids are plain spec-id numbers everywhere** — map keys in `before`/`after`/ + `input`, and reference fields hold the id they point at (`{ selectedTodo: 2 }`). The + ecs mints its own ids, so conformance compares up to an id-bijection. An + entity-addressed transition adds the `args` schema (above) marking each `Entity.schema` + field; the case then writes the id plain (`args: { id: 2 }`). For a minted scalar a + case won't pin (a timestamp), use `Match.anyNumber` / `Match.anyString`. +- **A `throws` case** declares `throws: true` (any error) or `throws: "substring"` + instead of `after`/`effects`/`responses` — the two are mutually exclusive. + +## `transforms.ts` — the conformed-function barrel -export const cases = /*@__PURE__*/ Conformance.cases(createTodo, - { name: "adds the first todo", - before: {}, // empty delta — the default State.create() (empty entities) - args: { name: "a", analytics: AnalyticsService.createFake() }, - // The map key is a PLAIN spec-id you choose (1). The ECS mints its own id, so - // conformance compares up to an id-bijection — the key need only be distinct. - // The value is id-less so content compares directly. (Maps compare entry-wise / - // order-independently — see conformance.md.) - after: { entities: new Map([[1, { name: "a", complete: false, order: 0 }]]) }, - effects: { analytics: [["todoCreated", { name: "a" }]] } }, -); +```ts +// transforms.ts — exactly the conformed transforms and derivations, nothing else +export { createTodo } from "./create-todo.js"; +export { toggleComplete } from "./toggle-complete.js"; +export { visibleTodos } from "./visible-todos.js"; +// … ``` -- **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** - (the case's `args` type is read from `fn`'s `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 for an ordinary "nothing to do here" - condition. Reserve `throw` for a genuine precondition violation the case wants - to name (see "A case that expects a throw" below) — the rare exception, not the - default guard style. -- **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. - Import the concrete helper directly (`import { create } from "./create.js"`) or - inline full-`State` literals. -- **Always write the call as `/*@__PURE__*/ Conformance.cases(fn, …)`** (same for - `Conformance.derivations`) — the annotation is not optional style, it is what - makes the co-location safe. `@adobe/data-testing`'s `sideEffects: false` only - lets a bundler drop an unused *import* of the package; `Conformance.cases(fn, - …)` is still an ordinary function call happening in *this* file, and an - unannotated call is conservatively kept even when the `cases` export it - initializes is never read — because the transition function `fn` is legitimately - used elsewhere, this module is very much reachable, so the unused `cases` const - is the only thing tree-shaking has to remove, and it can't without the hint. - Verified against this repo's actual Vite/Rollup production build: a transition - whose `cases` call lacked the annotation shipped the full case-fixture data - (and the builder's inlined logic) into the bundle; adding `/*@__PURE__*/` - removed it completely while leaving the transition function itself intact. A - linter/codemod catching a bare `Conformance.cases(`/`Conformance.derivations(` - call (missing the leading comment) in a `data/state/*.ts` file would catch this - by construction — worth adding if these calls keep getting hand-authored. -- **`Conformance.cases(fn, ...cases)`** (the builder above) derives each case's - types from `fn`'s own signature — pass the transition function and cases can't drift - from what it 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` uses PLAIN spec-ids everywhere — no hand-authored key matchers.** An - entity's identity is the `entities` map **key**, not a value field, so entity content - compares directly. The ECS mints its **own** ids (a different set from the spec's), so - conformance compares the two **up to an id-bijection**: the runner reads the store's - schemas, finds every entity reference (a map key, and any field whose schema is - `Entity.schema` — recursing into a bundle like `{ parent, order }`), and matches the - case's spec-ids to the ECS's ids one-to-one. So a case author writes ordinary numbers - — `[[1, value], [2, value]]` — and **a reference field just holds the id it points at** - (`{ asset: 2 }`, `placement: { parent: 1, … }`). The bijection makes a key and every - field that names it line up on one actual id automatically — this is what killed the - old `Match.ref` / `Match.refMap` key-labelling (no more distinct-label bookkeeping, - and cross-references between entities finally compare correctly). For a **minted scalar** - a case does not want to pin — a generated timestamp, a random value — still use - `Match.anyNumber` / `Match.anyString` (imported from `@adobe/data-testing`); `Match` is - framework-agnostic, so vitest's `expect.stringContaining(...)` interops on the expected - side too. (The id-correspondence solver is now internal — the runner generates it from - the schema-marked spec-ids; there is no case-facing `ref`/`refMap` matcher anymore.) -- **Entity-addressed cases pass an `args` schema in the builder options.** State - references are detected from the store's schemas; an **arg** has none, so a transition - that addresses an entity by id gives `Conformance.cases` a leading options object whose - `args` schema marks the reference fields: - `Conformance.cases(fn, { args: { type: "object", properties: { id: Entity.schema } } }, …cases)`. - The case then writes the id **plain** — `args: { id: 2 }` — and the runner resolves each - `Entity.schema`-marked field to the seeded entity (via the `fromState` seed map) on the - ECS side, while the pure side reads the plain spec-id directly. Describe **only** the - reference fields (services and non-id args are omitted); the args type must be assignable - to the schema's `Schema.ToType`, so the schema can't drift from the signature (the builder - fails to type-check otherwise). This is the args-side analog of the `Entity.schema` mark - the runner reads on state. -- **The `entities` map key convention across a case, in one place:** - - **`before`** (the seed): **plain spec-id numbers** (`new Map([[1, …], [2, …]])`). - `fromState` seeds from these and returns the `spec-id → entity` map, so a case's - `args: { id: 1 }` resolves to the entity seeded for `1`. - - **`after`** (the expectation, compared against the ECS): **plain spec-id numbers**, - distinct per entry, and reference fields hold the spec-id they point at. The runner - refifies them and compares up to an id-bijection, so the ECS's own ids need not match. - You may reuse the same spec-ids as `before` for entities that persist (it reads - clearly), but any distinct numbers work — the comparison is by content + reference - structure, never by the literal id. - - **`samples`** (round-tripped `toState ∘ fromState`): same as `after` — plain spec-id - keys, compared up to a bijection against the ECS-minted ids. -- No per-transform test. The single **`spec.test.ts`** is one call — - `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` (see - `conformance.md`); unordered collections are modeled as `ReadonlySet` / - `ReadonlyMap` on `State` (below), not declared as a match option. - 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. +Its keys are the conformance surface. A genuine **non-transition helper** (`appendTodo`, +`create`, `samples`) is *not* listed here and carries no cases; it keeps its own +`*.test.ts`. -### A case that expects a throw +## `spec.ts` — the conformance manifest -A case is normally `{ name, before, args?, after, effects? }`. When a transition -must reject its input outright — not the "guard no-op, return `{}`" case above, -but a genuine precondition violation the spec wants to name — declare `throws` -instead of `after`/`effects`: +The single object both `spec.test.ts` and the ecs `conformance.test.ts` import. It is a +**test-tier** file (excluded from the runtime build) — the one place the feature touches +`@adobe/data-testing`. ```ts -export const cases = Conformance.cases(withdrawFunds, - { name: "rejects a withdrawal over the balance", - before: { balance: 10 }, - args: { amount: 11 }, - throws: "insufficient funds" }, // substring the thrown Error.message must contain -); +// spec.ts +import { Conformance } from "@adobe/data-testing"; +import { State } from "./state.js"; +import * as transforms from "./transforms.js"; +import { MainService } from "../../services/main-service/main-service.js"; +import { ComputedDatabase } from "../../services/main-service/computed-database/computed-database.js"; +import { projection } from "../../services/main-service/conformance/projection.js"; +import { createFake as analytics } from "../../services/analytics-service/analytics.fake.js"; +import { cases as createTodo } from "./create-todo.cases.js"; +import { cases as toggleComplete } from "./toggle-complete.cases.js"; +import { cases as visibleTodos } from "./visible-todos.cases.js"; +// …one import per conformed fn + +export const spec = Conformance.feature({ + state: State, + fns: transforms, + plugin: MainService.plugin, + computedPlugin: ComputedDatabase.plugin, // required iff the feature has a derivation + projection, + hydrate: ["visibleTodos"], // EXACTLY the entity-id-list derivations + services: { analytics, nameGenerator }, // required iff a transition injects a service + cases: { createTodo, toggleComplete, visibleTodos, /* …one per fn */ }, +}); ``` -`throws: true` accepts any thrown error; a string narrows to one whose message -**contains** it. A case can declare `after`/`effects` **or** `throws`, never -both — the shared `Case` type makes them mutually exclusive, and there is -nothing to compare against `after` once a call never returns a patch. The same -case runs against the pure transform (`runSpec`) **and** the paired ecs -transaction/action (`runFeature`/`runTransactions`/`runActions`), so a `throws` -case proves both sides reject the same input the same way. Reach for this only -when throwing is the transition's actual contract — most invalid input should -still no-op per the rule above. - -## Injected services and side effects - -A transform that needs an outside capability injects it with **`Pick`** — never an ad-hoc inline type. `Services` is the feature's service map -(`services/services.ts`, see `../services/index.md`), keyed by the service name -minus its `-service` suffix (`analytics`, `nameGenerator`), so the key and its type -come from **one** place and can't drift per-transition. Plain data args sit -alongside via intersection: `{ readonly count: number } & Pick` -(or `Pick` when a transition takes only services). The **same -services appear on `db.services` for the matching action** — pinned to `Services` in -the ecs `service-database` — so the transition is the complete spec of *both* the -state change and the service calls. +`Conformance.feature` enforces four guards at **compile time**: +- **`cases` coverage** — exactly one case module per `fns` entry (missing or extra → error). +- **`hydrate` exactness** — exactly the derivations that return a collection of the + projection's entity value (its ecs computed emits ids); miss one or add a non-hydrating + name → error. +- **`services` completeness** — exactly the injected-service keys the transitions declare, + each a `() => Service` template; absent for a service-free feature. +- **`computedPlugin` presence** — required when any case module is a derivation. -- 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`). +## `spec.test.ts` — the pure spec -## Derivations — `(state) => value`, cases `{ input, value }` - -Pure selectors that **compose the aggregate** — a value drawn from **two or more -`State` fields** (`visibleTodos` from `entities` + `displayCompleted`; -`currentPlayer` from `board` + `firstPlayer`). An entity query like `visibleTodos` -returns entity **ids** (`ReadonlyArray` / `ReadonlySet`), never the -values (look those up in `entities`). 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. +```ts +// spec.test.ts +import { Conformance } from "@adobe/data-testing"; +import { spec } from "./spec.js"; +Conformance.checkSpec(spec); +``` -A `state/` derivation co-locates cases shaped `{ input, value }`, typed -`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. +One call verifies every transform and derivation against its cases (seeding `before` +over `State.create()`, synthesizing service doubles from `services` + `responses`, +asserting `after`/`value`/`effects`/`throws`). The ecs side is one matching call in +`conformance.test.ts` (`../services/main-service/conformance.md`). diff --git a/packages/data-ai/.claude/rules/features/index.md b/packages/data-ai/.claude/rules/features/index.md index 301094821..1eef607f3 100644 --- a/packages/data-ai/.claude/rules/features/index.md +++ b/packages/data-ai/.claude/rules/features/index.md @@ -32,8 +32,9 @@ one of two modes, discriminated by **the presence of `data/state/`**: - **State-based** — a **Functional State Specification (FSS)** is the source of truth: the pure `data/State` aggregate with its transitions and derivations, and the ECS is a conformance-verified implementation of it. Adds `data/state/` (State, - transitions, co-located `cases`, `spec.test.ts`), `services/main-service/conformance/`, - and the `state` projection computed. **This is how every new feature is authored.** + the pure transforms with sibling `*.cases.ts`, the `transforms.ts` barrel, the `spec.ts` + manifest, `spec.test.ts`), `services/main-service/conformance/`, and the `state` + projection computed. **This is how every new feature is authored.** - **ECS-based** — the **ECS is the source of truth**, authored directly: **no `data/state/`**, no FSS, no conformance. This is a **legacy** shape — features written before the state-based approach existed. New features are **never** authored @@ -129,16 +130,17 @@ 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 per-feature -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 -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 spec-owned cases +are authored as inert `data/state/*.cases.ts` and gathered by the `data/state/spec.ts` +**manifest**, which both the pure `spec.test.ts` (`Conformance.checkSpec(spec)`) and the +ecs `services/main-service/conformance/conformance.test.ts` +(`Conformance.checkFeature(spec)`) import. `checkFeature` pairs each ECS op to its +same-named transition, replays the shared cases, and round-trips the projection (see +`services/main-service/conformance.md`); the manifest's compile-time guards keep the two +calls the whole conformance surface. So conforming the implementation is "substitute the +implementation, reuse the expectations" — `main-service` is 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/index.md b/packages/data-ai/.claude/rules/features/services/index.md index 23734c5e0..348a13724 100644 --- a/packages/data-ai/.claude/rules/features/services/index.md +++ b/packages/data-ai/.claude/rules/features/services/index.md @@ -61,24 +61,33 @@ portability and lazy loading (`AsyncDataService.createLazy`). - Members are async only: `void | Promise | AsyncGenerator | Observe`. - Provide `create*` factories. -## Deterministic test doubles, adjacent to the contract - -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: `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 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. +## Test doubles are a test-tier `*.fake.ts` — never in the production barrel + +A service is the seam consumers swap out under test. Ship a **shape-only recording +template** alongside the interface, in the same namespace folder: `.fake.ts`, a +**single export** `createFake` that returns the service with each method present but +inert (void methods do nothing; value methods return a placeholder). It is **NOT** +re-exported through `public.ts` — so `MyService.createFake` is never reachable from +runtime code — and it imports the contract with `import type` only. The conformance +manifest (`data/state.md`) imports it directly; nothing else does. + +```ts +// analytics-service/analytics.fake.ts — test tier, not on the namespace +import type { AnalyticsService } from "./analytics-service.js"; +export const createFake = (): AnalyticsService => ({ + serviceName: "analytics", + todoToggled: () => {}, // void method: inert + randomTodoRequested: () => Promise.resolve({ startedAt: 0 }), // value method: placeholder + // …one entry per method +}); +``` + +The template supplies only the service's **shape** (the runner calls it once to +enumerate methods — no Proxy). It invents **no return values**: a conformance case +schedules each value-returning method's returns in its `responses` and asserts the +calls in its `effects` (`data/state.md`), so the case owns both the input and the +expectation. A `*.fake.ts` therefore takes no response parameter and hardcodes nothing +a case asserts. ## 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 b4dc7e9a4..94a990e1f 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 @@ -20,7 +20,7 @@ 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 the -action surface of `runFeature`. Give an action only to transitions a user/UI invokes directly +action surface of `checkFeature`. 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). @@ -53,18 +53,18 @@ export const addRandomTodo = async (service: ServiceDatabase) => { `services`, and assert the committed state and the recorded service calls. The bullet below applies only to **state-based** features (see `../../index.md`, Two modes). - **Conformance** is the action surface of the feature's single - `conformance/conformance.test.ts` `Conformance.runFeature({...})` call. It pulls + `conformance/conformance.test.ts` `Conformance.checkFeature(spec)` 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 + `db.services`, so the runner synthesizes each recording double from the manifest's + `services` templates + the case's `responses` and builds + `Database.toSystemDatabase(Database.create(plugin, { services }))`; it runs the action + with the case's data `args`, then `Match.assert`s `toState ≡ after` **and** checks the + recorded calls against the case's `effects`. No per-op wiring, 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. **A per-transition action kept out of the facet** (to bound the - plugin's type) is discovered via `runFeature`'s `ops.actions` glob — + plugin's type) is supplied via the manifest'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`). + (p2p negotiation). That is the *only* reason to set `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 aa54c5d73..bc85fae4b 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 @@ -23,13 +23,11 @@ not a hot per-entity or large-N path. Reusing a tested derivation is good — th 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 (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. +reads instead). A derivation takes **no services**, so its sibling `*.cases.ts` is +inert `{ input, value }` data (no test-doubles); like every `*.cases.ts` it is +test-tier and excluded from the runtime build by the project-reference wall, so it +never reaches the app bundle. **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"; @@ -53,10 +51,11 @@ only when the count changes, allocating no entity array. Prefer it over `db.observe.select(...)` mapped through `.length`. **Conform a computed to its `data/state` derivation** whenever one exists. The -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 +derivation's sibling `*.cases.ts` holds `{ input, value }` cases +(`Conformance.SpecDerivations`), and the feature's single +`conformance/conformance.test.ts` `Conformance.checkFeature(spec)` 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 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 be147c6b5..e54ccd708 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 @@ -5,240 +5,91 @@ paths: # services/main-service/conformance/ — keeping the ECS honest against the spec -**State-based features only.** This folder exists only when the feature has a -`data/state/` spec to conform to. An **ECS-based** feature (no `data/state/`) omits -this folder entirely and unit-tests its transactions/actions directly instead (see -`../../index.md`, Two modes, and `transactions.md` / `actions.md`). - -Test-only (imported only by `*.test.ts`, in no facet barrel). The `data/state` -cases are the shared truth; the shared **`@adobe/data-testing`** runner replays -them against the ECS. This folder holds only the *feature-specific projection* -(`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 projection helpers (`fromState`, `toState`, `toData(store, entity)`) and -`Match.matches` / `Match.assert` are test-only — never a production/runtime branch.** +**State-based features only** (a feature with a `data/state/` spec). Test-only: imported +only by `*.test.ts`, in no facet barrel. The `data/state` cases (authored per +`../../../data/state.md`, gathered by the `spec.ts` manifest) are the shared truth; the +`@adobe/data-testing` runner replays them against the ECS. This folder holds only the +**feature-specific projection** (`projection.ts`) plus one `conformance.test.ts`. +Reference: `data-lit-tictactoe` (minimal), `data-lit-todo` (entities + `hydrate`). + +**The projection helpers and `Match` are test-only — never a runtime branch.** `fromState`/`toState` rewrite the whole store out-of-band; runtime code reads through -observables/indexes and writes through transactions. (The conformance -`toData(store, entity)` reader is unrelated to the library's runtime `db.toData()` -store-serialization method — same name only.) - -## What `@adobe/data-testing` gives you - -Two namespaces, imported only from `*.test.ts` (the module is -`sideEffects: false`; add it as a devDependency alongside `vitest`, its -optional peer dependency — kept as a separate package from `@adobe/data` so -installing `@adobe/data` never pulls in a `vitest` peer dependency): - -- **`Match`** — the tolerant, matcher-aware value comparison: `matches(actual, - expected, options?)` and its throwing wrapper `assert(...)`, plus the matchers - `Match.anyNumber` / `Match.anyString` for minted scalars a case does not pin. - **Entity ids need no matcher**: a case writes plain spec-ids for map keys and - reference fields, and the runner compares up to an id-bijection (below), so `samples` - and `after` are ordinary `new Map([[1, value], …])` — no key labelling. Options are - `{ tolerance?: number }` — numbers snap to `tolerance` (default `0.01`) to absorb - F32↔f64 / trig noise. **Ordering is carried by the value's type**: a - `ReadonlyArray` compares **in order**, a `ReadonlySet` / `ReadonlyMap` - **order-independently** — there is no `unordered` option. Entity identity is the - key of `State.entities` (a `ReadonlyMap`), resolved to the allocated ECS - entity via `resolver` (below); entity *values* carry no `id`, so there is no id to - ignore when comparing content. Framework-agnostic: it honors any asymmetric matcher, - so vitest's `expect.any(...)` interops. -- **`Conformance`** — the case types (`Case`, `Cases`, `DerivationCase`, - `DerivationCases`, `Effects`, `ServiceCall`), the `casesBuilder` (a feature's - `Conformance.cases`), `assertState` for custom harnesses, 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 - -`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`: +observables/indexes and writes through transactions. + +## `projection.ts` — the store ⇄ State mapping (the one per-feature piece) + +A single aggregated export — three test-only helpers, one `export`: ```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`. +- `fromState(store, state)` seeds a store to a `State` (clear, insert entities, set + resources) and **returns the `id → entity` map** it built (`ReadonlyMap`). + The ecs mints its own ids, so the runner turns this map into the resolver that maps a + case's spec-id to the seeded entity — no feature writes id resolution by hand. Return + `void` for an index-addressed / singleton feature (ids then resolve to `Entity.none`). - `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` +- `toData(store, entity)` reads one entity as its `data/` value — the single place the + ecs↔data mapping lives. Its **return type** is what tells conformance which derivations + are entity-list computeds. Present only when the feature has entities. -`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. +## `conformance.test.ts` — one call ```ts -import { State } from "../../../data/state/state.js"; -import { transitions } from "../../../data/state/transitions.js"; - -Conformance.runFeature({ - state: State, - transitions, - plugin: MainService.plugin, - computedPlugin: ComputedDatabase.plugin, // omit if no state/ derivations - projection, - hydrate: ["visibleTodos"], // entity-id-list computeds; omit if none - match: { tolerance: 0.1 }, // float grid only; omit if 0.01 is fine - ops: { actions: import.meta.glob([...]) }, // ONLY when ops aren't in the plugin facet -}); +import { Conformance } from "@adobe/data-testing"; +import { spec } from "../../../data/state/spec.js"; +Conformance.checkFeature(spec); ``` -- **`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 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, - `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` holds its `bullet` / `asteroid` entities in the single -`entities: ReadonlyMap`, keyed by id, so they compare -order-independently by the map — no `match` option (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, importing the same `transitions`: +`checkFeature` replays the manifest's shared cases against the ECS: it pulls the +transactions/actions off `spec.plugin`, the computeds off `spec.computedPlugin`, seeds +each case's `before` over `State.create()`, synthesizes each service's recording double +from `spec.services` + the case's `responses`, round-trips `State.samples` through +`spec.projection`, and hydrates the id-emitting computeds named in `spec.hydrate` through +`toData`. It shares `spec` with `spec.test.ts` — same cases, substituted implementation. + +Everything `checkFeature` needs lives on the manifest (`../../../data/state.md`): +`plugin`, `computedPlugin`, `projection`, `hydrate`, `services`, `state`, `fns`, `cases`. +The manifest's compile-time guards mean the two calls (`checkSpec` + `checkFeature`) are +the whole conformance surface — no per-op wiring, no coverage guard to hand-maintain. + +- **Name-pairing.** Each ecs op conforms to its **same-named** transition/derivation. An + op with no same-named transition (infrastructure like `setInput`, a streaming action, + a richer UI op) is skipped — so when the real op is renamed (`dragTodo`), add a thin + **same-named** op (`reorderTodo`) for the transition to pair with; never a per-item + adapter. +- **Projection round-trip.** When `State.samples` is non-empty the runner adds a + `toState ∘ fromState ≡ identity` test per sample. +- **Comparison.** Ordering is carried by the value's type — `ReadonlyArray` positional, + `ReadonlySet`/`ReadonlyMap` order-independent. Entity ids compare up to an id-bijection + from the schema-marked spec-ids (no matcher). Float noise is absorbed by + `match?: { tolerance }` (default `0.01`). A minted scalar uses `Match.anyNumber` / + `Match.anyString`. + +## The escape hatch — per-surface `seedContext` / custom concurrency + +A feature that needs **ambient per-case context differing per surface** — a user-scoped +`userId` seeded before the raw transaction *and* independently before the action dispatch +(**p2p presence**) — cannot use `checkFeature` (it has no place to thread two different +`seedContext`s or a custom concurrency). It drives the lower-level runners directly, +feeding them the manifest's cases via `Conformance.adaptCases`: ```ts -import { State } from "./state.js"; -import { transitions } from "./transitions.js"; - -Conformance.runSpec({ state: State, transitions }); +import { Conformance } from "@adobe/data-testing"; +import { spec } from "../../../data/state/spec.js"; + +Conformance.runTransactions({ + createStore, fromState, toState, + transitions: Conformance.adaptCases(spec.fns, spec.cases, spec.services, false), + transactions, + seedContext: (store, _before, args) => seedUserId(store, (args as { mark: string }).mark), +}); ``` -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()`. On the ECS side it resolves the reference args a case's `args` -schema marks; the pure side reads them plain. A transition case may declare -`throws` instead of `after`/`effects` to assert the call rejects its input — -see `../../data/state.md` ("A case that expects a throw"). `runSpec`, -`runTransactions`, and `runActions` all honor it identically, so the same case -proves the pure transform and its paired ecs op fail the same way. - -## 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 `args` schema - -An entity-addressed transition declares an `args` schema in the builder options and -writes the id **plain**: `Conformance.cases(fn, { args: { type: "object", properties: -{ id: Entity.schema } } }, { …, args: { id: 2 }, … })`. The runner -finds each `Entity.schema`-marked arg field and resolves the plain spec-id to the -**seeded entity** via the id→entity map `fromState` returns (turned into a `resolve` -by `Conformance.resolver` — no feature writes `resolve` by hand); the pure side reads -the spec-id as-is. 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`). -`data-lit-todo` is the reference. Describe only the reference fields — services and -non-id args are omitted, and the args type must be assignable to the schema's -`Schema.ToType` so it cannot drift from the signature. - -## 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. - -## Ordering, tolerance, the id-bijection - -Ordering is carried by the value's **type**, not a match option — `ReadonlyArray` -positional, `ReadonlySet` / `ReadonlyMap` order-independent (the rule and its -rationale live in `../../../data-modelling.md`). What's specific to writing -conformance cases: - -- **The spec and the ECS mint different id sets, so comparison is up to an - id-bijection — automatically.** The runner reads the store's `componentSchemas` - (which also carry the resource schemas) and finds every entity reference: an - `entities` map **key** (an id by construction) and any field whose schema is - `Entity.schema` (`schema.entity === true`), recursing into bundled object schemas so - a `placement: { parent, order }` reference is found. It replaces each spec-id there - with a correspondence variable and solves a one-to-one matching against the ECS's - ids. So a case **writes plain spec-ids** for keys and reference fields, and a key and - every field naming it line up on one actual id with no author bookkeeping — this is - what retired `Match.ref` / `Match.refMap` for keys, and it is what makes a - cross-reference between entities (an `asset`, a `parent`) compare correctly at all. -- **Entity values carry no `id`** — identity is the key — so entity content compares - directly, with nothing to omit. -- **Float noise** is absorbed by the default `tolerance` (`0.01`), threaded through - `match?: { tolerance }`; raise it only when a case needs a looser grid. -- **A minted scalar** a case does not pin — a timestamp, a random draw — uses - `Match.anyNumber` / `Match.anyString`. Entity-id correspondence needs no matcher: the - runner derives it from the schema-marked spec-ids (the solver is internal). - -## 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`; 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. +`adaptCases(fns, cases, services, injectServices)` turns the manifest into the discovery +map the runners consume — `injectServices: false` for the transaction store (a +transaction never receives services), `true` for the action db. `runActions` / +`runComputeds` take the same shape and their own `makeDb` / `seedContext` / concurrency. +This is the **only** sanctioned use of the lower runners; ordinary features use +`checkFeature`. 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 72f056e01..5e34f038b 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 @@ -38,7 +38,7 @@ export const playMove = (t: CoreDatabase.Store, { index }: PlayMoveArgs) => { direct substitute for the conformance oracle (see `../../index.md`, Two modes). The bullet below applies only to **state-based** features. - **Conformance is wired once, centrally, and auto-paired** — not per-file. The - feature's single `conformance/conformance.test.ts` `Conformance.runFeature({...})` + feature's single `conformance/conformance.test.ts` `Conformance.checkFeature(spec)` 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` diff --git a/packages/data-ai/package.json b/packages/data-ai/package.json index 25082279d..4e8e42fb7 100644 --- a/packages/data-ai/package.json +++ b/packages/data-ai/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-ai", - "version": "0.10.19", + "version": "0.10.20", "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/.gitignore b/packages/data-gpu-hopper/.gitignore new file mode 100644 index 000000000..c18d52ae7 --- /dev/null +++ b/packages/data-gpu-hopper/.gitignore @@ -0,0 +1 @@ +.tsbuild/ diff --git a/packages/data-gpu-hopper/package.json b/packages/data-gpu-hopper/package.json index 9a9c236c7..9aa563d4b 100644 --- a/packages/data-gpu-hopper/package.json +++ b/packages/data-gpu-hopper/package.json @@ -11,7 +11,7 @@ "build": "vite build", "dev": "vite", "test": "vitest --run --passWithNoTests", - "typecheck": "tsc -p tsconfig.json --noEmit" + "typecheck": "tsc -b tsconfig.test.json" }, "dependencies": { "@adobe/data": "workspace:*", 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 deleted file mode 100644 index 62f0ca833..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/state/conformance-case.ts +++ /dev/null @@ -1,10 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Conformance as ConformanceApi } from "@adobe/data-testing"; -import type { State } from "./state.js"; - -// The per-feature conformance surface — the shared `@adobe/data-testing` machinery -// with `State` bound once. `Conformance.cases(fn, [options,] ...cases)` declares a -// transform's cases (the case types come from `fn`); `Conformance.derivations(fn, ...cases)` -// a derivation's. This tiny file is the only per-feature conformance -// declaration; everything else lives in `@adobe/data-testing`. -export const Conformance = { cases: ConformanceApi.casesBuilder(), derivations: ConformanceApi.derivationsBuilder() }; 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 new file mode 100644 index 000000000..eb15bbfaa --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/state/hop.cases.ts @@ -0,0 +1,47 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { hop } from "./hop.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: [], + entities: new Map(), + lives: 3, + score: 0, + status: "playing", +}; + +// Inert, 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). `direction` is plain data — no service double, no entity refs. +export const cases: Conformance.SpecCases = { + cases: [ + { name: "hops up toward the goal", + before: { ...base, frog: { x: 2, y: 0 } }, args: { direction: "up" }, after: { frog: { x: 2, y: 1 } } }, + { name: "hops down toward the start", + before: { ...base, frog: { x: 2, y: 1 } }, args: { direction: "down" }, after: { frog: { x: 2, y: 0 } } }, + { name: "hops left", + before: { ...base, frog: { x: 2, y: 1 } }, args: { direction: "left" }, after: { frog: { x: 1, y: 1 } } }, + { name: "hops right", + before: { ...base, frog: { x: 2, y: 1 } }, args: { direction: "right" }, after: { frog: { x: 3, y: 1 } } }, + { name: "clamps at the bottom row", + before: { ...base, frog: { x: 2, y: 0 } }, args: { direction: "down" }, after: { frog: { x: 2, y: 0 } } }, + { name: "clamps at the top (goal) row", + before: { ...base, frog: { x: 2, y: 2 } }, args: { direction: "up" }, after: { frog: { x: 2, y: 2 } } }, + { name: "clamps at the left edge", + before: { ...base, frog: { x: 0, y: 1 } }, args: { direction: "left" }, after: { frog: { x: 0, y: 1 } } }, + { name: "clamps at the right edge", + before: { ...base, frog: { x: 4, y: 1 } }, args: { direction: "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: { direction: "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: { direction: "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: { direction: "up" }, + after: { frog: { x: 2, y: 1 } } }, + ], +}; 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 2ae8f0821..a20fda6d6 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,7 +2,6 @@ import { Direction } from "../direction/direction.js"; import { GameStatus } from "../game-status/game-status.js"; import type { State } from "./state.js"; -import { Conformance } from "./conformance-case.js"; const clamp = (value: number, max: number): number => Math.max(0, Math.min(max, value)); @@ -11,7 +10,7 @@ const clamp = (value: number, max: number): number => Math.max(0, Math.min(max, // play, keeping it idempotent. Writes only `frog`. export const hop = ( state: Pick, - direction: Direction, + { direction }: { readonly direction: Direction }, ): Pick => { if (!GameStatus.isPlaying(state.status)) return { frog: state.frog }; const { dx, dy } = Direction.delta[direction]; @@ -22,43 +21,3 @@ export const hop = ( }, }; }; - -// 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: [], - entities: new Map(), - lives: 3, - score: 0, - status: "playing", -}; - -// 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 = /*@__PURE__*/ Conformance.cases(hop, - { name: "hops up toward the goal", - 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: { frog: { x: 2, y: 0 } } }, - { name: "hops left", - 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: { frog: { x: 3, y: 1 } } }, - { name: "clamps at the bottom row", - 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: { frog: { x: 2, y: 2 } } }, - { name: "clamps at the left edge", - 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: { 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: { 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: { 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: { 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 new file mode 100644 index 000000000..957bd21fb --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/state/lose-life.cases.ts @@ -0,0 +1,30 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { loseLife } from "./lose-life.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: [], + entities: new Map(), + score: 0, +}; + +// Inert, spec-owned cases, shared with the ecs `loseLife` transaction. No args and +// no services. +export const cases: Conformance.SpecCases = { + cases: [ + { name: "spends a life and respawns the frog at the start", + before: { ...base, lives: 3, status: "playing", frog: { x: 1, y: 1 } }, + 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: { 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: { 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 f41e1a517..47972e465 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,7 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { GameStatus } from "../game-status/game-status.js"; import type { State } from "./state.js"; -import { 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 @@ -17,26 +16,3 @@ export const loseLife = ( ? { 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 -// only lives / status / frog / width; the rest is inert here. -const base: Omit = { - width: 5, - height: 3, - lanes: [], - entities: new Map(), - score: 0, -}; - -// Spec-owned cases, shared with the ecs `loseLife` transaction. -export const cases = /*@__PURE__*/ Conformance.cases(loseLife, - { name: "spends a life and respawns the frog at the start", - before: { ...base, lives: 3, status: "playing", frog: { x: 1, y: 1 } }, - 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: { 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: { 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 new file mode 100644 index 000000000..02cd5bae1 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/state/new-game.cases.ts @@ -0,0 +1,34 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { newGame } from "./new-game.js"; +import { create } from "./create.js"; + +// Inert, 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. `after` is just the initial game +// (`create()`): its hazard-entity keys are plain spec-ids that conformance compares +// up to an id-bijection, so the ecs is free to mint its own. `newGame` takes no +// args (it ignores the prior state). +export const cases: Conformance.SpecCases = { + cases: [ + { + 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" }, + ], + entities: new Map([[1, { 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/new-game.ts b/packages/data-gpu-hopper/src/features/main/data/state/new-game.ts index df9d3f8fe..ec54e4a74 100644 --- 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 @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { State } from "./state.js"; -import { Conformance } from "./conformance-case.js"; import { create } from "./create.js"; // A "new game" TRANSITION: it deliberately **ignores** the prior `state` and @@ -9,29 +8,3 @@ import { create } from "./create.js"; // 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. `after` is just the initial game -// (`create()`): its hazard-entity keys are plain spec-ids that conformance compares -// up to an id-bijection, so the ecs is free to mint its own. -export const cases = /*@__PURE__*/ Conformance.cases(newGame, - { - 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" }, - ], - entities: new Map([[1, { 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/spec.test.ts b/packages/data-gpu-hopper/src/features/main/data/state/spec.test.ts index ba79b66c3..24f6f691a 100644 --- 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 @@ -1,13 +1,9 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Conformance } from "@adobe/data-testing"; -import { State } from "./state.js"; -import { transitions } from "./transitions.js"; +import { spec } from "./spec.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 is a `ReadonlySet`, so the comparator matches it order-independently. -Conformance.runSpec({ - state: State, - transitions, -}); +// The single pure-spec test for every transform in this folder. It runs the +// manifest's inert cases against the pure functions, asserting `after` (up to an +// id-bijection). The hazard bag is a `ReadonlyMap`, so the comparator matches it +// order-independently. +Conformance.checkSpec(spec); diff --git a/packages/data-gpu-hopper/src/features/main/data/state/spec.ts b/packages/data-gpu-hopper/src/features/main/data/state/spec.ts new file mode 100644 index 000000000..5bf728588 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/state/spec.ts @@ -0,0 +1,36 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Conformance } from "@adobe/data-testing"; +import { State } from "./state.js"; +import * as transforms from "./transforms.js"; +import { MainService } from "../../services/main-service/main-service.js"; +import { projection } from "../../services/main-service/conformance/projection.js"; + +import { cases as hop } from "./hop.cases.js"; +import { cases as step } from "./step.cases.js"; +import { cases as winGoal } from "./win-goal.cases.js"; +import { cases as loseLife } from "./lose-life.cases.js"; +import { cases as newGame } from "./new-game.cases.js"; + +// The feature's conformance manifest — the single object `spec.test.ts` (pure) and +// `conformance.test.ts` (ecs) both import. It wires each conformed fn to its inert cases module explicitly: `cases` names one module per conformed fn, +// checked at COMPILE TIME against `transforms` (a fn without cases, or cases without +// a fn, won't type). Hopper has no derivations (so no `computedPlugin`, no `hydrate`) +// and no capability services (so no `services`). `step` has no ecs transaction — the +// per-frame system loop conforms it — so `checkFeature` skips it while `checkSpec` +// verifies it against the pure function. +// +// This is a test-tier module (excluded from the runtime program) — the ONE place the +// feature touches `@adobe/data-testing`. No transform or `*.cases.ts` file does. +export const spec = Conformance.feature({ + state: State, + fns: transforms, + plugin: MainService.plugin, + projection, + cases: { + hop, + step, + winGoal, + loseLife, + newGame, + }, +}); 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 new file mode 100644 index 000000000..1da227bc3 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/state/step.cases.ts @@ -0,0 +1,86 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { Lane } from "../lane/lane.js"; +import type { State } from "./state.js"; +import type { step } from "./step.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" }, +]; + +// Inert, 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. `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.SpecCases = { + cases: [ + { name: "scrolls hazards while the frog rests on grass", + before: { width: 5, height: 3, lanes: roadLanes, + entities: new Map([[1, { kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }]]), + frog: { x: 2, y: 0 }, lives: 3, score: 0, status: "playing" }, + args: { dt: 1 }, + after: { entities: new Map([[1, { 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, + entities: new Map([[1, { kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }]]), + frog: { x: 1, y: 1 }, lives: 3, score: 0, status: "playing" }, + args: { dt: 1 }, + after: { entities: new Map([[1, { 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, + entities: new Map([[1, { kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }]]), + frog: { x: 1, y: 1 }, lives: 1, score: 0, status: "playing" }, + args: { dt: 1 }, + after: { entities: new Map([[1, { 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, + entities: new Map([[1, { kind: "log", lane: 1, x: 3, width: 1, velocity: 0 }]]), + frog: { x: 1, y: 1 }, lives: 3, score: 0, status: "playing" }, + args: { dt: 1 }, + after: { entities: new Map([[1, { 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, + entities: new Map([[1, { kind: "log", lane: 1, x: 0, width: 3, velocity: 1 }]]), + frog: { x: 1, y: 1 }, lives: 3, score: 0, status: "playing" }, + args: { dt: 1 }, + after: { entities: new Map([[1, { 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, + entities: new Map([[1, { kind: "log", lane: 1, x: 3, width: 2, velocity: 2 }]]), + frog: { x: 4, y: 1 }, lives: 3, score: 0, status: "playing" }, + args: { dt: 1 }, + after: { entities: new Map([[1, { 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, + entities: new Map([[1, { kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }]]), + frog: { x: 2, y: 2 }, lives: 3, score: 0, status: "playing" }, + args: { dt: 1 }, + after: { entities: new Map([[1, { 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, + entities: new Map([[1, { kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }]]), + frog: { x: 2, y: 0 }, lives: 0, score: 0, status: "gameOver" }, + args: { dt: 1 }, + after: { entities: new Map([[1, { 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.ts b/packages/data-gpu-hopper/src/features/main/data/state/step.ts index 83e863c29..d2dcdf7ea 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,9 +3,7 @@ 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 { Conformance } from "./conformance-case.js"; import { laneAt } from "./lane-at.js"; import { frogOutcome } from "./frog-outcome.js"; import { winGoal } from "./win-goal.js"; @@ -21,7 +19,7 @@ type StepPatch = Pick // once the game has ended, keeping it idempotent. export const step = ( state: Pick, - dt: number, + { dt }: { readonly dt: number }, ): StepPatch => { if (!GameStatus.isPlaying(state.status)) { return { @@ -69,81 +67,3 @@ export const step = ( if (Outcome.isFatal[outcome]) return { ...movedPatch, ...loseLife(moved) }; return movedPatch; }; - -// 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. `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 = /*@__PURE__*/ Conformance.cases(step, - { name: "scrolls hazards while the frog rests on grass", - before: { width: 5, height: 3, lanes: roadLanes, - entities: new Map([[1, { kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }]]), - frog: { x: 2, y: 0 }, lives: 3, score: 0, status: "playing" }, - args: 1, - after: { entities: new Map([[1, { 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, - entities: new Map([[1, { kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }]]), - frog: { x: 1, y: 1 }, lives: 3, score: 0, status: "playing" }, - args: 1, - after: { entities: new Map([[1, { 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, - entities: new Map([[1, { kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }]]), - frog: { x: 1, y: 1 }, lives: 1, score: 0, status: "playing" }, - args: 1, - after: { entities: new Map([[1, { 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, - entities: new Map([[1, { kind: "log", lane: 1, x: 3, width: 1, velocity: 0 }]]), - frog: { x: 1, y: 1 }, lives: 3, score: 0, status: "playing" }, - args: 1, - after: { entities: new Map([[1, { 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, - entities: new Map([[1, { kind: "log", lane: 1, x: 0, width: 3, velocity: 1 }]]), - frog: { x: 1, y: 1 }, lives: 3, score: 0, status: "playing" }, - args: 1, - after: { entities: new Map([[1, { 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, - entities: new Map([[1, { kind: "log", lane: 1, x: 3, width: 2, velocity: 2 }]]), - frog: { x: 4, y: 1 }, lives: 3, score: 0, status: "playing" }, - args: 1, - after: { entities: new Map([[1, { 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, - entities: new Map([[1, { kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }]]), - frog: { x: 2, y: 2 }, lives: 3, score: 0, status: "playing" }, - args: 1, - after: { entities: new Map([[1, { 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, - entities: new Map([[1, { kind: "car", lane: 1, x: 0, width: 1, velocity: 1 }]]), - frog: { x: 2, y: 0 }, lives: 0, score: 0, status: "gameOver" }, - args: 1, - after: { entities: new Map([[1, { 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/transforms.ts b/packages/data-gpu-hopper/src/features/main/data/state/transforms.ts new file mode 100644 index 000000000..0c21f78b5 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/state/transforms.ts @@ -0,0 +1,16 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +// The conformed functions of this feature — every pure transform the spec verifies, +// and nothing else (helpers like `create`, `samples`, `startPosition`, `laneAt`, and +// `frogOutcome` are deliberately absent; each keeps its own `*.test.ts`). Its keys +// ARE the conformance surface: the manifest's coverage guard (`Conformance.feature`) +// requires exactly one `*.cases.ts` module per key here, so adding a transform +// without cases — or cases without a transform — fails to compile. `step` has no ecs +// transaction (the per-frame system loop conforms it in +// `system-database/tick-loop.test.ts`), so `checkFeature` simply skips it; the pure +// `checkSpec` still verifies it here. +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"; 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 deleted file mode 100644 index 1092fd915..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/state/transitions.ts +++ /dev/null @@ -1,11 +0,0 @@ -// © 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 new file mode 100644 index 000000000..58023685d --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/state/win-goal.cases.ts @@ -0,0 +1,25 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { winGoal } from "./win-goal.js"; + +// winGoal reads only score / status; the rest is inert here. +const base: Omit = { + width: 5, + height: 3, + lanes: [], + entities: new Map(), + lives: 3, + frog: { x: 2, y: 2 }, +}; + +// Inert, spec-owned cases, shared with the ecs `winGoal` transaction. No args and +// no services — a bare read→write patch over score / status. +export const cases: Conformance.SpecCases = { + cases: [ + { name: "scores the goal and wins", + 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: { 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 1d688fbda..42cf4560e 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,7 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { GameStatus } from "../game-status/game-status.js"; import type { State } from "./state.js"; -import { Conformance } from "./conformance-case.js"; // Score the reached goal and end the game as won. A no-op once the game has // finished. Writes only `score` + `status`. @@ -11,21 +10,3 @@ export const winGoal = ( 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. -const base: Omit = { - width: 5, - height: 3, - lanes: [], - entities: new Map(), - lives: 3, - frog: { x: 2, y: 2 }, -}; - -// Spec-owned cases, shared with the ecs `winGoal` transaction. -export const cases = /*@__PURE__*/ Conformance.cases(winGoal, - { name: "scores the goal and wins", - 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: { 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 index b876531d5..06b2d1d83 100644 --- 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 @@ -1,23 +1,15 @@ // © 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"; +import { spec } from "../../../data/state/spec.js"; -// The whole ecs conformance for this feature in one call: `runFeature` pairs each +// The whole ecs conformance for this feature in one call: `checkFeature` 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 // hazards live in the identity-keyed `entities` `ReadonlyMap`, so the comparator // matches them order-independently (the ecs materialises them in nondeterministic -// row order, and mints its own ids the `after`/`samples` refs leave open). `step` has no -// transaction — the per-frame system loop conforms it in +// row order, and mints its own ids the `after`/`samples` refs leave open). `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, -}); +Conformance.checkFeature(spec); 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 index c6544bf3a..dd536d0a6 100644 --- 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 @@ -34,7 +34,7 @@ const readEntities = (store: CoreDatabase.Store): Map => { return entities; }; -// The test-only ecs↔`State` projection, passed to `Conformance.runFeature` (and +// The test-only ecs↔`State` projection, carried on the manifest and used by `Conformance.checkFeature` (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 diff --git a/packages/data-gpu-hopper/src/features/main/services/main-service/system-database/system-database.ts b/packages/data-gpu-hopper/src/features/main/services/main-service/system-database/system-database.ts index ccd1af1bf..610022b70 100644 --- a/packages/data-gpu-hopper/src/features/main/services/main-service/system-database/system-database.ts +++ b/packages/data-gpu-hopper/src/features/main/services/main-service/system-database/system-database.ts @@ -45,7 +45,7 @@ const systemDatabasePlugin = Database.Plugin.create({ if (!GameStatus.isPlaying(resources.status)) return; const direction = resources.pendingDirection; if (direction === null) return; - db.transactions.hop(direction); + db.transactions.hop({ direction }); resources.pendingDirection = null; }, }, 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 d33dc9bc3..a0532499f 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 @@ -21,7 +21,7 @@ import { describe, it } from "vitest"; import { Conformance } from "@adobe/data-testing"; import { State } from "../../../data/state/state.js"; -import { cases } from "../../../data/state/step.js"; +import { cases } from "../../../data/state/step.cases.js"; import { createSystemDatabase } from "../conformance/create-system-database.js"; import { projection } from "../conformance/projection.js"; import { driveFrame } from "../conformance/drive-frame.js"; @@ -29,15 +29,15 @@ import { driveFrame } from "../conformance/drive-frame.js"; describe("ECS system tick loop conforms to State.step (one frame = one step)", () => { for (const testCase of cases.cases) { it(testCase.name, () => { - const dt = testCase.args; + const { dt } = testCase.args; // 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. `db.store` + // patch (`SpecCase.before`/`after` are `Partial`), so materialise the + // full seed and the full expected state the same way the runners do. `db.store` // supplies the schemas `assertState` reads to compare up to an id-bijection. const before = { ...State.create(), ...testCase.before }; const expected = { ...before, ...testCase.after }; const db = createSystemDatabase(); - Conformance.assertState({ ...before, ...State.step(before, dt) }, expected, db.store); + Conformance.assertState({ ...before, ...State.step(before, { dt }) }, expected, db.store); projection.fromState(db.store, before); db.store.resources.frameDelta = dt; diff --git a/packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transactions/hop.ts b/packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transactions/hop.ts index 3029cc3a5..9d6491f0d 100644 --- a/packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transactions/hop.ts +++ b/packages/data-gpu-hopper/src/features/main/services/main-service/transaction-database/transactions/hop.ts @@ -8,11 +8,11 @@ import { readFrog } from "./read-frog.js"; // resources; the decision is the pure `data/` transform, and this only writes // the resulting position back to the frog entity. A no-op while not playing // writes the same values, so it stays idempotent. -export const hop = (t: CoreDatabase.Store, direction: Direction) => { +export const hop = (t: CoreDatabase.Store, { direction }: { readonly direction: Direction }) => { const { id, frog } = readFrog(t); const next = State.hop( { frog, width: t.resources.width, height: t.resources.height, status: t.resources.status }, - direction, + { direction }, ); t.update(id, { x: next.frog.x, y: next.frog.y }); }; diff --git a/packages/data-gpu-hopper/tsconfig.build.json b/packages/data-gpu-hopper/tsconfig.build.json new file mode 100644 index 000000000..1395b61d5 --- /dev/null +++ b/packages/data-gpu-hopper/tsconfig.build.json @@ -0,0 +1,29 @@ +{ + // The RUNTIME wall (dependency isolation). It compiles the shippable data + services + // tiers as a `composite` project, so a data/ or services/ runtime file that imports a + // test-tier file (`*.cases.ts`, `*.fake.ts`, `spec.ts`, anything under `conformance/`) + // is a COMPILE ERROR (TS6307: not in this project's file list). `ui/` and the app-shell + // (`src/*.ts`) live in the test project instead — they import test-tier only in their own + // `*.test.ts`, and keeping their (often very large) inferred element/plugin types out of + // this project avoids forcing them into serialized declarations. Emits declarations only, + // to a throwaway dir, purely to satisfy the composite contract `tsconfig.test.json` refs. + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "./.tsbuild", + "rootDir": "src" + }, + "include": ["src/features/**/*"], + "exclude": [ + "**/ui/**", + "**/*.test.ts", + "**/*.type-test.ts", + "**/*.cases.ts", + "**/*.fake.ts", + "**/spec.ts", + "**/conformance/**" + ] +} diff --git a/packages/data-gpu-hopper/tsconfig.test.json b/packages/data-gpu-hopper/tsconfig.test.json new file mode 100644 index 000000000..f328ac324 --- /dev/null +++ b/packages/data-gpu-hopper/tsconfig.test.json @@ -0,0 +1,26 @@ +{ + // The NON-EMITTING tier: everything the runtime wall (`tsconfig.build.json`) doesn't own — + // the test-tier (conformance manifest + inert cases, service fakes, `*.test.ts`, + // `conformance/`), plus `ui/` and the app-shell (`src/*.ts`). It `references` the runtime + // project, so its imports of data/services resolve through that project's declarations, + // and it alone may see `@adobe/data-testing`. Nothing here emits, so large element/plugin + // types are type-checked without being serialized. `tsc -b tsconfig.test.json` builds the + // wall first, then type-checks this tier — together covering all of `src`. + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": false, + "noEmit": true + }, + "include": [ + "src/*.ts", + "src/**/ui/**/*", + "src/**/*.test.ts", + "src/**/*.type-test.ts", + "src/**/*.cases.ts", + "src/**/*.fake.ts", + "src/**/spec.ts", + "src/**/conformance/**/*" + ], + "exclude": [], + "references": [{ "path": "./tsconfig.build.json" }] +} diff --git a/packages/data-gpu/package.json b/packages/data-gpu/package.json index 178a429cd..ca92e27d0 100644 --- a/packages/data-gpu/package.json +++ b/packages/data-gpu/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-gpu", - "version": "0.10.19", + "version": "0.10.20", "description": "Adobe data WebGPU plugins and types for graphics and compute", "type": "module", "private": false, diff --git a/packages/data-lit-space-rock-game/.gitignore b/packages/data-lit-space-rock-game/.gitignore new file mode 100644 index 000000000..c18d52ae7 --- /dev/null +++ b/packages/data-lit-space-rock-game/.gitignore @@ -0,0 +1 @@ +.tsbuild/ diff --git a/packages/data-lit-space-rock-game/package.json b/packages/data-lit-space-rock-game/package.json index f29222a74..e98ff4e78 100644 --- a/packages/data-lit-space-rock-game/package.json +++ b/packages/data-lit-space-rock-game/package.json @@ -11,7 +11,7 @@ "build": "vite build", "dev": "vite", "test": "vitest --run --passWithNoTests", - "typecheck": "tsc -p tsconfig.json --noEmit" + "typecheck": "tsc -b tsconfig.test.json" }, "dependencies": { "@adobe/data": "workspace:*", 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 deleted file mode 100644 index 62f0ca833..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/conformance-case.ts +++ /dev/null @@ -1,10 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Conformance as ConformanceApi } from "@adobe/data-testing"; -import type { State } from "./state.js"; - -// The per-feature conformance surface — the shared `@adobe/data-testing` machinery -// with `State` bound once. `Conformance.cases(fn, [options,] ...cases)` declares a -// transform's cases (the case types come from `fn`); `Conformance.derivations(fn, ...cases)` -// a derivation's. This tiny file is the only per-feature conformance -// declaration; everything else lives in `@adobe/data-testing`. -export const Conformance = { cases: ConformanceApi.casesBuilder(), derivations: ConformanceApi.derivationsBuilder() }; 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 new file mode 100644 index 000000000..7b3e12e5e --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/create-initial.cases.ts @@ -0,0 +1,64 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { createInitial } from "./create-initial.js"; + +// Inert, spec-owned cases, shared with the ecs `createInitial` 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 +// the transaction 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. `bounds` is plain +// data — no service double, no entity refs. +const dirty: State = { + bounds: [1, 1], + ship: { position: [10, 10], velocity: [5, 5], rotation: 1 }, + entities: new Map([ + [1, { position: [1, 1], velocity: [0, 0], age: 0.5 }], + [2, { position: [9, 9], velocity: [0, 0], size: "small" }], + ]), + score: 99, + lives: 1, + wave: 7, +}; + +export const cases: Conformance.SpecCases = { + cases: [ + { + 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 }, + entities: new Map([ + [1, { position: [180, 100], velocity: [0, 60], size: "large" }], + [2, { position: [100, 180], velocity: [-60, 0], size: "large" }], + [3, { position: [20, 100], velocity: [0, -60], size: "large" }], + [4, { 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 }, + entities: new Map([ + [1, { position: [360, 200], velocity: [0, 60], size: "large" }], + [2, { position: [200, 360], velocity: [-60, 0], size: "large" }], + [3, { position: [40, 200], velocity: [0, -60], size: "large" }], + [4, { 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.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/create-initial.ts index 5905aa361..06e696085 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,7 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Vec2 } from "@adobe/data/math"; import type { State } from "./state.js"; -import { Conformance } from "./conformance-case.js"; import { Ship } from "../ship/ship.js"; import { spawnWave } from "./spawn-wave.js"; @@ -27,60 +26,3 @@ export const createInitial = ( // spawnWave returns only { entities, 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 -// `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 }, - entities: new Map([ - [1, { position: [1, 1], velocity: [0, 0], age: 0.5 }], - [2, { position: [9, 9], velocity: [0, 0], size: "small" }], - ]), - score: 99, - lives: 1, - wave: 7, -}; - -export const cases = /*@__PURE__*/ Conformance.cases(createInitial, - { - 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 }, - entities: new Map([ - [1, { position: [180, 100], velocity: [0, 60], size: "large" }], - [2, { position: [100, 180], velocity: [-60, 0], size: "large" }], - [3, { position: [20, 100], velocity: [0, -60], size: "large" }], - [4, { 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 }, - entities: new Map([ - [1, { position: [360, 200], velocity: [0, 60], size: "large" }], - [2, { position: [200, 360], velocity: [-60, 0], size: "large" }], - [3, { position: [40, 200], velocity: [0, -60], size: "large" }], - [4, { 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/fire-bullet.cases.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/fire-bullet.cases.ts new file mode 100644 index 000000000..31a3628d8 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/fire-bullet.cases.ts @@ -0,0 +1,79 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { fireBullet } from "./fire-bullet.js"; +import { create } from "./create.js"; + +// Inert, spec-owned cases, shared with the ecs `fireBullet` transaction/action. 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. +// `fireBullet` takes no args, so no `args` field. `before`/`after` are full states +// over a fixed 800×600 field. +const field = { ...create(), bounds: [800, 600] as [number, number] }; + +export const cases: Conformance.SpecCases = { + cases: [ + { + name: "fires from a ship facing +x at rest", + before: { + ...field, + ship: { position: [100, 100], velocity: [0, 0], rotation: 0 }, + entities: new Map(), + }, + after: { + ...field, + ship: { position: [100, 100], velocity: [0, 0], rotation: 0 }, + entities: new Map([ + [1, { 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 }, + entities: new Map(), + }, + after: { + ...field, + ship: { position: [100, 100], velocity: [10, 20], rotation: 0 }, + entities: new Map([ + [1, { 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 }, + entities: new Map([[1, { position: [0, 0], velocity: [1, 0], age: 0.2 }]]), + }, + after: { + ...field, + ship: { position: [100, 100], velocity: [0, 0], rotation: 0 }, + entities: new Map([ + [1, { position: [0, 0], velocity: [1, 0], age: 0.2 }], + [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 }, + entities: new Map(), + }, + after: { + ...field, + ship: { position: [100, 100], velocity: [0, 0], rotation: -Math.PI / 2 }, + entities: new Map([ + [1, { position: [100, 88], velocity: [0, -400], age: 0 }], + ]), + }, + }, + ], +}; 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 a2ad4d9c6..8f55fb6a9 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,7 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { State } from "./state.js"; -import { Conformance } from "./conformance-case.js"; -import { create } from "./create.js"; import { Bullet } from "../bullet/bullet.js"; import { Ship } from "../ship/ship.js"; @@ -16,77 +14,3 @@ export const fireBullet = ( const id = Math.max(0, ...state.entities.keys()) + 1; return { entities: new Map(state.entities).set(id, 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 = /*@__PURE__*/ Conformance.cases(fireBullet, - { - name: "fires from a ship facing +x at rest", - before: { - ...field, - ship: { position: [100, 100], velocity: [0, 0], rotation: 0 }, - entities: new Map(), - }, - args: undefined, - after: { - ...field, - ship: { position: [100, 100], velocity: [0, 0], rotation: 0 }, - entities: new Map([ - [1, { 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 }, - entities: new Map(), - }, - args: undefined, - after: { - ...field, - ship: { position: [100, 100], velocity: [10, 20], rotation: 0 }, - entities: new Map([ - [1, { 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 }, - entities: new Map([[1, { position: [0, 0], velocity: [1, 0], age: 0.2 }]]), - }, - args: undefined, - after: { - ...field, - ship: { position: [100, 100], velocity: [0, 0], rotation: 0 }, - entities: new Map([ - [1, { position: [0, 0], velocity: [1, 0], age: 0.2 }], - [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 }, - entities: new Map(), - }, - args: undefined, - after: { - ...field, - ship: { position: [100, 100], velocity: [0, 0], rotation: -Math.PI / 2 }, - entities: new Map([ - [1, { position: [100, 88], velocity: [0, -400], age: 0 }], - ]), - }, - }, -); 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 new file mode 100644 index 000000000..064e0ac23 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/resolve-bullet-hits.cases.ts @@ -0,0 +1,201 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { resolveBulletHits } from "./resolve-bullet-hits.js"; +import { create } from "./create.js"; + +// Inert, spec-owned cases (args is `{ dt }`). `resolveBulletHits` has no same-named +// ecs op — the collision system dispatches the `hitAsteroid` transaction per hit and +// is conformed by the detection/tick-loop tests — so `checkFeature` skips it; the +// pure `checkSpec` verifies it here. 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 (collections compare as multisets). +const field = { ...create(), bounds: [800, 600] as [number, number] }; + +export const cases: Conformance.SpecCases = { + cases: [ + { + name: "destroys bullet + asteroid, scores, and spawns split children (large → 2 medium)", + before: { + ...field, + entities: new Map([ + [1, { position: [50, 50], velocity: [0, 0], age: 0 }], + [2, { position: [50, 50], velocity: [0, 0], size: "large" }], + ]), + score: 0, + }, + args: { dt: 1 / 60 }, + after: { + ...field, + entities: new Map([ + [1, { position: [50, 50], velocity: [0, 0], size: "medium" }], + [2, { position: [50, 50], velocity: [0, 0], size: "medium" }], + ]), + score: 20, + }, + }, + { + name: "medium splits into two small", + before: { + ...field, + entities: new Map([ + [1, { position: [50, 50], velocity: [0, 0], age: 0 }], + [2, { position: [50, 50], velocity: [0, 0], size: "medium" }], + ]), + score: 5, + }, + args: { dt: 1 / 60 }, + after: { + ...field, + entities: new Map([ + [1, { position: [50, 50], velocity: [0, 0], size: "small" }], + [2, { position: [50, 50], velocity: [0, 0], size: "small" }], + ]), + score: 55, + }, + }, + { + name: "the smallest tier is destroyed outright — no children", + before: { + ...field, + entities: new Map([ + [1, { position: [50, 50], velocity: [0, 0], age: 0 }], + [2, { position: [50, 50], velocity: [0, 0], size: "small" }], + ]), + score: 0, + }, + args: { dt: 1 / 60 }, + after: { ...field, entities: new Map(), score: 100 }, + }, + { + name: "a bullet that hits nothing is left untouched", + before: { + ...field, + entities: new Map([ + [1, { position: [10, 10], velocity: [0, 0], age: 0 }], + [2, { position: [500, 500], velocity: [0, 0], size: "large" }], + ]), + score: 7, + }, + args: { dt: 1 / 60 }, + after: { + ...field, + entities: new Map([ + [1, { position: [10, 10], velocity: [0, 0], age: 0 }], + [2, { position: [500, 500], velocity: [0, 0], size: "large" }], + ]), + score: 7, + }, + }, + { + name: "only the overlapping asteroid is hit; distant ones remain", + before: { + ...field, + entities: new Map([ + [1, { position: [50, 50], velocity: [0, 0], age: 0 }], + [2, { position: [50, 50], velocity: [0, 0], size: "large" }], + [3, { position: [500, 500], velocity: [0, 0], size: "small" }], + ]), + score: 0, + }, + args: { dt: 1 / 60 }, + after: { + ...field, + entities: new Map([ + [1, { position: [50, 50], velocity: [0, 0], size: "medium" }], + [2, { position: [50, 50], velocity: [0, 0], size: "medium" }], + [3, { position: [500, 500], velocity: [0, 0], size: "small" }], + ]), + score: 20, + }, + }, + { + name: "two bullets each destroy their own asteroid", + before: { + ...field, + entities: new Map([ + [1, { position: [50, 50], velocity: [0, 0], age: 0 }], + [2, { position: [500, 500], velocity: [0, 0], age: 0 }], + [3, { position: [50, 50], velocity: [0, 0], size: "small" }], + [4, { position: [500, 500], velocity: [0, 0], size: "small" }], + ]), + score: 0, + }, + args: { dt: 1 / 60 }, + after: { ...field, entities: new Map(), score: 200 }, + }, + { + name: "split children are not hittable by another bullet in the same pass", + before: { + ...field, + entities: new Map([ + [1, { position: [50, 50], velocity: [0, 0], age: 0 }], + [2, { position: [50, 50], velocity: [0, 0], age: 0 }], + [3, { position: [50, 50], velocity: [0, 0], size: "large" }], + ]), + score: 0, + }, + args: { dt: 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. + entities: new Map([ + [1, { position: [50, 50], velocity: [0, 0], age: 0 }], + [2, { position: [50, 50], velocity: [0, 0], size: "medium" }], + [3, { position: [50, 50], velocity: [0, 0], size: "medium" }], + ]), + score: 20, + }, + }, + { + name: "boundary: distance exactly equal to the radius sum still overlaps", + before: { + ...field, + entities: new Map([ + [1, { position: [0, 0], velocity: [0, 0], age: 0 }], + [2, { position: [42, 0], velocity: [0, 0], size: "large" }], + ]), + score: 0, + }, + args: { dt: 1 / 60 }, + after: { + ...field, + entities: new Map([ + [1, { position: [42, 0], velocity: [0, 0], size: "medium" }], + [2, { 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. + entities: new Map([ + [1, { position: [0, 0], velocity: [-3000, 0], age: 0 }], + [2, { position: [25, 0], velocity: [0, 0], size: "medium" }], + ]), + score: 0, + }, + args: { dt: 1 / 60 }, + after: { + ...field, + entities: new Map([ + [1, { position: [25, 0], velocity: [0, 0], size: "small" }], + [2, { 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.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/resolve-bullet-hits.ts index 9d9f022e8..6fae12907 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,8 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Vec2 } from "@adobe/data/math"; import type { State } from "./state.js"; -import { 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"; @@ -19,7 +17,7 @@ import { Collision } from "../collision/collision.js"; // drift ~1px/frame, negligible against the bullet's sweep. export const resolveBulletHits = ( state: Pick, - dt: number, + { dt }: { readonly dt: number }, ): Pick => { const entities = new Map(state.entities); // The asteroids that existed at the start of the pass, with their ids — a @@ -66,196 +64,3 @@ export const resolveBulletHits = ( } return { entities, 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 = /*@__PURE__*/ Conformance.cases(resolveBulletHits, - { - name: "destroys bullet + asteroid, scores, and spawns split children (large → 2 medium)", - before: { - ...field, - entities: new Map([ - [1, { position: [50, 50], velocity: [0, 0], age: 0 }], - [2, { position: [50, 50], velocity: [0, 0], size: "large" }], - ]), - score: 0, - }, - args: 1 / 60, - after: { - ...field, - entities: new Map([ - [1, { position: [50, 50], velocity: [0, 0], size: "medium" }], - [2, { position: [50, 50], velocity: [0, 0], size: "medium" }], - ]), - score: 20, - }, - }, - { - name: "medium splits into two small", - before: { - ...field, - entities: new Map([ - [1, { position: [50, 50], velocity: [0, 0], age: 0 }], - [2, { position: [50, 50], velocity: [0, 0], size: "medium" }], - ]), - score: 5, - }, - args: 1 / 60, - after: { - ...field, - entities: new Map([ - [1, { position: [50, 50], velocity: [0, 0], size: "small" }], - [2, { position: [50, 50], velocity: [0, 0], size: "small" }], - ]), - score: 55, - }, - }, - { - name: "the smallest tier is destroyed outright — no children", - before: { - ...field, - entities: new Map([ - [1, { position: [50, 50], velocity: [0, 0], age: 0 }], - [2, { position: [50, 50], velocity: [0, 0], size: "small" }], - ]), - score: 0, - }, - args: 1 / 60, - after: { ...field, entities: new Map(), score: 100 }, - }, - { - name: "a bullet that hits nothing is left untouched", - before: { - ...field, - entities: new Map([ - [1, { position: [10, 10], velocity: [0, 0], age: 0 }], - [2, { position: [500, 500], velocity: [0, 0], size: "large" }], - ]), - score: 7, - }, - args: 1 / 60, - after: { - ...field, - entities: new Map([ - [1, { position: [10, 10], velocity: [0, 0], age: 0 }], - [2, { position: [500, 500], velocity: [0, 0], size: "large" }], - ]), - score: 7, - }, - }, - { - name: "only the overlapping asteroid is hit; distant ones remain", - before: { - ...field, - entities: new Map([ - [1, { position: [50, 50], velocity: [0, 0], age: 0 }], - [2, { position: [50, 50], velocity: [0, 0], size: "large" }], - [3, { position: [500, 500], velocity: [0, 0], size: "small" }], - ]), - score: 0, - }, - args: 1 / 60, - after: { - ...field, - entities: new Map([ - [1, { position: [50, 50], velocity: [0, 0], size: "medium" }], - [2, { position: [50, 50], velocity: [0, 0], size: "medium" }], - [3, { position: [500, 500], velocity: [0, 0], size: "small" }], - ]), - score: 20, - }, - }, - { - name: "two bullets each destroy their own asteroid", - before: { - ...field, - entities: new Map([ - [1, { position: [50, 50], velocity: [0, 0], age: 0 }], - [2, { position: [500, 500], velocity: [0, 0], age: 0 }], - [3, { position: [50, 50], velocity: [0, 0], size: "small" }], - [4, { position: [500, 500], velocity: [0, 0], size: "small" }], - ]), - score: 0, - }, - args: 1 / 60, - after: { ...field, entities: new Map(), score: 200 }, - }, - { - name: "split children are not hittable by another bullet in the same pass", - before: { - ...field, - entities: new Map([ - [1, { position: [50, 50], velocity: [0, 0], age: 0 }], - [2, { position: [50, 50], velocity: [0, 0], age: 0 }], - [3, { 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. - entities: new Map([ - [1, { position: [50, 50], velocity: [0, 0], age: 0 }], - [2, { position: [50, 50], velocity: [0, 0], size: "medium" }], - [3, { position: [50, 50], velocity: [0, 0], size: "medium" }], - ]), - score: 20, - }, - }, - { - name: "boundary: distance exactly equal to the radius sum still overlaps", - before: { - ...field, - entities: new Map([ - [1, { position: [0, 0], velocity: [0, 0], age: 0 }], - [2, { position: [42, 0], velocity: [0, 0], size: "large" }], - ]), - score: 0, - }, - args: 1 / 60, - after: { - ...field, - entities: new Map([ - [1, { position: [42, 0], velocity: [0, 0], size: "medium" }], - [2, { 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. - entities: new Map([ - [1, { position: [0, 0], velocity: [-3000, 0], age: 0 }], - [2, { position: [25, 0], velocity: [0, 0], size: "medium" }], - ]), - score: 0, - }, - args: 1 / 60, - after: { - ...field, - entities: new Map([ - [1, { position: [25, 0], velocity: [0, 0], size: "small" }], - [2, { 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 new file mode 100644 index 000000000..2608d0cff --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/resolve-ship-hits.cases.ts @@ -0,0 +1,97 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { resolveShipHits } from "./resolve-ship-hits.js"; +import { create } from "./create.js"; +import { Ship } from "../ship/ship.js"; + +// Inert, spec-owned cases (no args). `resolveShipHits` has no same-named ecs op — +// the collision system dispatches the `loseLife` transaction when the ship is struck +// and is conformed by the detection/tick-loop tests — so `checkFeature` skips it; the +// pure `checkSpec` verifies it here. 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.SpecCases = { + cases: [ + { + 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 }, + entities: new Map([[1, { position: [10, 10], velocity: [0, 0], size: "large" }]]), + lives: 3, + }, + after: { + ...field, + ship: respawned, + entities: new Map([[1, { 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 }, + entities: new Map([[1, { position: [500, 500], velocity: [0, 0], size: "large" }]]), + lives: 3, + }, + after: { + ...field, + ship: { position: [10, 10], velocity: [0, 0], rotation: 0 }, + entities: new Map([[1, { 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 }, + entities: new Map([[1, { position: [10, 10], velocity: [0, 0], size: "large" }]]), + lives: 0, + }, + after: { + ...field, + ship: respawned, + entities: new Map([[1, { 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 }, + entities: new Map([[1, { position: [52, 0], velocity: [0, 0], size: "large" }]]), + lives: 3, + }, + after: { + ...field, + ship: respawned, + entities: new Map([[1, { 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 }, + entities: new Map(), + lives: 3, + }, + after: { + ...field, + ship: { position: [10, 10], velocity: [0, 0], rotation: 0 }, + entities: new Map(), + lives: 3, + }, + }, + ], +}; 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 f359822ef..4655bebfd 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,8 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Vec2 } from "@adobe/data/math"; import type { State } from "./state.js"; -import { 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"; @@ -30,111 +28,3 @@ 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 = /*@__PURE__*/ Conformance.cases(resolveShipHits, - { - 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 }, - entities: new Map([ - [1, { position: [10, 10], velocity: [0, 0], size: "large" }], - ]), - lives: 3, - }, - args: undefined, - after: { - ...field, - ship: respawned, - entities: new Map([ - [1, { 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 }, - entities: new Map([ - [1, { position: [500, 500], velocity: [0, 0], size: "large" }], - ]), - lives: 3, - }, - args: undefined, - after: { - ...field, - ship: { position: [10, 10], velocity: [0, 0], rotation: 0 }, - entities: new Map([ - [1, { 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 }, - entities: new Map([ - [1, { position: [10, 10], velocity: [0, 0], size: "large" }], - ]), - lives: 0, - }, - args: undefined, - after: { - ...field, - ship: respawned, - entities: new Map([ - [1, { 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 }, - entities: new Map([ - [1, { position: [52, 0], velocity: [0, 0], size: "large" }], - ]), - lives: 3, - }, - args: undefined, - after: { - ...field, - ship: respawned, - entities: new Map([ - [1, { 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 }, - entities: new Map(), - lives: 3, - }, - args: undefined, - after: { - ...field, - ship: { position: [10, 10], velocity: [0, 0], rotation: 0 }, - entities: new Map(), - 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 new file mode 100644 index 000000000..13de73a5a --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-random-wave.cases.ts @@ -0,0 +1,56 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { spawnRandomWave } from "./spawn-random-wave.js"; +import { create } from "./create.js"; + +// Inert, spec-owned cases for the value-returning, service-injected +// `spawnRandomWave`. The injected `random` port is DATA-only here: its draws are +// scheduled in `responses` (the case owns the sequence), and the runner synthesizes +// the recording double from the manifest's `services` template. `next` is a +// value-returning read, not a fire-and-forget effect, so it is NOT declared in +// `effects`. Shared with the ecs `spawnRandomWave` action (the transaction is +// system-dispatched and skipped by name). +// +// 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 + sequence[i])` → [30, 60, 45, 75] in +// ring order. +const field = { ...create(), bounds: [200, 200] as [number, number] }; + +export const cases: Conformance.SpecCases = { + cases: [ + { + name: "spawns a randomized wave (jittered drift speeds) when the field is clear", + before: { ...field, entities: new Map(), wave: 0 }, + responses: { random: { next: [0, 0.5, 0.25, 0.75] } }, + after: { + ...field, + wave: 1, + entities: new Map([ + [1, { position: [180, 100], velocity: [0, 30], size: "large" }], + [2, { position: [100, 180], velocity: [-60, 0], size: "large" }], + [3, { position: [20, 100], velocity: [0, -45], size: "large" }], + [4, { position: [100, 20], velocity: [75, 0], size: "large" }], + ]), + }, + }, + { + name: "does nothing while asteroids still remain", + before: { + ...field, + wave: 1, + entities: new Map([ + [1, { position: [10, 10], velocity: [0, 0], size: "large" }], + ]), + }, + after: { + ...field, + wave: 1, + entities: new Map([ + [1, { 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.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-random-wave.ts index e70d06161..4af88abbb 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,12 +1,9 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Vec2 } from "@adobe/data/math"; import type { State } from "./state.js"; -import { Conformance } from "./conformance-case.js"; -import { create } from "./create.js"; import { Asteroid } from "../asteroid/asteroid.js"; import { Size } from "../size/size.js"; import { Motion } from "../motion/motion.js"; -import { RandomService } from "../../services/random-service/random-service.js"; import type { Services } from "../../services/services.js"; // Base drift speed; each rock's actual speed is jittered around it. @@ -54,53 +51,3 @@ export const spawnRandomWave = ( } return { wave, entities }; }; - -// 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 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 + 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 = /*@__PURE__*/ Conformance.cases(spawnRandomWave, - { - name: "spawns a randomized wave (jittered drift speeds) when the field is clear", - before: { ...field, entities: new Map(), wave: 0 }, - args: { random: RandomService.createFake(randoms) }, - after: { - ...field, - wave: 1, - entities: new Map([ - [1, { position: [180, 100], velocity: [0, 30], size: "large" }], - [2, { position: [100, 180], velocity: [-60, 0], size: "large" }], - [3, { position: [20, 100], velocity: [0, -45], size: "large" }], - [4, { position: [100, 20], velocity: [75, 0], size: "large" }], - ]), - }, - }, - { - name: "does nothing while asteroids still remain", - before: { - ...field, - wave: 1, - entities: new Map([ - [1, { position: [10, 10], velocity: [0, 0], size: "large" }], - ]), - }, - args: { random: RandomService.createFake(randoms) }, - after: { - ...field, - wave: 1, - entities: new Map([ - [1, { 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 new file mode 100644 index 000000000..9100699d4 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/spawn-wave.cases.ts @@ -0,0 +1,51 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { spawnWave } from "./spawn-wave.js"; +import { create } from "./create.js"; + +// Inert, 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. `spawnWave` has no ecs op — `checkFeature` skips it; the pure +// `checkSpec` verifies it here. +const field = { ...create(), bounds: [200, 200] as [number, number] }; + +export const cases: Conformance.SpecCases = { + cases: [ + { + name: "spawns the next wave of large asteroids when the field is clear", + before: { ...field, entities: new Map(), wave: 0 }, + after: { + ...field, + wave: 1, + entities: new Map([ + [1, { position: [180, 100], velocity: [0, 60], size: "large" }], + [2, { position: [100, 180], velocity: [-60, 0], size: "large" }], + [3, { position: [20, 100], velocity: [0, -60], size: "large" }], + [4, { position: [100, 20], velocity: [60, 0], size: "large" }], + ]), + }, + }, + { + name: "does nothing while asteroids still remain", + before: { + ...field, + wave: 1, + entities: new Map([ + [1, { position: [10, 10], velocity: [0, 0], size: "large" }], + ]), + }, + after: { + ...field, + wave: 1, + entities: new Map([ + [1, { position: [10, 10], velocity: [0, 0], size: "large" }], + ]), + }, + }, + ], +}; 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 b255d5b2d..c81a4bf41 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,8 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Vec2 } from "@adobe/data/math"; import type { State } from "./state.js"; -import { Conformance } from "./conformance-case.js"; -import { create } from "./create.js"; import { Asteroid } from "../asteroid/asteroid.js"; import { Size } from "../size/size.js"; import { Motion } from "../motion/motion.js"; @@ -43,47 +41,3 @@ export const spawnWave = ( } return { wave, entities }; }; - -// 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 = /*@__PURE__*/ Conformance.cases(spawnWave, - { - name: "spawns the next wave of large asteroids when the field is clear", - before: { ...field, entities: new Map(), wave: 0 }, - args: undefined, - after: { - ...field, - wave: 1, - entities: new Map([ - [1, { position: [180, 100], velocity: [0, 60], size: "large" }], - [2, { position: [100, 180], velocity: [-60, 0], size: "large" }], - [3, { position: [20, 100], velocity: [0, -60], size: "large" }], - [4, { position: [100, 20], velocity: [60, 0], size: "large" }], - ]), - }, - }, - { - name: "does nothing while asteroids still remain", - before: { - ...field, - wave: 1, - entities: new Map([ - [1, { position: [10, 10], velocity: [0, 0], size: "large" }], - ]), - }, - args: undefined, - after: { - ...field, - wave: 1, - entities: new Map([ - [1, { 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 index 08e1493ff..727baba0d 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,13 +1,9 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Conformance } from "@adobe/data-testing"; -import { State } from "./state.js"; -import { transitions } from "./transitions.js"; +import { spec } from "./spec.js"; -// The single pure-spec test for every transform AND derivation 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. Each case's `before`/`input` is a delta over `State.create()`. -Conformance.runSpec({ - state: State, - transitions, -}); +// The single pure-spec test for every transform in this folder. It runs the +// manifest's inert cases against the pure functions, synthesizing each injected +// service's recording double from the `services` templates + the case's `responses`, +// and asserting `after` (up to an id-bijection) and declared `effects`. +Conformance.checkSpec(spec); diff --git a/packages/data-lit-space-rock-game/src/features/main/data/state/spec.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/spec.ts new file mode 100644 index 000000000..bba4d5b43 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/spec.ts @@ -0,0 +1,54 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Conformance } from "@adobe/data-testing"; +import { State } from "./state.js"; +import * as transforms from "./transforms.js"; +import { MainService } from "../../services/main-service/main-service.js"; +import { projection } from "../../services/main-service/conformance/projection.js"; +import { createFake as random } from "../../services/random-service/random.fake.js"; + +import { cases as createInitial } from "./create-initial.cases.js"; +import { cases as fireBullet } from "./fire-bullet.cases.js"; +import { cases as spawnWave } from "./spawn-wave.cases.js"; +import { cases as spawnRandomWave } from "./spawn-random-wave.cases.js"; +import { cases as stepShip } from "./step-ship.cases.js"; +import { cases as stepBullets } from "./step-bullets.cases.js"; +import { cases as stepAsteroids } from "./step-asteroids.cases.js"; +import { cases as resolveBulletHits } from "./resolve-bullet-hits.cases.js"; +import { cases as resolveShipHits } from "./resolve-ship-hits.cases.js"; +import { cases as step } from "./step.cases.js"; + +// The feature's conformance manifest — the single object `spec.test.ts` (pure) and +// `conformance.test.ts` (ecs) both import. It wires each conformed fn to its inert cases module explicitly: `cases` names one module per conformed fn, +// checked at COMPILE TIME against `transforms` (a fn without cases, or cases without +// a fn, won't type). `spawnRandomWave` and `step` inject the `random` port, so +// `services` supplies its shape-only recording template; each case owns the draws via +// its `responses`. Space-rock has no `state/` derivations (so no `computedPlugin`, no +// `hydrate`) and no entity-reference args (so no case declares an `args` schema). +// +// `checkFeature` pairs each ecs op to its same-named transition: `createInitial` +// (transaction), `fireBullet` (transaction + action), and `spawnRandomWave` (action — +// its transaction is renamed `refillWave`, the system-dispatched form, so it is not +// paired and never receives the stripped transaction args). Every per-frame transition +// has no same-named op and is skipped there; the system tick-loop test conforms them. +// +// This is a test-tier module (excluded from the runtime program) — the ONE place the +// feature touches `@adobe/data-testing`. No transform or `*.cases.ts` file does. +export const spec = Conformance.feature({ + state: State, + fns: transforms, + plugin: MainService.plugin, + projection, + services: { random }, + cases: { + createInitial, + fireBullet, + spawnWave, + spawnRandomWave, + stepShip, + stepBullets, + stepAsteroids, + resolveBulletHits, + resolveShipHits, + step, + }, +}); 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 new file mode 100644 index 000000000..373cd2ca9 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/step-asteroids.cases.ts @@ -0,0 +1,59 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { stepAsteroids } from "./step-asteroids.js"; +import { create } from "./create.js"; +import { Size } from "../size/size.js"; + +// Inert, spec-owned cases (args is `{ dt }`). `stepAsteroids` has no ecs op — the +// asteroid half of the per-frame `movement` system reproduces it — so `checkFeature` +// skips it; the pure `checkSpec` verifies it here. 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.SpecCases = { + cases: [ + { + name: "drifts an asteroid by its velocity", + before: { ...field, entities: new Map([[1, { position: [10, 10], velocity: [30, 0], size: Size.largest }]]) }, + args: { dt: 1 }, + after: { ...field, entities: new Map([[1, { position: [40, 10], velocity: [30, 0], size: Size.largest }]]) }, + }, + { + name: "wraps an asteroid around the toroidal field", + before: { ...field, entities: new Map([[1, { position: [80, 80], velocity: [50, 50], size: Size.largest }]]) }, + args: { dt: 1 }, + after: { ...field, entities: new Map([[1, { position: [30, 30], velocity: [50, 50], size: Size.largest }]]) }, + }, + { + name: "wraps negatively across the left edge", + before: { ...field, entities: new Map([[1, { position: [10, 10], velocity: [-50, 0], size: "medium" }]]) }, + args: { dt: 1 }, + after: { ...field, entities: new Map([[1, { position: [60, 10], velocity: [-50, 0], size: "medium" }]]) }, + }, + { + name: "advances several asteroids of different sizes independently", + before: { + ...field, + entities: new Map([ + [1, { position: [10, 10], velocity: [10, 0], size: "large" }], + [2, { position: [20, 20], velocity: [0, 10], size: "small" }], + ]), + }, + args: { dt: 1 }, + after: { + ...field, + entities: new Map([ + [1, { position: [20, 10], velocity: [10, 0], size: "large" }], + [2, { position: [20, 30], velocity: [0, 10], size: "small" }], + ]), + }, + }, + { + name: "an empty field stays empty", + before: { ...field, entities: new Map() }, + args: { dt: 1 }, + after: { ...field, entities: new Map() }, + }, + ], +}; 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 744785410..79eccea65 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,16 +1,15 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { State } from "./state.js"; -import { Conformance } from "./conformance-case.js"; -import { create } from "./create.js"; import { Asteroid } from "../asteroid/asteroid.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 -// (keeping each asteroid's entity id). Non-asteroid entities pass through. +// (keeping each asteroid's entity id). Non-asteroid entities pass through. `dt` is +// bundled into one args object (second parameter) as the conformance case model +// requires. export const stepAsteroids = ( state: Pick, - dt: number, + { dt }: { readonly dt: number }, ): Pick => { const entities = new Map(state.entities); for (const [id, value] of state.entities) { @@ -25,83 +24,3 @@ export const stepAsteroids = ( } return { entities }; }; - -// 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 = /*@__PURE__*/ Conformance.cases(stepAsteroids, - { - name: "drifts an asteroid by its velocity", - before: { - ...field, - entities: new Map([ - [1, { position: [10, 10], velocity: [30, 0], size: Size.largest }], - ]), - }, - args: 1, - after: { - ...field, - entities: new Map([ - [1, { position: [40, 10], velocity: [30, 0], size: Size.largest }], - ]), - }, - }, - { - name: "wraps an asteroid around the toroidal field", - before: { - ...field, - entities: new Map([ - [1, { position: [80, 80], velocity: [50, 50], size: Size.largest }], - ]), - }, - args: 1, - after: { - ...field, - entities: new Map([ - [1, { position: [30, 30], velocity: [50, 50], size: Size.largest }], - ]), - }, - }, - { - name: "wraps negatively across the left edge", - before: { - ...field, - entities: new Map([ - [1, { position: [10, 10], velocity: [-50, 0], size: "medium" }], - ]), - }, - args: 1, - after: { - ...field, - entities: new Map([ - [1, { position: [60, 10], velocity: [-50, 0], size: "medium" }], - ]), - }, - }, - { - name: "advances several asteroids of different sizes independently", - before: { - ...field, - entities: new Map([ - [1, { position: [10, 10], velocity: [10, 0], size: "large" }], - [2, { position: [20, 20], velocity: [0, 10], size: "small" }], - ]), - }, - args: 1, - after: { - ...field, - entities: new Map([ - [1, { position: [20, 10], velocity: [10, 0], size: "large" }], - [2, { position: [20, 30], velocity: [0, 10], size: "small" }], - ]), - }, - }, - { - name: "an empty field stays empty", - before: { ...field, entities: new Map() }, - args: 1, - after: { ...field, entities: new Map() }, - }, -); 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 new file mode 100644 index 000000000..5fd769afc --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/step-bullets.cases.ts @@ -0,0 +1,63 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { stepBullets } from "./step-bullets.js"; +import { create } from "./create.js"; +import { Bullet } from "../bullet/bullet.js"; + +// Inert, spec-owned cases (args is `{ dt }`). `stepBullets` has no ecs op — the +// per-frame `lifetime` system reproduces its advance/age/expire path — so +// `checkFeature` skips it; the pure `checkSpec` verifies it here. `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.SpecCases = { + cases: [ + { + name: "moves and ages a live bullet", + before: { ...field, entities: new Map([[1, { position: [10, 50], velocity: [100, 0], age: 0 }]]) }, + args: { dt: 0.1 }, + after: { ...field, entities: new Map([[1, { position: [20, 50], velocity: [100, 0], age: 0.1 }]]) }, + }, + { + name: "wraps a bullet across the right edge", + before: { ...field, entities: new Map([[1, { position: [95, 50], velocity: [100, 0], age: 0 }]]) }, + args: { dt: 0.1 }, + after: { ...field, entities: new Map([[1, { position: [5, 50], velocity: [100, 0], age: 0.1 }]]) }, + }, + { + name: "drops a bullet that expires this tick (age + dt ≥ lifetime)", + before: { + ...field, + entities: new Map([[1, { position: [10, 50], velocity: [100, 0], age: Bullet.lifetime }]]), + }, + args: { dt: 0.1 }, + after: { ...field, entities: new Map() }, + }, + { + name: "keeps and ages a bullet still under its lifetime", + before: { ...field, entities: new Map([[1, { position: [10, 50], velocity: [0, 0], age: 1.0 }]]) }, + args: { dt: 0.1 }, + after: { ...field, entities: new Map([[1, { position: [10, 50], velocity: [0, 0], age: 1.1 }]]) }, + }, + { + name: "advances survivors and drops only the expired bullet", + before: { + ...field, + entities: new Map([ + [1, { position: [10, 50], velocity: [100, 0], age: 0 }], + [2, { position: [10, 60], velocity: [100, 0], age: Bullet.lifetime }], + ]), + }, + args: { dt: 0.1 }, + after: { ...field, entities: new Map([[1, { position: [20, 50], velocity: [100, 0], age: 0.1 }]]) }, + }, + { + name: "an empty list stays empty", + before: { ...field, entities: new Map() }, + args: { dt: 0.1 }, + after: { ...field, entities: new Map() }, + }, + ], +}; 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 34965043d..99d01bcd5 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,16 +1,15 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { State } from "./state.js"; -import { Conformance } from "./conformance-case.js"; -import { create } from "./create.js"; import { Bullet } from "../bullet/bullet.js"; import { Motion } from "../motion/motion.js"; // Advance every bullet one tick: drop the ones that expire this tick, and move // + age + wrap the survivors (keeping each survivor's entity id). Non-bullet -// entities pass through untouched. +// entities pass through untouched. `dt` is bundled into one args object (second +// parameter) as the conformance case model requires. export const stepBullets = ( state: Pick, - dt: number, + { dt }: { readonly dt: number }, ): Pick => { const entities = new Map(state.entities); for (const [id, value] of state.entities) { @@ -30,88 +29,3 @@ export const stepBullets = ( } return { entities }; }; - -// 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 = /*@__PURE__*/ Conformance.cases(stepBullets, - { - name: "moves and ages a live bullet", - before: { - ...field, - entities: new Map([[1, { position: [10, 50], velocity: [100, 0], age: 0 }]]), - }, - args: 0.1, - after: { - ...field, - entities: new Map([ - [1, { position: [20, 50], velocity: [100, 0], age: 0.1 }], - ]), - }, - }, - { - name: "wraps a bullet across the right edge", - before: { - ...field, - entities: new Map([[1, { position: [95, 50], velocity: [100, 0], age: 0 }]]), - }, - args: 0.1, - after: { - ...field, - entities: new Map([ - [1, { position: [5, 50], velocity: [100, 0], age: 0.1 }], - ]), - }, - }, - { - name: "drops a bullet that expires this tick (age + dt ≥ lifetime)", - before: { - ...field, - entities: new Map([ - [1, { position: [10, 50], velocity: [100, 0], age: Bullet.lifetime }], - ]), - }, - args: 0.1, - after: { ...field, entities: new Map() }, - }, - { - name: "keeps and ages a bullet still under its lifetime", - before: { - ...field, - entities: new Map([[1, { position: [10, 50], velocity: [0, 0], age: 1.0 }]]), - }, - args: 0.1, - after: { - ...field, - entities: new Map([ - [1, { position: [10, 50], velocity: [0, 0], age: 1.1 }], - ]), - }, - }, - { - name: "advances survivors and drops only the expired bullet", - before: { - ...field, - entities: new Map([ - [1, { position: [10, 50], velocity: [100, 0], age: 0 }], - [2, { position: [10, 60], velocity: [100, 0], age: Bullet.lifetime }], - ]), - }, - args: 0.1, - after: { - ...field, - entities: new Map([ - [1, { position: [20, 50], velocity: [100, 0], age: 0.1 }], - ]), - }, - }, - { - name: "an empty list stays empty", - before: { ...field, entities: new Map() }, - args: 0.1, - after: { ...field, entities: new Map() }, - }, -); 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 new file mode 100644 index 000000000..ce381e423 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/step-ship.cases.ts @@ -0,0 +1,63 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { stepShip } from "./step-ship.js"; +import type { Input } from "../input/input.js"; +import { create } from "./create.js"; + +// Inert, spec-owned cases (args is `{ dt, input }`). `stepShip` has no ecs op — the +// per-frame system loop (`control` + the ship half of `movement`) reproduces it — +// so `checkFeature` skips it; the pure `checkSpec` verifies it here. 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.SpecCases = { + cases: [ + { + 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.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/step-ship.ts index de5f4960a..90d14181d 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,9 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { State } from "./state.js"; -import { Conformance } from "./conformance-case.js"; -import { create } from "./create.js"; import { Ship } from "../ship/ship.js"; -import { Input } from "../input/input.js"; +import type { Input } from "../input/input.js"; import { Motion } from "../motion/motion.js"; // Advance the ship one tick: turn, optionally thrust, then coast by its velocity @@ -25,98 +23,3 @@ export const stepShip = ( ); return { 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 = /*@__PURE__*/ Conformance.cases(stepShip, - { - 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 new file mode 100644 index 000000000..92bd79170 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/step.cases.ts @@ -0,0 +1,138 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { step } from "./step.js"; +import { Input } from "../input/input.js"; +import { Ship } from "../ship/ship.js"; + +// Inert, spec-owned cases for the whole-tick `step` (args is `{ dt, input }`; the +// injected `random` port is stripped from the case args and synthesized by the +// runner from the manifest's `services` template). `step` has no ecs transaction — +// the per-frame system loop conforms it in `system-database/tick-loop.test.ts` +// (see systems.md), which also reads these cases — so `checkFeature` skips it while +// the pure `checkSpec` verifies it here. +// +// 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; the randomized refill is conformed out-of-band via the +// `spawnRandomWave` cases. +export const cases: Conformance.SpecCases = { + cases: [ + { + name: "advances and wraps every body (movement)", + before: { + bounds: [200, 200], + ship: { position: [190, 100], velocity: [30, 0], rotation: 0 }, + entities: new Map([[1, { 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 }, + entities: new Map([[1, { 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 }, + entities: new Map([[1, { 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 }, + entities: new Map([ + [1, { position: [152, 100], velocity: [400, 0], age: 0.1 }], + [2, { 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 }, + entities: new Map([ + [1, { position: [100, 100], velocity: [0, 0], age: 0 }], + [2, { 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 }, + entities: new Map([ + [1, { position: [100, 100], velocity: [0, 0], size: "medium" }], + [2, { 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]), + entities: new Map([[1, { 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]), + entities: new Map([[1, { 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 }, + entities: new Map([ + [1, { position: [60, 60], velocity: [0, 0], age: 0.5 }], + [2, { 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 }, + entities: new Map([ + [1, { position: [60, 60], velocity: [0, 0], age: 0.5 }], + [2, { 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.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/step.ts index 66e4e4ff1..dcce1c371 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,8 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { State } from "./state.js"; -import { Conformance } from "./conformance-case.js"; -import { Input } from "../input/input.js"; -import { Ship } from "../ship/ship.js"; +import type { Input } from "../input/input.js"; import { stepShip } from "./step-ship.js"; import { fireBullet } from "./fire-bullet.js"; import { stepBullets } from "./step-bullets.js"; @@ -11,7 +9,6 @@ import { resolveBulletHits } from "./resolve-bullet-hits.js"; import { resolveShipHits } from "./resolve-ship-hits.js"; import { spawnRandomWave } from "./spawn-random-wave.js"; import { isGameOver } from "./is-game-over.js"; -import { RandomService } from "../../services/random-service/random-service.js"; import type { Services } from "../../services/services.js"; // Advance the whole game one tick. This is the authoritative spec the ECS systems @@ -48,153 +45,10 @@ export const step = ( if (input.fire) { next = { ...next, ...fireBullet(next) }; } - next = { ...next, ...stepBullets(next, dt) }; - next = { ...next, ...stepAsteroids(next, dt) }; - next = { ...next, ...resolveBulletHits(next, dt) }; + 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; }; - -// 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 = /*@__PURE__*/ Conformance.cases(step, - { - name: "advances and wraps every body (movement)", - before: { - bounds: [200, 200], - ship: { position: [190, 100], velocity: [30, 0], rotation: 0 }, - entities: new Map([ - [1, { 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 }, - entities: new Map([ - [1, { 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 }, - entities: new Map([ - [1, { 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 }, - entities: new Map([ - [1, { position: [152, 100], velocity: [400, 0], age: 0.1 }], - [2, { 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 }, - entities: new Map([ - [1, { position: [100, 100], velocity: [0, 0], age: 0 }], - [2, { 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 }, - entities: new Map([ - [1, { position: [100, 100], velocity: [0, 0], size: "medium" }], - [2, { 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]), - entities: new Map([ - [1, { 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]), - entities: new Map([ - [1, { 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 }, - entities: new Map([ - [1, { position: [60, 60], velocity: [0, 0], age: 0.5 }], - [2, { 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 }, - entities: new Map([ - [1, { position: [60, 60], velocity: [0, 0], age: 0.5 }], - [2, { 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/transforms.ts b/packages/data-lit-space-rock-game/src/features/main/data/state/transforms.ts new file mode 100644 index 000000000..7a490b628 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/data/state/transforms.ts @@ -0,0 +1,26 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +// The conformed functions of this feature — every pure transform the spec verifies, +// and nothing else (helpers like `create`, `samples`, `createInitial`'s siblings, +// and `isGameOver` are not conformance transforms and keep their own `*.test.ts`). +// Its keys ARE the conformance surface: the manifest's coverage guard +// (`Conformance.feature`) requires exactly one `*.cases.ts` module per key here, so +// adding a transform without cases — or cases without a transform — fails to compile. +// +// The discrete transitions (`createInitial`, `fireBullet`, `spawnRandomWave`) pair +// with same-named ecs transactions/actions and are conformed by both `checkSpec` and +// `checkFeature`. The per-frame transitions (`spawnWave`, `stepShip`, `stepBullets`, +// `stepAsteroids`, `resolveBulletHits`, `resolveShipHits`, `step`) have no same-named +// ecs op — the system tick loop realizes them (conformed in +// `system-database/tick-loop.test.ts`) — so `checkFeature` skips them while +// `checkSpec` still verifies each against its pure function here. +export { createInitial } from "./create-initial.js"; +export { fireBullet } from "./fire-bullet.js"; +export { spawnWave } from "./spawn-wave.js"; +export { spawnRandomWave } from "./spawn-random-wave.js"; +export { stepShip } from "./step-ship.js"; +export { stepBullets } from "./step-bullets.js"; +export { stepAsteroids } from "./step-asteroids.js"; +export { resolveBulletHits } from "./resolve-bullet-hits.js"; +export { resolveShipHits } from "./resolve-ship-hits.js"; +export { step } from "./step.js"; 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 deleted file mode 100644 index 1092fd915..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/data/state/transitions.ts +++ /dev/null @@ -1,11 +0,0 @@ -// © 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/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 index 511e41d0b..c634acd06 100644 --- 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 @@ -3,10 +3,12 @@ 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 +// wave through the `refillWave` transaction (which reads the current field/wave/bounds +// from the store — never a cached computed — and is a no-op while asteroids remain). +// The action keeps the transition's name so `checkFeature` pairs it here (the seam +// where the injected `random` double is supplied); the transaction is the +// system-dispatched `refillWave`. `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 }); + db.transactions.refillWave({ random: db.services.random }); }; 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 6eb3c9b42..9b98e4e9c 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,21 +1,16 @@ // © 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"; +import { spec } from "../../../data/state/spec.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. `transitions` (the discovered `{ fn, cases }` modules) is shared with -// spec.test. The entities the ecs materialises in nondeterministic row order live in -// one `entities: ReadonlyMap`, so the comparator matches -// them order-independently. There is no `computedPlugin` — space-rock has no `state/` -// derivations. -Conformance.runFeature({ - state: State, - transitions, - plugin: MainService.plugin, - projection, -}); +// The whole ecs conformance for this feature in one call: `checkFeature` pairs each +// transaction/action on `MainService.plugin` with its same-named `data/state` +// transition (`createInitial` transaction, `fireBullet` transaction + action, +// `spawnRandomWave` action), seeds each case's `before` over `State.create()`, and +// round-trips `State.samples` through the `projection`. The bullets and asteroids live +// in one identity-keyed `entities` `ReadonlyMap`, so the comparator matches them +// order-independently (the ecs materialises them in nondeterministic row order and +// mints its own ids the `after`/`samples` refs leave open). The per-frame transitions +// (`step*`, `resolve*`, `spawnWave`) have no same-named ecs op — the system tick loop +// conforms them in `system-database/tick-loop.test.ts` (see systems.md) — so they are +// simply skipped here. Space-rock has no derivations, so no `computedPlugin`. +Conformance.checkFeature(spec); 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 index e6d877d25..6aad496a4 100644 --- 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 @@ -42,7 +42,7 @@ const toData = ( ); }; -// The test-only ecs↔`State` projection, passed to `Conformance.runFeature`. +// The test-only ecs↔`State` projection, carried on the manifest and used by `Conformance.checkFeature`. // `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 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 ea2acc364..7fecac2d0 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 @@ -268,7 +268,7 @@ const systemDatabasePlugin = Database.Plugin.create({ for (const arch of db.store.queryArchetypes(["size"])) { if (arch.rowCount > 0) return; } - db.transactions.spawnRandomWave({ random }); + db.transactions.refillWave({ random }); }; }, }, 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 f504e58a1..db6075daf 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,8 +26,9 @@ import { State } from "../../../data/state/state.js"; import { Ship } from "../../../data/ship/ship.js"; import { Asteroid } from "../../../data/asteroid/asteroid.js"; import { Input } from "../../../data/input/input.js"; -import { cases } from "../../../data/state/step.js"; +import { cases } from "../../../data/state/step.cases.js"; import { Conformance } from "@adobe/data-testing"; +import { createFake as createRandom } from "../../random-service/random.fake.js"; import { createSystemDatabase } from "../conformance/create-system-database.js"; import { projection } from "../conformance/projection.js"; import { driveFrame } from "../conformance/drive-frame.js"; @@ -36,15 +37,20 @@ describe("ECS system tick loop conforms to State.step (one frame = one step)", ( for (const testCase of cases.cases) { it(testCase.name, () => { const { dt, input } = testCase.args; - // A case `before` is a delta over the feature default (`Case.before` is + // A case `before` is a delta over the feature default (`SpecCase.before` is // `Partial`), so materialise the full seed the same way the runners do. // `db.store` supplies the schemas `assertState` reads to compare up to an // id-bijection (a tick spawns split children, so keys differ on both sides). const before = { ...State.create(), ...testCase.before }; const db = createSystemDatabase(); - // 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. - Conformance.assertState(State.step(before, testCase.args), testCase.after, db.store); + // The case args omit the injected `random` port (data-only), so supply an inert + // double here — no step case clears the field, so it is never drawn — to satisfy + // the oracle's signature while keeping both sides exact. + Conformance.assertState( + State.step(before, { dt, input, random: createRandom() }), + testCase.after, + db.store, + ); projection.fromState(db.store, before); db.store.resources.frameDelta = dt; 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 650d1eb73..a9e480b8a 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 @@ -3,7 +3,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 "./refill-wave.js"; export * from "./fire-bullet.js"; export * from "./hit-asteroid.js"; export * from "./lose-life.js"; diff --git a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/refill-wave.ts b/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/refill-wave.ts new file mode 100644 index 000000000..4e16fcde6 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/refill-wave.ts @@ -0,0 +1,38 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { State } from "../../../../data/state/state.js"; +import { Asteroid } from "../../../../data/asteroid/asteroid.js"; +import type { CoreDatabase } from "../../core-database/core-database.js"; +import { RandomService } from "../../../random-service/random-service.js"; +import { readAsteroids } from "./read-asteroids.js"; + +// The system-dispatched refill: advance to the next wave with a randomized layout. +// Thin wrapper over State.spawnRandomWave: project the {asteroids, wave, bounds} +// slice, let the data/ transform decide the count and per-rock drift with the +// INJECTED `random` service, then bump `wave` and insert the spawned rocks. The +// transform is a no-op while the field still has asteroids (returns the same object), +// so dispatching mid-wave changes nothing. +// +// This transaction takes an injected service, so it is NOT a conformance-paired +// transaction (a transaction never receives services from the runner). It is named +// for its system-dispatched role — the `waves` system and the `spawnRandomWave` +// action call it, and the action is the seam `checkFeature` conforms the +// `spawnRandomWave` transition through. The `waves` system supplies the real random +// source; the action supplies `db.services.random` (a recording double under test). +export const refillWave = ( + t: CoreDatabase.Store, + { random }: { random: RandomService }, +): void => { + // refillWave 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( + { entities: new Map(), wave: t.resources.wave, bounds: t.resources.bounds }, + { random }, + ); + t.resources.wave = after.wave; + // The seed's entities were empty, so the patch holds exactly the spawned rocks. + for (const value of after.entities.values()) { + if (Asteroid.is(value)) t.archetypes.Asteroid.insert(value); + } +}; 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 deleted file mode 100644 index e351e7389..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/main-service/transaction-database/transactions/spawn-random-wave.ts +++ /dev/null @@ -1,32 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { State } from "../../../../data/state/state.js"; -import { Asteroid } from "../../../../data/asteroid/asteroid.js"; -import type { CoreDatabase } from "../../core-database/core-database.js"; -import { RandomService } from "../../../random-service/random-service.js"; -import { readAsteroids } from "./read-asteroids.js"; - -// Advance to the next wave with a randomized layout. Thin wrapper over -// State.spawnRandomWave: project the {asteroids, wave, bounds} slice, let the -// data/ transform decide the count and per-rock drift with the INJECTED `random` -// service, then bump `wave` and insert the spawned rocks. The transform is a -// no-op while the field still has asteroids (returns the same object), so -// dispatching mid-wave changes nothing. The `waves` system supplies the real -// random source; tests inject RandomService.createFake for exact assertions. -export const spawnRandomWave = ( - t: CoreDatabase.Store, - { random }: { random: RandomService }, -): void => { - // 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( - { entities: new Map(), wave: t.resources.wave, bounds: t.resources.bounds }, - { random }, - ); - t.resources.wave = after.wave; - // The seed's entities were empty, so the patch holds exactly the spawned rocks. - for (const value of after.entities.values()) { - if (Asteroid.is(value)) t.archetypes.Asteroid.insert(value); - } -}; 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 deleted file mode 100644 index cfe42b18f..000000000 --- a/packages/data-lit-space-rock-game/src/features/main/services/random-service/create-fake.ts +++ /dev/null @@ -1,23 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { RandomService } from "./random-service.js"; - -/** - * Deterministic test double for {@link RandomService}. Unlike the real source it - * 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[] = [0], -): RandomService => { - let index = 0; - return { - serviceName: "random", - next: () => { - const value = sequence[index % sequence.length]; - index += 1; - return value; - }, - }; -}; 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 56c941d3d..6523855b6 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,2 @@ // © 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-space-rock-game/src/features/main/services/random-service/random.fake.ts b/packages/data-lit-space-rock-game/src/features/main/services/random-service/random.fake.ts new file mode 100644 index 000000000..10cc19cd0 --- /dev/null +++ b/packages/data-lit-space-rock-game/src/features/main/services/random-service/random.fake.ts @@ -0,0 +1,17 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { RandomService } from "./random-service.js"; + +/** + * Shape-only recording template for {@link RandomService} — a TEST-TIER double, NOT + * re-exported through `public.ts`, so it can never be reached as + * `RandomService.createFake` from runtime code. Conformance calls it once purely to + * enumerate the service's methods; the RETURNS come from each case's `responses` (the + * per-asteroid draws a `spawnRandomWave` case owns), so this file hardcodes no value + * a case then has to guess at. `next` is a value method, so its placeholder is + * inert. Imported directly by the conformance manifest (`data/state/spec.ts`) and the + * system tick-loop test (as an inert `random` for a `State.step` that never draws). + */ +export const createFake = (): RandomService => ({ + serviceName: "random", + next: () => 0, +}); diff --git a/packages/data-lit-space-rock-game/tsconfig.build.json b/packages/data-lit-space-rock-game/tsconfig.build.json new file mode 100644 index 000000000..1395b61d5 --- /dev/null +++ b/packages/data-lit-space-rock-game/tsconfig.build.json @@ -0,0 +1,29 @@ +{ + // The RUNTIME wall (dependency isolation). It compiles the shippable data + services + // tiers as a `composite` project, so a data/ or services/ runtime file that imports a + // test-tier file (`*.cases.ts`, `*.fake.ts`, `spec.ts`, anything under `conformance/`) + // is a COMPILE ERROR (TS6307: not in this project's file list). `ui/` and the app-shell + // (`src/*.ts`) live in the test project instead — they import test-tier only in their own + // `*.test.ts`, and keeping their (often very large) inferred element/plugin types out of + // this project avoids forcing them into serialized declarations. Emits declarations only, + // to a throwaway dir, purely to satisfy the composite contract `tsconfig.test.json` refs. + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "./.tsbuild", + "rootDir": "src" + }, + "include": ["src/features/**/*"], + "exclude": [ + "**/ui/**", + "**/*.test.ts", + "**/*.type-test.ts", + "**/*.cases.ts", + "**/*.fake.ts", + "**/spec.ts", + "**/conformance/**" + ] +} diff --git a/packages/data-lit-space-rock-game/tsconfig.test.json b/packages/data-lit-space-rock-game/tsconfig.test.json new file mode 100644 index 000000000..f328ac324 --- /dev/null +++ b/packages/data-lit-space-rock-game/tsconfig.test.json @@ -0,0 +1,26 @@ +{ + // The NON-EMITTING tier: everything the runtime wall (`tsconfig.build.json`) doesn't own — + // the test-tier (conformance manifest + inert cases, service fakes, `*.test.ts`, + // `conformance/`), plus `ui/` and the app-shell (`src/*.ts`). It `references` the runtime + // project, so its imports of data/services resolve through that project's declarations, + // and it alone may see `@adobe/data-testing`. Nothing here emits, so large element/plugin + // types are type-checked without being serialized. `tsc -b tsconfig.test.json` builds the + // wall first, then type-checks this tier — together covering all of `src`. + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": false, + "noEmit": true + }, + "include": [ + "src/*.ts", + "src/**/ui/**/*", + "src/**/*.test.ts", + "src/**/*.type-test.ts", + "src/**/*.cases.ts", + "src/**/*.fake.ts", + "src/**/spec.ts", + "src/**/conformance/**/*" + ], + "exclude": [], + "references": [{ "path": "./tsconfig.build.json" }] +} diff --git a/packages/data-lit-tictactoe/.gitignore b/packages/data-lit-tictactoe/.gitignore new file mode 100644 index 000000000..c18d52ae7 --- /dev/null +++ b/packages/data-lit-tictactoe/.gitignore @@ -0,0 +1 @@ +.tsbuild/ diff --git a/packages/data-lit-tictactoe/package.json b/packages/data-lit-tictactoe/package.json index 5b037705d..2987c965c 100644 --- a/packages/data-lit-tictactoe/package.json +++ b/packages/data-lit-tictactoe/package.json @@ -11,7 +11,7 @@ "build": "vite build", "dev": "vite", "test": "vitest --run --passWithNoTests", - "typecheck": "tsc -p tsconfig.json --noEmit" + "typecheck": "tsc -b tsconfig.test.json" }, "dependencies": { "@adobe/data": "workspace:*", 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 deleted file mode 100644 index d1d3ac3ae..000000000 --- a/packages/data-lit-tictactoe/src/features/main/data/state/conformance-case.ts +++ /dev/null @@ -1,11 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Conformance as ConformanceApi } from "@adobe/data-testing"; -import type { State } from "./state.js"; - -// The per-feature conformance surface — the shared `@adobe/data-testing` machinery -// with `State` bound once. `Conformance.cases(fn, [options,] ...cases)` declares a -// transform's cases (the case types come from `fn`); `Conformance.derivations(fn, ...cases)` -// a derivation's. This tiny file is the only per-feature conformance -// declaration; everything else lives in `@adobe/data-testing`. -export const Conformance = { cases: ConformanceApi.casesBuilder(), derivations: ConformanceApi.derivationsBuilder() }; -export type Effects = ConformanceApi.Effects; diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/current-player.cases.ts b/packages/data-lit-tictactoe/src/features/main/data/state/current-player.cases.ts new file mode 100644 index 000000000..d7c1d0934 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/state/current-player.cases.ts @@ -0,0 +1,43 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { currentPlayer } from "./current-player.js"; + +// Inert derivation cases for `currentPlayer` — `{ input, value }` pairs, the +// `Conformance` binding is `import type`. Shared with the ecs `currentPlayer` +// computed. `input` is a full `State` delta; `value` is the mark to move. +export const cases: Conformance.SpecDerivations = { + cases: [ + { + name: "the first player moves on an empty board", + input: { + board: " ", + firstPlayer: "X", + }, + value: "X", + }, + { + name: "honors a first player of O on an empty board", + input: { + board: " ", + firstPlayer: "O", + }, + value: "O", + }, + { + name: "alternates to the opponent after the first move", + input: { + board: " X ", + firstPlayer: "X", + }, + value: "O", + }, + { + name: "returns to the first player after both have moved", + input: { + board: "XO ", + firstPlayer: "X", + }, + value: "X", + }, + ], +}; 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 e8fb4fcb2..065ca5f27 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 @@ -2,7 +2,6 @@ import { BoardState } from "../board-state/board-state.js"; import type { PlayerMark } from "../player-mark/player-mark.js"; import type { State } from "./state.js"; -import { Conformance } 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 @@ -12,40 +11,3 @@ import { Conformance } from "./conformance-case.js"; 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. -export const cases = /*@__PURE__*/ Conformance.derivations(currentPlayer, - { - name: "the first player moves on an empty board", - input: { - board: " ", - firstPlayer: "X", - }, - value: "X", - }, - { - name: "honors a first player of O on an empty board", - input: { - board: " ", - firstPlayer: "O", - }, - value: "O", - }, - { - name: "alternates to the opponent after the first move", - input: { - board: " X ", - firstPlayer: "X", - }, - value: "O", - }, - { - name: "returns to the first player after both have moved", - input: { - board: "XO ", - firstPlayer: "X", - }, - value: "X", - }, -); 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 new file mode 100644 index 000000000..b8c866345 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/state/play-move.cases.ts @@ -0,0 +1,52 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { playMove } from "./play-move.js"; + +// Inert, spec-owned cases for `playMove` — data only, no test-framework runtime +// import (the `Conformance` binding is `import type`). `before` is a delta over +// `State.create()` (empty board, X first, zeroed scores); `after` lists only what +// the move writes — the board. `index` is a plain cell index, not an entity +// reference, so no `args` schema is authored. 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.SpecCases = { + cases: [ + { + name: "places the first player's mark into an empty cell", + before: {}, + args: { index: 4 }, + after: { board: " X " }, + }, + { + name: "alternates to the opponent by move count", + before: { board: " X " }, + args: { index: 0 }, + after: { board: "O X " }, + }, + { + name: "completes a three-in-a-row (winning placement is still just a placement)", + before: { board: "XX OO ", xWins: 1, oWins: 2 }, + args: { index: 2 }, + after: { board: "XXX OO " }, + }, + { + name: "ignores an occupied cell (no-op)", + before: { board: " X " }, + args: { index: 4 }, + after: { board: " X " }, + }, + { + name: "ignores an out-of-bounds index (no-op)", + before: { firstPlayer: "O" }, + args: { index: 9 }, + after: { board: " " }, + }, + { + name: "ignores a move once the game is already won (no-op)", + before: { board: "XXX " }, + args: { index: 4 }, + after: { board: "XXX " }, + }, + ], +}; 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 a8c2f05cc..8e980d298 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,7 +2,6 @@ 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 { Conformance } from "./conformance-case.js"; // 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, @@ -25,47 +24,3 @@ export const playMove = ( }), }; }; - -// 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 = /*@__PURE__*/ Conformance.cases(playMove, - { - name: "places the first player's mark into an empty cell", - before: {}, - args: { index: 4 }, - after: { board: " X " }, - }, - { - name: "alternates to the opponent by move count", - before: { board: " X " }, - args: { index: 0 }, - after: { board: "O X " }, - }, - { - name: "completes a three-in-a-row (winning placement is still just a placement)", - before: { board: "XX OO ", xWins: 1, oWins: 2 }, - args: { index: 2 }, - after: { board: "XXX OO " }, - }, - { - name: "ignores an occupied cell (no-op)", - before: { board: " X " }, - args: { index: 4 }, - after: { board: " X " }, - }, - { - name: "ignores an out-of-bounds index (no-op)", - before: { firstPlayer: "O" }, - args: { index: 9 }, - after: { board: " " }, - }, - { - name: "ignores a move once the game is already won (no-op)", - before: { board: "XXX " }, - args: { index: 4 }, - after: { board: "XXX " }, - }, -); 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 new file mode 100644 index 000000000..c0f287939 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/state/restart-game.cases.ts @@ -0,0 +1,58 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { restartGame } from "./restart-game.js"; + +// Inert, spec-owned cases for `restartGame` (no args) — data only, the `Conformance` +// binding is `import type`. `before` is a delta over `State.create()`. 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.SpecCases = { + cases: [ + { + name: "tallies an X win, alternates first player, clears the board", + before: { board: "XXX " }, + after: { + board: " ", + firstPlayer: "O", + xWins: 1, + oWins: 0, + draws: 0, + }, + }, + { + name: "tallies an O win", + before: { board: "OOOXX ", firstPlayer: "O", xWins: 1, oWins: 2 }, + 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 }, + after: { + board: " ", + firstPlayer: "X", + xWins: 2, + oWins: 1, + draws: 1, + }, + }, + { + name: "restarts an unfinished game without touching any counter", + before: { board: "X 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/restart-game.ts b/packages/data-lit-tictactoe/src/features/main/data/state/restart-game.ts index 4e0d6387a..4ab570d2d 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,7 +2,6 @@ import { BoardState } from "../board-state/board-state.js"; import { PlayerMark } from "../player-mark/player-mark.js"; import type { State } from "./state.js"; -import { Conformance } from "./conformance-case.js"; // Tally the finished game into the scoreboard, hand the first move to the other // player, and clear the board. @@ -17,58 +16,3 @@ 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 = /*@__PURE__*/ Conformance.cases(restartGame, - { - name: "tallies an X win, alternates first player, clears the board", - before: { board: "XXX " }, - 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 }, - 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 }, - 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 ", 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 index 3160277a4..ebd7cc8f6 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,8 +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"; +import { spec } from "./spec.js"; -// 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 }); +// The single pure-spec test for every transform AND derivation in this folder. It +// runs the manifest's inert cases against the pure functions and asserts `after` +// (up to an id-bijection) and declared `effects`. +Conformance.checkSpec(spec); diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/spec.ts b/packages/data-lit-tictactoe/src/features/main/data/state/spec.ts new file mode 100644 index 000000000..26d5d8dcc --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/state/spec.ts @@ -0,0 +1,33 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Conformance } from "@adobe/data-testing"; +import { State } from "./state.js"; +import * as transforms from "./transforms.js"; +import { MainService } from "../../services/main-service/main-service.js"; +import { ComputedDatabase } from "../../services/main-service/computed-database/computed-database.js"; +import { projection } from "../../services/main-service/conformance/projection.js"; + +import { cases as playMove } from "./play-move.cases.js"; +import { cases as restartGame } from "./restart-game.cases.js"; +import { cases as currentPlayer } from "./current-player.cases.js"; + +// The feature's conformance manifest — the single object `spec.test.ts` (pure) and +// `conformance.test.ts` (ecs) both import. It wires each conformed fn to its inert cases module explicitly: `cases` names one module per conformed fn, +// checked at COMPILE TIME against `transforms` (a fn without cases, or cases without +// a fn, won't type). `computedPlugin` is required because `currentPlayer` is a +// derivation. This feature injects no capability services, so `services` is omitted; +// tictactoe is index-addressed (no entity-id-list derivation), so no `hydrate`. +// +// This is a test-tier module (excluded from the runtime program) — the ONE place the +// feature touches `@adobe/data-testing`. No transform or `*.cases.ts` file does. +export const spec = Conformance.feature({ + state: State, + fns: transforms, + plugin: MainService.plugin, + computedPlugin: ComputedDatabase.plugin, + projection, + cases: { + playMove, + restartGame, + currentPlayer, + }, +}); diff --git a/packages/data-lit-tictactoe/src/features/main/data/state/transforms.ts b/packages/data-lit-tictactoe/src/features/main/data/state/transforms.ts new file mode 100644 index 000000000..5543e3760 --- /dev/null +++ b/packages/data-lit-tictactoe/src/features/main/data/state/transforms.ts @@ -0,0 +1,12 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +// The conformed functions of this feature — every pure transform and derivation the +// spec verifies, and nothing else (helpers like `create`, `samples` are deliberately +// absent). Its keys ARE the conformance surface: the manifest's coverage guard +// (`Conformance.feature`) requires exactly one `*.cases.ts` module per key here, so +// adding a transform without cases — or cases without a transform — fails to compile. +// This is a per-feature, local barrel (not a cross-feature registry), authored where +// the transforms live. +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/transitions.ts b/packages/data-lit-tictactoe/src/features/main/data/state/transitions.ts deleted file mode 100644 index 1092fd915..000000000 --- a/packages/data-lit-tictactoe/src/features/main/data/state/transitions.ts +++ /dev/null @@ -1,11 +0,0 @@ -// © 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 eba525c7f..c656214d7 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,20 +1,10 @@ // © 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"; +import { spec } from "../../../data/state/spec.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. -// `transitions` (the discovered `{ fn, cases }` modules) is shared with spec.test. -Conformance.runFeature({ - state: State, - transitions, - plugin: MainService.plugin, - computedPlugin: ComputedDatabase.plugin, - projection, -}); +// The whole ecs conformance for this feature in one call: `checkFeature` replays the +// manifest's shared cases against the transactions/actions on `MainService.plugin` +// and the computeds on `ComputedDatabase`, seeds each `before` over `State.create()`, +// and round-trips `State.samples` through the `projection`. It shares `spec` with +// `spec.test.ts` — same cases, substituted implementation. +Conformance.checkFeature(spec); 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 index c630babfc..3c5531488 100644 --- 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 @@ -15,7 +15,7 @@ const toData = (store: CoreDatabase.Store, entity: Entity): PlacedMark => { return { mark: row.mark, index: row.index }; }; -// The test-only ecs↔`State` projection, passed to `Conformance.runFeature`. +// The test-only ecs↔`State` projection, carried on the manifest and used by `Conformance.checkFeature`. // `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 = { diff --git a/packages/data-lit-tictactoe/tsconfig.build.json b/packages/data-lit-tictactoe/tsconfig.build.json new file mode 100644 index 000000000..1395b61d5 --- /dev/null +++ b/packages/data-lit-tictactoe/tsconfig.build.json @@ -0,0 +1,29 @@ +{ + // The RUNTIME wall (dependency isolation). It compiles the shippable data + services + // tiers as a `composite` project, so a data/ or services/ runtime file that imports a + // test-tier file (`*.cases.ts`, `*.fake.ts`, `spec.ts`, anything under `conformance/`) + // is a COMPILE ERROR (TS6307: not in this project's file list). `ui/` and the app-shell + // (`src/*.ts`) live in the test project instead — they import test-tier only in their own + // `*.test.ts`, and keeping their (often very large) inferred element/plugin types out of + // this project avoids forcing them into serialized declarations. Emits declarations only, + // to a throwaway dir, purely to satisfy the composite contract `tsconfig.test.json` refs. + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "./.tsbuild", + "rootDir": "src" + }, + "include": ["src/features/**/*"], + "exclude": [ + "**/ui/**", + "**/*.test.ts", + "**/*.type-test.ts", + "**/*.cases.ts", + "**/*.fake.ts", + "**/spec.ts", + "**/conformance/**" + ] +} diff --git a/packages/data-lit-tictactoe/tsconfig.test.json b/packages/data-lit-tictactoe/tsconfig.test.json new file mode 100644 index 000000000..f328ac324 --- /dev/null +++ b/packages/data-lit-tictactoe/tsconfig.test.json @@ -0,0 +1,26 @@ +{ + // The NON-EMITTING tier: everything the runtime wall (`tsconfig.build.json`) doesn't own — + // the test-tier (conformance manifest + inert cases, service fakes, `*.test.ts`, + // `conformance/`), plus `ui/` and the app-shell (`src/*.ts`). It `references` the runtime + // project, so its imports of data/services resolve through that project's declarations, + // and it alone may see `@adobe/data-testing`. Nothing here emits, so large element/plugin + // types are type-checked without being serialized. `tsc -b tsconfig.test.json` builds the + // wall first, then type-checks this tier — together covering all of `src`. + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": false, + "noEmit": true + }, + "include": [ + "src/*.ts", + "src/**/ui/**/*", + "src/**/*.test.ts", + "src/**/*.type-test.ts", + "src/**/*.cases.ts", + "src/**/*.fake.ts", + "src/**/spec.ts", + "src/**/conformance/**/*" + ], + "exclude": [], + "references": [{ "path": "./tsconfig.build.json" }] +} diff --git a/packages/data-lit-todo/.gitignore b/packages/data-lit-todo/.gitignore new file mode 100644 index 000000000..c18d52ae7 --- /dev/null +++ b/packages/data-lit-todo/.gitignore @@ -0,0 +1 @@ +.tsbuild/ diff --git a/packages/data-lit-todo/package.json b/packages/data-lit-todo/package.json index 0d97b7274..e26b84e8a 100644 --- a/packages/data-lit-todo/package.json +++ b/packages/data-lit-todo/package.json @@ -11,7 +11,7 @@ "build": "vite build", "dev": "vite", "test": "vitest --run --passWithNoTests", - "typecheck": "tsc -p tsconfig.json --noEmit" + "typecheck": "tsc -b tsconfig.test.json" }, "dependencies": { "@adobe/data": "workspace:*", 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 deleted file mode 100644 index d1d3ac3ae..000000000 --- a/packages/data-lit-todo/src/features/main/data/state/conformance-case.ts +++ /dev/null @@ -1,11 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Conformance as ConformanceApi } from "@adobe/data-testing"; -import type { State } from "./state.js"; - -// The per-feature conformance surface — the shared `@adobe/data-testing` machinery -// with `State` bound once. `Conformance.cases(fn, [options,] ...cases)` declares a -// transform's cases (the case types come from `fn`); `Conformance.derivations(fn, ...cases)` -// a derivation's. This tiny file is the only per-feature conformance -// declaration; everything else lives in `@adobe/data-testing`. -export const Conformance = { cases: ConformanceApi.casesBuilder(), derivations: ConformanceApi.derivationsBuilder() }; -export type Effects = ConformanceApi.Effects; diff --git a/packages/data-lit-todo/src/features/main/data/state/create-bulk-todos.cases.ts b/packages/data-lit-todo/src/features/main/data/state/create-bulk-todos.cases.ts new file mode 100644 index 000000000..ffc878ed0 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/data/state/create-bulk-todos.cases.ts @@ -0,0 +1,63 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { createBulkTodos } from "./create-bulk-todos.js"; + +// Inert cases for `createBulkTodos`. `count` (floored, clamped at 0) numbered todos +// are appended; the `bulkTodosCreated` effect logs the raw count, even on a no-op. +export const cases: Conformance.SpecCases = { + cases: [ + { + name: "appends count numbered todos to an empty list", + before: {}, + args: { count: 3 }, + after: { + entities: new Map([ + [1, { name: "Todo 0", complete: false, order: 0 }], + [2, { name: "Todo 1", complete: false, order: 1 }], + [3, { name: "Todo 2", complete: false, order: 2 }], + ]), + }, + effects: { analytics: [["bulkTodosCreated", { count: 3 }]] }, + }, + { + name: "continues names after existing todos", + before: { + entities: new Map([[1, { name: "a", complete: false, order: 0 }]]), + }, + args: { count: 2 }, + after: { + entities: new Map([ + [1, { name: "a", complete: false, order: 0 }], + [2, { name: "Todo 1", complete: false, order: 1 }], + [3, { name: "Todo 2", complete: false, order: 2 }], + ]), + }, + effects: { analytics: [["bulkTodosCreated", { count: 2 }]] }, + }, + { + name: "floors a fractional count", + before: {}, + args: { count: 2.9 }, + after: { + entities: new Map([ + [1, { name: "Todo 0", complete: false, order: 0 }], + [2, { name: "Todo 1", complete: false, order: 1 }], + ]), + }, + effects: { analytics: [["bulkTodosCreated", { count: 2.9 }]] }, + }, + { + name: "is a no-op for count 0 but still logs the request", + before: { + entities: new Map([[1, { name: "a", complete: false, order: 0 }]]), + displayCompleted: true, + }, + args: { count: 0 }, + after: { + entities: new Map([[1, { name: "a", complete: false, order: 0 }]]), + }, + effects: { analytics: [["bulkTodosCreated", { count: 0 }]] }, + }, + ], +}; 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 0904f7972..a02f40b0a 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 @@ -1,8 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { AnalyticsService } from "../../services/analytics-service/analytics-service.js"; import type { Services } from "../../services/services.js"; import type { State } from "./state.js"; -import { Conformance } from "./conformance-case.js"; import { appendTodo } from "./append-todo.js"; /** Adds numbered placeholder todos for demos and performance testing. Reads and * writes the entities — an `{ entities }` patch. */ @@ -21,65 +19,3 @@ export const createBulkTodos = ( } return next; }; - -// Spec-owned cases, shared with the ecs `createBulkTodos` transaction. `before` -// is a delta over `State.create()` (plain spec-id keys); `after` lists only the -// written entities with plain spec-id keys. `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. -export const cases = /*@__PURE__*/ Conformance.cases(createBulkTodos, - { - name: "appends count numbered todos to an empty list", - before: {}, - args: { count: 3, analytics: AnalyticsService.createFake() }, - after: { - entities: new Map([ - [1, { name: "Todo 0", complete: false, order: 0 }], - [2, { name: "Todo 1", complete: false, order: 1 }], - [3, { name: "Todo 2", complete: false, order: 2 }], - ]), - }, - effects: { analytics: [["bulkTodosCreated", { count: 3 }]] }, - }, - { - name: "continues names after existing todos", - before: { - entities: new Map([[1, { name: "a", complete: false, order: 0 }]]), - }, - args: { count: 2, analytics: AnalyticsService.createFake() }, - after: { - entities: new Map([ - [1, { name: "a", complete: false, order: 0 }], - [2, { name: "Todo 1", complete: false, order: 1 }], - [3, { name: "Todo 2", complete: false, order: 2 }], - ]), - }, - effects: { analytics: [["bulkTodosCreated", { count: 2 }]] }, - }, - { - name: "floors a fractional count", - before: {}, - args: { count: 2.9, analytics: AnalyticsService.createFake() }, - after: { - entities: new Map([ - [1, { name: "Todo 0", complete: false, order: 0 }], - [2, { name: "Todo 1", complete: false, order: 1 }], - ]), - }, - effects: { analytics: [["bulkTodosCreated", { count: 2.9 }]] }, - }, - { - name: "is a no-op for count 0 but still logs the request", - before: { - entities: new Map([[1, { name: "a", complete: false, order: 0 }]]), - displayCompleted: true, - }, - args: { count: 0, analytics: AnalyticsService.createFake() }, - after: { - entities: new Map([ - [1, { name: "a", complete: false, order: 0 }], - ]), - }, - effects: { analytics: [["bulkTodosCreated", { count: 0 }]] }, - }, -); diff --git a/packages/data-lit-todo/src/features/main/data/state/create-random-todo.cases.ts b/packages/data-lit-todo/src/features/main/data/state/create-random-todo.cases.ts new file mode 100644 index 000000000..4f71ebdfa --- /dev/null +++ b/packages/data-lit-todo/src/features/main/data/state/create-random-todo.cases.ts @@ -0,0 +1,50 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { createRandomTodo } from "./create-random-todo.js"; + +// Inert cases for the value-returning, service-injected `createRandomTodo`. The +// doubles' RETURNS are scheduled as data in `responses` — the case OWNS the name the +// generator yields and the `{ startedAt: 0 }` timing the analytics port hands back — +// so `after`/`effects` never guess at a value the fake invented. `nameGenerator` is +// a read (value-returning), so it is scheduled but not asserted; `analytics`' +// fire-and-forget calls are asserted in `effects`, and the timing it returned flows +// verbatim into `randomTodoAdded`. +export const cases: Conformance.SpecCases = { + cases: [ + { + name: "names the new todo from the generator and logs the timed add", + before: {}, + responses: { + nameGenerator: { generateName: ["random task"] }, + analytics: { randomTodoRequested: [{ startedAt: 0 }] }, + }, + after: { + entities: new Map([[1, { name: "random task", complete: false, order: 0 }]]), + }, + effects: { + analytics: [ + ["randomTodoRequested"], + ["randomTodoAdded", { timing: { startedAt: 0 }, name: "random task" }], + ], + }, + }, + { + name: "uses an explicit response schedule when supplied", + before: {}, + responses: { + nameGenerator: { generateName: ["only name"] }, + analytics: { randomTodoRequested: [{ startedAt: 0 }] }, + }, + after: { + entities: new Map([[1, { name: "only name", complete: false, order: 0 }]]), + }, + effects: { + analytics: [ + ["randomTodoRequested"], + ["randomTodoAdded", { timing: { startedAt: 0 }, name: "only name" }], + ], + }, + }, + ], +}; 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 b2f3a5472..357a7583c 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 @@ -1,9 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { NameGeneratorService } from "../../services/name-generator-service/name-generator-service.js"; -import { AnalyticsService } from "../../services/analytics-service/analytics-service.js"; import type { Services } from "../../services/services.js"; import type { State } from "./state.js"; -import { Conformance } from "./conformance-case.js"; import { appendTodo } from "./append-todo.js"; /** * Async, service-injected transition: brackets the slow name generation with @@ -26,58 +23,3 @@ export const createRandomTodo = async ( analytics.randomTodoAdded({ timing, name }); return next; }; - -// Spec-owned cases. `before` is a delta over `State.create()`; `after` lists only -// the written entities with plain spec-id keys. 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 — it -// being a declared service — its full call sequence is listed; `nameGenerator` is -// not declared, so its read is ignored. -export const cases = /*@__PURE__*/ Conformance.cases(createRandomTodo, - { - name: "names the new todo from the generator and logs the timed add", - before: {}, - args: { - nameGenerator: NameGeneratorService.createFake(["random task"]), - analytics: AnalyticsService.createFake(), - }, - after: { - entities: new Map([ - [1, { name: "random task", complete: false, order: 0 }], - ]), - }, - effects: { - analytics: [ - ["randomTodoRequested"], - [ - "randomTodoAdded", - { - timing: { startedAt: 0 }, - name: "random task", - }, - ], - ], - }, - }, - { - name: "uses an explicit response schedule when supplied", - before: {}, - args: { - nameGenerator: NameGeneratorService.createFake(["only name"]), - analytics: AnalyticsService.createFake(), - }, - after: { - entities: new Map([ - [1, { name: "only name", complete: false, order: 0 }], - ]), - }, - effects: { - analytics: [ - ["randomTodoRequested"], - ["randomTodoAdded", { timing: { startedAt: 0 }, name: "only name" }], - ], - }, - }, -); diff --git a/packages/data-lit-todo/src/features/main/data/state/create-todo.cases.ts b/packages/data-lit-todo/src/features/main/data/state/create-todo.cases.ts new file mode 100644 index 000000000..08701bbdc --- /dev/null +++ b/packages/data-lit-todo/src/features/main/data/state/create-todo.cases.ts @@ -0,0 +1,57 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { createTodo } from "./create-todo.js"; + +// Inert cases for `createTodo`. `args` carries only data (`name`, optional +// `complete`); the injected `analytics` double is synthesized by the runner and its +// call asserted in `effects`. `before`/`after` are keyed by plain spec-ids and +// compared up to an id-bijection. `complete` defaults to false. +export const cases: Conformance.SpecCases = { + cases: [ + { + name: "appends the first todo to an empty list", + before: {}, + args: { name: "a" }, + after: { + entities: new Map([[1, { name: "a", complete: false, order: 0 }]]), + }, + effects: { analytics: [["todoCreated", { name: "a" }]] }, + }, + { + name: "appends a complete todo", + before: { + entities: new Map([[1, { name: "a", complete: false, order: 0 }]]), + }, + args: { name: "b", complete: true }, + after: { + entities: new Map([ + [1, { name: "a", complete: false, order: 0 }], + [2, { name: "b", complete: true, order: 1 }], + ]), + }, + effects: { analytics: [["todoCreated", { name: "b" }]] }, + }, + { + name: "appends onto a longer list", + before: { + entities: new Map([ + [1, { name: "a", complete: false, order: 0 }], + [2, { name: "b", complete: true, order: 1 }], + [3, { name: "c", complete: false, order: 2 }], + ]), + displayCompleted: true, + }, + args: { name: "d" }, + after: { + entities: new Map([ + [1, { name: "a", complete: false, order: 0 }], + [2, { name: "b", complete: true, order: 1 }], + [3, { name: "c", complete: false, order: 2 }], + [4, { name: "d", complete: false, order: 3 }], + ]), + }, + effects: { analytics: [["todoCreated", { name: "d" }]] }, + }, + ], +}; 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 ba1351abe..4c7c698ff 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 @@ -1,8 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { AnalyticsService } from "../../services/analytics-service/analytics-service.js"; import type { Services } from "../../services/services.js"; import type { State } from "./state.js"; -import { Conformance } from "./conformance-case.js"; import { appendTodo } from "./append-todo.js"; // Reads the entities, writes the entities — an `{ entities }` patch — by @@ -21,61 +19,3 @@ export const createTodo = ( analytics.todoCreated({ name }); return appendTodo(state, { name, complete }); }; - -// Spec-owned cases, shared with the ecs `createTodo` transaction. `before` is a -// delta over `State.create()` (empty entities, completed hidden); the `before` -// map is keyed by PLAIN spec-id numbers. `after` lists only what the transition -// writes — the entities — keyed by plain spec-ids (the ecs mints its own ids; conformance compares -// up to an id-bijection; values are id-less and compare by content). `complete` defaults to false; it logs `todoCreated`. -export const cases = /*@__PURE__*/ Conformance.cases(createTodo, - { - name: "appends the first todo to an empty list", - before: {}, - args: { name: "a", analytics: AnalyticsService.createFake() }, - after: { - entities: new Map([ - [1, { name: "a", complete: false, order: 0 }], - ]), - }, - effects: { analytics: [["todoCreated", { name: "a" }]] }, - }, - { - name: "appends a complete todo", - before: { - entities: new Map([[1, { name: "a", complete: false, order: 0 }]]), - }, - args: { - name: "b", - complete: true, - analytics: AnalyticsService.createFake(), - }, - after: { - entities: new Map([ - [1, { name: "a", complete: false, order: 0 }], - [2, { name: "b", complete: true, order: 1 }], - ]), - }, - effects: { analytics: [["todoCreated", { name: "b" }]] }, - }, - { - name: "appends onto a longer list", - before: { - entities: new Map([ - [1, { name: "a", complete: false, order: 0 }], - [2, { name: "b", complete: true, order: 1 }], - [3, { name: "c", complete: false, order: 2 }], - ]), - displayCompleted: true, - }, - args: { name: "d", analytics: AnalyticsService.createFake() }, - after: { - entities: new Map([ - [1, { name: "a", complete: false, order: 0 }], - [2, { name: "b", complete: true, order: 1 }], - [3, { name: "c", complete: false, order: 2 }], - [4, { name: "d", complete: false, order: 3 }], - ]), - }, - effects: { analytics: [["todoCreated", { name: "d" }]] }, - }, -); diff --git a/packages/data-lit-todo/src/features/main/data/state/delete-all-todos.cases.ts b/packages/data-lit-todo/src/features/main/data/state/delete-all-todos.cases.ts new file mode 100644 index 000000000..a8f6ecc15 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/data/state/delete-all-todos.cases.ts @@ -0,0 +1,30 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { deleteAllTodos } from "./delete-all-todos.js"; + +// Inert cases for `deleteAllTodos` (service-only args — no data `args`). Every todo +// is removed, `displayCompleted` is untouched, and `allTodosCleared` is logged. +export const cases: Conformance.SpecCases = { + cases: [ + { + name: "empties a populated list, preserving displayCompleted", + before: { + entities: new Map([ + [1, { name: "a", complete: false, order: 0 }], + [2, { name: "b", complete: true, order: 1 }], + [3, { name: "c", complete: false, order: 2 }], + ]), + displayCompleted: true, + }, + after: { entities: new Map() }, + effects: { analytics: [["allTodosCleared"]] }, + }, + { + name: "is a no-op on an already empty list but still logs the clear", + before: {}, + after: { entities: new Map() }, + effects: { analytics: [["allTodosCleared"]] }, + }, + ], +}; 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 8b7b67004..6e20df485 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 @@ -1,9 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { AnalyticsService } from "../../services/analytics-service/analytics-service.js"; import type { Services } from "../../services/services.js"; import type { State } from "./state.js"; import type { Todo } from "../todo/todo.js"; -import { Conformance } from "./conformance-case.js"; // Reads the entities, writes the entities — an `{ entities }` patch — clearing // them; `displayCompleted` is untouched. Logs `allTodosCleared`. @@ -14,31 +12,3 @@ export const deleteAllTodos = ( analytics.allTodosCleared(); return { entities: new Map() }; }; - -// Spec-owned cases, shared with the ecs `deleteAllTodos` transaction. `before` is -// a delta over `State.create()`; `after` lists only the written entities (empty). -// Every todo is removed and `displayCompleted` is untouched; the transition logs -// `allTodosCleared` (as the action does). -export const cases = /*@__PURE__*/ Conformance.cases(deleteAllTodos, - { - name: "empties a populated list, preserving displayCompleted", - before: { - entities: new Map([ - [1, { name: "a", complete: false, order: 0 }], - [2, { name: "b", complete: true, order: 1 }], - [3, { name: "c", complete: false, order: 2 }], - ]), - displayCompleted: true, - }, - args: { analytics: AnalyticsService.createFake() }, - after: { entities: new Map() }, - effects: { analytics: [["allTodosCleared"]] }, - }, - { - name: "is a no-op on an already empty list but still logs the clear", - before: {}, - args: { analytics: AnalyticsService.createFake() }, - after: { entities: new Map() }, - effects: { analytics: [["allTodosCleared"]] }, - }, -); diff --git a/packages/data-lit-todo/src/features/main/data/state/delete-todo.cases.ts b/packages/data-lit-todo/src/features/main/data/state/delete-todo.cases.ts new file mode 100644 index 000000000..1d888e747 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/data/state/delete-todo.cases.ts @@ -0,0 +1,71 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Entity } from "@adobe/data/ecs"; +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { deleteTodo } from "./delete-todo.js"; + +// Inert cases for `deleteTodo`. The `args` schema marks `id` as an entity reference +// (the ecs side resolves the spec-id to its seeded entity). The addressed todo is +// removed and survivors keep their `order`; an unknown id is a no-op; the delete is +// logged unconditionally. +export const cases: Conformance.SpecCases = { + args: { type: "object", properties: { id: Entity.schema } }, + cases: [ + { + name: "removes a middle todo", + before: { + entities: new Map([ + [1, { name: "a", complete: false, order: 0 }], + [2, { name: "b", complete: true, order: 1 }], + [3, { name: "c", complete: false, order: 2 }], + ]), + }, + args: { id: 2 }, + after: { + entities: new Map([ + [1, { name: "a", complete: false, order: 0 }], + [2, { name: "c", complete: false, order: 2 }], + ]), + }, + effects: { analytics: [["todoDeleted"]] }, + }, + { + name: "removes the first todo", + before: { + entities: new Map([ + [1, { name: "a", complete: false, order: 0 }], + [2, { name: "b", complete: true, order: 1 }], + [3, { name: "c", complete: false, order: 2 }], + ]), + displayCompleted: true, + }, + args: { id: 1 }, + after: { + entities: new Map([ + [1, { name: "b", complete: true, order: 1 }], + [2, { name: "c", complete: false, order: 2 }], + ]), + }, + effects: { analytics: [["todoDeleted"]] }, + }, + { + name: "is a no-op for an unknown id but still logs the delete", + before: { + entities: new Map([ + [1, { name: "a", complete: false, order: 0 }], + [2, { name: "b", complete: true, order: 1 }], + [3, { name: "c", complete: false, order: 2 }], + ]), + }, + args: { id: 99 }, + after: { + entities: new Map([ + [1, { name: "a", complete: false, order: 0 }], + [2, { name: "b", complete: true, order: 1 }], + [3, { name: "c", complete: false, order: 2 }], + ]), + }, + effects: { analytics: [["todoDeleted"]] }, + }, + ], +}; 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 4236bb91c..a13b175e6 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,10 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Entity } from "@adobe/data/ecs"; -import { AnalyticsService } from "../../services/analytics-service/analytics-service.js"; import type { Services } from "../../services/services.js"; import type { State } from "./state.js"; -import type { Todo } from "../todo/todo.js"; -import { Conformance } from "./conformance-case.js"; // Reads the entities, writes the entities — an `{ entities }` patch — dropping the // addressed id; also logs `todoDeleted`. Surviving todos keep their `order` (the @@ -21,54 +17,3 @@ export const deleteTodo = ( entities.delete(id); return { entities }; }; - -const three: readonly (readonly [number, Todo])[] = [ - [1, { name: "a", complete: false, order: 0 }], - [2, { name: "b", complete: true, order: 1 }], - [3, { name: "c", complete: false, order: 2 }], -]; - -// Spec-owned cases, shared with the ecs `deleteTodo` transaction. `before` is a -// delta over `State.create()` keyed by PLAIN spec-ids (so `2` resolves via -// the seed map); `after` lists the surviving entities with plain spec-id -// keys. The addressed todo is removed; an unknown id is a no-op. The transition -// logs `todoDeleted`. -export const cases = /*@__PURE__*/ Conformance.cases(deleteTodo, { args: { type: "object", properties: { id: Entity.schema } } }, - { - name: "removes a middle todo", - before: { entities: new Map(three) }, - args: { id: 2, analytics: AnalyticsService.createFake() }, - after: { - entities: new Map([ - [1, { name: "a", complete: false, order: 0 }], - [2, { name: "c", complete: false, order: 2 }], - ]), - }, - effects: { analytics: [["todoDeleted"]] }, - }, - { - name: "removes the first todo", - before: { entities: new Map(three), displayCompleted: true }, - args: { id: 1, analytics: AnalyticsService.createFake() }, - after: { - entities: new Map([ - [1, { name: "b", complete: true, order: 1 }], - [2, { name: "c", complete: false, order: 2 }], - ]), - }, - effects: { analytics: [["todoDeleted"]] }, - }, - { - name: "is a no-op for an unknown id but still logs the delete", - before: { entities: new Map(three) }, - args: { id: 99, analytics: AnalyticsService.createFake() }, - after: { - entities: new Map([ - [1, { name: "a", complete: false, order: 0 }], - [2, { name: "b", complete: true, order: 1 }], - [3, { name: "c", complete: false, order: 2 }], - ]), - }, - effects: { analytics: [["todoDeleted"]] }, - }, -); diff --git a/packages/data-lit-todo/src/features/main/data/state/reorder-todo.cases.ts b/packages/data-lit-todo/src/features/main/data/state/reorder-todo.cases.ts new file mode 100644 index 000000000..cb2f2038d --- /dev/null +++ b/packages/data-lit-todo/src/features/main/data/state/reorder-todo.cases.ts @@ -0,0 +1,93 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Entity } from "@adobe/data/ecs"; +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { reorderTodo } from "./reorder-todo.js"; + +// Inert cases for the pure `reorderTodo` (no services). Shared with the ecs +// `dragTodo` transaction (its drop is the same move + `normalizeOrder`). The `args` +// schema marks `id` as an entity reference. Every case keeps all todos incomplete +// with `displayCompleted` true, so the visible list `dragTodo` indexes equals the +// full list; `after` lists RECOMPUTED contiguous `order`. The unknown-id no-op is +// exercised only by the pure transform — `dragTodo` has no such guard. +export const cases: Conformance.SpecCases = { + args: { type: "object", properties: { id: Entity.schema } }, + cases: [ + { + name: "moves the first todo to the end", + before: { + entities: new Map([ + [1, { name: "a", complete: false, order: 0 }], + [2, { name: "b", complete: false, order: 1 }], + [3, { name: "c", complete: false, order: 2 }], + ]), + displayCompleted: true, + }, + args: { id: 1, toIndex: 2 }, + after: { + entities: new Map([ + [1, { name: "b", complete: false, order: 0 }], + [2, { name: "c", complete: false, order: 1 }], + [3, { name: "a", complete: false, order: 2 }], + ]), + }, + }, + { + name: "moves the last todo to the front", + before: { + entities: new Map([ + [1, { name: "a", complete: false, order: 0 }], + [2, { name: "b", complete: false, order: 1 }], + [3, { name: "c", complete: false, order: 2 }], + ]), + displayCompleted: true, + }, + args: { id: 3, toIndex: 0 }, + after: { + entities: new Map([ + [1, { name: "c", complete: false, order: 0 }], + [2, { name: "a", complete: false, order: 1 }], + [3, { name: "b", complete: false, order: 2 }], + ]), + }, + }, + { + name: "clamps an out-of-range index to the end", + before: { + entities: new Map([ + [1, { name: "a", complete: false, order: 0 }], + [2, { name: "b", complete: false, order: 1 }], + [3, { name: "c", complete: false, order: 2 }], + ]), + displayCompleted: true, + }, + args: { id: 1, toIndex: 99 }, + after: { + entities: new Map([ + [1, { name: "b", complete: false, order: 0 }], + [2, { name: "c", complete: false, order: 1 }], + [3, { name: "a", complete: false, order: 2 }], + ]), + }, + }, + { + name: "keeps the order when moving to the same index", + before: { + entities: new Map([ + [1, { name: "a", complete: false, order: 0 }], + [2, { name: "b", complete: false, order: 1 }], + [3, { name: "c", complete: false, order: 2 }], + ]), + displayCompleted: true, + }, + args: { id: 2, toIndex: 1 }, + after: { + entities: new Map([ + [1, { name: "a", complete: false, order: 0 }], + [2, { name: "b", complete: false, order: 1 }], + [3, { name: "c", complete: false, order: 2 }], + ]), + }, + }, + ], +}; 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 4cdd695ce..1e7da5d43 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,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Entity } from "@adobe/data/ecs"; import type { State } from "./state.js"; -import type { Todo } from "../todo/todo.js"; -import { Conformance } from "./conformance-case.js"; /** * Moves the todo with the given id to `toIndex` within the display order, * preserving the relative order of every other todo, then recomputes every @@ -31,66 +28,3 @@ export const reorderTodo = ( ), }; }; - -const three: readonly (readonly [number, Todo])[] = [ - [1, { name: "a", complete: false, order: 0 }], - [2, { name: "b", complete: false, order: 1 }], - [3, { name: "c", complete: false, order: 2 }], -]; -// Spec-owned cases, shared with the ecs `dragTodo` transaction (its final drop is -// the same move — `finalIndex` is `toIndex`, followed by `normalizeOrder`). -// `before` is a delta over `State.create()` keyed by PLAIN spec-ids; `after` lists -// the entities with plain spec-id keys and their RECOMPUTED contiguous -// `order`. Every case keeps all todos incomplete with `displayCompleted` true, so -// the visible list `dragTodo` indexes equals the full list. The unknown-id no-op is -// exercised only by the pure transform — `dragTodo` has no such guard. -export const cases = /*@__PURE__*/ Conformance.cases(reorderTodo, { args: { type: "object", properties: { id: Entity.schema } } }, - { - name: "moves the first todo to the end", - before: { entities: new Map(three), displayCompleted: true }, - args: { id: 1, toIndex: 2 }, - after: { - entities: new Map([ - [1, { name: "b", complete: false, order: 0 }], - [2, { name: "c", complete: false, order: 1 }], - [3, { name: "a", complete: false, order: 2 }], - ]), - }, - }, - { - name: "moves the last todo to the front", - before: { entities: new Map(three), displayCompleted: true }, - args: { id: 3, toIndex: 0 }, - after: { - entities: new Map([ - [1, { name: "c", complete: false, order: 0 }], - [2, { name: "a", complete: false, order: 1 }], - [3, { name: "b", complete: false, order: 2 }], - ]), - }, - }, - { - name: "clamps an out-of-range index to the end", - before: { entities: new Map(three), displayCompleted: true }, - args: { id: 1, toIndex: 99 }, - after: { - entities: new Map([ - [1, { name: "b", complete: false, order: 0 }], - [2, { name: "c", complete: false, order: 1 }], - [3, { name: "a", complete: false, order: 2 }], - ]), - }, - }, - { - name: "keeps the order when moving to the same index", - before: { entities: new Map(three), displayCompleted: true }, - args: { id: 2, toIndex: 1 }, - after: { - entities: new Map([ - [1, { name: "a", complete: false, order: 0 }], - [2, { name: "b", complete: false, order: 1 }], - [3, { name: "c", complete: false, order: 2 }], - ]), - }, - }, -); diff --git a/packages/data-lit-todo/src/features/main/data/state/select-todo.cases.ts b/packages/data-lit-todo/src/features/main/data/state/select-todo.cases.ts new file mode 100644 index 000000000..5b8e0c248 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/data/state/select-todo.cases.ts @@ -0,0 +1,40 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Entity } from "@adobe/data/ecs"; +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { selectTodo } from "./select-todo.js"; + +// Inert cases for the pure `selectTodo`. The `args` schema marks `id` as an entity +// reference, and `after` writes `selectedTodo` as the plain spec-id of the referenced +// todo; the round-trip compares the reference up to an id-bijection, so it lines up +// with the same entity's map key on both spec and ecs sides. +export const cases: Conformance.SpecCases = { + args: { type: "object", properties: { id: Entity.schema } }, + cases: [ + { + name: "selects an existing todo", + before: { + entities: new Map([ + [1, { name: "a", complete: false, order: 0 }], + [2, { name: "b", complete: false, order: 1 }], + [3, { name: "c", complete: false, order: 2 }], + ]), + }, + args: { id: 2 }, + after: { selectedTodo: 2 }, + }, + { + name: "leaves the selection unchanged for an id that names no todo", + before: { + entities: new Map([ + [1, { name: "a", complete: false, order: 0 }], + [2, { name: "b", complete: false, order: 1 }], + [3, { name: "c", complete: false, order: 2 }], + ]), + selectedTodo: 1, + }, + args: { id: 99 }, + after: { selectedTodo: 1 }, + }, + ], +}; diff --git a/packages/data-lit-todo/src/features/main/data/state/select-todo.ts b/packages/data-lit-todo/src/features/main/data/state/select-todo.ts index 59cf41c68..c34aa767a 100644 --- a/packages/data-lit-todo/src/features/main/data/state/select-todo.ts +++ b/packages/data-lit-todo/src/features/main/data/state/select-todo.ts @@ -1,7 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Entity } from "@adobe/data/ecs"; +import type { Entity } from "@adobe/data/ecs"; import type { State } from "./state.js"; -import { Conformance } from "./conformance-case.js"; // Point the `selectedTodo` reference at an existing todo. A no-op (selection // unchanged) for an id that names no todo — so selection never dangles. @@ -10,30 +9,3 @@ export const selectTodo = ( { id }: { id: Entity }, ): Pick => state.entities.has(id) ? { selectedTodo: id } : { selectedTodo: state.selectedTodo }; - -const three = new Map([ - [1, { name: "a", complete: false, order: 0 }], - [2, { name: "b", complete: false, order: 1 }], - [3, { name: "c", complete: false, order: 2 }], -]); - -// Spec-owned cases, shared with the ecs `selectTodo` transaction. The `args` schema -// marks `id` as an entity reference (`Entity.schema`), so the runner resolves the -// plain spec-id to the seeded entity on the ecs side. `after` writes `selectedTodo` -// as the PLAIN spec-id of the referenced todo; conformance compares -// it to the ecs (which mints its own ids) up to an id-bijection, so the reference -// lines up with the same entity's map key on both sides with no author bookkeeping. -export const cases = /*@__PURE__*/ Conformance.cases(selectTodo, { args: { type: "object", properties: { id: Entity.schema } } }, - { - name: "selects an existing todo", - before: { entities: three }, - args: { id: 2 }, - after: { selectedTodo: 2 }, - }, - { - name: "leaves the selection unchanged for an id that names no todo", - before: { entities: three, selectedTodo: 1 }, - args: { id: 99 }, - after: { selectedTodo: 1 }, - }, -); 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 e55024de9..ca20b296c 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,14 +1,9 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Conformance } from "@adobe/data-testing"; -import { State } from "./state.js"; -import { transitions } from "./transitions.js"; -import { MainService } from "../../services/main-service/main-service.js"; +import { spec } from "./spec.js"; -// The single pure-spec test for every transform AND derivation 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. Each case's `before`/`input` is a delta over `State.create()`. The `plugin` -// is passed because `State` carries a reference singleton (`selectedTodo`): the pure -// comparison reads its `Entity.schema` mark from the plugin to bijection-match that -// reference, the same way the ecs side does. -Conformance.runSpec({ state: State, transitions, plugin: MainService.plugin }); +// The single pure-spec test for every transform AND derivation in this folder. It +// runs the manifest's inert cases against the pure functions, synthesizing each +// injected service's recording double from the `services` templates + the case's +// `responses`, and asserting `after` (up to an id-bijection) and declared `effects`. +Conformance.checkSpec(spec); diff --git a/packages/data-lit-todo/src/features/main/data/state/spec.ts b/packages/data-lit-todo/src/features/main/data/state/spec.ts new file mode 100644 index 000000000..502078c70 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/data/state/spec.ts @@ -0,0 +1,51 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Conformance } from "@adobe/data-testing"; +import { State } from "./state.js"; +import * as transforms from "./transforms.js"; +import { MainService } from "../../services/main-service/main-service.js"; +import { ComputedDatabase } from "../../services/main-service/computed-database/computed-database.js"; +import { projection } from "../../services/main-service/conformance/projection.js"; +import { createFake as analytics } from "../../services/analytics-service/analytics.fake.js"; +import { createFake as nameGenerator } from "../../services/name-generator-service/name-generator.fake.js"; + +import { cases as createTodo } from "./create-todo.cases.js"; +import { cases as createRandomTodo } from "./create-random-todo.cases.js"; +import { cases as createBulkTodos } from "./create-bulk-todos.cases.js"; +import { cases as deleteTodo } from "./delete-todo.cases.js"; +import { cases as deleteAllTodos } from "./delete-all-todos.cases.js"; +import { cases as toggleComplete } from "./toggle-complete.cases.js"; +import { cases as toggleDisplayCompleted } from "./toggle-display-completed.cases.js"; +import { cases as reorderTodo } from "./reorder-todo.cases.js"; +import { cases as selectTodo } from "./select-todo.cases.js"; +import { cases as visibleTodos } from "./visible-todos.cases.js"; + +// The feature's conformance manifest — the single object `spec.test.ts` (pure) and +// `conformance.test.ts` (ecs) both import. It wires each conformed fn to its inert cases module explicitly: `cases` names one module per conformed fn, +// checked at COMPILE TIME against `transforms` (a fn without cases, or cases without +// a fn, won't type). `services` are the shape-only recording-double templates the +// runner synthesizes returns into from each case's `responses`; `visibleTodos` emits +// entity ids, so it is named in `hydrate` to project each through `toData`. +// +// This is a test-tier module (excluded from the runtime program) — the ONE place the +// feature touches `@adobe/data-testing`. No transform or `*.cases.ts` file does. +export const spec = Conformance.feature({ + state: State, + fns: transforms, + plugin: MainService.plugin, + computedPlugin: ComputedDatabase.plugin, + projection, + hydrate: ["visibleTodos"], + services: { analytics, nameGenerator }, + cases: { + createTodo, + createRandomTodo, + createBulkTodos, + deleteTodo, + deleteAllTodos, + toggleComplete, + toggleDisplayCompleted, + reorderTodo, + selectTodo, + visibleTodos, + }, +}); diff --git a/packages/data-lit-todo/src/features/main/data/state/toggle-complete.cases.ts b/packages/data-lit-todo/src/features/main/data/state/toggle-complete.cases.ts new file mode 100644 index 000000000..0726434c1 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/data/state/toggle-complete.cases.ts @@ -0,0 +1,65 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Entity } from "@adobe/data/ecs"; +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { toggleComplete } from "./toggle-complete.js"; + +// Inert, spec-owned cases for `toggleComplete` — data only: no live service double, +// no test-framework runtime import (the `Conformance` binding is `import type`). The +// `analytics` double is synthesized by the runner from the manifest's `services` +// template; this file states only the calls to expect (`effects`). `before` is a +// delta over `State.create()`; `after` lists the written entities by plain spec-id. +// The `args` schema marks `id` as an entity reference so the ecs side resolves the +// spec-id to its seeded entity. Only the addressed todo's `complete` flips; an +// unknown id is a no-op; the toggle is logged unconditionally. +export const cases: Conformance.SpecCases = { + args: { type: "object", properties: { id: Entity.schema } }, + cases: [ + { + name: "marks an incomplete todo complete", + before: { + entities: new Map([ + [1, { name: "a", complete: false, order: 0 }], + [2, { name: "b", complete: false, order: 1 }], + ]), + }, + args: { id: 1 }, + after: { + entities: new Map([ + [1, { name: "a", complete: true, order: 0 }], + [2, { name: "b", complete: false, order: 1 }], + ]), + }, + effects: { analytics: [["todoToggled"]] }, + }, + { + name: "marks a complete todo incomplete", + before: { + entities: new Map([ + [1, { name: "a", complete: true, order: 0 }], + [2, { name: "b", complete: false, order: 1 }], + ]), + displayCompleted: true, + }, + args: { id: 1 }, + after: { + entities: new Map([ + [1, { name: "a", complete: false, order: 0 }], + [2, { name: "b", complete: false, order: 1 }], + ]), + }, + effects: { analytics: [["todoToggled"]] }, + }, + { + name: "is a no-op for an unknown id but still logs the toggle", + before: { + entities: new Map([[1, { name: "a", complete: false, order: 0 }]]), + }, + args: { id: 99 }, + after: { + entities: new Map([[1, { name: "a", complete: false, order: 0 }]]), + }, + effects: { analytics: [["todoToggled"]] }, + }, + ], +}; 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 a582c0adf..07c5d9e90 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,10 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Entity } from "@adobe/data/ecs"; -import { AnalyticsService } from "../../services/analytics-service/analytics-service.js"; import type { Services } from "../../services/services.js"; import type { State } from "./state.js"; -import type { Todo } from "../todo/todo.js"; -import { Conformance } from "./conformance-case.js"; // Reads the entities, writes the entities — an `{ entities }` patch — flipping the // addressed todo's `complete`; also logs `todoToggled`. @@ -25,59 +21,3 @@ export const toggleComplete = ( }), }; }; - -const two: readonly (readonly [number, Todo])[] = [ - [1, { name: "a", complete: false, order: 0 }], - [2, { name: "b", complete: false, order: 1 }], -]; - -// Spec-owned cases, shared with the ecs `toggleComplete` transaction. `before` is -// a delta over `State.create()` keyed by PLAIN spec-ids; `after` lists the written -// entities with plain spec-id keys. Only the addressed todo's `complete` -// flips; an unknown id is a no-op. The transition logs `todoToggled` -// unconditionally (as the action does). -export const cases = /*@__PURE__*/ Conformance.cases(toggleComplete, { args: { type: "object", properties: { id: Entity.schema } } }, - { - name: "marks an incomplete todo complete", - before: { entities: new Map(two) }, - args: { id: 1, analytics: AnalyticsService.createFake() }, - after: { - entities: new Map([ - [1, { name: "a", complete: true, order: 0 }], - [2, { name: "b", complete: false, order: 1 }], - ]), - }, - effects: { analytics: [["todoToggled"]] }, - }, - { - name: "marks a complete todo incomplete", - before: { - entities: new Map([ - [1, { name: "a", complete: true, order: 0 }], - [2, { name: "b", complete: false, order: 1 }], - ]), - displayCompleted: true, - }, - args: { id: 1, analytics: AnalyticsService.createFake() }, - after: { - entities: new Map([ - [1, { name: "a", complete: false, order: 0 }], - [2, { name: "b", complete: false, order: 1 }], - ]), - }, - effects: { analytics: [["todoToggled"]] }, - }, - { - name: "is a no-op for an unknown id but still logs the toggle", - before: { - entities: new Map([[1, { name: "a", complete: false, order: 0 }]]), - }, - args: { id: 99, analytics: AnalyticsService.createFake() }, - after: { - entities: new Map([ - [1, { name: "a", complete: false, order: 0 }], - ]), - }, - effects: { analytics: [["todoToggled"]] }, - }, -); diff --git a/packages/data-lit-todo/src/features/main/data/state/toggle-display-completed.cases.ts b/packages/data-lit-todo/src/features/main/data/state/toggle-display-completed.cases.ts new file mode 100644 index 000000000..9c0ae8e39 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/data/state/toggle-display-completed.cases.ts @@ -0,0 +1,29 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { toggleDisplayCompleted } from "./toggle-display-completed.js"; + +// Inert cases for `toggleDisplayCompleted` (service-only args — no data `args`). Only +// the flag flips; entities are untouched; `displayCompletedToggled` is logged. +export const cases: Conformance.SpecCases = { + cases: [ + { + name: "turns the completed view on", + before: {}, + after: { displayCompleted: true }, + effects: { analytics: [["displayCompletedToggled"]] }, + }, + { + name: "turns the completed view off, leaving todos intact", + before: { + entities: new Map([[1, { name: "a", complete: true, order: 0 }]]), + displayCompleted: true, + }, + after: { + entities: new Map([[1, { name: "a", complete: true, order: 0 }]]), + displayCompleted: false, + }, + effects: { analytics: [["displayCompletedToggled"]] }, + }, + ], +}; 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 268cd4ac6..a670687c5 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 @@ -1,8 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { AnalyticsService } from "../../services/analytics-service/analytics-service.js"; import type { Services } from "../../services/services.js"; import type { State } from "./state.js"; -import { Conformance } from "./conformance-case.js"; // Reads `displayCompleted`, writes `displayCompleted` — a `{ displayCompleted }` // patch — flipping the flag; also logs `displayCompletedToggled`. @@ -13,34 +11,3 @@ export const toggleDisplayCompleted = ( analytics.displayCompletedToggled(); return { displayCompleted: !state.displayCompleted }; }; - -// Spec-owned cases, shared with the ecs `toggleDisplayCompleted` transaction. -// `before` is a delta over `State.create()`; `after` lists only the written -// `displayCompleted`. Only the flag flips; entities are untouched; the transition -// logs `displayCompletedToggled` (as the action does). -export const cases = /*@__PURE__*/ Conformance.cases(toggleDisplayCompleted, - { - name: "turns the completed view on", - before: {}, - args: { analytics: AnalyticsService.createFake() }, - after: { displayCompleted: true }, - effects: { analytics: [["displayCompletedToggled"]] }, - }, - { - name: "turns the completed view off, leaving todos intact", - before: { - entities: new Map([[1, { name: "a", complete: true, order: 0 }]]), - displayCompleted: true, - }, - args: { analytics: AnalyticsService.createFake() }, - // Only `displayCompleted` is written; the carried-through entity is restated - // with a plain spec-id; the round-trip compares up to an id-bijection. - after: { - entities: new Map([ - [1, { name: "a", complete: true, order: 0 }], - ]), - displayCompleted: false, - }, - effects: { analytics: [["displayCompletedToggled"]] }, - }, -); diff --git a/packages/data-lit-todo/src/features/main/data/state/transforms.ts b/packages/data-lit-todo/src/features/main/data/state/transforms.ts new file mode 100644 index 000000000..4bd1570c7 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/data/state/transforms.ts @@ -0,0 +1,19 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +// The conformed functions of this feature — every pure transform and derivation the +// spec verifies, and nothing else (helpers like `appendTodo`, `create`, `samples` +// are deliberately absent). Its keys ARE the conformance surface: the manifest's +// coverage guard (`Conformance.feature`) requires exactly one `*.cases.ts` module +// per key here, so adding a transform without cases — or cases without a transform — +// fails to compile. This is a per-feature, local barrel (not a cross-feature +// registry), authored where the transforms live. +export { createTodo } from "./create-todo.js"; +export { createRandomTodo } from "./create-random-todo.js"; +export { createBulkTodos } from "./create-bulk-todos.js"; +export { deleteTodo } from "./delete-todo.js"; +export { deleteAllTodos } from "./delete-all-todos.js"; +export { toggleComplete } from "./toggle-complete.js"; +export { toggleDisplayCompleted } from "./toggle-display-completed.js"; +export { reorderTodo } from "./reorder-todo.js"; +export { selectTodo } from "./select-todo.js"; +export { visibleTodos } from "./visible-todos.js"; 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 deleted file mode 100644 index 1092fd915..000000000 --- a/packages/data-lit-todo/src/features/main/data/state/transitions.ts +++ /dev/null @@ -1,11 +0,0 @@ -// © 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/data/state/visible-todos.cases.ts b/packages/data-lit-todo/src/features/main/data/state/visible-todos.cases.ts new file mode 100644 index 000000000..2136aa59a --- /dev/null +++ b/packages/data-lit-todo/src/features/main/data/state/visible-todos.cases.ts @@ -0,0 +1,41 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { visibleTodos } from "./visible-todos.js"; + +// Inert derivation cases for `visibleTodos` — `{ input, value }` pairs. Shared with +// the ecs `visibleTodos` computed (an entity-id-list the runner hydrates through +// `toData` into these id-less values). `input` is keyed by plain spec-ids; `value` +// is id-less content in significant display order. +export const cases: Conformance.SpecDerivations = { + cases: [ + { + name: "hides completed todos unless the completed view is on", + input: { + entities: new Map([ + [1, { name: "a", complete: false, order: 0 }], + [2, { name: "b", complete: true, order: 1 }], + [3, { name: "c", complete: false, order: 2 }], + ]), + displayCompleted: false, + }, + value: [ + { name: "a", complete: false, order: 0 }, + { name: "c", complete: false, order: 2 }, + ], + }, + { + name: "shows every todo when the completed view is on", + input: { + entities: new Map([ + [1, { name: "a", complete: false, order: 0 }], + [2, { name: "b", complete: true, order: 1 }], + ]), + displayCompleted: true, + }, + value: [ + { name: "a", complete: false, order: 0 }, + { name: "b", complete: true, order: 1 }, + ], + }, + ], +}; 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 efb35e480..7bf0c0029 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 @@ -1,7 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { State } from "./state.js"; import type { Todo } from "../todo/todo.js"; -import { Conformance } from "./conformance-case.js"; // The todos the user should see, in display order (ascending `order`): all of them // when `displayCompleted`, otherwise only the incomplete ones. Yields id-less // values — identity is the `entities` key, so the visible list compares by content. @@ -11,39 +10,3 @@ export const visibleTodos = ( [...state.entities.values()] .filter((todo) => state.displayCompleted || !todo.complete) .sort((a, b) => a.order - b.order); - -// Spec-owned cases, shared with the ecs `visibleTodos` computed (an entity-id-list -// computed the runner hydrates through `toData` into these id-less values). A -// derivation case is `{ input, value }`; `input` is keyed by PLAIN spec-ids; -// `value` is id-less content in significant display order. -export const cases = /*@__PURE__*/ Conformance.derivations(visibleTodos, - { - name: "hides completed todos unless the completed view is on", - input: { - entities: new Map([ - [1, { name: "a", complete: false, order: 0 }], - [2, { name: "b", complete: true, order: 1 }], - [3, { name: "c", complete: false, order: 2 }], - ]), - displayCompleted: false, - }, - value: [ - { name: "a", complete: false, order: 0 }, - { name: "c", complete: false, order: 2 }, - ], - }, - { - name: "shows every todo when the completed view is on", - input: { - entities: new Map([ - [1, { name: "a", complete: false, order: 0 }], - [2, { name: "b", complete: true, order: 1 }], - ]), - displayCompleted: true, - }, - value: [ - { name: "a", complete: false, order: 0 }, - { name: "b", complete: true, order: 1 }, - ], - }, -); diff --git a/packages/data-lit-todo/src/features/main/services/analytics-service/analytics.fake.ts b/packages/data-lit-todo/src/features/main/services/analytics-service/analytics.fake.ts new file mode 100644 index 000000000..2fab83c45 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/services/analytics-service/analytics.fake.ts @@ -0,0 +1,23 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { AnalyticsService } from "./analytics-service.js"; + +/** + * Shape-only recording template for {@link AnalyticsService} — a TEST-TIER double, + * NOT re-exported through `public.ts`, so it can never be reached as + * `AnalyticsService.createFake` from runtime code. Conformance calls it once purely + * to enumerate the service's methods; the RETURNS come from each case's `responses` + * (e.g. the `randomTodoRequested` timing the case owns), so this file no longer + * hardcodes any value a case then has to guess at. Imported directly by the + * conformance manifest (`data/state/spec.ts`) and nowhere else. + */ +export const createFake = (): AnalyticsService => ({ + serviceName: "analytics", + todoCreated: () => {}, + bulkTodosCreated: () => {}, + todoToggled: () => {}, + todoDeleted: () => {}, + allTodosCleared: () => {}, + displayCompletedToggled: () => {}, + randomTodoRequested: () => Promise.resolve({ startedAt: 0 }), + randomTodoAdded: () => {}, +}); 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 deleted file mode 100644 index da40c49c8..000000000 --- a/packages/data-lit-todo/src/features/main/services/analytics-service/create-fake.ts +++ /dev/null @@ -1,22 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { AnalyticsService } from "./analytics-service.js"; - -/** - * Deterministic test double for {@link AnalyticsService}. The `void` - * 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", - todoCreated: () => {}, - bulkTodosCreated: () => {}, - todoToggled: () => {}, - todoDeleted: () => {}, - allTodosCleared: () => {}, - displayCompletedToggled: () => {}, - 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 56c941d3d..6523855b6 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,2 @@ // © 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/services/main-service/conformance/conformance.test.ts b/packages/data-lit-todo/src/features/main/services/main-service/conformance/conformance.test.ts index b3a196f5f..86fd6af93 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,23 +1,11 @@ // © 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"; +import { spec } from "../../../data/state/spec.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. -// `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, - plugin: MainService.plugin, - computedPlugin: ComputedDatabase.plugin, - projection, - hydrate: ["visibleTodos"], -}); +// The whole ecs conformance for this feature in one call: `checkFeature` replays the +// manifest's shared cases against the transactions/actions on `MainService.plugin` +// and the computeds on `ComputedDatabase`, seeds each `before` over `State.create()`, +// round-trips `State.samples` through the `projection`, and hydrates the id-emitting +// `visibleTodos` computed through `toData`. It shares `spec` with `spec.test.ts` — +// same cases, substituted implementation. +Conformance.checkFeature(spec); 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 index 26ab694f9..98e0b7163 100644 --- 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 @@ -17,7 +17,7 @@ const toData = (store: CoreDatabase.Store, entity: Entity): Todo => { return { name: row.name, complete: row.complete, order: row.order }; }; -// The test-only ecs↔`State` projection, passed to `Conformance.runFeature`. +// The test-only ecs↔`State` projection, carried on the manifest and used by `Conformance.checkFeature`. // `fromState` seeds a store to a `State` (clearing every todo, setting the // `displayCompleted` resource, then inserting each entity with its own `order`; // the implementation-only slots (`dragPosition`, `assignees`) and the `todo` tag 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 deleted file mode 100644 index 67a55ea78..000000000 --- a/packages/data-lit-todo/src/features/main/services/name-generator-service/create-fake.ts +++ /dev/null @@ -1,24 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { NameGeneratorService } from "./name-generator-service.js"; - -/** - * Deterministic test double for {@link NameGeneratorService}. Unlike the real - * generator it uses no randomness and no timers: `generateName` returns the - * `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[] = ["a task"], -): NameGeneratorService => { - let index = 0; - return { - serviceName: "nameGenerator", - generateName: () => { - const name = responses[index % responses.length]; - index += 1; - return Promise.resolve(name); - }, - }; -}; diff --git a/packages/data-lit-todo/src/features/main/services/name-generator-service/name-generator.fake.ts b/packages/data-lit-todo/src/features/main/services/name-generator-service/name-generator.fake.ts new file mode 100644 index 000000000..4b7913667 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/services/name-generator-service/name-generator.fake.ts @@ -0,0 +1,14 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { NameGeneratorService } from "./name-generator-service.js"; + +/** + * Shape-only recording template for {@link NameGeneratorService} — a TEST-TIER + * double, NOT re-exported through `public.ts`. Conformance calls it once to + * enumerate the service's methods; `generateName`'s returns come from each case's + * `responses` schedule, not from any behavior here (the old double cycled a default + * list, silently masking over-calls). Imported directly by the conformance manifest. + */ +export const createFake = (): NameGeneratorService => ({ + serviceName: "nameGenerator", + generateName: () => Promise.resolve(""), +}); 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 56c941d3d..6523855b6 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,2 @@ // © 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/tsconfig.build.json b/packages/data-lit-todo/tsconfig.build.json new file mode 100644 index 000000000..1395b61d5 --- /dev/null +++ b/packages/data-lit-todo/tsconfig.build.json @@ -0,0 +1,29 @@ +{ + // The RUNTIME wall (dependency isolation). It compiles the shippable data + services + // tiers as a `composite` project, so a data/ or services/ runtime file that imports a + // test-tier file (`*.cases.ts`, `*.fake.ts`, `spec.ts`, anything under `conformance/`) + // is a COMPILE ERROR (TS6307: not in this project's file list). `ui/` and the app-shell + // (`src/*.ts`) live in the test project instead — they import test-tier only in their own + // `*.test.ts`, and keeping their (often very large) inferred element/plugin types out of + // this project avoids forcing them into serialized declarations. Emits declarations only, + // to a throwaway dir, purely to satisfy the composite contract `tsconfig.test.json` refs. + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "./.tsbuild", + "rootDir": "src" + }, + "include": ["src/features/**/*"], + "exclude": [ + "**/ui/**", + "**/*.test.ts", + "**/*.type-test.ts", + "**/*.cases.ts", + "**/*.fake.ts", + "**/spec.ts", + "**/conformance/**" + ] +} diff --git a/packages/data-lit-todo/tsconfig.test.json b/packages/data-lit-todo/tsconfig.test.json new file mode 100644 index 000000000..f328ac324 --- /dev/null +++ b/packages/data-lit-todo/tsconfig.test.json @@ -0,0 +1,26 @@ +{ + // The NON-EMITTING tier: everything the runtime wall (`tsconfig.build.json`) doesn't own — + // the test-tier (conformance manifest + inert cases, service fakes, `*.test.ts`, + // `conformance/`), plus `ui/` and the app-shell (`src/*.ts`). It `references` the runtime + // project, so its imports of data/services resolve through that project's declarations, + // and it alone may see `@adobe/data-testing`. Nothing here emits, so large element/plugin + // types are type-checked without being serialized. `tsc -b tsconfig.test.json` builds the + // wall first, then type-checks this tier — together covering all of `src`. + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": false, + "noEmit": true + }, + "include": [ + "src/*.ts", + "src/**/ui/**/*", + "src/**/*.test.ts", + "src/**/*.type-test.ts", + "src/**/*.cases.ts", + "src/**/*.fake.ts", + "src/**/spec.ts", + "src/**/conformance/**/*" + ], + "exclude": [], + "references": [{ "path": "./tsconfig.build.json" }] +} diff --git a/packages/data-lit/package.json b/packages/data-lit/package.json index 7a26884b4..aeb839542 100644 --- a/packages/data-lit/package.json +++ b/packages/data-lit/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-lit", - "version": "0.10.19", + "version": "0.10.20", "description": "Adobe data Lit bindings - hooks, elements, decorators", "type": "module", "private": false, diff --git a/packages/data-p2p-tictactoe/.gitignore b/packages/data-p2p-tictactoe/.gitignore new file mode 100644 index 000000000..c18d52ae7 --- /dev/null +++ b/packages/data-p2p-tictactoe/.gitignore @@ -0,0 +1 @@ +.tsbuild/ diff --git a/packages/data-p2p-tictactoe/package.json b/packages/data-p2p-tictactoe/package.json index 1fd8b6160..c8c525155 100644 --- a/packages/data-p2p-tictactoe/package.json +++ b/packages/data-p2p-tictactoe/package.json @@ -8,7 +8,7 @@ "dev": "vite", "build": "vite build", "test": "vitest --run --passWithNoTests", - "typecheck": "tsc -p tsconfig.json --noEmit" + "typecheck": "tsc -b tsconfig.test.json" }, "dependencies": { "@adobe/data": "workspace:*", 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 deleted file mode 100644 index 255ecbfdd..000000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/conformance-case.ts +++ /dev/null @@ -1,13 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { 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` -// 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/enter-game.cases.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/enter-game.cases.ts new file mode 100644 index 000000000..de378a2d4 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/enter-game.cases.ts @@ -0,0 +1,17 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { enterGame } from "./enter-game.js"; + +// Inert, spec-owned cases for `enterGame`, shared with the ecs `setGameDb` +// transaction (a differently named transaction whose visible effect is exactly +// this) and the `enterGame` action. A no-arg transition. +export const cases: Conformance.SpecCases = { + cases: [ + { + name: "moves to the game phase, connected", + before: { phase: "host-signaling", connection: "connecting", role: "host" }, + after: { phase: "game", connection: "connected" }, + }, + ], +}; 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 37558aa61..477723676 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,6 +1,5 @@ // © 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 @@ -13,23 +12,3 @@ 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/set-answer-code.cases.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-answer-code.cases.ts new file mode 100644 index 000000000..c2107ee35 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-answer-code.cases.ts @@ -0,0 +1,17 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { setAnswerCode } from "./set-answer-code.js"; + +// Inert, spec-owned cases for `setAnswerCode`, shared with the ecs `setAnswerCode` +// transaction and action. +export const cases: Conformance.SpecCases = { + cases: [ + { + name: "stores the answer code and clears the banner", + before: { bannerText: "Generating answer — please wait…" }, + args: { code: "ANSWER-456" }, + after: { answerCode: "ANSWER-456", bannerText: "" }, + }, + ], +}; 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 f0df080b4..1f1c81725 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,27 +1,8 @@ // © 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.cases.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-banner.cases.ts new file mode 100644 index 000000000..cc93dc972 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-banner.cases.ts @@ -0,0 +1,23 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { setBanner } from "./set-banner.js"; + +// Inert, spec-owned cases for `setBanner`, shared with the ecs `setBanner` +// transaction and action. +export const cases: Conformance.SpecCases = { + cases: [ + { + name: "sets an informational banner", + before: {}, + args: { text: "Generating answer — please wait…" }, + after: { bannerText: "Generating answer — please wait…" }, + }, + { + name: "sets an error banner", + before: {}, + args: { text: "Connection failed: boom", error: true }, + after: { bannerText: "Connection failed: boom", bannerError: true }, + }, + ], +}; 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 37ae7730f..40cca2121 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,41 +1,8 @@ // © 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.cases.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-connection.cases.ts new file mode 100644 index 000000000..51e48ef83 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-connection.cases.ts @@ -0,0 +1,23 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { setConnection } from "./set-connection.js"; + +// Inert, spec-owned cases for `setConnection`, shared with the ecs `setConnection` +// transaction and action. Omitting `sessionId` leaves the existing value untouched. +export const cases: Conformance.SpecCases = { + cases: [ + { + name: "records a connected state with a session id", + before: { connection: "connecting" }, + args: { connection: "connected", sessionId: "sess-1" }, + after: { connection: "connected", sessionId: "sess-1" }, + }, + { + name: "updates only the connection when no session id is supplied", + before: { connection: "connected", sessionId: "sess-1" }, + args: { connection: "disconnected" }, + after: { connection: "disconnected" }, + }, + ], +}; 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 cc706e1de..a02921417 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,7 +1,6 @@ // © 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. @@ -15,35 +14,3 @@ 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.cases.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-host-answer-input.cases.ts new file mode 100644 index 000000000..c6660f5e8 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-host-answer-input.cases.ts @@ -0,0 +1,17 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { setHostAnswerInput } from "./set-host-answer-input.js"; + +// Inert, spec-owned cases for `setHostAnswerInput`, shared with the ecs +// `setHostAnswerInput` transaction and action. +export const cases: Conformance.SpecCases = { + cases: [ + { + name: "stores the host answer input", + before: {}, + args: { value: "ANSWER-abc" }, + after: { hostAnswerInput: "ANSWER-abc" }, + }, + ], +}; 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 09072188e..5a33263fd 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,27 +1,8 @@ // © 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.cases.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-joiner-offer-input.cases.ts new file mode 100644 index 000000000..99903f98f --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-joiner-offer-input.cases.ts @@ -0,0 +1,17 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { setJoinerOfferInput } from "./set-joiner-offer-input.js"; + +// Inert, spec-owned cases for `setJoinerOfferInput`, shared with the ecs +// `setJoinerOfferInput` transaction and action. +export const cases: Conformance.SpecCases = { + cases: [ + { + name: "stores the joiner offer input", + before: {}, + args: { value: "OFFER-xyz" }, + after: { joinerOfferInput: "OFFER-xyz" }, + }, + ], +}; 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 1dcef0389..33f7b2a6d 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,27 +1,8 @@ // © 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.cases.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-offer-code.cases.ts new file mode 100644 index 000000000..5a5a6dcc0 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/set-offer-code.cases.ts @@ -0,0 +1,17 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { setOfferCode } from "./set-offer-code.js"; + +// Inert, spec-owned cases for `setOfferCode`, shared with the ecs `setOfferCode` +// transaction and action. +export const cases: Conformance.SpecCases = { + cases: [ + { + name: "stores the offer code and clears the banner", + before: { bannerText: "please wait" }, + args: { code: "OFFER-123" }, + after: { offerCode: "OFFER-123", bannerText: "" }, + }, + ], +}; 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 af6107d20..8f3436ee0 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,27 +1,8 @@ // © 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 index f03dac441..e6614eb52 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,10 +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"; +import { spec } from "./spec.js"; -// The single pure-spec test for every transform AND derivation 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. Each case's `before`/`input` is a delta over `State.create()`. -Conformance.runSpec({ state: State, transitions }); +// The single pure-spec test for every transform in this folder — runs the manifest's +// inert cases against the pure functions, seeding each case's `before` over +// `State.create()` and asserting the `after` writes patch. +Conformance.checkSpec(spec); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/spec.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/spec.ts new file mode 100644 index 000000000..d57500e66 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/spec.ts @@ -0,0 +1,52 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +/// +import { Conformance } from "@adobe/data-testing"; +import { State } from "./state.js"; +import * as transforms from "./transforms.js"; +import { MainService } from "../../services/main-service/main-service.js"; +import { projection } from "../../services/main-service/conformance/projection.js"; + +import { cases as startHostSignaling } from "./start-host-signaling.cases.js"; +import { cases as startJoinSignaling } from "./start-join-signaling.cases.js"; +import { cases as setOfferCode } from "./set-offer-code.cases.js"; +import { cases as setAnswerCode } from "./set-answer-code.cases.js"; +import { cases as setBanner } from "./set-banner.cases.js"; +import { cases as setConnection } from "./set-connection.cases.js"; +import { cases as setHostAnswerInput } from "./set-host-answer-input.cases.js"; +import { cases as setJoinerOfferInput } from "./set-joiner-offer-input.cases.js"; +import { cases as enterGame } from "./enter-game.cases.js"; + +// The negotiation conformance manifest — the single object `spec.test.ts` (pure) and +// `conformance.test.ts` (ecs) both import. No services (none of the pure transitions +// inject one — the signaling capability is driven imperatively from the connection +// service, not from a conformed transition), and no derivations (no `computedPlugin`/ +// `hydrate`). Negotiation's per-transition actions are deliberately kept OUT of the +// plugin's `actions` facet (registering them would grow the composed database's type +// past tsc's instantiation budget), so they are supplied to `checkFeature` via the +// `ops.actions` directory glob rather than discovered off `plugin.actions`. +// +// A test-tier module (excluded from the runtime build) — the one place the feature +// touches `@adobe/data-testing`. +export const spec = Conformance.feature({ + state: State, + fns: transforms, + plugin: MainService.plugin, + projection, + cases: { + startHostSignaling, + startJoinSignaling, + setOfferCode, + setAnswerCode, + setBanner, + setConnection, + setHostAnswerInput, + setJoinerOfferInput, + enterGame, + }, + ops: { + actions: import.meta.glob( + ["../../services/main-service/action-database/actions/*.ts", "!**/index.ts"], + { eager: true }, + ), + }, +}); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/start-host-signaling.cases.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/start-host-signaling.cases.ts new file mode 100644 index 000000000..19b5d7e50 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/start-host-signaling.cases.ts @@ -0,0 +1,23 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { startHostSignaling } from "./start-host-signaling.js"; + +// Inert, spec-owned cases for `startHostSignaling` — data only, `Conformance` is +// `import type`. Shared with the ecs `startHostSignaling` transaction and action. A +// no-arg transition, so no `args`. `before` is a delta over `State.create()`; +// `after` is the writes patch. +export const cases: Conformance.SpecCases = { + cases: [ + { + name: "enters host-signaling as host, connecting, with a waiting banner", + before: {}, + after: { + phase: "host-signaling", + role: "host", + connection: "connecting", + bannerText: "Generating invite code — please wait…", + }, + }, + ], +}; 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 b6c766756..602f10961 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,6 +1,5 @@ // © 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 => ({ @@ -11,22 +10,3 @@ 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.cases.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/start-join-signaling.cases.ts new file mode 100644 index 000000000..42a428afe --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/start-join-signaling.cases.ts @@ -0,0 +1,23 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { startJoinSignaling } from "./start-join-signaling.js"; + +// Inert, spec-owned cases for `startJoinSignaling`, shared with the ecs +// `startJoinSignaling` transaction and action. A no-arg transition; it clears any +// stale banner on entry. +export const cases: Conformance.SpecCases = { + cases: [ + { + name: "enters join-signaling as joiner, connecting, clearing the banner", + before: { bannerText: "stale", bannerError: true }, + after: { + phase: "join-signaling", + role: "joiner", + connection: "connecting", + bannerText: "", + bannerError: false, + }, + }, + ], +}; 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 5c9018f63..e63fc2e7e 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,6 +1,5 @@ // © 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 => ({ @@ -11,22 +10,3 @@ 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/data/state/transforms.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/transforms.ts new file mode 100644 index 000000000..381585d78 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/transforms.ts @@ -0,0 +1,16 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +// The conformed functions of this feature — every pure transform the spec verifies, +// and nothing else (`create`/`samples` are deliberately absent). Its keys ARE the +// conformance surface: `Conformance.feature`'s coverage guard requires exactly one +// `*.cases.ts` module per key here, so adding a transform without cases — or cases +// without a transform — fails to compile. +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/transitions.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/state/transitions.ts deleted file mode 100644 index 1092fd915..000000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/state/transitions.ts +++ /dev/null @@ -1,11 +0,0 @@ -// © 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 82cd1e03a..6add10c66 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 @@ -1,29 +1,10 @@ // © 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"; +import { spec } from "../../../data/state/spec.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 -// (`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, - plugin: MainService.plugin, - projection, - ops: { - actions: import.meta.glob( - ["../action-database/actions/*.ts", "!**/index.ts"], - { eager: true }, - ), - }, -}); +// The whole ecs conformance for this feature in one call: `checkFeature` replays the +// manifest's shared cases against the transactions on `MainService.plugin` and the +// per-transition actions supplied via `spec.ops.actions`, seeds each case's `before` +// over `State.create()`, and round-trips `State.samples` through the projection. It +// shares `spec` with `spec.test.ts` — same cases, substituted implementation. +Conformance.checkFeature(spec); 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 index 5b70e5a86..0a25a4eef 100644 --- 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 @@ -2,7 +2,7 @@ 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`. +// The test-only store↔`State` projection, carried on the conformance manifest. // 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 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 acec12bc0..f3902a55e 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 @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest"; import { Database } from "@adobe/data/ecs"; import { TransactionDatabase } from "../../transaction-database/transaction-database.js"; -import { SignalingService } from "../../../signaling-service/signaling-service.js"; +import { createFake } from "../../../signaling-service/signaling.fake.js"; import { createConnectionService } from "./create-connection-service.js"; // A microtask/timer flush so the fake signaling promises resolve. @@ -11,7 +11,7 @@ const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); describe("connection service (deterministic fake signaling)", () => { it("startHost enters host-signaling and records the invite code", async () => { const db = Database.create(TransactionDatabase.plugin); - const service = createConnectionService(db, SignalingService.createFake); + const service = createConnectionService(db, createFake); service.startHost(); expect(db.resources.phase).toBe("host-signaling"); @@ -24,7 +24,7 @@ describe("connection service (deterministic fake signaling)", () => { it("generateAnswer records the answer code", async () => { const db = Database.create(TransactionDatabase.plugin); - const service = createConnectionService(db, SignalingService.createFake); + const service = createConnectionService(db, createFake); db.transactions.setJoinerOfferInput({ value: "OFFER-xyz" }); service.startJoin(); diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/service-database/services/create-connection-service.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/service-database/services/create-connection-service.ts index 7583bc734..e2535c613 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/service-database/services/create-connection-service.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/services/main-service/service-database/services/create-connection-service.ts @@ -46,8 +46,8 @@ const serverLog = (msg: string) => console.log(`[sync-server] ${msg}`); /** * Create the connection service bound to its database. `signalingFactory` - * defaults to the production {@link SignalingService.create}; tests inject - * {@link SignalingService.createFake} for deterministic code exchange. + * defaults to the production {@link SignalingService.create}; tests inject the + * shape-only `createFake` from `signaling.fake.ts` for deterministic code exchange. */ export const createConnectionService = ( db: TransactionDatabase, 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 deleted file mode 100644 index 0f865d2f6..000000000 --- a/packages/data-p2p-tictactoe/src/features/negotiation/services/signaling-service/create-fake.ts +++ /dev/null @@ -1,25 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { SignalingService } from "./signaling-service.js"; -import type { Handlers } from "./types.js"; - -/** - * Deterministic test double for {@link SignalingService}. Unlike the real - * service it performs no WebRTC I/O: `createHostInvite` always resolves - * `"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. 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("fake-invite-code"), - acceptHostAnswer: () => Promise.resolve(), - 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 3e5a7d568..826f850ee 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,3 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. export type * from "./types.js"; export { create } from "./create.js"; -export { createFake } from "./create-fake.js"; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/services/signaling-service/signaling.fake.ts b/packages/data-p2p-tictactoe/src/features/negotiation/services/signaling-service/signaling.fake.ts new file mode 100644 index 000000000..6e3baebb5 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/negotiation/services/signaling-service/signaling.fake.ts @@ -0,0 +1,20 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { SignalingService } from "./signaling-service.js"; + +/** + * Shape-only recording template for {@link SignalingService} — a TEST-TIER double, + * NOT re-exported through `public.ts`, so it can never be reached as + * `SignalingService.createFake` from runtime code. Every method is present: `void` + * methods are inert, and the value-returning code-exchange methods hand back fixed + * placeholder codes (it performs no WebRTC I/O and never fires `onConnected`). It + * takes no handlers/response parameters. Imported directly by the negotiation + * main-service tests (and the connection-service unit test) and nowhere else. + */ +export const createFake = (): SignalingService => ({ + serviceName: "signaling", + createHostInvite: () => Promise.resolve("fake-invite-code"), + acceptHostAnswer: () => Promise.resolve(), + createJoinAnswer: () => Promise.resolve("fake-answer-code"), + reset: () => {}, + dispose: () => {}, +}); 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 deleted file mode 100644 index 255ecbfdd..000000000 --- a/packages/data-p2p-tictactoe/src/features/presence/data/state/conformance-case.ts +++ /dev/null @@ -1,13 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { 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` -// 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/move-presence.cases.ts b/packages/data-p2p-tictactoe/src/features/presence/data/state/move-presence.cases.ts new file mode 100644 index 000000000..f5f62d912 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/presence/data/state/move-presence.cases.ts @@ -0,0 +1,28 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Vec2 } from "@adobe/data/math"; +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { movePresence } from "./move-presence.js"; + +const at = (x: number, y: number): Vec2 => [x, y] as Vec2; + +// Inert cases for `movePresence`. `mark` is a plain data arg (the peer's mark); on the +// ecs side the escape-hatch runner seeds it as the transaction `userId` via +// `seedContext` (see conformance/actions.test.ts). `before` is a delta over +// `State.create()`; cursor positions are `Vec2` tuples compared in order. +export const cases: Conformance.SpecCases = { + cases: [ + { + name: "records the first cursor position for a peer", + before: {}, + 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/move-presence.ts b/packages/data-p2p-tictactoe/src/features/presence/data/state/move-presence.ts index 36173d8e7..ec9fbc588 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,37 +1,16 @@ // © 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 - * ecs implementation it is injected from the transaction's `userId` (the peer's - * assigned mark), so each peer only ever writes its own entry. + * Update the given peer's cursor position — a `{ cursors }` patch. `mark` identifies + * the peer; in the ecs implementation it is injected from the transaction's `userId` + * (the peer's assigned mark), so each peer only ever writes its own entry. */ -export const movePresence = ( - state: T, +export const movePresence = ( + state: Pick, { mark, x, y }: { mark: PlayerMark; x: number; y: number }, -): T => ({ - ...state, +): Pick => ({ 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/spec.test.ts b/packages/data-p2p-tictactoe/src/features/presence/data/state/spec.test.ts index 64c2f5a68..e627bf405 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,10 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Conformance } from "@adobe/data-testing"; -import { transitions } from "./transitions.js"; +import { spec } from "./spec.js"; -// The single pure-spec test for every transform AND derivation 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. 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 }); +// The pure-spec test for every transform in this folder — runs the manifest's inert +// cases against the pure functions. Presence cursor positions are `Vec2` tuples +// (compared in order); `cursors` compares by key. +Conformance.checkSpec(spec); diff --git a/packages/data-p2p-tictactoe/src/features/presence/data/state/spec.ts b/packages/data-p2p-tictactoe/src/features/presence/data/state/spec.ts new file mode 100644 index 000000000..60221ff6e --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/presence/data/state/spec.ts @@ -0,0 +1,20 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Conformance } from "@adobe/data-testing"; +import { State } from "./state.js"; +import * as transforms from "./transforms.js"; +import { MainService } from "../../services/main-service/main-service.js"; +import { projection } from "../../services/main-service/conformance/projection.js"; +import { cases as movePresence } from "./move-presence.cases.js"; + +// The presence conformance manifest. No services (the peer `mark` is ambient `userId`, +// not an injected service), no derivations (no `computedPlugin`/`hydrate`). The pure +// side runs through `checkSpec`; the ecs side needs per-surface `seedContext` + a custom +// concurrency, so it drives the lower runners via `Conformance.adaptCases(spec, …)` +// (see the conformance/ escape-hatch tests) rather than `checkFeature`. +export const spec = Conformance.feature({ + state: State, + fns: transforms, + plugin: MainService.plugin, + projection, + cases: { movePresence }, +}); diff --git a/packages/data-p2p-tictactoe/src/features/presence/data/state/transforms.ts b/packages/data-p2p-tictactoe/src/features/presence/data/state/transforms.ts new file mode 100644 index 000000000..6af82af62 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/presence/data/state/transforms.ts @@ -0,0 +1,4 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +// The conformed functions of the presence feature — just the one cursor transition. +export { movePresence } from "./move-presence.js"; 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 deleted file mode 100644 index 1092fd915..000000000 --- a/packages/data-p2p-tictactoe/src/features/presence/data/state/transitions.ts +++ /dev/null @@ -1,11 +0,0 @@ -// © 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 89d412a8c..9561ccb63 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,22 +3,18 @@ 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 { spec } from "../../../data/state/spec.js"; import { MainService } from "../main-service.js"; -import { fromState } from "./from-state.js"; -import { toState } from "./to-state.js"; +import { projection } from "./projection.js"; -// `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. +// The escape hatch (see conformance.md): `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. The per-transition +// `movePresence` action isn't in the facet barrel (the UI streams via `trackPresence`), +// so it's discovered via the actions glob. Cases come from the shared manifest. let peerUserId: string | undefined; -const peerConcurrency: ConcurrencyStrategyFactory = ( - execute, - getTransaction, -) => ({ +const peerConcurrency: ConcurrencyStrategyFactory = (execute, getTransaction) => ({ deferredCommit: false, apply: (envelope) => { if (envelope.time === 0) return undefined; @@ -35,18 +31,17 @@ const peerConcurrency: ConcurrencyStrategyFactory = ( Conformance.runActions({ makeDb: () => - Database.toSystemDatabase( - Database.create(MainService.plugin, { concurrency: peerConcurrency }), - ), + Database.toSystemDatabase(Database.create(MainService.plugin, { concurrency: peerConcurrency })), store: (db) => db.store, - fromState, - toState, - transitions, + fromState: projection.fromState, + toState: projection.toState, + transitions: Conformance.adaptCases(spec.fns, spec.cases, spec.services, true), actions: import.meta.glob( ["../action-database/actions/*.ts", "!../action-database/actions/index.ts"], { eager: true }, ), seedContext: (_db, _before, args) => { + // Runtime invariant: presence cases carry a `mark` (see move-presence.cases.ts). peerUserId = (args as { mark: string }).mark; }, }); 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 deleted file mode 100644 index e8748c6e6..000000000 --- a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/from-state.ts +++ /dev/null @@ -1,16 +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 match a `data/` presence `State`. The inverse of `toState`. -// 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 1e891c563..86b31d853 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,22 +1,21 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. // // 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. +// Presence conforms its ecs via the lower-level runners (see conformance.md), so — unlike +// a `checkFeature` 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 { 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"; +import { projection } from "./projection.js"; describe("presence conformance projection round-trips (toState ∘ fromState ≡ identity)", () => { State.samples.forEach((state, i) => { it(`sample ${i}`, () => { const store = createStore(); - fromState(store, state); - Match.assert(toState(store), state); + projection.fromState(store, state); + Match.assert(projection.toState(store), state); }); }); }); diff --git a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/projection.ts b/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/projection.ts new file mode 100644 index 000000000..dd2ef1f97 --- /dev/null +++ b/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/projection.ts @@ -0,0 +1,19 @@ +// © 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 store ⇄ `State` projection. Presence keeps its whole state in the +// `cursors` resource — there are no entity collections — so `fromState` seeds that one +// resource and returns an empty `id → entity` map (the runners' `resolve` is never +// used), and there is no per-entity `toData`. Shared by the manifest (`data/state/spec.ts`) +// and, because presence conforms its ecs via the lower-level runners, by the escape-hatch +// tests and the projection round-trip test. +const fromState = (store: CoreDatabase.Store, state: State): ReadonlyMap => { + store.resources.cursors = state.cursors; + return new Map(); +}; + +const toState = (store: CoreDatabase.Store): State => ({ cursors: store.resources.cursors }); + +export const projection = { fromState, toState }; diff --git a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/to-state.ts b/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/to-state.ts deleted file mode 100644 index de3f881b9..000000000 --- a/packages/data-p2p-tictactoe/src/features/presence/services/main-service/conformance/to-state.ts +++ /dev/null @@ -1,9 +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/` presence `State`. The inverse of `fromState`. -// Test-only. -export const toState = (store: CoreDatabase.Store): State => ({ - cursors: store.resources.cursors, -}); 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 6c0749b0f..2cbde9c25 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,21 +1,23 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Conformance } from "@adobe/data-testing"; -import { transitions } from "../../../data/state/transitions.js"; +import { spec } from "../../../data/state/spec.js"; import * as transactions from "../transaction-database/transactions/index.js"; import { createStore } from "./create-store.js"; +import { projection } from "./projection.js"; import { seedUserId } from "./seed-user-id.js"; -import { fromState } from "./from-state.js"; -import { toState } from "./to-state.js"; -// `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). +// The escape hatch (see conformance.md): `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 +// cases come from the shared manifest via `adaptCases` (services off — a transaction +// never receives services). Conformance.runTransactions({ createStore, - fromState, - toState, - transitions, + fromState: projection.fromState, + toState: projection.toState, + transitions: Conformance.adaptCases(spec.fns, spec.cases, spec.services, false), transactions, seedContext: (store, _before, args) => + // Runtime invariant: presence cases carry a `mark` (see move-presence.cases.ts). seedUserId(store, (args as { mark: string }).mark), }); diff --git a/packages/data-p2p-tictactoe/tsconfig.build.json b/packages/data-p2p-tictactoe/tsconfig.build.json new file mode 100644 index 000000000..1395b61d5 --- /dev/null +++ b/packages/data-p2p-tictactoe/tsconfig.build.json @@ -0,0 +1,29 @@ +{ + // The RUNTIME wall (dependency isolation). It compiles the shippable data + services + // tiers as a `composite` project, so a data/ or services/ runtime file that imports a + // test-tier file (`*.cases.ts`, `*.fake.ts`, `spec.ts`, anything under `conformance/`) + // is a COMPILE ERROR (TS6307: not in this project's file list). `ui/` and the app-shell + // (`src/*.ts`) live in the test project instead — they import test-tier only in their own + // `*.test.ts`, and keeping their (often very large) inferred element/plugin types out of + // this project avoids forcing them into serialized declarations. Emits declarations only, + // to a throwaway dir, purely to satisfy the composite contract `tsconfig.test.json` refs. + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "./.tsbuild", + "rootDir": "src" + }, + "include": ["src/features/**/*"], + "exclude": [ + "**/ui/**", + "**/*.test.ts", + "**/*.type-test.ts", + "**/*.cases.ts", + "**/*.fake.ts", + "**/spec.ts", + "**/conformance/**" + ] +} diff --git a/packages/data-p2p-tictactoe/tsconfig.test.json b/packages/data-p2p-tictactoe/tsconfig.test.json new file mode 100644 index 000000000..f328ac324 --- /dev/null +++ b/packages/data-p2p-tictactoe/tsconfig.test.json @@ -0,0 +1,26 @@ +{ + // The NON-EMITTING tier: everything the runtime wall (`tsconfig.build.json`) doesn't own — + // the test-tier (conformance manifest + inert cases, service fakes, `*.test.ts`, + // `conformance/`), plus `ui/` and the app-shell (`src/*.ts`). It `references` the runtime + // project, so its imports of data/services resolve through that project's declarations, + // and it alone may see `@adobe/data-testing`. Nothing here emits, so large element/plugin + // types are type-checked without being serialized. `tsc -b tsconfig.test.json` builds the + // wall first, then type-checks this tier — together covering all of `src`. + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": false, + "noEmit": true + }, + "include": [ + "src/*.ts", + "src/**/ui/**/*", + "src/**/*.test.ts", + "src/**/*.type-test.ts", + "src/**/*.cases.ts", + "src/**/*.fake.ts", + "src/**/spec.ts", + "src/**/conformance/**/*" + ], + "exclude": [], + "references": [{ "path": "./tsconfig.build.json" }] +} diff --git a/packages/data-persistence/package.json b/packages/data-persistence/package.json index 7eef6fe3f..75e4ab0aa 100644 --- a/packages/data-persistence/package.json +++ b/packages/data-persistence/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-persistence", - "version": "0.10.19", + "version": "0.10.20", "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-pixie/.gitignore b/packages/data-react-pixie/.gitignore new file mode 100644 index 000000000..c18d52ae7 --- /dev/null +++ b/packages/data-react-pixie/.gitignore @@ -0,0 +1 @@ +.tsbuild/ diff --git a/packages/data-react-pixie/package.json b/packages/data-react-pixie/package.json index 6bb9d7073..6f6e47cd5 100644 --- a/packages/data-react-pixie/package.json +++ b/packages/data-react-pixie/package.json @@ -7,7 +7,7 @@ "scripts": { "build": "vite build", "dev": "vite", - "typecheck": "tsc -p tsconfig.json --noEmit", + "typecheck": "tsc -b tsconfig.test.json", "test": "vitest --run --passWithNoTests" }, "dependencies": { 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 deleted file mode 100644 index 62f0ca833..000000000 --- a/packages/data-react-pixie/src/features/main/data/state/conformance-case.ts +++ /dev/null @@ -1,10 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Conformance as ConformanceApi } from "@adobe/data-testing"; -import type { State } from "./state.js"; - -// The per-feature conformance surface — the shared `@adobe/data-testing` machinery -// with `State` bound once. `Conformance.cases(fn, [options,] ...cases)` declares a -// transform's cases (the case types come from `fn`); `Conformance.derivations(fn, ...cases)` -// a derivation's. This tiny file is the only per-feature conformance -// declaration; everything else lives in `@adobe/data-testing`. -export const Conformance = { cases: ConformanceApi.casesBuilder(), derivations: ConformanceApi.derivationsBuilder() }; diff --git a/packages/data-react-pixie/src/features/main/data/state/create-sprite.cases.ts b/packages/data-react-pixie/src/features/main/data/state/create-sprite.cases.ts new file mode 100644 index 000000000..8fd93bfab --- /dev/null +++ b/packages/data-react-pixie/src/features/main/data/state/create-sprite.cases.ts @@ -0,0 +1,76 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { createSprite } from "./create-sprite.js"; + +// Inert, spec-owned cases for `createSprite` — data only, no test-framework runtime +// import (the `Conformance` binding is `import type`). A sprite is appended with +// rotation defaulting to 0 and hovered/active to false; existing sprites are +// untouched. `before` is a delta over `State.create()`; `after` is the writes patch — +// map keys are plain spec-ids (the ecs mints its own; compared up to an id-bijection). +export const cases: Conformance.SpecCases = { + cases: [ + { + name: "appends the first sprite to an empty scene", + before: {}, + args: { position: [100, 100], kind: "bunny" }, + after: { + entities: new Map([ + [ + 1, + { + position: [100, 100], + rotation: 0, + kind: "bunny", + hovered: false, + active: false, + }, + ], + ]), + }, + }, + { + name: "appends a fox with the next id and an explicit rotation", + before: { + entities: new Map([ + [ + 1, + { + position: [100, 100], + rotation: 0, + kind: "bunny", + hovered: false, + active: false, + }, + ], + ]), + filter: "sepia", + }, + args: { position: [300, 200], rotation: 1, kind: "fox" }, + after: { + entities: new Map([ + [ + 1, + { + position: [100, 100], + rotation: 0, + kind: "bunny", + hovered: false, + active: false, + }, + ], + [ + 2, + { + position: [300, 200], + rotation: 1, + kind: "fox", + hovered: false, + active: false, + }, + ], + ]), + }, + }, + ], +}; 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 4b634ecc8..c4f0f60c4 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,7 +2,6 @@ import type { Vec2 } from "@adobe/data/math"; import type { SpriteKind } from "../sprite-kind/sprite-kind.js"; import type { State } from "./state.js"; -import { Conformance } from "./conformance-case.js"; // Append a sprite to the scene. The spec mints the id (the map key); the value // carries none. Returns only the field it writes (`entities`). @@ -25,73 +24,3 @@ export const createSprite = ( }), }; }; - -// Spec-owned cases, shared with the ecs `createSprite` transaction. A sprite is -// appended with rotation defaulting to 0 and hovered/active to false; existing -// sprites are untouched. `before` is a delta over `State.create()`; `after` is -// the writes patch — map keys are plain spec-ids (the ecs mints its own; conformance -// compares up to an id-bijection). -export const cases = /*@__PURE__*/ Conformance.cases(createSprite, - { - name: "appends the first sprite to an empty scene", - before: {}, - args: { position: [100, 100], kind: "bunny" }, - after: { - entities: new Map([ - [ - 1, - { - position: [100, 100], - rotation: 0, - kind: "bunny", - hovered: false, - active: false, - }, - ], - ]), - }, - }, - { - name: "appends a fox with the next id and an explicit rotation", - before: { - entities: new Map([ - [ - 1, - { - position: [100, 100], - rotation: 0, - kind: "bunny", - hovered: false, - active: false, - }, - ], - ]), - filter: "sepia", - }, - args: { position: [300, 200], rotation: 1, kind: "fox" }, - after: { - entities: new Map([ - [ - 1, - { - position: [100, 100], - rotation: 0, - kind: "bunny", - hovered: false, - active: false, - }, - ], - [ - 2, - { - position: [300, 200], - rotation: 1, - kind: "fox", - hovered: false, - active: false, - }, - ], - ]), - }, - }, -); diff --git a/packages/data-react-pixie/src/features/main/data/state/set-filter.cases.ts b/packages/data-react-pixie/src/features/main/data/state/set-filter.cases.ts new file mode 100644 index 000000000..d96f0d1c4 --- /dev/null +++ b/packages/data-react-pixie/src/features/main/data/state/set-filter.cases.ts @@ -0,0 +1,23 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { setFilter } from "./set-filter.js"; + +// Inert, spec-owned cases for `setFilter`, shared with the ecs transaction. The +// scene-wide filter is replaced; sprites are untouched. +export const cases: Conformance.SpecCases = { + cases: [ + { + name: "sets the filter from none to sepia", + before: {}, + args: { filter: "sepia" }, + after: { filter: "sepia" }, + }, + { + name: "replaces an existing filter", + before: { filter: "blur" }, + args: { filter: "night" }, + after: { filter: "night" }, + }, + ], +}; 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 4555e80ce..9e51e95e0 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,26 +1,9 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { FilterKind } from "../filter-kind/filter-kind.js"; import type { State } from "./state.js"; -import { Conformance } from "./conformance-case.js"; // Replace the scene-wide filter. Writes only `filter`; sprites are untouched. export const setFilter = ( _state: Pick, input: { readonly filter: FilterKind }, ): Pick => ({ filter: input.filter }); - -// Spec-owned cases, shared with the ecs `setFilter` transaction. -export const cases = /*@__PURE__*/ Conformance.cases(setFilter, - { - name: "sets the filter from none to sepia", - before: {}, - args: { filter: "sepia" }, - after: { filter: "sepia" }, - }, - { - name: "replaces an existing filter", - before: { filter: "blur" }, - args: { filter: "night" }, - after: { filter: "night" }, - }, -); diff --git a/packages/data-react-pixie/src/features/main/data/state/set-sprite-active.cases.ts b/packages/data-react-pixie/src/features/main/data/state/set-sprite-active.cases.ts new file mode 100644 index 000000000..2be4671e7 --- /dev/null +++ b/packages/data-react-pixie/src/features/main/data/state/set-sprite-active.cases.ts @@ -0,0 +1,45 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Entity } from "@adobe/data/ecs"; +import type { Conformance } from "@adobe/data-testing"; +import type { Sprite } from "../sprite/sprite.js"; +import type { State } from "./state.js"; +import type { setSpriteActive } from "./set-sprite-active.js"; + +const bunny: Sprite = { + position: [100, 100], rotation: 0, kind: "bunny", hovered: false, active: false, +}; +const fox: Sprite = { + position: [300, 200], rotation: 1, kind: "fox", hovered: false, active: false, +}; + +// Inert, spec-owned cases for `setSpriteActive`, shared with the ecs transaction. +// The `args` schema marks `id` as an entity reference so the ecs side resolves the +// spec-id to its seeded entity. `before`/`after` keys are plain spec-ids (the ecs +// mints its own; compared up to an id-bijection). +export const cases: Conformance.SpecCases = { + args: { type: "object", properties: { id: Entity.schema } }, + cases: [ + { + name: "sets active true on the addressed sprite only", + before: { entities: new Map([[1, bunny], [2, fox]]) }, + args: { id: 2, active: true }, + after: { + entities: new Map([ + [1, bunny], + [2, { ...fox, active: true }], + ]), + }, + }, + { + name: "is a no-op for an unknown id", + before: { entities: new Map([[1, bunny], [2, fox]]) }, + args: { id: 99, active: true }, + after: { + entities: new Map([ + [1, bunny], + [2, fox], + ]), + }, + }, + ], +}; 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 b4ceb7bda..50c52c4ac 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,8 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Entity } from "@adobe/data/ecs"; -import type { Sprite } from "../sprite/sprite.js"; import type { State } from "./state.js"; -import { Conformance } from "./conformance-case.js"; // Set the addressed sprite's `active` flag. Writes only `entities`. export const setSpriteActive = ( @@ -18,38 +15,3 @@ export const setSpriteActive = ( }), }; }; - -const bunny: Sprite = { - position: [100, 100], rotation: 0, kind: "bunny", hovered: false, active: false, -}; -const fox: Sprite = { - position: [300, 200], rotation: 1, kind: "fox", hovered: false, active: false, -}; - -// Spec-owned cases, shared with the ecs `setSpriteActive` transaction. `before` -// keys are plain spec-ids the `args` address via `2`; `after` keys are -// plain spec-ids (the ecs mints its own; compared up to an id-bijection). -export const cases = /*@__PURE__*/ Conformance.cases(setSpriteActive, { args: { type: "object", properties: { id: Entity.schema } } }, - { - name: "sets active true on the addressed sprite only", - before: { entities: new Map([[1, bunny], [2, fox]]) }, - args: { id: 2, active: true }, - after: { - entities: new Map([ - [1, bunny], - [2, { ...fox, active: true }], - ]), - }, - }, - { - name: "is a no-op for an unknown id", - before: { entities: new Map([[1, bunny], [2, fox]]) }, - args: { id: 99, active: true }, - after: { - entities: new Map([ - [1, bunny], - [2, fox], - ]), - }, - }, -); diff --git a/packages/data-react-pixie/src/features/main/data/state/set-sprite-hovered.cases.ts b/packages/data-react-pixie/src/features/main/data/state/set-sprite-hovered.cases.ts new file mode 100644 index 000000000..f84bd0415 --- /dev/null +++ b/packages/data-react-pixie/src/features/main/data/state/set-sprite-hovered.cases.ts @@ -0,0 +1,45 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Entity } from "@adobe/data/ecs"; +import type { Conformance } from "@adobe/data-testing"; +import type { Sprite } from "../sprite/sprite.js"; +import type { State } from "./state.js"; +import type { setSpriteHovered } from "./set-sprite-hovered.js"; + +const bunny: Sprite = { + position: [100, 100], rotation: 0, kind: "bunny", hovered: false, active: false, +}; +const fox: Sprite = { + position: [300, 200], rotation: 1, kind: "fox", hovered: false, active: false, +}; + +// Inert, spec-owned cases for `setSpriteHovered`, shared with the ecs transaction. +// The `args` schema marks `id` as an entity reference so the ecs side resolves the +// spec-id to its seeded entity. `before`/`after` keys are plain spec-ids (the ecs +// mints its own; compared up to an id-bijection). +export const cases: Conformance.SpecCases = { + args: { type: "object", properties: { id: Entity.schema } }, + cases: [ + { + name: "sets hovered true on the addressed sprite only", + before: { entities: new Map([[1, bunny], [2, fox]]) }, + args: { id: 1, hovered: true }, + after: { + entities: new Map([ + [1, { ...bunny, hovered: true }], + [2, fox], + ]), + }, + }, + { + name: "is a no-op for an unknown id", + before: { entities: new Map([[1, bunny], [2, fox]]) }, + args: { id: 99, hovered: true }, + after: { + entities: new Map([ + [1, bunny], + [2, fox], + ]), + }, + }, + ], +}; 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 4599b7de3..6c2489104 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,8 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Entity } from "@adobe/data/ecs"; -import type { Sprite } from "../sprite/sprite.js"; import type { State } from "./state.js"; -import { Conformance } from "./conformance-case.js"; // Set the addressed sprite's `hovered` flag. Writes only `entities`. export const setSpriteHovered = ( @@ -18,38 +15,3 @@ export const setSpriteHovered = ( }), }; }; - -const bunny: Sprite = { - position: [100, 100], rotation: 0, kind: "bunny", hovered: false, active: false, -}; -const fox: Sprite = { - position: [300, 200], rotation: 1, kind: "fox", hovered: false, active: false, -}; - -// Spec-owned cases, shared with the ecs `setSpriteHovered` transaction. `before` -// keys are plain spec-ids the `args` address via `1`; `after` keys are -// plain spec-ids (the ecs mints its own; compared up to an id-bijection). -export const cases = /*@__PURE__*/ Conformance.cases(setSpriteHovered, { args: { type: "object", properties: { id: Entity.schema } } }, - { - name: "sets hovered true on the addressed sprite only", - before: { entities: new Map([[1, bunny], [2, fox]]) }, - args: { id: 1, hovered: true }, - after: { - entities: new Map([ - [1, { ...bunny, hovered: true }], - [2, fox], - ]), - }, - }, - { - name: "is a no-op for an unknown id", - before: { entities: new Map([[1, bunny], [2, fox]]) }, - args: { id: 99, hovered: true }, - after: { - entities: new Map([ - [1, bunny], - [2, fox], - ]), - }, - }, -); 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 f03dac441..26a256253 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,10 +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"; +import { spec } from "./spec.js"; -// The single pure-spec test for every transform AND derivation 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. Each case's `before`/`input` is a delta over `State.create()`. -Conformance.runSpec({ state: State, transitions }); +// The single pure-spec test for every transform in this folder. It runs the +// manifest's inert cases against the pure functions and asserts `after` (up to an +// id-bijection). +Conformance.checkSpec(spec); diff --git a/packages/data-react-pixie/src/features/main/data/state/spec.ts b/packages/data-react-pixie/src/features/main/data/state/spec.ts new file mode 100644 index 000000000..eecdc1744 --- /dev/null +++ b/packages/data-react-pixie/src/features/main/data/state/spec.ts @@ -0,0 +1,36 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Conformance } from "@adobe/data-testing"; +import { State } from "./state.js"; +import * as transforms from "./transforms.js"; +import { MainService } from "../../services/main-service/main-service.js"; +import { projection } from "../../services/main-service/conformance/projection.js"; + +import { cases as createSprite } from "./create-sprite.cases.js"; +import { cases as setSpriteHovered } from "./set-sprite-hovered.cases.js"; +import { cases as setSpriteActive } from "./set-sprite-active.cases.js"; +import { cases as toggleSpriteActive } from "./toggle-sprite-active.cases.js"; +import { cases as tick } from "./tick.cases.js"; +import { cases as setFilter } from "./set-filter.cases.js"; + +// The feature's conformance manifest — the single object `spec.test.ts` (pure) and +// `conformance.test.ts` (ecs) both import. It wires each conformed fn to its inert cases module explicitly: `cases` names one module per conformed fn, +// checked at COMPILE TIME against `transforms` (a fn without cases, or cases without +// a fn, won't type). This feature has no capability services and no derivations, so +// `services`, `computedPlugin`, and `hydrate` are all omitted. +// +// This is a test-tier module (excluded from the runtime program) — the ONE place the +// feature touches `@adobe/data-testing`. No transform or `*.cases.ts` file does. +export const spec = Conformance.feature({ + state: State, + fns: transforms, + plugin: MainService.plugin, + projection, + cases: { + createSprite, + setSpriteHovered, + setSpriteActive, + toggleSpriteActive, + tick, + setFilter, + }, +}); diff --git a/packages/data-react-pixie/src/features/main/data/state/tick.cases.ts b/packages/data-react-pixie/src/features/main/data/state/tick.cases.ts new file mode 100644 index 000000000..4536a43df --- /dev/null +++ b/packages/data-react-pixie/src/features/main/data/state/tick.cases.ts @@ -0,0 +1,37 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { Sprite } from "../sprite/sprite.js"; +import type { State } from "./state.js"; +import type { tick } from "./tick.js"; + +const bunny: Sprite = { + position: [100, 100], rotation: 0, kind: "bunny", hovered: false, active: false, +}; +const fox: Sprite = { + position: [300, 200], rotation: 1, kind: "fox", hovered: false, active: false, +}; + +// Inert, spec-owned cases for `tick`, shared with the ecs transaction. Every +// sprite's rotation advances by delta * 0.1; `after` keys are plain spec-ids +// (compared up to an id-bijection). +export const cases: Conformance.SpecCases = { + cases: [ + { + name: "advances every sprite's rotation by delta * 0.1", + before: { entities: new Map([[1, bunny], [2, fox]]) }, + args: { delta: 10 }, + after: { + entities: new Map([ + [1, { ...bunny, rotation: 1 }], + [2, { ...fox, rotation: 2 }], + ]), + }, + }, + { + name: "is a no-op on an empty scene", + before: { entities: new Map(), filter: "blur" }, + args: { delta: 5 }, + after: { entities: new Map() }, + }, + ], +}; 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 2ea7d7be6..2a0ea1a5c 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,7 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Sprite } from "../sprite/sprite.js"; import type { State } from "./state.js"; -import { Conformance } from "./conformance-case.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). @@ -17,32 +16,3 @@ export const tick = ( ]), ), }); - -const bunny: Sprite = { - position: [100, 100], rotation: 0, kind: "bunny", hovered: false, active: false, -}; -const fox: Sprite = { - 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; `after` keys are plain spec-ids (compared up to an id-bijection). -export const cases = /*@__PURE__*/ Conformance.cases(tick, - { - name: "advances every sprite's rotation by delta * 0.1", - before: { entities: new Map([[1, bunny], [2, fox]]) }, - args: { delta: 10 }, - after: { - entities: new Map([ - [1, { ...bunny, rotation: 1 }], - [2, { ...fox, rotation: 2 }], - ]), - }, - }, - { - name: "is a no-op on an empty scene", - before: { entities: new Map(), filter: "blur" }, - args: { delta: 5 }, - after: { entities: new Map() }, - }, -); diff --git a/packages/data-react-pixie/src/features/main/data/state/toggle-sprite-active.cases.ts b/packages/data-react-pixie/src/features/main/data/state/toggle-sprite-active.cases.ts new file mode 100644 index 000000000..bf0e2cd6e --- /dev/null +++ b/packages/data-react-pixie/src/features/main/data/state/toggle-sprite-active.cases.ts @@ -0,0 +1,56 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Entity } from "@adobe/data/ecs"; +import type { Conformance } from "@adobe/data-testing"; +import type { Sprite } from "../sprite/sprite.js"; +import type { State } from "./state.js"; +import type { toggleSpriteActive } from "./toggle-sprite-active.js"; + +const bunny: Sprite = { + position: [100, 100], rotation: 0, kind: "bunny", hovered: false, active: false, +}; +const activeFox: Sprite = { + position: [300, 200], rotation: 1, kind: "fox", hovered: false, active: true, +}; + +// Inert, spec-owned cases for `toggleSpriteActive`, shared with the ecs transaction. +// The `args` schema marks `id` as an entity reference so the ecs side resolves the +// spec-id to its seeded entity. `before`/`after` keys are plain spec-ids (the ecs +// mints its own; compared up to an id-bijection). +export const cases: Conformance.SpecCases = { + args: { type: "object", properties: { id: Entity.schema } }, + cases: [ + { + name: "toggles a sprite from inactive to active", + before: { entities: new Map([[1, bunny], [2, activeFox]]) }, + args: { id: 1 }, + after: { + entities: new Map([ + [1, { ...bunny, active: true }], + [2, activeFox], + ]), + }, + }, + { + name: "toggles a sprite from active to inactive", + before: { entities: new Map([[1, bunny], [2, activeFox]]) }, + args: { id: 2 }, + after: { + entities: new Map([ + [1, bunny], + [2, { ...activeFox, active: false }], + ]), + }, + }, + { + name: "is a no-op for an unknown id", + before: { entities: new Map([[1, bunny], [2, activeFox]]) }, + args: { id: 99 }, + after: { + entities: new Map([ + [1, bunny], + [2, activeFox], + ]), + }, + }, + ], +}; 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 487a5e32d..3e8fc3b00 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,8 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Entity } from "@adobe/data/ecs"; -import type { Sprite } from "../sprite/sprite.js"; import type { State } from "./state.js"; -import { Conformance } from "./conformance-case.js"; // Flip the addressed sprite's `active` flag. Writes only `entities`. export const toggleSpriteActive = ( @@ -18,49 +15,3 @@ export const toggleSpriteActive = ( }), }; }; - -const bunny: Sprite = { - position: [100, 100], rotation: 0, kind: "bunny", hovered: false, active: false, -}; -const activeFox: Sprite = { - position: [300, 200], rotation: 1, kind: "fox", hovered: false, active: true, -}; - -// Spec-owned cases, shared with the ecs `toggleSpriteActive` transaction. `before` -// keys are plain spec-ids the `args` address by id (the `args` schema marks `id` as a reference); `after` keys are -// plain spec-ids (the ecs mints its own; compared up to an id-bijection). -export const cases = /*@__PURE__*/ Conformance.cases(toggleSpriteActive, { args: { type: "object", properties: { id: Entity.schema } } }, - { - name: "toggles a sprite from inactive to active", - before: { entities: new Map([[1, bunny], [2, activeFox]]) }, - args: { id: 1 }, - after: { - entities: new Map([ - [1, { ...bunny, active: true }], - [2, activeFox], - ]), - }, - }, - { - name: "toggles a sprite from active to inactive", - before: { entities: new Map([[1, bunny], [2, activeFox]]) }, - args: { id: 2 }, - after: { - entities: new Map([ - [1, bunny], - [2, { ...activeFox, active: false }], - ]), - }, - }, - { - name: "is a no-op for an unknown id", - before: { entities: new Map([[1, bunny], [2, activeFox]]) }, - args: { id: 99 }, - after: { - entities: new Map([ - [1, bunny], - [2, activeFox], - ]), - }, - }, -); diff --git a/packages/data-react-pixie/src/features/main/data/state/transforms.ts b/packages/data-react-pixie/src/features/main/data/state/transforms.ts new file mode 100644 index 000000000..12f856883 --- /dev/null +++ b/packages/data-react-pixie/src/features/main/data/state/transforms.ts @@ -0,0 +1,15 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +// The conformed functions of this feature — every pure transform the spec verifies, +// and nothing else (helpers like `create` and `samples` are deliberately absent). +// Its keys ARE the conformance surface: the manifest's coverage guard +// (`Conformance.feature`) requires exactly one `*.cases.ts` module per key here, so +// adding a transform without cases — or cases without a transform — fails to compile. +// This is a per-feature, local barrel (not a cross-feature registry), authored where +// the transforms live. +export { createSprite } from "./create-sprite.js"; +export { setSpriteHovered } from "./set-sprite-hovered.js"; +export { setSpriteActive } from "./set-sprite-active.js"; +export { toggleSpriteActive } from "./toggle-sprite-active.js"; +export { tick } from "./tick.js"; +export { setFilter } from "./set-filter.js"; 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 deleted file mode 100644 index 1092fd915..000000000 --- a/packages/data-react-pixie/src/features/main/data/state/transitions.ts +++ /dev/null @@ -1,11 +0,0 @@ -// © 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 147ef9392..48e046088 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,19 +1,10 @@ // © 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"; +import { spec } from "../../../data/state/spec.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 reference -// args (its `args` schema fields) through the `fromState` id map, and round-trips `State.samples` through -// 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, - plugin: MainService.plugin, - projection, -}); +// The whole ecs conformance for this feature in one call: `checkFeature` replays the +// manifest's shared cases against the transactions/actions on `MainService.plugin`, +// seeds each `before` over `State.create()`, and round-trips `State.samples` through +// the `projection`. It shares `spec` with `spec.test.ts` — same cases, substituted +// implementation. +Conformance.checkFeature(spec); 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 index e40a7bb8a..6b3f92e64 100644 --- 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 @@ -20,7 +20,7 @@ const toData = (store: CoreDatabase.Store, entity: Entity): Sprite => { }; }; -// The test-only ecs↔`State` projection, passed to `Conformance.runFeature`. +// The test-only ecs↔`State` projection, carried on the manifest and used by `Conformance.checkFeature`. // `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 diff --git a/packages/data-react-pixie/tsconfig.build.json b/packages/data-react-pixie/tsconfig.build.json new file mode 100644 index 000000000..1395b61d5 --- /dev/null +++ b/packages/data-react-pixie/tsconfig.build.json @@ -0,0 +1,29 @@ +{ + // The RUNTIME wall (dependency isolation). It compiles the shippable data + services + // tiers as a `composite` project, so a data/ or services/ runtime file that imports a + // test-tier file (`*.cases.ts`, `*.fake.ts`, `spec.ts`, anything under `conformance/`) + // is a COMPILE ERROR (TS6307: not in this project's file list). `ui/` and the app-shell + // (`src/*.ts`) live in the test project instead — they import test-tier only in their own + // `*.test.ts`, and keeping their (often very large) inferred element/plugin types out of + // this project avoids forcing them into serialized declarations. Emits declarations only, + // to a throwaway dir, purely to satisfy the composite contract `tsconfig.test.json` refs. + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "./.tsbuild", + "rootDir": "src" + }, + "include": ["src/features/**/*"], + "exclude": [ + "**/ui/**", + "**/*.test.ts", + "**/*.type-test.ts", + "**/*.cases.ts", + "**/*.fake.ts", + "**/spec.ts", + "**/conformance/**" + ] +} diff --git a/packages/data-react-pixie/tsconfig.test.json b/packages/data-react-pixie/tsconfig.test.json new file mode 100644 index 000000000..f328ac324 --- /dev/null +++ b/packages/data-react-pixie/tsconfig.test.json @@ -0,0 +1,26 @@ +{ + // The NON-EMITTING tier: everything the runtime wall (`tsconfig.build.json`) doesn't own — + // the test-tier (conformance manifest + inert cases, service fakes, `*.test.ts`, + // `conformance/`), plus `ui/` and the app-shell (`src/*.ts`). It `references` the runtime + // project, so its imports of data/services resolve through that project's declarations, + // and it alone may see `@adobe/data-testing`. Nothing here emits, so large element/plugin + // types are type-checked without being serialized. `tsc -b tsconfig.test.json` builds the + // wall first, then type-checks this tier — together covering all of `src`. + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": false, + "noEmit": true + }, + "include": [ + "src/*.ts", + "src/**/ui/**/*", + "src/**/*.test.ts", + "src/**/*.type-test.ts", + "src/**/*.cases.ts", + "src/**/*.fake.ts", + "src/**/spec.ts", + "src/**/conformance/**/*" + ], + "exclude": [], + "references": [{ "path": "./tsconfig.build.json" }] +} diff --git a/packages/data-react/package.json b/packages/data-react/package.json index f7f669091..2dd65ba47 100644 --- a/packages/data-react/package.json +++ b/packages/data-react/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-react", - "version": "0.10.19", + "version": "0.10.20", "description": "Adobe data React bindings — hooks and context for ECS database", "type": "module", "private": false, diff --git a/packages/data-rpc/package.json b/packages/data-rpc/package.json index b1953d940..a41728d61 100644 --- a/packages/data-rpc/package.json +++ b/packages/data-rpc/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-rpc", - "version": "0.10.19", + "version": "0.10.20", "description": "Schema-driven, bidirectional projection of @adobe/data async data services across a boundary (iframe / MessagePort / Worker). Only Data crosses the wire.", "type": "module", "sideEffects": false, diff --git a/packages/data-solid-dashboard/.gitignore b/packages/data-solid-dashboard/.gitignore new file mode 100644 index 000000000..c18d52ae7 --- /dev/null +++ b/packages/data-solid-dashboard/.gitignore @@ -0,0 +1 @@ +.tsbuild/ diff --git a/packages/data-solid-dashboard/package.json b/packages/data-solid-dashboard/package.json index 3e183e3ae..e93e0aeae 100644 --- a/packages/data-solid-dashboard/package.json +++ b/packages/data-solid-dashboard/package.json @@ -7,7 +7,7 @@ "scripts": { "build": "vite build", "dev": "vite", - "typecheck": "tsc -p tsconfig.json --noEmit", + "typecheck": "tsc -b tsconfig.test.json", "test": "vitest --run --passWithNoTests" }, "dependencies": { diff --git a/packages/data-solid-dashboard/src/features/main/data/state/clear-log.cases.ts b/packages/data-solid-dashboard/src/features/main/data/state/clear-log.cases.ts new file mode 100644 index 000000000..1554566ac --- /dev/null +++ b/packages/data-solid-dashboard/src/features/main/data/state/clear-log.cases.ts @@ -0,0 +1,21 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { clearLog } from "./clear-log.js"; + +// Inert, spec-owned cases for `clearLog` — data only, shared with the ecs +// `clearLog` transaction and action. +export const cases: Conformance.SpecCases = { + cases: [ + { + name: "empties a populated log, leaving count and name intact", + before: { count: 2, log: ["a", "b"], userName: "Ada" }, + 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/clear-log.ts b/packages/data-solid-dashboard/src/features/main/data/state/clear-log.ts index 2176c28dc..2f09a182a 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,19 +1,8 @@ // © 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 — 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", - before: { count: 2, log: ["a", "b"], userName: "Ada" }, - 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/conformance-case.ts b/packages/data-solid-dashboard/src/features/main/data/state/conformance-case.ts deleted file mode 100644 index 255ecbfdd..000000000 --- a/packages/data-solid-dashboard/src/features/main/data/state/conformance-case.ts +++ /dev/null @@ -1,13 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { 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` -// 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/decrement.cases.ts b/packages/data-solid-dashboard/src/features/main/data/state/decrement.cases.ts new file mode 100644 index 000000000..d843650e5 --- /dev/null +++ b/packages/data-solid-dashboard/src/features/main/data/state/decrement.cases.ts @@ -0,0 +1,21 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { decrement } from "./decrement.js"; + +// Inert, spec-owned cases for `decrement` — data only, shared with the ecs +// `decrement` transaction and action. +export const cases: Conformance.SpecCases = { + cases: [ + { + 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/decrement.ts b/packages/data-solid-dashboard/src/features/main/data/state/decrement.ts index 6fdcd6060..78b16d653 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,6 +1,5 @@ // © 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. At the floor it writes the slice // back unchanged (no log entry), keeping the transform idempotent at zero. @@ -11,13 +10,3 @@ export const decrement = ( const count = state.count - 1; 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"] }, - 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.cases.ts b/packages/data-solid-dashboard/src/features/main/data/state/increment.cases.ts new file mode 100644 index 000000000..3c2c63d83 --- /dev/null +++ b/packages/data-solid-dashboard/src/features/main/data/state/increment.cases.ts @@ -0,0 +1,22 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { increment } from "./increment.js"; + +// Inert, spec-owned cases for `increment` — data only, shared with the ecs +// `increment` transaction and action. `before` is a delta over `State.create()`; +// `after` is the writes patch. +export const cases: Conformance.SpecCases = { + cases: [ + { + 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/increment.ts b/packages/data-solid-dashboard/src/features/main/data/state/increment.ts index 3df45dda5..3d536b8b2 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,6 +1,5 @@ // © 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. Returns only the // fields it writes (count + log); the runner merges the patch over the rest. @@ -10,14 +9,3 @@ export const increment = ( const count = state.count + 1; return { count, log: [...state.log, `Incremented to ${count}`] }; }; - -// Spec-owned cases, shared with the ecs `increment` transaction and action. -// `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: {}, - 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.cases.ts b/packages/data-solid-dashboard/src/features/main/data/state/reset.cases.ts new file mode 100644 index 000000000..42f1879e3 --- /dev/null +++ b/packages/data-solid-dashboard/src/features/main/data/state/reset.cases.ts @@ -0,0 +1,21 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { reset } from "./reset.js"; + +// Inert, spec-owned cases for `reset` — data only, shared with the ecs `reset` +// transaction and action. +export const cases: Conformance.SpecCases = { + cases: [ + { + 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/reset.ts b/packages/data-solid-dashboard/src/features/main/data/state/reset.ts index 9532afb47..376921999 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,6 +1,5 @@ // © 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 = ( @@ -9,13 +8,3 @@ export const reset = ( 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"] }, - 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.cases.ts b/packages/data-solid-dashboard/src/features/main/data/state/set-user-name.cases.ts new file mode 100644 index 000000000..e744051af --- /dev/null +++ b/packages/data-solid-dashboard/src/features/main/data/state/set-user-name.cases.ts @@ -0,0 +1,23 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { setUserName } from "./set-user-name.js"; + +// Inert, spec-owned cases for `setUserName` — data-only `args` ({ name }), shared +// with the ecs `setUserName` transaction and action. +export const cases: Conformance.SpecCases = { + cases: [ + { + name: "sets the name and logs the change", + before: {}, + args: { name: "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: { userName: "Grace", log: ["earlier", "Name changed to Grace"] }, + }, + ], +}; 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 b7b2fe502..12fb8ba22 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,6 +1,5 @@ // © 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,15 +9,3 @@ 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: {}, - args: { name: "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: { userName: "Grace", log: ["earlier", "Name changed to 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 index f03dac441..316c33081 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,10 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Conformance } from "@adobe/data-testing"; -import { State } from "./state.js"; -import { transitions } from "./transitions.js"; +import { spec } from "./spec.js"; -// The single pure-spec test for every transform AND derivation 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. Each case's `before`/`input` is a delta over `State.create()`. -Conformance.runSpec({ state: State, transitions }); +// The single pure-spec test for every transform in this folder. It runs the +// manifest's inert cases against the pure functions and asserts `after`. +Conformance.checkSpec(spec); diff --git a/packages/data-solid-dashboard/src/features/main/data/state/spec.ts b/packages/data-solid-dashboard/src/features/main/data/state/spec.ts new file mode 100644 index 000000000..a82d90d5a --- /dev/null +++ b/packages/data-solid-dashboard/src/features/main/data/state/spec.ts @@ -0,0 +1,34 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Conformance } from "@adobe/data-testing"; +import { State } from "./state.js"; +import * as transforms from "./transforms.js"; +import { MainService } from "../../services/main-service/main-service.js"; +import { projection } from "../../services/main-service/conformance/projection.js"; + +import { cases as increment } from "./increment.cases.js"; +import { cases as decrement } from "./decrement.cases.js"; +import { cases as reset } from "./reset.cases.js"; +import { cases as setUserName } from "./set-user-name.cases.js"; +import { cases as clearLog } from "./clear-log.cases.js"; + +// The feature's conformance manifest — the single object `spec.test.ts` (pure) and +// `conformance.test.ts` (ecs) both import. `cases` names one module per conformed fn, +// checked at COMPILE TIME against `transforms`. This feature is all scalar +// singletons — no entities, no derivations, no capability services — so it needs no +// `computedPlugin`, `hydrate`, or `services`. +// +// This is a test-tier module (excluded from the runtime program) — the ONE place the +// feature touches `@adobe/data-testing`. No transform or `*.cases.ts` file does. +export const spec = Conformance.feature({ + state: State, + fns: transforms, + plugin: MainService.plugin, + projection, + cases: { + increment, + decrement, + reset, + setUserName, + clearLog, + }, +}); diff --git a/packages/data-solid-dashboard/src/features/main/data/state/transforms.ts b/packages/data-solid-dashboard/src/features/main/data/state/transforms.ts new file mode 100644 index 000000000..72504dac7 --- /dev/null +++ b/packages/data-solid-dashboard/src/features/main/data/state/transforms.ts @@ -0,0 +1,12 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +// The conformed functions of this feature — every pure transform the spec verifies, +// and nothing else (helpers like `create` and `samples` are deliberately absent). Its +// keys ARE the conformance surface: the manifest's coverage guard +// (`Conformance.feature`) requires exactly one `*.cases.ts` module per key here, so +// adding a transform without cases — or cases without a transform — fails to compile. +export { increment } from "./increment.js"; +export { decrement } from "./decrement.js"; +export { reset } from "./reset.js"; +export { setUserName } from "./set-user-name.js"; +export { clearLog } from "./clear-log.js"; 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 deleted file mode 100644 index 1092fd915..000000000 --- a/packages/data-solid-dashboard/src/features/main/data/state/transitions.ts +++ /dev/null @@ -1,11 +0,0 @@ -// © 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 c906408f7..48e046088 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,18 +1,10 @@ // © 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"; +import { spec } from "../../../data/state/spec.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. `transitions` (the discovered `{ fn, cases }` modules) is shared with -// spec.test. This feature has no derivations, so no `computedPlugin`. -Conformance.runFeature({ - state: State, - transitions, - plugin: MainService.plugin, - projection, -}); +// The whole ecs conformance for this feature in one call: `checkFeature` replays the +// manifest's shared cases against the transactions/actions on `MainService.plugin`, +// seeds each `before` over `State.create()`, and round-trips `State.samples` through +// the `projection`. It shares `spec` with `spec.test.ts` — same cases, substituted +// implementation. +Conformance.checkFeature(spec); 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 index a8b8980d7..17b674a5f 100644 --- 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 @@ -3,7 +3,7 @@ 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`. +// The test-only ecs↔`State` projection, carried on the manifest for `checkFeature`. // `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`. diff --git a/packages/data-solid-dashboard/tsconfig.build.json b/packages/data-solid-dashboard/tsconfig.build.json new file mode 100644 index 000000000..1395b61d5 --- /dev/null +++ b/packages/data-solid-dashboard/tsconfig.build.json @@ -0,0 +1,29 @@ +{ + // The RUNTIME wall (dependency isolation). It compiles the shippable data + services + // tiers as a `composite` project, so a data/ or services/ runtime file that imports a + // test-tier file (`*.cases.ts`, `*.fake.ts`, `spec.ts`, anything under `conformance/`) + // is a COMPILE ERROR (TS6307: not in this project's file list). `ui/` and the app-shell + // (`src/*.ts`) live in the test project instead — they import test-tier only in their own + // `*.test.ts`, and keeping their (often very large) inferred element/plugin types out of + // this project avoids forcing them into serialized declarations. Emits declarations only, + // to a throwaway dir, purely to satisfy the composite contract `tsconfig.test.json` refs. + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "./.tsbuild", + "rootDir": "src" + }, + "include": ["src/features/**/*"], + "exclude": [ + "**/ui/**", + "**/*.test.ts", + "**/*.type-test.ts", + "**/*.cases.ts", + "**/*.fake.ts", + "**/spec.ts", + "**/conformance/**" + ] +} diff --git a/packages/data-solid-dashboard/tsconfig.test.json b/packages/data-solid-dashboard/tsconfig.test.json new file mode 100644 index 000000000..f328ac324 --- /dev/null +++ b/packages/data-solid-dashboard/tsconfig.test.json @@ -0,0 +1,26 @@ +{ + // The NON-EMITTING tier: everything the runtime wall (`tsconfig.build.json`) doesn't own — + // the test-tier (conformance manifest + inert cases, service fakes, `*.test.ts`, + // `conformance/`), plus `ui/` and the app-shell (`src/*.ts`). It `references` the runtime + // project, so its imports of data/services resolve through that project's declarations, + // and it alone may see `@adobe/data-testing`. Nothing here emits, so large element/plugin + // types are type-checked without being serialized. `tsc -b tsconfig.test.json` builds the + // wall first, then type-checks this tier — together covering all of `src`. + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": false, + "noEmit": true + }, + "include": [ + "src/*.ts", + "src/**/ui/**/*", + "src/**/*.test.ts", + "src/**/*.type-test.ts", + "src/**/*.cases.ts", + "src/**/*.fake.ts", + "src/**/spec.ts", + "src/**/conformance/**/*" + ], + "exclude": [], + "references": [{ "path": "./tsconfig.build.json" }] +} diff --git a/packages/data-solid/package.json b/packages/data-solid/package.json index 7662d649d..bd9be7f8c 100644 --- a/packages/data-solid/package.json +++ b/packages/data-solid/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-solid", - "version": "0.10.19", + "version": "0.10.20", "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 4b0532417..8e1d33638 100644 --- a/packages/data-sync/package.json +++ b/packages/data-sync/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-sync", - "version": "0.10.19", + "version": "0.10.20", "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-testing/package.json b/packages/data-testing/package.json index 01e6887b1..c27f31693 100644 --- a/packages/data-testing/package.json +++ b/packages/data-testing/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-testing", - "version": "0.10.19", + "version": "0.10.20", "description": "Conformance-testing utilities (Match + Conformance runners) for @adobe/data ECS features", "type": "module", "sideEffects": false, diff --git a/packages/data-testing/src/conformance/adapt-spec.ts b/packages/data-testing/src/conformance/adapt-spec.ts new file mode 100644 index 000000000..770f22b1f --- /dev/null +++ b/packages/data-testing/src/conformance/adapt-spec.ts @@ -0,0 +1,62 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Schema } from "@adobe/data/schema"; +import { buildDouble, type Schedule } from "./build-double.js"; + +type AnyFn = (...args: never[]) => unknown; +// The erased case-module shape the adapter reads structurally. The strongly-typed +// authored envelopes (`SpecCases` / `SpecDerivations`) widen to this — the runner +// only needs the case list and the optional entity-reference `args` schema. +type LooseCaseModule = { readonly args?: Schema; readonly cases: readonly Record[] }; + +// Synthesize this case's recording doubles from the feature's shape-only `services` +// templates and the case's `responses`, then merge them into `args` where the +// transform expects each injected service. ALL registered services are injected (a +// superset): a service a transform calls but the case neither schedules nor asserts +// must still be present, and an unused one is harmless (its recorded calls aren't +// asserted). The test-only `responses` channel is dropped — it is not a fn arg. +const injectDoubles = ( + testCase: Record, + services: Readonly object>>, +): Record => { + const responses = testCase["responses"] as Readonly> | undefined; + const args = testCase["args"] as object | undefined; + const built: Record = {}; + for (const [name, template] of Object.entries(services)) { + built[name] = buildDouble(template, responses?.[name]); + } + const { responses: _responses, args: _args, ...rest } = testCase; + return { ...rest, args: { ...built, ...(args ?? {}) } }; +}; + +// Adapt a manifest's `{ fns, cases }` into the `transitions` map shape the +// discovery-based runners (`runSpec` / `runFeature`) already consume: one entry per +// fn, `{ [fnName]: fn, cases: }`. Derivation modules pass through as +// `{ cases }`; transition modules keep their optional `args` schema, and — when +// `injectServices` — gain per-case recording doubles in `args`. This is the entire +// bridge from inert, data-only cases to the unchanged runners: the pure spec and the +// ecs action side adapt WITH services (each side builds its own fresh doubles, so +// their FIFO schedules never cross); the ecs transaction side adapts WITHOUT (a +// transaction is a pure store mutation and never receives services). +export const adaptCases = ( + fns: Readonly>, + cases: Readonly>, + services: Readonly object>> | undefined, + injectServices: boolean, +): Record> => { + const out: Record> = {}; + for (const [name, fn] of Object.entries(fns)) { + const module = cases[name]!; + const list = module.cases; + const first = list[0] as Record | undefined; + if (first !== undefined && "value" in first) { + out[`./${name}`] = { [name]: fn, cases: { cases: list } }; + continue; + } + const adapted = injectServices && services ? list.map((testCase) => injectDoubles(testCase, services)) : list; + out[`./${name}`] = { + [name]: fn, + cases: module.args ? { args: module.args, cases: adapted } : { cases: adapted }, + }; + } + return out; +}; diff --git a/packages/data-testing/src/conformance/build-double.ts b/packages/data-testing/src/conformance/build-double.ts new file mode 100644 index 000000000..3cd6e7d20 --- /dev/null +++ b/packages/data-testing/src/conformance/build-double.ts @@ -0,0 +1,40 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +// A per-method FIFO response schedule, keyed by method name (a case's `responses` +// for one service, erased to data). +export type Schedule = Readonly>; + +// Build a recording double for one service from a SHAPE-ONLY template and a case's +// response schedule. The template — the feature's registered fake — is called once +// purely to enumerate the service's members (types are erased at runtime, and Proxy +// is banned on these paths, so the method names must come from a real value); its +// own behavior is never used. Every value-returning call drains that method's FIFO; +// a `void` method (or any method with no schedule) returns `undefined`. Draining an +// EXHAUSTED schedule throws, so a transform that calls a method more times than the +// case scheduled is a hard failure — the determinism a conformance oracle needs +// (the old fakes cycled, silently masking an over-call). +// +// The returned double still gets wrapped by `recordCalls` (call recording) on its +// way into a case's args, exactly like the live fakes it replaces — so effect +// assertion is unchanged; only the RETURNS now come from the case, not the fake. +export const buildDouble = (template: () => object, responses: Schedule | undefined): object => { + const shape = template(); + const queues = new Map( + Object.entries(responses ?? {}).map(([method, schedule]) => [method, [...schedule]]), + ); + return Object.fromEntries( + Object.entries(shape).map(([key, value]) => + typeof value !== "function" + ? [key, value] // serviceName and other non-method members pass through + : [ + key, + (..._args: unknown[]): unknown => { + const queue = queues.get(key); + if (!queue) return undefined; // void method, or an unscheduled call + if (queue.length === 0) throw new Error(`response schedule exhausted for "${key}"`); + return queue.shift(); + }, + ], + ), + ); +}; diff --git a/packages/data-testing/src/conformance/cases.ts b/packages/data-testing/src/conformance/cases.ts deleted file mode 100644 index db422b929..000000000 --- a/packages/data-testing/src/conformance/cases.ts +++ /dev/null @@ -1,93 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Schema } from "@adobe/data/schema"; -import type { Case, DerivationCase } from "./types.js"; - -type AnyFn = (...args: never[]) => unknown; -// The transition's args type — its second parameter, or `void` when it takes none. -type ArgsOf = Parameters extends [unknown, infer A, ...unknown[]] ? A : void; - -// Surfaced at the call when the `args` schema does not describe the transition's -// args faithfully (a typo, a wrong or too-narrow field type). It only bites when -// the args type is NOT assignable to `Schema.ToType`. -type ArgsSchemaMismatch = { - readonly __argsSchemaMismatch: "the transition's args type must be assignable to Schema.ToType"; -}; - -// Options for a transition's cases. An object (not a bare schema) so more options -// can be added later without changing the call shape. `name?: never` keeps a `Case` -// (which always has a `name`) from matching this overload, so `cases(fn, ...cases)` -// with no options resolves to the second overload. -type CasesOptions = { - readonly name?: never; - /** - * Optional schema for this transition's `args`. Conformance uses it for ONE - * purpose: to identify which argument fields are ENTITY REFERENCES — a field - * typed with `Entity.schema` — so the runner resolves that spec-id to the entity - * seeded for it (the args-side counterpart of the component schemas it reads for - * state). Provide it whenever a transition addresses an entity by id in its - * `args`; omit it otherwise. Describe only the fields you need identified — an - * injected service, and any non-reference arg, may be left off. The args type - * must be assignable to this schema's `Schema.ToType`, so the schema cannot drift - * from the signature. - */ - readonly args?: S & (ArgsOf extends Schema.ToType ? unknown : ArgsSchemaMismatch); -}; - -// The single envelope EVERY `cases` export holds — always an object, never a bare -// array, whether or not an `args` schema was given. One shape means discovery and the -// runners read `.cases` / `.args` with no branch, and there is exactly one way to -// author cases (a bare `cases = [ … ]` array is no longer a valid export). -export type CasesResult = { - readonly args?: Schema; - readonly cases: readonly Case>[]; -}; - -// The same envelope for a derivation's `{ input, value }` cases (never any `args`). -export type DerivationsResult = { - readonly cases: readonly DerivationCase[0], ReturnType>[]; -}; - -// The `State`-bound cases builder a feature aliases (`Conformance.cases`). `fn` is -// passed for TYPING only (so both the case types and the `args`-schema pin infer); -// discovery pairs the ecs op by the function's own name, so the builder ignores -// `fn` at runtime. Both overloads emit the SAME `CasesResult` envelope. -export interface CasesBuilder { - // No-options overload FIRST: a `cases(fn, ...cases)` call matches here immediately, - // so the case literals keep their contextual types (a `Vec2` tuple stays a tuple). - // `NoInfer` pins `F` to `fn`, not the cases. - (fn: F, ...cases: readonly Case>>[]): CasesResult; - // Options overload SECOND: reached only when the second arg is an options object - // (a `Case` has a `name`, which the options type forbids, so it can't match here). - ( - fn: F, - options: CasesOptions, - ...cases: readonly Case>>[] - ): CasesResult; -} - -// The derivation builder a feature aliases (`Conformance.derivations`) — emits the -// same `{ cases }` envelope so discovery reads one shape for transitions and -// derivations alike (it dispatches on case shape, not export shape). -export interface DerivationsBuilder { - ( - fn: F, - ...cases: readonly DerivationCase>[0], ReturnType>>[] - ): DerivationsResult; -} - -export const casesBuilder = (): CasesBuilder => { - const build = (_fn: AnyFn, ...rest: readonly unknown[]): unknown => { - const first = rest[0]; - // An options object has no `name` (every case does), so it discriminates the - // two overloads at runtime; either way the result is one `{ args?, cases }` shape. - const isOptions = - first !== null && typeof first === "object" && !Array.isArray(first) && !("name" in first); - return isOptions ? { ...(first as object), cases: rest.slice(1) } : { cases: rest }; - }; - return build as CasesBuilder; -}; - -export const derivationsBuilder = (): DerivationsBuilder => { - const build = (_fn: AnyFn, ...cases: readonly unknown[]): unknown => ({ cases }); - return build as DerivationsBuilder; -}; diff --git a/packages/data-testing/src/conformance/cases.type-test.ts b/packages/data-testing/src/conformance/cases.type-test.ts deleted file mode 100644 index 66aa035e0..000000000 --- a/packages/data-testing/src/conformance/cases.type-test.ts +++ /dev/null @@ -1,54 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -// Type-level checks for the `cases` builder's args-schema pin and its uniform envelope. -import { Entity } from "@adobe/data/ecs"; -import { casesBuilder, derivationsBuilder } from "./cases.js"; - -type State = { readonly entities: ReadonlyMap; readonly selected: number }; -const cases = casesBuilder(); -const derivations = derivationsBuilder(); - -const reparent = (_s: Pick, args: { readonly id: number; readonly count: number }): Pick => ({ - selected: args.id, -}); -const noArgs = (_s: Pick): Pick => ({ selected: 0 }); - -// A schema whose ToType is assignable-from the args type — accepted. -export const good = cases( - reparent, - // No `required`: the args type `{ id, count }` is still assignable to the schema's - // `ToType` (`{ id?, count? }`), and a marked field is detected regardless. - { args: { type: "object", properties: { id: Entity.schema, count: { type: "integer" } } } }, - { name: "ok", before: {}, args: { id: 1, count: 2 }, after: { selected: 1 } }, -); -// Both forms emit the SAME envelope `{ args?, cases }` — never a bare array. -const _hasArgs: (typeof good)["args"] = good.args; -const _hasCases: (typeof good)["cases"] = good.cases; - -// The no-args overload emits the same envelope (no bare array). -export const noArgsResult = cases(noArgs, { name: "n", before: {}, after: { selected: 0 } }); -const _noArgsCases: readonly unknown[] = noArgsResult.cases; - -// A derivation emits `{ cases }` through the parallel builder. -const derive = (_s: State): number => _s.selected; -export const derived = derivations(derive, { name: "d", input: { entities: new Map(), selected: 0 }, value: 0 }); -const _derivedCases: readonly unknown[] = derived.cases; - -// A schema that DIVERGES from the signature (id typed as string) must be rejected: -// `Schema.ToType.id` is `string`, but the transition's `args.id` is `number`. -const badArgs = { type: "object", properties: { id: { type: "string" }, count: { type: "integer" } } } as const; -// @ts-expect-error — args type is not assignable to Schema.ToType -export const bad = cases(reparent, { args: badArgs }, { name: "bad", before: {}, args: { id: 1, count: 2 }, after: { selected: 1 } }); - -// A `throws` case takes no `after`/`effects` — `true` for any message, or a -// substring the thrown message must contain. -export const throwsAny = cases( - reparent, - { name: "throws (any message)", before: {}, args: { id: 1, count: 2 }, throws: true }, -); -export const throwsSubstring = cases( - reparent, - { name: "throws (substring)", before: {}, args: { id: 1, count: 2 }, throws: "not found" }, -); -// A case cannot declare both `after` and `throws` — the union forbids it. -// @ts-expect-error — `after` and `throws` are mutually exclusive -export const badBoth = cases(reparent, { name: "bad", before: {}, args: { id: 1, count: 2 }, after: { selected: 1 }, throws: true }); diff --git a/packages/data-testing/src/conformance/feature-spec.ts b/packages/data-testing/src/conformance/feature-spec.ts new file mode 100644 index 000000000..7e85fa680 --- /dev/null +++ b/packages/data-testing/src/conformance/feature-spec.ts @@ -0,0 +1,124 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Database } from "@adobe/data/ecs"; +import type { MatchOptions } from "../match/match.js"; +import type { + CaseModule, + HasDerivations, + HydrateExact, + HydrationKeys, + ProjectionShape, + ProjectionStore, + ProjectionValue, + RequiredServices, +} from "./feature-types.js"; +import type { Projection } from "./run-feature.js"; +import type { SchemaSource } from "./refify.js"; +import { runSpec } from "./run-spec.js"; +import { runFeature } from "./run-feature.js"; +import { adaptCases } from "./adapt-spec.js"; + +type AnyFn = (...args: never[]) => unknown; + +// One feature's whole conformance declaration — the single object both +// `spec.test.ts` (pure) and `conformance.test.ts` (ecs) import. It replaces the +// per-file `import.meta.glob` discovery with an explicit, local manifest: adding a +// transform means one line here, checked at COMPILE TIME (see `feature`). +export interface FeatureSpec, StoreT> { + // The `State` namespace: `create()` is the default each case's `before`/`input` + // deltas over; `samples` are full states for the projection round-trip. + readonly state: { create(): State; readonly samples?: readonly State[] }; + // The conformed functions — exactly the transforms and derivations under test, + // authored as an `import * as transforms` barrel. Its keys drive the guard below. + readonly fns: Fns; + // The corpus, one inert `*.cases.ts` module per fn. The mapped type over `keyof + // Fns` is the coverage guard: a fn with no cases, or a case with no fn, is a + // compile error — the guarantee the glob shape could never give. + readonly cases: { readonly [K in keyof Fns]: CaseModule }; + // Shape-only recording-double templates, keyed by injected-service arg name. The + // runner calls each once to enumerate the service's methods (no Proxy), then + // synthesizes returns from each case's `responses`. Omit for a service-free feature. + readonly services?: Readonly object>>; + readonly plugin: Database.Plugin; + readonly computedPlugin?: Database.Plugin; + readonly projection: Projection; + readonly hydrate?: readonly string[]; + readonly match?: MatchOptions; + readonly ops?: { + readonly transactions?: Record; + readonly actions?: Record; + readonly computeds?: Record; + }; +} + +// Author a feature's conformance manifest. Purely a typed identity — it exists so the +// generics infer from the argument and arm the compile-time guards. `P` (projection) +// and `C` (the concrete case modules) are captured, not widened, so their real types +// feed the checks: +// • `cases` coverage — `C extends { [K in keyof Fns]: CaseModule }` requires a module +// for every fn (missing → error); the `NoExtraCases` factor rejects a module whose +// key is not a fn (extra → error). +// • `hydrate` exactness — must be EXACTLY the entity-list derivations (`HydrateExact`). +// • `services` requiredness — exactly the injected-service keys the fns declare, or +// absent when none (`RequiredServices`); a missing template is a compile error, not +// a runtime crash. +// • `computedPlugin` requiredness — required when any authored case module is a +// derivation (`HasDerivations`), so a feature with derivations can't silently skip +// its computed conformance by omitting the plugin. +export const feature = < + State extends object, + Fns extends Record, + P extends ProjectionShape, + C extends { readonly [K in keyof Fns]: CaseModule }, + const H extends readonly Extract[] = readonly [], +>( + spec: Omit>, "projection" | "cases" | "hydrate" | "services" | "computedPlugin"> & { + readonly projection: P; + readonly cases: C; + readonly hydrate?: H; + } & RequiredServices & + (HasDerivations extends true ? { readonly computedPlugin: Database.Plugin } : { readonly computedPlugin?: Database.Plugin }) & + ([Exclude] extends [never] + ? unknown + : { readonly __casesError: "a case module names something that is not a conformed fn"; readonly extra: Exclude }) & + HydrateExact>>, +): FeatureSpec> => + // Runtime invariant the checker can't see: the constrained param IS this FeatureSpec + // (`P` widens to the stored `Projection`, `C` to the `cases` mapped type, `H` to + // `readonly string[]`, the fake factories to `() => object`). The several requiredness + // factors are compile-only phantoms with no runtime shape, and together they defeat + // TS's structural-overlap check for a direct `as`, so this goes through `unknown`. + spec as unknown as FeatureSpec>; + +// The pure-spec suite for a manifest: adapt the inert cases into the discovery map +// `runSpec` consumes — WITH freshly-built recording doubles merged into each case's +// args from the `services` templates + `responses` — and run it unchanged. This +// test file's doubles are its own, independent of the ecs side's. +export const checkSpec = , StoreT>( + spec: FeatureSpec, +): void => { + runSpec({ + state: spec.state, + transitions: adaptCases(spec.fns, spec.cases, spec.services, true), + plugin: spec.plugin, + match: spec.match, + }); +}; + +// The ecs conformance suite for a manifest: the same adapted, doubles-injected map +// drives `runFeature` unchanged (transactions ignore the injected services; only the +// actions consume each case's response schedule, exactly once). Fresh doubles again, +// so the pure and ecs runs never share FIFO state. +export const checkFeature = , StoreT extends SchemaSource>( + spec: FeatureSpec, +): void => { + runFeature({ + state: spec.state, + transitions: adaptCases(spec.fns, spec.cases, spec.services, true), + plugin: spec.plugin, + computedPlugin: spec.computedPlugin, + projection: spec.projection, + hydrate: spec.hydrate, + match: spec.match, + ops: spec.ops, + }); +}; diff --git a/packages/data-testing/src/conformance/feature-spec.type-test.ts b/packages/data-testing/src/conformance/feature-spec.type-test.ts new file mode 100644 index 000000000..7a280403f --- /dev/null +++ b/packages/data-testing/src/conformance/feature-spec.type-test.ts @@ -0,0 +1,174 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +// Type-level checks for the `feature` manifest's two compile-time guards: the `cases` +// coverage guard (exactly one case module per conformed fn) and the `hydrate` +// exactness guard (exactly the entity-list derivations, no more, no fewer). Each guard +// gets a POSITIVE case (must compile) and NEGATIVE cases (`@ts-expect-error`, must NOT +// compile) — a negative that stops failing turns the directive into an "unused +// directive" error, so the guard can't silently rot. +import type { Database, Entity } from "@adobe/data/ecs"; +import { feature } from "./feature-spec.js"; +import type { CaseModule, SpecCases, SpecDerivations } from "./feature-types.js"; + +type Foo = { readonly name: string }; +type State = { readonly entities: ReadonlyMap; readonly count: number }; + +// A transition (2 params) and an entity-list derivation (returns `readonly Foo[]`, so +// its ecs computed emits ids → it MUST be hydrated). +const bump = (s: Pick, args: { readonly by: number }): Pick => ({ count: s.count + args.by }); +const listFoos = (s: Pick): readonly Foo[] => [...s.entities.values()]; +const fns = { bump, listFoos }; + +interface Store { + readonly componentSchemas: Record; +} +declare const plugin: Database.Plugin; +declare const computedPlugin: Database.Plugin; +declare const state: { create(): State; readonly samples?: readonly State[] }; + +// A concrete projection whose `toData` returns the entity value `Foo` — that return +// type is what tells the guard `listFoos` is an entity-list derivation. +const projection = { + fromState: (_store: Store, _s: State): void => {}, + toState: (_store: Store): State => ({ entities: new Map(), count: 0 }), + toData: (_store: Store, _e: Entity): Foo => ({ name: "" }), +}; + +const bumpCases: SpecCases = { + cases: [{ name: "bumps", before: {}, args: { by: 1 }, after: { count: 1 } }], +}; +const listFoosCases: SpecDerivations = { + cases: [{ name: "lists", input: { entities: new Map() }, value: [] }], +}; + +// POSITIVE — every fn has a case, and `hydrate` is exactly the entity-list derivation. +export const ok = feature({ state, fns, plugin, computedPlugin, projection, hydrate: ["listFoos"], cases: { bump: bumpCases, listFoos: listFoosCases } }); + +// NEGATIVE (cases coverage) — a conformed fn with no case module. +// @ts-expect-error — `cases` is missing `listFoos` +export const missingCase = feature({ state, fns, plugin, computedPlugin, projection, hydrate: ["listFoos"], cases: { bump: bumpCases } }); + +// NEGATIVE (cases coverage) — a case module for a name that is not a conformed fn. +// @ts-expect-error — `cases` has `bogus`, which is not in `fns` +export const extraCase = feature({ state, fns, plugin, computedPlugin, projection, hydrate: ["listFoos"], cases: { bump: bumpCases, listFoos: listFoosCases, bogus: bumpCases } }); + +// NEGATIVE (hydrate exactness) — an entity-list derivation left out of `hydrate`. +// @ts-expect-error — `hydrate` is missing the entity-list derivation `listFoos` +export const missingHydrate = feature({ state, fns, plugin, computedPlugin, projection, hydrate: [], cases: { bump: bumpCases, listFoos: listFoosCases } }); + +// NEGATIVE (hydrate exactness) — a non-entity-list fn listed in `hydrate`. +// @ts-expect-error — `hydrate` lists `bump`, which does not need hydration +export const extraHydrate = feature({ state, fns, plugin, computedPlugin, projection, hydrate: ["listFoos", "bump"], cases: { bump: bumpCases, listFoos: listFoosCases } }); + +// ── `SpecCases` case-shape guards (tested directly, without the manifest) ────────── +// A service-injected transition: `log` is fire-and-forget (void), `next` returns a value. +interface Logger { + log: (msg: string) => void; + next: () => Promise; +} +const record = (s: Pick, args: { readonly by: number; readonly logger: Logger }): Pick => ({ + count: s.count + args.by + (args.logger.log("x"), 0), +}); + +// POSITIVE — data-only `args` (the `logger` service is stripped), a scheduled return for +// the value method `next`, and the void `log` asserted in `effects`. +export const recOk: SpecCases = { + cases: [{ name: "ok", before: {}, args: { by: 1 }, responses: { logger: { next: [5] } }, after: { count: 6 }, effects: { logger: [["log", "x"]] } }], +}; + +// NEGATIVE — a live service instance in `args` (args are DATA only). +// @ts-expect-error — `logger` is a service; it must not appear in `args` +export const recBadArgsService: SpecCases = { cases: [{ name: "x", before: {}, args: { by: 1, logger: {} }, after: { count: 1 } }] }; + +// NEGATIVE — a required data arg omitted. +// @ts-expect-error — `args` is missing the required `by` +export const recMissingArg: SpecCases = { cases: [{ name: "x", before: {}, args: {}, after: { count: 1 } }] }; + +// NEGATIVE — scheduling a response for a `void` method (only value methods take one). +// @ts-expect-error — `log` is void; it cannot be scheduled in `responses` +export const recVoidResponse: SpecCases = { cases: [{ name: "x", before: {}, args: { by: 1 }, responses: { logger: { log: ["y"] } }, after: { count: 1 } }] }; + +// NEGATIVE — a scheduled response of the wrong payload type (`next` yields `number`). +// @ts-expect-error — `next` yields `number`, not `string` +export const recWrongResponse: SpecCases = { cases: [{ name: "x", before: {}, args: { by: 1 }, responses: { logger: { next: ["nope"] } }, after: { count: 1 } }] }; + +// POSITIVE — a `throws` case (no `after`/`effects`/`responses`). +export const recThrows: SpecCases = { cases: [{ name: "x", before: {}, args: { by: 1 }, throws: "boom" }] }; + +// NEGATIVE — `after` and `throws` are mutually exclusive. +// @ts-expect-error — a case is either an `after` expectation or a `throws`, never both +export const recBoth: SpecCases = { cases: [{ name: "x", before: {}, args: { by: 1 }, after: { count: 1 }, throws: true }] }; + +// ── `CaseModule` transition/derivation discrimination ────────────────────────────── +// The KIND is the authored `SpecCases`/`SpecDerivations` annotation, NOT the fn's +// signature — arity and return-type structure are both unsound discriminators, so +// `CaseModule` is a bare union. These pin the cases the union must (and must not) admit. + +// POSITIVE — a no-arg, service-free transition `(state) => patch`. This is the exact +// regression the old arity-based `CaseModule` caused (one param → misread as derivation). +declare const reset: (s: Pick) => Pick; +declare const resetCases: SpecCases; +export const resetOk: CaseModule = resetCases; + +// POSITIVE — an async transition `(state, args) => Promise` (return is a Promise, +// so a return-type-`extends Partial` check would have misclassified it). +declare const saveAsync: (s: Pick, a: { readonly id: number }) => Promise>; +declare const asyncCases: SpecCases; +export const asyncOk: CaseModule = asyncCases; + +// POSITIVE — derivations returning an array (also a hydration key), a set, and a scalar. +export const arrOk: CaseModule = listFoosCases; +declare const idsOf: (s: State) => ReadonlySet; +declare const idsCases: SpecDerivations; +export const setOk: CaseModule = idsCases; +declare const total: (s: State) => number; +declare const totalCases: SpecDerivations; +export const scalarOk: CaseModule = totalCases; + +// NEGATIVE — wrong-arm annotation, caught by the envelope's content (not its arity). +// @ts-expect-error — a transition envelope cannot hold derivation-shaped `{ input, value }` cases +export const wrongTransition: SpecCases = { cases: [{ name: "x", input: { count: 0 }, value: 0 }] }; +// @ts-expect-error — a derivation envelope cannot hold transition-shaped `{ before, after }` cases +export const wrongDerivation: SpecDerivations = { cases: [{ name: "x", before: {}, after: { count: 1 } }] }; + +// NEGATIVE — mis-pairing an array-derivation module into a transition fn's slot, caught +// by the `ReturnType`/args mismatch inside the envelope generics. +// @ts-expect-error — a derivation module for `listFoos` is not a valid case module for the transition `bump` +export const mispair: CaseModule = listFoosCases; + +// RESIDUAL GAP (documented, deliberately compiles — NOT a `@ts-expect-error`): a +// transition-shaped module accepted for a `(state) => value` derivation, because args +// align (`ArgsOf` is void) and a state patch is a valid `after`. Unreachable from the +// signature; caught by the conformance run when it name-pairs against a computed. +declare const count: (s: State) => number; +export const residualGap: CaseModule = resetCases; + +// ── `computedPlugin` requiredness (needed iff the manifest has a derivation) ───────── +// `fns` above has `listFoos` (a derivation), so `ok` (which includes `computedPlugin`) +// is the POSITIVE. NEGATIVE — omit it with a derivation present: +// @ts-expect-error — `computedPlugin` is required because `listFoos` is a derivation +export const missingComputedPlugin = feature({ state, fns, plugin, projection, hydrate: ["listFoos"], cases: { bump: bumpCases, listFoos: listFoosCases } }); + +// POSITIVE — a transition-only feature may omit `computedPlugin` (no derivations). +const txFns = { bump }; +declare const txCases: SpecCases; +export const noComputedOk = feature({ state, fns: txFns, plugin, projection, cases: { bump: txCases } }); + +// ── `services` requiredness (needed iff a fn injects a service) ────────────────────── +declare const logger: Logger; +const svcFns = { record }; +declare const recordCases: SpecCases; + +// POSITIVE — the injected service `logger` has a template. +export const servicesOk = feature({ state, fns: svcFns, plugin, projection, services: { logger: () => logger }, cases: { record: recordCases } }); + +// NEGATIVE — a fn injects `logger`, but `services` is omitted (today: a runtime crash). +// @ts-expect-error — `services` is required because `record` injects `logger` +export const missingServices = feature({ state, fns: svcFns, plugin, projection, cases: { record: recordCases } }); + +// NEGATIVE — `services` omits the required `logger` template. +// @ts-expect-error — `services` is missing the `logger` template +export const incompleteServices = feature({ state, fns: svcFns, plugin, projection, services: {}, cases: { record: recordCases } }); + +// NEGATIVE — a service-free feature must not carry dead templates. +// @ts-expect-error — `bump` injects no service, so `services` must be absent +export const extraServices = feature({ state, fns: txFns, plugin, projection, services: { logger: () => logger }, cases: { bump: txCases } }); diff --git a/packages/data-testing/src/conformance/feature-types.ts b/packages/data-testing/src/conformance/feature-types.ts new file mode 100644 index 000000000..cdc3451ef --- /dev/null +++ b/packages/data-testing/src/conformance/feature-types.ts @@ -0,0 +1,196 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Schema } from "@adobe/data/schema"; +import type { Effects } from "./types.js"; + +// A service arg is an object with method members (not an array, not a function) — +// the same discriminator `types.ts` uses, re-derived here so this file stands alone. +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 transform's args type — its second parameter, or `void` when it takes none. +type ArgsOf = F extends (...a: never[]) => unknown + ? Parameters extends [unknown, infer A, ...unknown[]] + ? A + : void + : void; + +// The payload a method's return unwraps to: a Promise's resolution, an async +// generator's yield, or a sync value as-is. A case schedules PAYLOADS — the runner +// hands each straight back, and an `await` on a non-promise unwraps it fine. +type Payload = R extends Promise + ? T + : R extends AsyncGenerator + ? T + : R; +type ScheduleOf = M extends (...a: never[]) => infer R ? readonly Payload[] : never; + +// Only VALUE-returning methods take a schedule; `void` methods are excluded from +// the type, so a case can't author a response for a fire-and-forget effect. +type ValueMethodKeys = { + [M in keyof S]-?: S[M] extends (...a: never[]) => infer R ? ([Payload] extends [void] ? never : M) : never; +}[keyof S]; +type ServiceResponses = { readonly [M in ValueMethodKeys]?: ScheduleOf }; + +// A case's scheduled service RETURNS, keyed by the service-typed args only — the +// return-value counterpart of `Effects`. Each entry is a per-method FIFO the +// runner's recording double drains; the case owns the values, so its `after`/ +// `effects` never guess at what the double invents (the anti-pattern the old +// hardcoded fakes fell into). +export type Responses = { + readonly [K in keyof Args as IsService extends true ? K : never]?: ServiceResponses; +}; + +// The case's authored `args`, with injected-service fields removed — a case carries +// only DATA; the recording doubles are synthesized by the runner from the feature's +// `services` templates plus this case's `responses`. +type DataArgKeys = { [K in keyof Args]-?: IsService extends true ? never : K }[keyof Args]; +type DataArgs = Pick>; + +// One inert transition case: `before`/`after` state deltas, DATA-only `args`, and +// the test-only channels `effects` (calls to assert) and `responses` (returns to +// schedule) — or a `throws` expectation. No live service instance ever appears. +export type SpecCase = ({ readonly name: string; readonly before: Partial } & ( + | { + readonly after: Partial; + readonly effects?: Effects; + readonly responses?: Responses; + readonly throws?: undefined; + } + | { readonly throws: true | string; readonly after?: undefined; readonly effects?: undefined; readonly responses?: undefined } +)) & + ([keyof DataArgs] extends [never] ? { readonly args?: undefined } : { readonly args: DataArgs }); + +// One inert derivation case: a state `input` and the `value` it yields. +export type SpecDerivationCase = { + readonly name: string; + readonly input: Input; + readonly value: Value; +}; + +// The envelope authored in a `*.cases.ts` file for a transition: the case list plus +// an optional `args` schema marking entity-reference fields (the ecs side resolves +// a spec-id to its seeded entity through it; the pure side never resolves). +export type SpecCases unknown> = { + readonly args?: Schema; + readonly cases: readonly SpecCase>[]; +}; + +// The `*.cases.ts` envelope for a derivation — cases only, never an `args` schema. +export type SpecDerivations unknown> = { + readonly cases: readonly SpecDerivationCase[0], ReturnType>[]; +}; + +// The value a projection hydrates one entity into — its `toData` return type. `never` +// for a feature with no entities (no `toData`), which makes nothing require hydration. +export type ProjectionValue

= P extends { toData?: (store: never, entity: never) => infer V } ? V : never; + +// The store type a projection reads — recovered from its `toState`, so a manifest +// captures the concrete projection and the store type follows, rather than being a +// separate generic with no inference site (which would collapse to `unknown`). +export type ProjectionStore

= P extends { toState: (store: infer S) => unknown } ? S : unknown; + +// The shape `feature` constrains a projection to — store positions are `never` (a +// bottom, so any concrete store satisfies it) purely so `P` is captured concretely +// and `ProjectionStore` / `ProjectionValue` can read its real types. +export type ProjectionShape = { + readonly fromState: (store: never, state: State) => unknown; + readonly toState: (store: never) => State; + readonly toData?: (store: never, entity: never) => unknown; +}; + +// The derivations that MUST be hydrated: those whose pure value is a collection of the +// entity value `V`. Their ecs computed emits entity ids (not values), so the runner +// must map each id through `toData` to compare against the case's value. A scalar +// derivation, or one that itself returns ids, is not listed. Transitions (which return +// state patches, never a `V` collection) never match. +export type HydrationKeys = [V] extends [never] + ? never + : { + [K in keyof Fns]: Fns[K] extends (...a: never[]) => infer R + ? [R] extends [readonly V[]] + ? K & string + : [R] extends [ReadonlySet] + ? K & string + : never + : never; + }[keyof Fns]; + +// Compile-time exactness for `hydrate`: `unknown` (no constraint) when the provided +// list is EXACTLY the required set, otherwise an un-satisfiable error object naming +// what is missing or extra — so `feature({...})` fails to type until `hydrate` matches. +export type HydrateExact = [ + Exclude, +] extends [never] + ? [Exclude] extends [never] + ? unknown + : { + readonly __hydrateError: "hydrate lists a computed that does not need hydration"; + readonly unexpected: Exclude; + } + : { + readonly __hydrateError: "hydrate is missing required entity-list computed(s)"; + readonly missing: Exclude; + }; + +// ── Requiredness of `services` and `computedPlugin`, derived at compile time ───────── + +// The injected-service arg keys used across all fns — the services the runner must +// have recording-double templates for. Service detection on an ARG TYPE is reliable +// (unlike transition/derivation kind), so this set is sound. A derivation or a no-arg +// transition contributes nothing (its `ArgsOf` is `void`). +export type ServiceArgKeys = { + [K in keyof Fns]: { + [A in keyof ArgsOf]-?: IsService[A]> extends true ? A : never; + }[keyof ArgsOf]; +}[keyof Fns] & + string; + +// The service type registered under one arg key — unioned across the fns that inject it +// (consistent, by the one-property-one-type rule the feature model enforces). +export type ServiceForKey = { + [F in keyof Fns]: K extends keyof ArgsOf ? ArgsOf[K] : never; +}[keyof Fns]; + +// `services` requiredness: when any fn injects a service, exactly those keys are +// required (each a factory returning that service type); otherwise `services` must be +// absent. So a feature that needs doubles cannot omit them (today: a runtime crash on +// the first service call), and a service-free feature cannot carry dead templates. +export type RequiredServices = [ServiceArgKeys] extends [never] + ? { readonly services?: undefined } + : { readonly services: { readonly [K in ServiceArgKeys]: () => ServiceForKey } }; + +// Whether any authored case module is a derivation envelope — i.e. the feature has +// `state/` derivations whose ecs computeds need conformance, so `computedPlugin` is +// required. Read from the concrete authored `cases` (the reliable kind marker), since +// the fn signature cannot tell a `(state) => value` derivation from a no-arg transition. +export type HasDerivations = true extends { + [K in keyof Cases]: Cases[K] extends SpecDerivations<(...a: never[]) => unknown> ? true : false; +}[keyof Cases] + ? true + : false; + +// The case module the manifest requires for a given fn. The KIND — transition vs +// derivation — is authored EXPLICITLY per file: each `*.cases.ts` annotates its `cases` +// export as `SpecCases` or `SpecDerivations`, and that annotation is the discriminator. +// The union lets the authored shape flow through; nothing is inferred from the fn's +// signature, because neither axis is sound — a no-arg transition `(state) => patch` and +// a `(state) => value` derivation share an arity, an async transition returns +// `Promise>` (not `Partial`), and an object-shaped derivation can +// structurally look like a state patch. The two envelopes are content-disjoint +// (`before`+`after`/`throws` vs `input`+`value`) and both are generic in `F`, so a +// wrong-arm annotation, or a module paired to a fn whose args/return differ, is a +// compile error. The one residual case the compiler cannot reject — a transition-shaped +// module supplied for a `(state) => value` derivation, where args align and a state +// patch is a valid `after` — is caught by the conformance run (`discover.ts` routes on +// `"value" in case`, then name-pairs and fails loudly), the intended safety net. +export type CaseModule unknown> = + | SpecCases + | SpecDerivations; diff --git a/packages/data-testing/src/conformance/public.ts b/packages/data-testing/src/conformance/public.ts index cc5be2f94..ccea6a910 100644 --- a/packages/data-testing/src/conformance/public.ts +++ b/packages/data-testing/src/conformance/public.ts @@ -1,22 +1,29 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -export type { Case, Cases, DerivationCase, DerivationCases, Effects, ServiceCall } from "./types.js"; -export { - casesBuilder, - derivationsBuilder, - type CasesBuilder, - type CasesResult, - type DerivationsBuilder, - type DerivationsResult, -} from "./cases.js"; -export { runSpec, type SpecRunConfig } from "./run-spec.js"; + +// The conformance manifest API — the standard surface. `feature` builds a feature's +// manifest; `checkSpec` runs the pure spec suite, `checkFeature` the ecs suite. +export { feature, checkSpec, checkFeature, type FeatureSpec } from "./feature-spec.js"; +export type { + Responses, + SpecCase, + SpecCases, + SpecDerivationCase, + SpecDerivations, + CaseModule, +} from "./feature-types.js"; +export type { Effects, ServiceCall } from "./types.js"; + +// The escape hatch — the lower-level runners, for a feature whose ecs conformance needs +// per-surface `seedContext` or a custom concurrency/db (e.g. a user-scoped `userId`); it +// feeds them from its manifest with `adaptCases`. Ordinary features use `checkFeature`. +export { adaptCases } from "./adapt-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"; -// For custom conformance harnesses (e.g. an ECS-system frame): compare two States -// up to an id-bijection, the same way the runners do. +export type { Projection } from "./run-feature.js"; + +// For custom conformance harnesses: compare two States up to an id-bijection, and assert +// a call throws, the same way the built-in runners do. export { assertState } from "./assert-state.js"; -// For custom conformance harnesses: assert a call throws, the same way a case's -// `throws` field does in the built-in runners. export { expectThrows } from "./expect-throws.js"; export type { SchemaSource } from "./refify.js"; diff --git a/packages/data-testing/src/conformance/run-spec.empty-cases.test.ts b/packages/data-testing/src/conformance/run-spec.empty-cases.test.ts index a6c8797e6..6c88f4f24 100644 --- a/packages/data-testing/src/conformance/run-spec.empty-cases.test.ts +++ b/packages/data-testing/src/conformance/run-spec.empty-cases.test.ts @@ -1,10 +1,10 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Conformance } from "../index.js"; +import { runSpec } from "./run-spec.js"; // A module whose envelope holds `cases: []` opens an empty `describe` and used to // leave the file suite-less. Empty discovery registers a no-op so Vitest still // collects a suite. -Conformance.runSpec({ +runSpec({ state: { create: () => ({ n: 0 }) }, transitions: { "./empty-cases.ts": { diff --git a/packages/data-testing/src/conformance/run-spec.empty.test.ts b/packages/data-testing/src/conformance/run-spec.empty.test.ts index 6777f6e10..22851cb8e 100644 --- a/packages/data-testing/src/conformance/run-spec.empty.test.ts +++ b/packages/data-testing/src/conformance/run-spec.empty.test.ts @@ -1,11 +1,11 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Conformance } from "../index.js"; +import { runSpec } from "./run-spec.js"; // Features with empty State and no pure transforms still call `runSpec` from a // dedicated `spec.test.ts`. Discovery with zero case modules must not leave the // file suite-less (Vitest: "No test suite found"). This file is the regression: // it is only this call — if empty discovery failed, vitest would fail the file. -Conformance.runSpec({ +runSpec({ state: { create: () => ({}) }, transitions: {}, }); diff --git a/packages/data-testing/src/conformance/run-spec.test.ts b/packages/data-testing/src/conformance/run-spec.test.ts index 82378cfda..b1fc74471 100644 --- a/packages/data-testing/src/conformance/run-spec.test.ts +++ b/packages/data-testing/src/conformance/run-spec.test.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Conformance } from "../index.js"; +import { runSpec } from "./run-spec.js"; // Non-empty discovery: one pure transition with co-located cases still pairs and // asserts as before (empty-discovery path must not break populated specs). @@ -17,7 +17,7 @@ const withdraw = ( return { n: state.n - amount }; }; -Conformance.runSpec({ +runSpec({ state: { create: () => ({ n: 0 }) }, transitions: { "./increment.ts": { diff --git a/packages/data-testing/src/conformance/types.ts b/packages/data-testing/src/conformance/types.ts index 98b20fbfc..1c4f083b3 100644 --- a/packages/data-testing/src/conformance/types.ts +++ b/packages/data-testing/src/conformance/types.ts @@ -28,49 +28,11 @@ export type Effects = { | 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, 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. -// -// A case is EITHER a normal `after`/`effects` expectation OR a `throws` -// expectation — never both, since a thrown call never produces a writes patch or -// records effects to assert. `throws: true` accepts any thrown error; a string -// narrows to one the error's `message` must contain as a substring. -export type Case = ({ readonly name: string; readonly before: Partial } & ( - | { readonly after: Partial; readonly effects?: Effects; readonly throws?: undefined } - | { readonly throws: true | string; readonly after?: undefined; readonly effects?: undefined } -)) & - ([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. +// One case for a derivation (`(state) => value`): a state `input` and the `value` it +// yields. `value` may use asymmetric matchers. Used internally by the runners to +// dispatch on case shape (the authored surface is `SpecDerivationCase` in feature-types). 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-testing/src/index.ts b/packages/data-testing/src/index.ts index d6e0f7b68..10dd00133 100644 --- a/packages/data-testing/src/index.ts +++ b/packages/data-testing/src/index.ts @@ -2,34 +2,27 @@ // 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` lets a bundler drop an -// unused *import* of this package, but a co-located `Conformance.cases(fn, …)` / -// `Conformance.derivations(fn, …)` call in a transform file is still a plain -// function call — an unannotated call is kept even when its result (the `cases` -// export) is never read. Every call site MUST be written -// `/*@__PURE__*/ Conformance.cases(...)` (see `data/state.md`), or the case -// fixtures — and this package's builder code — ship in the production bundle -// of any app that actually calls the transition. Verified against a Vite/Rollup -// production build: without the annotation the case data is retained; with it, -// gone. +// Conformance — the manifest API (`feature` / `checkSpec` / `checkFeature`), the case +// types, and the lower-level runner escape hatch. +// Import only from a feature's test tier (its `*.cases.ts`, `spec.ts`, `*.fake.ts`, and +// `*.test.ts`), which the package's project-reference wall keeps out of the runtime build. export * as Match from "./match/public.js"; export * as Conformance from "./conformance/public.js"; -// The conformance case TYPES, also re-exported top-level (compile-time only, so the -// `Conformance` namespace API is unchanged). A `Conformance.cases(...)` result flows -// into a downstream package's emitted `.d.ts` as one of these types; TS cannot name a -// type reached only through an `export * as` namespace, so without a top-level path it -// falls back to the deep `dist/…` file path — which is not a package export, and the -// consumer's build fails with TS2883. Naming them here makes that reference portable. +// The conformance case TYPES, also re-exported top-level (compile-time only). A +// `Conformance.SpecCases<…>` result flows into a downstream package's emitted `.d.ts` as +// one of these types; TS cannot name a type reached only through an `export * as` +// namespace, so without a top-level path it falls back to the deep `dist/…` file path — +// which is not a package export, and the consumer's build fails with TS2883. Naming them +// here makes that reference portable. export type { - Case, - Cases, - CasesResult, - DerivationCase, - DerivationCases, - DerivationsResult, + SpecCase, + SpecCases, + SpecDerivationCase, + SpecDerivations, + Responses, + CaseModule, + FeatureSpec, Effects, ServiceCall, } from "./conformance/public.js"; diff --git a/packages/data/package.json b/packages/data/package.json index a5b3da5a1..a35a94c74 100644 --- a/packages/data/package.json +++ b/packages/data/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data", - "version": "0.10.19", + "version": "0.10.20", "description": "Adobe data oriented programming library", "type": "module", "sideEffects": false,