diff --git a/packages/data-solid-dashboard/src/components/control-panel.presentation.tsx b/packages/data-solid-dashboard/src/features/main/ui/control-panel/control-panel.presentation.tsx similarity index 100% rename from packages/data-solid-dashboard/src/components/control-panel.presentation.tsx rename to packages/data-solid-dashboard/src/features/main/ui/control-panel/control-panel.presentation.tsx diff --git a/packages/data-solid-dashboard/src/components/control-panel.tsx b/packages/data-solid-dashboard/src/features/main/ui/control-panel/control-panel.tsx similarity index 56% rename from packages/data-solid-dashboard/src/components/control-panel.tsx rename to packages/data-solid-dashboard/src/features/main/ui/control-panel/control-panel.tsx index 57e9e4b5..d482eaa7 100644 --- a/packages/data-solid-dashboard/src/components/control-panel.tsx +++ b/packages/data-solid-dashboard/src/features/main/ui/control-panel/control-panel.tsx @@ -1,13 +1,14 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { fromObserve, useDatabase } from "@adobe/data-solid"; -import { dashboardPlugin } from "../state/dashboard-plugin"; -import * as presentation from "./control-panel.presentation"; +import { MainService } from "../../services/main-service/main-service.js"; +import * as presentation from "./control-panel.presentation.jsx"; export function ControlPanel() { - const db = useDatabase(dashboardPlugin); + const db = useDatabase(MainService.plugin); const count = fromObserve(db.observe.resources.count, 0); const { increment, decrement, reset, setUserName } = db.transactions; + const setName = (name: string) => setUserName({ name }); - return presentation.render({ count, increment, decrement, reset, setUserName }); + return presentation.render({ count, increment, decrement, reset, setUserName: setName }); } diff --git a/packages/data-solid-dashboard/src/components/counter-display.presentation.tsx b/packages/data-solid-dashboard/src/features/main/ui/counter-display/counter-display.presentation.tsx similarity index 100% rename from packages/data-solid-dashboard/src/components/counter-display.presentation.tsx rename to packages/data-solid-dashboard/src/features/main/ui/counter-display/counter-display.presentation.tsx diff --git a/packages/data-solid-dashboard/src/components/counter-display.tsx b/packages/data-solid-dashboard/src/features/main/ui/counter-display/counter-display.tsx similarity index 57% rename from packages/data-solid-dashboard/src/components/counter-display.tsx rename to packages/data-solid-dashboard/src/features/main/ui/counter-display/counter-display.tsx index b4f9b8d5..2f71e8b3 100644 --- a/packages/data-solid-dashboard/src/components/counter-display.tsx +++ b/packages/data-solid-dashboard/src/features/main/ui/counter-display/counter-display.tsx @@ -1,11 +1,11 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { fromObserve, useDatabase } from "@adobe/data-solid"; -import { dashboardPlugin } from "../state/dashboard-plugin"; -import * as presentation from "./counter-display.presentation"; +import { MainService } from "../../services/main-service/main-service.js"; +import * as presentation from "./counter-display.presentation.jsx"; export function CounterDisplay() { - const db = useDatabase(dashboardPlugin); + const db = useDatabase(MainService.plugin); const count = fromObserve(db.observe.resources.count, 0); return presentation.render({ count }); diff --git a/packages/data-solid-dashboard/src/components/status-bar.presentation.tsx b/packages/data-solid-dashboard/src/features/main/ui/status-bar/status-bar.presentation.tsx similarity index 100% rename from packages/data-solid-dashboard/src/components/status-bar.presentation.tsx rename to packages/data-solid-dashboard/src/features/main/ui/status-bar/status-bar.presentation.tsx diff --git a/packages/data-solid-dashboard/src/components/status-bar.tsx b/packages/data-solid-dashboard/src/features/main/ui/status-bar/status-bar.tsx similarity index 73% rename from packages/data-solid-dashboard/src/components/status-bar.tsx rename to packages/data-solid-dashboard/src/features/main/ui/status-bar/status-bar.tsx index 26bdbe54..5f2cfe24 100644 --- a/packages/data-solid-dashboard/src/components/status-bar.tsx +++ b/packages/data-solid-dashboard/src/features/main/ui/status-bar/status-bar.tsx @@ -2,11 +2,11 @@ import { createMemo } from "solid-js"; import { fromObserve, useDatabase } from "@adobe/data-solid"; -import { dashboardPlugin } from "../state/dashboard-plugin"; -import * as presentation from "./status-bar.presentation"; +import { MainService } from "../../services/main-service/main-service.js"; +import * as presentation from "./status-bar.presentation.jsx"; export function StatusBar() { - const db = useDatabase(dashboardPlugin); + const db = useDatabase(MainService.plugin); const userName = fromObserve(db.observe.resources.userName, "Guest"); const count = fromObserve(db.observe.resources.count, 0); const log = fromObserve(db.observe.resources.log, []); diff --git a/packages/data-solid-dashboard/src/main.tsx b/packages/data-solid-dashboard/src/main.tsx index dfb12c85..8fbc4fd4 100644 --- a/packages/data-solid-dashboard/src/main.tsx +++ b/packages/data-solid-dashboard/src/main.tsx @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { render } from "solid-js/web"; -import { App } from "./app"; +import { App } from "./features/main/ui/app/app.jsx"; const root = document.getElementById("root"); if (root) { diff --git a/packages/data-solid-dashboard/src/state/dashboard-plugin.ts b/packages/data-solid-dashboard/src/state/dashboard-plugin.ts deleted file mode 100644 index 9deb9151..00000000 --- a/packages/data-solid-dashboard/src/state/dashboard-plugin.ts +++ /dev/null @@ -1,34 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. - -import { Database } from "@adobe/data/ecs"; - -export const dashboardPlugin = Database.Plugin.create({ - resources: { - count: { default: 0 as number }, - log: { default: [] as readonly string[] }, - userName: { default: "Guest" as string }, - }, - transactions: { - increment: (t) => { - t.resources.count += 1; - t.resources.log = [...t.resources.log, `Incremented to ${t.resources.count}`]; - }, - decrement: (t) => { - if (t.resources.count > 0) { - t.resources.count -= 1; - t.resources.log = [...t.resources.log, `Decremented to ${t.resources.count}`]; - } - }, - reset: (t) => { - t.resources.count = 0; - t.resources.log = [...t.resources.log, "Reset to 0"]; - }, - setUserName: (t, name: string) => { - t.resources.userName = name; - t.resources.log = [...t.resources.log, `Name changed to ${name}`]; - }, - clearLog: (t) => { - t.resources.log = []; - }, - }, -}); diff --git a/packages/data-solid/package.json b/packages/data-solid/package.json index 35bda073..55cd34bf 100644 --- a/packages/data-solid/package.json +++ b/packages/data-solid/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-solid", - "version": "0.9.92", + "version": "0.9.93", "description": "Adobe data SolidJS bindings — context and provider for ECS database", "type": "module", "private": false, diff --git a/packages/data-sync/package.json b/packages/data-sync/package.json index 8481a413..31f2a680 100644 --- a/packages/data-sync/package.json +++ b/packages/data-sync/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-sync", - "version": "0.9.92", + "version": "0.9.93", "description": "Multi-user real-time synchronisation for @adobe/data ECS — server, client, and in-process loopback.", "type": "module", "sideEffects": false, diff --git a/packages/data/package.json b/packages/data/package.json index edb58236..d04b8478 100644 --- a/packages/data/package.json +++ b/packages/data/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data", - "version": "0.9.92", + "version": "0.9.93", "description": "Adobe data oriented programming library", "type": "module", "sideEffects": false, @@ -91,6 +91,14 @@ "hash-wasm": "^4.12.0", "jsonpath": "^1.1.1" }, + "peerDependencies": { + "vitest": "^1.6.0" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + }, "typesVersions": { "*": { "*": [ @@ -131,6 +139,9 @@ ], "math": [ "./dist/math/index.d.ts" + ], + "testing": [ + "./dist/testing/index.d.ts" ] } }, @@ -139,6 +150,10 @@ "import": "./dist/index.js", "types": "./dist/index.d.ts" }, + "./testing": { + "import": "./dist/testing/index.js", + "types": "./dist/testing/index.d.ts" + }, "./functions": { "import": "./dist/functions/index.js", "types": "./dist/functions/index.d.ts" diff --git a/packages/data/src/testing/conformance/discover.ts b/packages/data/src/testing/conformance/discover.ts new file mode 100644 index 00000000..c501dee6 --- /dev/null +++ b/packages/data/src/testing/conformance/discover.ts @@ -0,0 +1,56 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +// One discovered `data/state` file: its function and its cases. +export interface Discovered { + readonly fn: (...args: unknown[]) => unknown; + readonly cases: readonly Record[]; +} + +const scan = ( + modules: Record>, + isKind: (firstCase: Record) => boolean, +): Map => { + const out = new Map(); + for (const [path, module] of Object.entries(modules)) { + const names = Object.keys(module); + if (!names.includes("cases")) continue; + const cases = module["cases"]; + if (!Array.isArray(cases) || cases.length === 0) continue; + const first = cases[0]; + if (typeof first !== "object" || first === null || !isKind(first as Record)) continue; + const fnName = names.find((key) => typeof module[key] === "function"); + if (!fnName) throw new Error(`${path} exports \`cases\` but no function to pair`); + out.set(fnName, { fn: module[fnName] as Discovered["fn"], cases: cases as Discovered["cases"] }); + } + return out; +}; + +// Transitions — files whose cases are `{ before, args?, after }` — keyed by the +// transform's function name (the name the ecs transaction/action must share). +export const discoverTransitions = (modules: Record>): Map => + scan(modules, (c) => "after" in c); + +// Derivations — files whose cases are `{ input, value }` — keyed by the +// derivation's function name (the name the ecs computed must share). +export const discoverDerivations = (modules: Record>): Map => + scan(modules, (c) => "value" in c); + +// Normalize the ecs-op source to `name → fn`. Accepts EITHER a facet barrel +// (`import * as x` — values are the functions, keyed by export name) OR a directory +// glob (`import.meta.glob(..., { eager: true })` — values are modules, each +// contributing its function exports). The glob form finds ops that live beside a +// barrel but aren't registered in it (a conformance-only action kept out of the +// plugin facet), so they still pair by name. +export const discoverOps = (source: Record): Map unknown> => { + const out = new Map unknown>(); + for (const [key, value] of Object.entries(source)) { + if (typeof value === "function") { + out.set(key, value as (...a: never[]) => unknown); + } else if (value !== null && typeof value === "object") { + for (const [name, member] of Object.entries(value)) { + if (typeof member === "function") out.set(name, member as (...a: never[]) => unknown); + } + } + } + return out; +}; diff --git a/packages/data/src/testing/conformance/effects.type-test.ts b/packages/data/src/testing/conformance/effects.type-test.ts new file mode 100644 index 00000000..72691120 --- /dev/null +++ b/packages/data/src/testing/conformance/effects.type-test.ts @@ -0,0 +1,59 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +// +// Compile-time only (no runtime tests): proves the shared `Effects` conformance +// shape accepts valid side-effect declarations and REJECTS invalid ones, ONCE for +// the whole library rather than per feature. `tsc` checks this file; vitest does +// not run it (it is not a `.test.ts`). If any `@ts-expect-error` stops erroring, or +// any positive stops compiling, the build fails — which is the point. +import type { Effects } from "./types.js"; + +// A representative injected service, and a transition arg shape: plain data + the +// service. (A stand-in for any feature's `SomethingService` — the type machinery +// is identical, which is exactly why this test lives here and not per feature.) +interface AnalyticsService { + readonly serviceName: "analytics"; + todoCreated(input: { readonly name: string }): void; + todoToggled(): void; + allTodosCleared(): void; + displayCompletedToggled(): void; +} +type Args = { readonly name: string; readonly complete?: boolean; readonly analytics: AnalyticsService }; + +// ===== POSITIVE — must compile ===== +const ordered: Effects = { analytics: [["todoCreated", { name: "a" }], ["todoToggled"]] }; +const anyOrder: Effects = { + analytics: new Set([["todoToggled"] as const, ["allTodosCleared"] as const]), +}; +const noArgMethod: Effects = { analytics: [["displayCompletedToggled"]] }; +const empty: Effects = {}; +void ordered; +void anyOrder; +void noArgMethod; +void empty; + +// ===== NEGATIVE — each must error ===== +const badMethod: Effects = { + // @ts-expect-error - "noSuchMethod" is not a method of AnalyticsService + analytics: [["noSuchMethod", { name: "a" }]], +}; +const badArgs: Effects = { + // @ts-expect-error - todoCreated takes { name: string }, not { count } + analytics: [["todoCreated", { count: 1 }]], +}; +const missingArgs: Effects = { + // @ts-expect-error - todoCreated requires its args element + analytics: [["todoCreated"]], +}; +const extraArg: Effects = { + // @ts-expect-error - todoToggled takes no args + analytics: [["todoToggled", { name: "a" }]], +}; +const dataKey: Effects = { + // @ts-expect-error - "name" is a data arg, not a service + name: [["todoCreated", { name: "a" }]], +}; +void badMethod; +void badArgs; +void missingArgs; +void extraArg; +void dataKey; diff --git a/packages/data/src/testing/conformance/entity-ref.ts b/packages/data/src/testing/conformance/entity-ref.ts new file mode 100644 index 00000000..292ea7c2 --- /dev/null +++ b/packages/data/src/testing/conformance/entity-ref.ts @@ -0,0 +1,36 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Entity } from "../../ecs/entity/entity.js"; +import type { Resolve } from "./resolve.js"; + +// A spec entity reference inside a case's `args`: `{ id: entity(1) }` names "the +// entity seeded for spec-id 1". It closes the one irreducible spec↔ecs vocabulary +// gap — identity: the pure spec reads the data-id, the ecs reads the entity the +// runner resolves. Typed as the id it stands in for, so it slots into the +// transform's own arg type (`{ id: number }`), exactly like `Match.anyNumber`. +const ENTITY_REF = Symbol.for("@adobe/data/testing:entity-ref"); + +export const entity = (specId: T): T => ({ [ENTITY_REF]: specId }) as unknown as T; + +const isEntityRef = (value: unknown): value is { readonly [ENTITY_REF]: unknown } => + typeof value === "object" && value !== null && ENTITY_REF in value; + +// Adapt a case's `args` for one side of the conformance. `resolve` present → the +// ecs side (refs become seeded entities); absent → the pure-spec side (refs become +// their data-id). Only the top-level arg values are inspected — a `ref` is always a +// direct arg field. Non-ref values pass through untouched; a non-object `args` +// (a scalar `dt`, or `undefined`) passes through whole. +export const adaptArgs = (args: Args, resolve?: Resolve): Args => { + if (args === null || typeof args !== "object" || Array.isArray(args)) return args; + let changed = false; + const next: Record = { ...(args as object) }; + for (const [key, value] of Object.entries(next)) { + if (isEntityRef(value)) { + const specId = value[ENTITY_REF]; + next[key] = resolve ? (resolve(specId) as unknown) : specId; + changed = true; + } + } + return (changed ? next : args) as Args; +}; + +export type { Entity }; diff --git a/packages/data/src/testing/conformance/public.ts b/packages/data/src/testing/conformance/public.ts new file mode 100644 index 00000000..bf546e41 --- /dev/null +++ b/packages/data/src/testing/conformance/public.ts @@ -0,0 +1,8 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +export type { Case, Cases, DerivationCase, DerivationCases, Effects, ServiceCall } from "./types.js"; +export { entity } from "./entity-ref.js"; +export { runSpec, type SpecRunConfig } from "./run-spec.js"; +export { runTransactions, type TransactionRunConfig } from "./run-transactions.js"; +export { runActions, type ActionRunConfig } from "./run-actions.js"; +export { runComputeds, type ComputedRunConfig } from "./run-computeds.js"; +export { runFeature, type FeatureRunConfig, type Projection } from "./run-feature.js"; diff --git a/packages/data/src/testing/conformance/record-effects.ts b/packages/data/src/testing/conformance/record-effects.ts new file mode 100644 index 00000000..2db73639 --- /dev/null +++ b/packages/data/src/testing/conformance/record-effects.ts @@ -0,0 +1,118 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { equalsUnordered } from "../../equals-unordered.js"; +import { matches } from "../match/match.js"; +import type { Effects } from "./types.js"; + +export type RecordedCall = readonly [string, ...unknown[]]; + +// A runtime service value: a non-array object with at least one method (mirrors +// the compile-time service detection in `types.ts`). +const isServiceValue = (value: unknown): value is object => + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.values(value).some((member) => typeof member === "function"); + +// Wrap a plain-object service so each method call is recorded, then delegates. +// No Proxy (services are plain objects with own enumerable methods), so we +// enumerate and closure-wrap each function. +export const recordCalls = (service: S): { service: S; calls: RecordedCall[] } => { + const calls: RecordedCall[] = []; + const wrapped = Object.fromEntries( + Object.entries(service).map(([key, value]) => [ + key, + typeof value === "function" + ? (...args: unknown[]): unknown => { + calls.push([key, ...args]); + return (value as (...a: unknown[]) => unknown)(...args); + } + : value, + ]), + ) as S; + return { service: wrapped, calls }; +}; + +// Wrap the injected services IN PLACE within a case's `args` (leaving plain data +// untouched), returning the args ready to pass to a pure transform plus the +// per-service `calls` map. Used by the pure spec runner, which calls the transform +// with its full args; the ecs action runner uses `splitAndRecordServices` instead. +export const recordArgServices = ( + args: Args, +): { args: Args; calls: Record } => { + const calls: Record = {}; + if (args === null || typeof args !== "object" || Array.isArray(args)) return { args, calls }; + const next = { ...(args as object) } as Record; + for (const [key, value] of Object.entries(args as object)) { + if (isServiceValue(value)) { + const recorded = recordCalls(value); + next[key] = recorded.service; + calls[key] = recorded.calls; + } + } + return { args: next as Args, calls }; +}; + +// Split a case's `args` into the injected services (wrapped for recording) and +// the remaining plain data. Keyed by the same arg name so `calls` matches against +// `effects`. A no-arg case (`undefined` args) splits into nothing. +export const splitAndRecordServices = ( + args: Args, +): { services: Record; input: Record; calls: Record } => { + const services: Record = {}; + const input: Record = {}; + const calls: Record = {}; + if (args !== null && typeof args === "object" && !Array.isArray(args)) { + for (const [key, value] of Object.entries(args)) { + if (isServiceValue(value)) { + const recorded = recordCalls(value); + services[key] = recorded.service; + calls[key] = recorded.calls; + } else { + input[key] = value; + } + } + } + return { services, input, calls }; +}; + +const showCalls = (calls: unknown): string => { + try { + return JSON.stringify(calls) ?? String(calls); + } catch { + return String(calls); + } +}; + +// Assert the calls recorded against one service match the case's expectation for +// it: an Array expects exactly these calls in order; a Set expects the same calls +// in any order (multiset). Absent expectation ⇒ no calls expected. Ordered calls +// compare matcher-aware, so a call arg may itself use `anyNumber`. +const expectServiceCalls = ( + key: string, + recorded: readonly RecordedCall[], + expected: readonly RecordedCall[] | ReadonlySet | undefined, +): void => { + const ok = + expected instanceof Set ? equalsUnordered(recorded, [...expected]) : matches(recorded, expected ?? []); + if (!ok) { + throw new Error( + `effects mismatch on "${key}":\n recorded: ${showCalls(recorded)}\n expected: ${showCalls( + expected instanceof Set ? [...expected] : (expected ?? []), + )}`, + ); + } +}; + +// Assert each service DECLARED in `effects` saw exactly its expected calls (an +// extra or missing call on a declared service fails). Services not listed — e.g. a +// value-returning read like `generateName` — are ignored, so `effects` captures the +// fire-and-forget side effects a case chooses to assert. +export const expectEffects = ( + calls: Record, + effects: Effects> | undefined, +): void => { + const expected = (effects ?? {}) as Record>; + for (const key of Object.keys(expected)) { + expectServiceCalls(key, calls[key] ?? [], expected[key]); + } +}; diff --git a/packages/data/src/testing/conformance/resolve.ts b/packages/data/src/testing/conformance/resolve.ts new file mode 100644 index 00000000..052b3a9a --- /dev/null +++ b/packages/data/src/testing/conformance/resolve.ts @@ -0,0 +1,16 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Entity } from "../../ecs/entity/entity.js"; + +// Maps a spec-domain id to the ecs entity seeded for it. A feature's `fromState` +// returns the `Id → Entity` map (it already loops its collections to seed); the +// conformance runners turn that map into this resolver, so no feature writes id +// resolution by hand. An id no entity carries resolves to `Entity.none`, so an +// id-addressed transaction reads no such entity and is a no-op. +export type Resolve = (id: Id) => Entity; + +// Build a resolver from a `fromState` seed map. A feature whose transactions are +// addressed by index or are singleton (no id → entity mapping) returns `void` +// from `fromState`; its resolver is then never called, and any id resolves to +// `Entity.none`. +export const resolver = (seeded: ReadonlyMap | void): Resolve => (id) => + (seeded ? seeded.get(id) : undefined) ?? Entity.none; diff --git a/packages/data/src/testing/conformance/run-actions.ts b/packages/data/src/testing/conformance/run-actions.ts new file mode 100644 index 00000000..62df8b84 --- /dev/null +++ b/packages/data/src/testing/conformance/run-actions.ts @@ -0,0 +1,57 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it } from "vitest"; +import type { Entity } from "../../ecs/entity/entity.js"; +import { assert } from "../match/assert.js"; +import type { MatchOptions } from "../match/match.js"; +import { adaptArgs } from "./entity-ref.js"; +import { discoverTransitions, discoverOps } from "./discover.js"; +import { splitAndRecordServices, expectEffects } from "./record-effects.js"; +import { resolver } from "./resolve.js"; + +// Discover transitions and the ecs actions (a facet barrel or a directory glob), +// pair by name, and conform each — no per-item wiring. The action is the app-facing +// seam: its injected services come from `db.services` (the case's service args +// become recording overrides via `makeDb`), and its plain args are the case args +// with service fields removed and `entity(specId)` markers resolved. Both the +// resulting state and the declared `effects` are asserted. An action with no +// same-named transition (e.g. a streaming port) is skipped. +export interface ActionRunConfig { + readonly makeDb: (services: Record) => Db; + readonly store: (db: Db) => Store; + readonly fromState: (store: Store, before: State) => ReadonlyMap | void; + readonly toState: (store: Store) => State; + readonly transitions: Record>; + readonly actions: Record; + // The feature's default `State`; each case's `before` is merged over it. + readonly initial?: State; + // Optional ambient, non-spec context a user-scoped feature needs before dispatch + // (e.g. the acting peer's `userId`) — the one seam not derivable from cases. + readonly seedContext?: (db: Db, before: State, args: unknown) => void; + readonly match?: MatchOptions; +} + +// The single conformance test for every ecs action: each transition's cases run +// against its same-named action, asserting state and the declared effects. +export function runActions(config: ActionRunConfig): void { + const transitions = discoverTransitions(config.transitions); + for (const [name, action] of discoverOps(config.actions)) { + const paired = transitions.get(name); + if (!paired) continue; // action with no transition (e.g. a streaming port) — not conformed here + describe(`${name} action conforms`, () => { + for (const testCase of paired.cases) { + it(testCase.name as string, async () => { + const { services, input, calls } = splitAndRecordServices(testCase.args); + // Case `before` is a delta over the feature default. + const before = { ...(config.initial ?? {}), ...(testCase.before as object) } as State; + const db = config.makeDb(services); + const resolve = resolver(config.fromState(config.store(db), before)); + config.seedContext?.(db, before, testCase.args); + await (action as (d: Db, a?: unknown) => Promise | void)(db, adaptArgs(input, resolve)); + // `after` is a writes patch — compare `toState` against it merged over `before`. + assert(config.toState(config.store(db)), { ...(before as object), ...(testCase.after as object) }, config.match); + expectEffects(calls, testCase.effects as never); + }); + } + }); + } +} diff --git a/packages/data/src/testing/conformance/run-computeds.ts b/packages/data/src/testing/conformance/run-computeds.ts new file mode 100644 index 00000000..2cbc6ef5 --- /dev/null +++ b/packages/data/src/testing/conformance/run-computeds.ts @@ -0,0 +1,68 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it } from "vitest"; +import type { Observe } from "../../observe/index.js"; +import type { Entity } from "../../ecs/entity/entity.js"; +import { assert } from "../match/assert.js"; +import type { MatchOptions } from "../match/match.js"; +import { discoverDerivations, discoverOps } from "./discover.js"; + +// Read a computed's synchronous emission: subscribe once, capture, unsubscribe. +const readComputed = (observe: Observe): T => { + let value!: T; + let read = false; + const unsubscribe = observe((next) => { + value = next; + read = true; + }); + unsubscribe(); + if (!read) throw new Error("computed did not emit synchronously on subscribe"); + return value; +}; + +// Auto-pairing config: discover derivations and the registered computeds, pair by +// name, and conform each — no per-item wiring. A computed with no same-named +// derivation is skipped (single-`data/` math is covered by that helper's own +// test). The comparison is identity by default; a computed that emits an entity-id +// list names itself in `hydrate` so the runner maps each id through `toData` into +// the value shape the derivation yields. +export interface ComputedRunConfig { + readonly makeDb: () => Db; + readonly store: (db: Db) => Store; + readonly fromState: (store: Store, input: State) => unknown; + readonly toData?: (store: Store, entity: Entity) => unknown; + readonly derivations: Record>; + readonly computeds: Record; + readonly hydrate?: readonly string[]; + // The feature's default `State`; each case's `input` is merged over it before + // seeding, so a derivation case names only the fields it reads. + readonly initial?: State; + readonly match?: MatchOptions; +} + +// The single conformance test for every ecs computed backing a `data/state` +// derivation. Build `makeDb` from the `ComputedDatabase` layer (not the assembled +// db): a behaviour layer above may `withCache` a pre-seed value that a direct +// `fromState` seed cannot invalidate. +export function runComputeds(config: ComputedRunConfig): void { + const derivations = discoverDerivations(config.derivations); + const hydrate = new Set(config.hydrate ?? []); + for (const [name, computed] of discoverOps(config.computeds)) { + const paired = derivations.get(name); + if (!paired) continue; // computed with no `state/` derivation — covered by its data/ helper + describe(`${name} computed conforms`, () => { + for (const testCase of paired.cases) { + it(testCase.name as string, () => { + const db = config.makeDb(); + const input = { ...(config.initial ?? {}), ...(testCase.input as object) } as State; + config.fromState(config.store(db), input); + const raw = readComputed((computed as (d: Db) => Observe)(db)); + const value = + hydrate.has(name) && config.toData + ? (raw as readonly Entity[]).map((e) => config.toData!(config.store(db), e)) + : raw; + assert(value, testCase.value, config.match); + }); + } + }); + } +} diff --git a/packages/data/src/testing/conformance/run-feature.ts b/packages/data/src/testing/conformance/run-feature.ts new file mode 100644 index 00000000..2bbf15b7 --- /dev/null +++ b/packages/data/src/testing/conformance/run-feature.ts @@ -0,0 +1,117 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it } from "vitest"; +import { Database, Store } from "../../ecs/index.js"; +import type { Entity } from "../../ecs/entity/entity.js"; +import { assert } from "../match/assert.js"; +import type { MatchOptions } from "../match/match.js"; +import { runTransactions } from "./run-transactions.js"; +import { runActions } from "./run-actions.js"; +import { runComputeds } from "./run-computeds.js"; + +// The feature's ecs↔`State` projection — the one genuinely feature-specific piece. +export interface Projection { + readonly fromState: (store: Store, state: State) => ReadonlyMap | void; + readonly toState: (store: Store) => State; + readonly toData?: (store: Store, entity: Entity) => unknown; +} + +// One call conforms a whole feature. The runner pulls the ops off the plugin's +// registered facets (`plugin.transactions` / `plugin.actions` / +// `computedPlugin.computed`) and constructs the stores/dbs itself, so a feature +// supplies only its `State` namespace (default + representative samples), the +// `data/state` glob (the `{ fn, cases }` source), the plugin(s), and its +// projection. It runs the transaction, action, and computed conformance plus a +// projection round-trip (`toState ∘ fromState ≡ identity`) over `State.samples`. +// +// A feature whose ops aren't registered in the facet (a conformance-only action), +// or that needs ambient per-case context (a user-scoped `userId`), uses the +// lower-level `runTransactions`/`runActions`/`runComputeds` directly instead. +export interface FeatureRunConfig { + // The `State` namespace: `create()` is the default seed each case's `before` + // deltas over; `samples` (optional) are representative full states for the + // projection round-trip. + readonly state: { create(): State; readonly samples?: readonly State[] }; + // `import.meta.glob(["../../../data/state/*.ts", "!**/*.test.ts", "!**/*.type-test.ts"], { eager: true })`. + readonly transitions: Record>; + // The assembled feature plugin (`MainService.plugin`) — its `.transactions` and + // `.actions` facets are the ops, and it builds the transaction store + action db. + readonly plugin: Database.Plugin; + // The `ComputedDatabase` layer plugin — its `.computed` facet is the ops, built + // from this layer for seed-freshness. Omit when the feature has no derivations. + readonly computedPlugin?: Database.Plugin; + readonly projection: Projection; + // Names of computeds that emit an entity-id list (hydrated through `toData`). + readonly hydrate?: readonly string[]; + readonly match?: MatchOptions; + // Override the ops discovered from the plugin when they aren't registered in a + // facet (e.g. per-transition actions kept out of the plugin to bound its type). + readonly ops?: { + readonly transactions?: Record; + readonly actions?: Record; + readonly computeds?: Record; + }; +} + +// Runtime invariant: a plugin object carries its registered facet maps (see +// `create-plugin.ts`), so this reads the ops directly off it. +type PluginFacets = { transactions: Record; actions: Record; computed: Record }; + +export function runFeature( + config: FeatureRunConfig, +): void { + const initial = config.state.create(); + const { fromState, toState, toData } = config.projection; + const facets = config.plugin as unknown as PluginFacets; + // A plugin carries the schema facets, so `Store.create` / `Database.create` + // accept it; the resulting store/db is the projection's `StoreT`/`Db`. + const makeStore = (): StoreT => Store.create(config.plugin as never) as StoreT; + + runTransactions({ + createStore: makeStore, + fromState, + toState, + initial, + transitions: config.transitions, + transactions: config.ops?.transactions ?? facets.transactions, + match: config.match, + }); + + runActions({ + makeDb: (services) => Database.toSystemDatabase(Database.create(config.plugin as never, { services })) as unknown as Db, + store: (db) => db.store, + fromState, + toState, + initial, + transitions: config.transitions, + actions: config.ops?.actions ?? facets.actions, + match: config.match, + }); + + if (config.computedPlugin) { + const computedFacets = config.computedPlugin as unknown as PluginFacets; + runComputeds({ + makeDb: () => Database.toSystemDatabase(Database.create(config.computedPlugin as never)) as unknown as Db, + store: (db) => db.store, + fromState, + toData, + initial, + derivations: config.transitions, + computeds: config.ops?.computeds ?? computedFacets.computed, + hydrate: config.hydrate, + match: config.match, + }); + } + + const samples = config.state.samples ?? []; + if (samples.length > 0) { + describe("projection round-trips (toState ∘ fromState ≡ identity)", () => { + samples.forEach((sample, index) => { + it(`sample ${index}`, () => { + const store = makeStore(); + fromState(store, sample); + assert(toState(store), sample, config.match); + }); + }); + }); + } +} diff --git a/packages/data/src/testing/conformance/run-spec.ts b/packages/data/src/testing/conformance/run-spec.ts new file mode 100644 index 00000000..a3fe0a53 --- /dev/null +++ b/packages/data/src/testing/conformance/run-spec.ts @@ -0,0 +1,89 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it } from "vitest"; +import { assert } from "../match/assert.js"; +import type { MatchOptions } from "../match/match.js"; +import { adaptArgs } from "./entity-ref.js"; +import { recordArgServices, expectEffects } from "./record-effects.js"; +import type { DerivationCase, Effects } from "./types.js"; + +const isDerivationCase = (c: unknown): c is DerivationCase => + typeof c === "object" && c !== null && "value" in c; + +export interface SpecRunConfig { + // The feature's `State` namespace (the same `state` `runFeature` takes). Its + // `create()` is the default each case's `before` deltas over, so a case names + // only the fields it sets differently. Omit it and cases must carry a full `before`. + readonly state?: { create(): object }; + // `import.meta.glob(["./*.ts", "!./*.test.ts", "!./*.type-test.ts"], { eager: true })` + // — the same `transitions` glob `runFeature` takes. + readonly transitions: Record>; + // Passed through to `matches` (float tolerance, unordered collections). + readonly match?: MatchOptions; + // Override the `describe` label per module (default `State.`). + readonly label?: (path: string, fnName: string | undefined) => string; +} + +// The single pure-spec test for every transform AND derivation in a `data/state/` +// folder. It auto-discovers each file that exports `cases`, requires that file to +// export exactly its function plus `cases`, and dispatches on case shape: a `value` +// case checks a derivation `(state) => value`; otherwise a transition `(state, +// args) => state`, whose declared `effects` on injected services are also asserted. +// A service-injected transition is async, so results are awaited uniformly. +export const runSpec = (config: SpecRunConfig): void => { + for (const [path, module] of Object.entries(config.transitions)) { + const exportNames = Object.keys(module); + if (!exportNames.includes("cases")) continue; + const functionNames = exportNames.filter( + (key) => typeof module[key] === "function", + ); + const fnName = functionNames.length === 1 ? functionNames[0] : undefined; + const label = config.label + ? config.label(path, fnName) + : `State.${fnName ?? path}`; + describe(label, () => { + if (exportNames.length !== 2 || functionNames.length !== 1) { + it("exports exactly its function and `cases`", () => { + throw new Error( + `${path} exports [${exportNames.join(", ")}] — expected one function + cases`, + ); + }); + return; + } + // Runtime invariant: a participating file exports one function and its cases. + const fn = module[functionNames[0]] as (...args: unknown[]) => unknown; + const cases = module["cases"] as readonly unknown[]; + for (const testCase of cases) { + if (isDerivationCase(testCase)) { + it(testCase.name, () => + assert(fn(testCase.input), testCase.value, config.match), + ); + continue; + } + const tc = testCase as { + readonly name: string; + readonly before: unknown; + readonly args?: unknown; + readonly after: unknown; + readonly effects?: Effects>; + }; + it(tc.name, async () => { + // Unwrap `entity(specId)` markers to their data-id for the pure spec, then + // wrap injected services so their calls are recorded. + const { args, calls } = recordArgServices(adaptArgs(tc.args)); + // Case `before` is a delta over the feature default; `after` a writes patch. + const before = { + ...(config.state?.create() ?? {}), + ...(tc.before as Record), + }; + const result = (await fn(before, args)) as Record; + assert( + { ...before, ...result }, + { ...before, ...(tc.after as Record) }, + config.match, + ); + expectEffects(calls, tc.effects); + }); + } + }); + } +}; diff --git a/packages/data/src/testing/conformance/run-transactions.ts b/packages/data/src/testing/conformance/run-transactions.ts new file mode 100644 index 00000000..686396c9 --- /dev/null +++ b/packages/data/src/testing/conformance/run-transactions.ts @@ -0,0 +1,58 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it } from "vitest"; +import type { Entity } from "../../ecs/entity/entity.js"; +import { assert } from "../match/assert.js"; +import type { MatchOptions } from "../match/match.js"; +import { adaptArgs } from "./entity-ref.js"; +import { discoverTransitions, discoverOps } from "./discover.js"; +import { resolver } from "./resolve.js"; + +// Discover transitions (the `data/state` glob) and the ecs transactions (a facet +// barrel or a directory glob), pair them by name, and conform each — no per-item +// wiring. A transaction with no same-named transition is infrastructure (e.g. +// `setInput`) or system-dispatched and is skipped; a transition realized by an +// action is conformed there. Entity-addressed args carry a `Conformance.entity` +// marker the runner resolves; a transaction ignores any injected-service arg (its +// effects are asserted through the action). +export interface TransactionRunConfig { + readonly createStore: () => Store; + readonly fromState: (store: Store, before: State) => ReadonlyMap | void; + readonly toState: (store: Store) => State; + // `import.meta.glob("../../../data/state/*.ts", { eager: true })`. + readonly transitions: Record>; + // `import * as transactions from ".../transactions/index.js"`, OR a directory + // glob when ops live beside a barrel they aren't registered in. + readonly transactions: Record; + // The feature's default `State`; each case's `before` is merged over it, so a + // case names only what differs from the default. + readonly initial?: State; + // Optional ambient, non-spec context a user-scoped feature needs before the raw + // transaction runs (e.g. seed the acting peer's `userId`) — the one seam not + // derivable from cases. Runs after `fromState`, before the transaction. + readonly seedContext?: (store: Store, before: State, args: unknown) => void; + readonly match?: MatchOptions; +} + +// The single conformance test for every ecs transaction, proving +// `toState(apply(fromState(before), args)) ≡ after` for each shared case. +export function runTransactions(config: TransactionRunConfig): void { + const transitions = discoverTransitions(config.transitions); + for (const [name, transaction] of discoverOps(config.transactions)) { + const paired = transitions.get(name); + if (!paired) continue; // infrastructure / system-dispatched — no transition to conform to + describe(`${name} transaction conforms`, () => { + for (const testCase of paired.cases) { + it(testCase.name as string, () => { + // Case `before` is a delta over the feature default. + const before = { ...(config.initial ?? {}), ...(testCase.before as object) } as State; + const store = config.createStore(); + const resolve = resolver(config.fromState(store, before)); + config.seedContext?.(store, before, testCase.args); + (transaction as (s: Store, a?: unknown) => void)(store, adaptArgs(testCase.args, resolve)); + // `after` is a writes patch — compare `toState` against it merged over `before`. + assert(config.toState(store), { ...(before as object), ...(testCase.after as object) }, config.match); + }); + } + }); + } +} diff --git a/packages/data/src/testing/conformance/types.ts b/packages/data/src/testing/conformance/types.ts new file mode 100644 index 00000000..e5574fea --- /dev/null +++ b/packages/data/src/testing/conformance/types.ts @@ -0,0 +1,72 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +// A service arg is an object with method members (not an array, not a function); +// this is what distinguishes an injected service from plain data args. +type MethodKeys = { [K in keyof T]-?: T[K] extends (...a: never[]) => unknown ? K : never }[keyof T]; +type IsService = T extends readonly unknown[] + ? false + : T extends (...a: never[]) => unknown + ? false + : T extends object + ? [MethodKeys] extends [never] + ? false + : true + : false; + +// A strongly-typed call to one method of service `S`: `[methodName, ...its args]`. +// A no-arg method is just `[methodName]`. +export type ServiceCall = { + [M in keyof S]-?: S[M] extends (...a: infer A) => unknown ? readonly [M, ...A] : never; +}[keyof S]; + +// Expected side effects for a case, keyed by the service-typed args only. An +// `Array` value asserts these calls in this order; a `Set` value asserts the same +// calls in any order. Method names and their args are checked against the service. +export type Effects = { + readonly [K in keyof Args as IsService extends true ? K : never]?: + | readonly ServiceCall[] + | ReadonlySet>; +}; + +// The case `args` type read from a transform's own signature — its second +// parameter, or `void` when it takes none. +type ArgsOf unknown> = Parameters extends [unknown, infer Args, ...unknown[]] + ? Args + : void; + +// One spec-owned conformance case, authored as **deltas over the feature default** +// (the runner's `initial` state). `before` lists only the fields this case sets +// differently from the default; `after` is the transform's **writes patch** — only +// the fields it changes. The runner seeds `{ ...initial, ...before }` and compares +// against `{ ...initial, ...before, ...after }`, so every field a case doesn't +// mention is the default and stays unchanged. (A full `before`/`after` still works +// — it just overrides the default wholesale.) `args` is OMITTABLE exactly when the +// transform takes none. Shared by the spec aggregator and the ecs runners. +export type Case = { + readonly name: string; + readonly before: Partial; + readonly after: Partial; + readonly effects?: Effects; +} & ([Args] extends [void] ? { readonly args?: undefined } : { readonly args: Args }); + +// A transform's cases, with the case `args` derived from the transform's own +// signature — author `export const cases: Conformance.Cases` +// (features alias it to a one-arg `Cases` binding `State` once), so the cases +// cannot drift from what the function accepts. +export type Cases unknown> = readonly Case>[]; + +// One case for a derivation (`(state) => value`): a state `input` and the `value` +// it yields. `value` may use asymmetric matchers. Both the pure derivation and its +// ecs computed are checked against these. +export type DerivationCase = { + readonly name: string; + readonly input: Input; + readonly value: Value; +}; + +// A derivation's cases, with `input` and `value` read from the derivation's own +// signature — the `Cases` analog for value-producing derivations. +export type DerivationCases unknown> = readonly DerivationCase< + Parameters[0], + ReturnType +>[]; diff --git a/packages/data/src/testing/index.ts b/packages/data/src/testing/index.ts new file mode 100644 index 00000000..992015f4 --- /dev/null +++ b/packages/data/src/testing/index.ts @@ -0,0 +1,9 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +// Shared test-only utilities for the spec↔ecs conformance pattern. Two namespaces: +// Match — tolerant, matcher-aware value comparison (framework-agnostic). +// Conformance — the case types, effect recording, id resolution, and the +// spec/transaction/action/computed runner drivers. +// Import only from `*.test.ts`; `sideEffects: false` keeps it out of app builds. +export * as Match from "./match/public.js"; +export * as Conformance from "./conformance/public.js"; diff --git a/packages/data/src/testing/match/assert.ts b/packages/data/src/testing/match/assert.ts new file mode 100644 index 00000000..d29ef78c --- /dev/null +++ b/packages/data/src/testing/match/assert.ts @@ -0,0 +1,19 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { matches, type MatchOptions } from "./match.js"; + +const show = (value: unknown): string => { + try { + return JSON.stringify(value) ?? String(value); + } catch { + return String(value); + } +}; + +// Throwing assertion built on `matches` — a mismatch throws an `Error` the test +// runner reports. Framework-agnostic (no `expect` import), so it works under any +// runner; the message shows both sides. +export const assert = (actual: unknown, expected: unknown, options?: MatchOptions): void => { + if (!matches(actual, expected, options)) { + throw new Error(`match failed:\n actual: ${show(actual)}\n expected: ${show(expected)}`); + } +}; diff --git a/packages/data/src/testing/match/match.test.ts b/packages/data/src/testing/match/match.test.ts new file mode 100644 index 00000000..44688e83 --- /dev/null +++ b/packages/data/src/testing/match/match.test.ts @@ -0,0 +1,51 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it, expect } from "vitest"; +import { matches, ref, anyNumber, anyString } from "./public.js"; + +describe("Match.matches", () => { + it("compares plain structures deeply", () => { + expect(matches({ a: 1, b: [2, 3] }, { a: 1, b: [2, 3] })).toBe(true); + expect(matches({ a: 1 }, { a: 2 })).toBe(false); + expect(matches({ a: 1, b: 2 }, { a: 1 })).toBe(false); // extra key on actual + }); + + it("absorbs F32/f64 and trig float noise onto the tolerance grid", () => { + expect(matches(Math.fround(0.1), 0.1)).toBe(true); + expect(matches(3e-15, 0)).toBe(true); + expect(matches(0.5, 0.5001)).toBe(true); // within 0.01 + expect(matches(0.5, 0.52)).toBe(false); // outside 0.01 + expect(matches(-0, 0)).toBe(true); + }); + + it("honors anyNumber / anyString and vitest-style asymmetric matchers", () => { + expect(matches({ id: 42, name: "x" }, { id: anyNumber, name: anyString })).toBe(true); + expect(matches({ id: "no" }, { id: anyNumber })).toBe(false); + expect(matches(7, expect.any(Number))).toBe(true); + }); + + it("compares arrays in order by default, as multisets when named", () => { + expect(matches([1, 2, 3], [1, 2, 3])).toBe(true); + expect(matches([1, 2, 3], [3, 2, 1])).toBe(false); + const opts = { unordered: new Set(["bag"]) }; + expect(matches({ bag: [1, 2, 3] }, { bag: [3, 1, 2] }, opts)).toBe(true); + expect(matches({ bag: [1, 2] }, { bag: [1, 2, 3] }, opts)).toBe(false); + }); + + describe("ref — id correspondence up to renaming", () => { + it("binds a label to the first actual and requires later ones to match", () => { + // Same ecs id in two places must be the same actual value. + expect(matches({ sel: 100, items: [{ id: 100 }] }, { sel: ref("a"), items: [{ id: ref("a") }] })).toBe( + true, + ); + // A dangling reference (sel points at an id no item has) fails. + expect(matches({ sel: 999, items: [{ id: 100 }] }, { sel: ref("a"), items: [{ id: ref("a") }] })).toBe( + false, + ); + }); + + it("is injective — two labels cannot bind the same actual", () => { + expect(matches([5, 6], [ref("a"), ref("b")])).toBe(true); + expect(matches([5, 5], [ref("a"), ref("b")])).toBe(false); + }); + }); +}); diff --git a/packages/data/src/testing/match/match.ts b/packages/data/src/testing/match/match.ts new file mode 100644 index 00000000..2d410dbb --- /dev/null +++ b/packages/data/src/testing/match/match.ts @@ -0,0 +1,102 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +// Options controlling the tolerant structural comparison. +export interface MatchOptions { + // Object keys whose array values compare as multisets (order-independent) at + // any depth — for ecs entity collections materialised in nondeterministic row + // order. Every other array stays order-sensitive (positional tuples like a + // `Vec2`, or a display-ordered list whose order a case verifies). + readonly unordered?: ReadonlySet; + // Float grid that absorbs F32↔f64 storage rounding and trig epsilon. Numbers + // are snapped to this grid before comparing. Default `0.01`. + readonly tolerance?: number; +} + +// A `ref(label)` on the EXPECTED side asserts id CORRESPONDENCE without pinning +// the value: the first occurrence of a label binds to whatever actual value sits +// there; later occurrences of the same label must equal that binding, and two +// labels can never bind the same actual (a bijection). This checks that ecs ids +// line up structurally — e.g. a `selectedId` points at the entity a case means — +// even though the ecs assigns ids from its own space. For an id a case does not +// care about, use `anyNumber` instead. +const REF = Symbol.for("@adobe/data/testing:ref"); +export const ref = (label: string): { readonly [REF]: string } => ({ [REF]: label }); +const isRef = (value: unknown): value is { readonly [REF]: string } => + typeof value === "object" && value !== null && REF in value; + +// An asymmetric matcher (this module's `anyNumber`/`anyString`, or vitest's +// `expect.any(...)`): honored on the EXPECTED side so a case asserts a shape it +// does not pin. Recognised structurally, so no test framework is imported. +const isMatcher = (value: unknown): value is { asymmetricMatch(actual: unknown): boolean } => + typeof value === "object" && + value !== null && + typeof (value as { asymmetricMatch?: unknown }).asymmetricMatch === "function"; + +const quantize = (n: number, tolerance: number): number => { + const factor = 1 / tolerance; + return Math.round(Math.fround(n) * factor) / factor + 0; // `+ 0` normalises `-0` to `0` +}; + +// Multiset (order-independent) match: greedy pairing, sufficient for concrete +// values. Elements compare with the ordered, matcher-aware path; `ref` bindings +// do not cross element boundaries here (ids in bags are `anyNumber`, not refs). +const matchesUnordered = ( + actual: readonly unknown[], + expected: readonly unknown[], + options: MatchOptions, +): boolean => { + if (actual.length !== expected.length) return false; + const used = new Array(actual.length).fill(false); + return expected.every((exp) => { + const index = actual.findIndex((act, i) => !used[i] && matchesWith(act, exp, options, new Map())); + if (index < 0) return false; + used[index] = true; + return true; + }); +}; + +const matchesWith = ( + actual: unknown, + expected: unknown, + options: MatchOptions, + bindings: Map, +): boolean => { + if (isRef(expected)) { + const label = expected[REF]; + if (bindings.has(label)) return Object.is(bindings.get(label), actual); + for (const bound of bindings.values()) if (Object.is(bound, actual)) return false; // injective + bindings.set(label, actual); + return true; + } + if (isMatcher(expected)) return expected.asymmetricMatch(actual); + if (typeof expected === "number" && typeof actual === "number") { + const tolerance = options.tolerance ?? 0.01; + return quantize(actual, tolerance) === quantize(expected, tolerance); + } + if (Array.isArray(expected)) { + if (!Array.isArray(actual) || actual.length !== expected.length) return false; + return expected.every((exp, index) => matchesWith(actual[index], exp, options, bindings)); + } + if (expected !== null && typeof expected === "object") { + if (actual === null || typeof actual !== "object" || Array.isArray(actual)) return false; + const expectedKeys = Object.keys(expected as object); + const actualKeys = Object.keys(actual as object); + if (expectedKeys.length !== actualKeys.length) return false; + return expectedKeys.every((key) => { + const exp = (expected as Record)[key]; + const act = (actual as Record)[key]; + if (options.unordered?.has(key) && Array.isArray(exp) && Array.isArray(act)) { + return matchesUnordered(act, exp, options); + } + return matchesWith(act, exp, options, bindings); + }); + } + return Object.is(actual, expected); +}; + +// Tolerant structural comparison: honors asymmetric matchers and `ref` +// correspondence on the expected side, absorbs float noise, and compares arrays +// in order except where `options.unordered` names a multiset collection. Pure +// and framework-agnostic — `assert` wraps it for a throwing test assertion. +export const matches = (actual: unknown, expected: unknown, options: MatchOptions = {}): boolean => + matchesWith(actual, expected, options, new Map()); diff --git a/packages/data/src/testing/match/matchers.ts b/packages/data/src/testing/match/matchers.ts new file mode 100644 index 00000000..f25b7e4f --- /dev/null +++ b/packages/data/src/testing/match/matchers.ts @@ -0,0 +1,15 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +// Asymmetric matchers for a value a case does not pin — chiefly an entity `id` +// the ecs assigns from its own id-space, so the spec and the ecs projection +// satisfy the same case without agreeing on the value. Plain `{ asymmetricMatch }` +// objects (no test-framework dependency), recognised by `matches` and +// interchangeable with vitest's `expect.any(Number)` / `expect.any(String)`. +const numberMatcher = { asymmetricMatch: (actual: unknown): boolean => typeof actual === "number" }; +const stringMatcher = { asymmetricMatch: (actual: unknown): boolean => typeof actual === "string" }; + +// Typed as the value each stands in for (as vitest types `expect.any`), so it +// slots into a pinned `number` / `string` field of a case's expected value; +// `matches` recognises the object at runtime by its `asymmetricMatch` method. +export const anyNumber = numberMatcher as unknown as number; +export const anyString = stringMatcher as unknown as string; diff --git a/packages/data/src/testing/match/public.ts b/packages/data/src/testing/match/public.ts new file mode 100644 index 00000000..6d7c2d91 --- /dev/null +++ b/packages/data/src/testing/match/public.ts @@ -0,0 +1,4 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +export { matches, ref, type MatchOptions } from "./match.js"; +export { assert } from "./assert.js"; +export { anyNumber, anyString } from "./matchers.js"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f4ad2f05..bcc4864e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -489,6 +489,9 @@ importers: '@vitejs/plugin-react': specifier: ^4.3.0 version: 4.3.0(vite@5.1.1) + jsdom: + specifier: ^24.1.0 + version: 24.1.0 typescript: specifier: ^5.8.3 version: 5.8.3 @@ -498,6 +501,9 @@ importers: vite-plugin-checker: specifier: ^0.12.0 version: 0.12.0(typescript@5.8.3)(vite@5.1.1) + vitest: + specifier: ^1.6.0 + version: 1.6.0(@types/node@25.6.0)(@vitest/browser@1.6.0)(jsdom@24.1.0) packages/data-solid: dependencies: @@ -527,6 +533,9 @@ importers: specifier: ^1.9.12 version: 1.9.12 devDependencies: + jsdom: + specifier: ^24.1.0 + version: 24.1.0 typescript: specifier: ^5.8.3 version: 5.8.3 @@ -539,6 +548,9 @@ importers: vite-plugin-solid: specifier: ^2.11.0 version: 2.11.0(solid-js@1.9.12)(vite@5.1.1) + vitest: + specifier: ^1.6.0 + version: 1.6.0(@types/node@25.6.0)(@vitest/browser@1.6.0)(jsdom@24.1.0) packages/data-sync: dependencies: