Testing Helpers
/testing is for a host's own tests: without a browser or a server, check what the host declared — whether its definitions are admitted, whether every key has words, whether its actions' business rules hold, where a route resolves. It runs the engine's own logic for each, not an approximation written beside it.
It needs no React or DOM. memorySource answers with Wow's query semantics through the optional peer mingo, which a project that uses it installs itself.
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { text } from '@ahoo-wang/wow-view-engine';
import { actionHarness, admit } from '@ahoo-wang/wow-view-engine/testing';
test('the definition is admitted, every key worded', () => {
assert.deepEqual(
admit([ordersDefinition], { order: ordersDescriptor }, { text: key => ORDERS_WORDS[key] }),
[],
);
});
test('only a paid order ships, and says why not', () => {
const rows = orders.map(data => ({ key: String(data.aggregateId), data }));
const harness = actionHarness(orderActions, rows);
assert.equal(harness.state('ship', 'SO-0001').reason, null);
assert.equal(harness.state('ship', 'SO-0003').reason, text('orders.notPaid'));
// One order ships at a press; a selection is asked first.
assert.equal(harness.asks('ship', 'row').asks, false);
});Guides: Fitting the View Engine into a Host (testing a host's wiring with /testing); Declared Actions (testing actions' rules with actionHarness); Writing a Definition (self-checking a definition with admit); Where Views Live (running the conformance suite on your own store).
admit
Every finding the engine would have about definitions over descriptors (the snapshots, by DataViewDefinition.source), each with the definition it is about; [] for a host's declarations that hold.
Each definition is admitted as the engine admits it when registered — its keys said in text, its own rules, its boards against every other definition, each judged as declared, keys and all — and each data definition narrowed to its descriptor as a source would narrow it, what that takes away said too. A definition over a source with no descriptor given is said to be, since its capabilities go unchecked. A host's list of resources ({ definition, source }) is passed as it is. When at least one resource passed carries its source, a data definition passed as a resource is held to the engine's start-up check too: some resource passed registers a source under its source key, or it is definition.source.unregistered. A list of { definition } alone, or of bare definitions, says nothing of sources and is not checked for them.
export declare function admit(definitions: readonly Admissible[], descriptors: Readonly<Record<string, QueryModelDescriptor>>, options?: AdmitOptions): AdmitFinding[];
export interface AdmitOptions {
kinds?: FieldKindRegistry;
limits?: Partial<RuntimeLimits>;
text?(key: string): string | undefined;
}
export type Admissible = ViewDefinition | { definition: ViewDefinition; source?: ViewSource };
export type AdmitFinding = Issue & { definition: string };actionHarness
A host's declared actions over some records, without a screen: the engine's own reading of them — which place offers which, whether a record takes one now and why not, how a selection splits, what the confirmation and the form ask, when the rules change on their own — so a host's unit test pins one business rule in a line.
| Member | Role |
|---|---|
ids, at(place) | Every action's id, in the order declared; the ids offered at a place |
state(id, key, input?) | One record's state: available, hidden, reason (as the host wrote it, a key staying a key; null when able) |
bulk(id, keys?, input?) | How a selection splits: the able, the refused, and the refused by reason (the commonest first) — what the dialog lists |
asks(id, place, input?) | Whether pressing it at a place asks first, and what: the declared question, or null when the engine asks in its own words (a selection with no confirm) |
form(id), choice(id), missing(id, input) | The form's fields; whether it is a choice; the required fields input leaves blank (the form's initial under input) |
changesAt(key?) | The soonest time after now a record's availability flips on its own |
run(id, key, input?) | Sends one record the command as the engine would: refused with the reason (ActionRefusedError) where it does not take it, else the host's run itself |
options.now is the time the rules are asked at; Date.now() when left out.
export declare function actionHarness(list: RecordActions, rows: readonly ActionRow[], input?: ActionHarnessOptions): ActionHarness;
export interface ActionHarnessOptions {
now?: number;
}
export interface ActionHarness {
asks(id: string, place: ActionPlace, input?: ActionInput): { asks: boolean; confirm: ActionConfirm | null };
at(place: ActionPlace): string[];
bulk(id: string, keys?: readonly RecordKey[], input?: ActionInput): HarnessBulk;
changesAt(key?: RecordKey): number | null;
choice(id: string): readonly FieldOption[] | null;
form(id: string): HarnessField[] | null;
readonly ids: readonly string[];
missing(id: string, input: ActionInput): string[];
run(id: string, key: RecordKey, input?: ActionInput): Promise<unknown>;
state(id: string, key: RecordKey, input?: ActionInput): HarnessState;
}
export interface HarnessState {
available: boolean;
hidden: boolean;
reason: string | null;
}
export interface HarnessBulk {
able: RecordKey[];
reasons: { reason: string; count: number; keys: RecordKey[] }[];
refused: ActionRefusal[];
}
export interface HarnessField {
input: 'select' | 'text' | 'number' | 'boolean';
label: string;
name: string;
options: readonly FieldOption[] | null;
required: boolean;
}
export declare class ActionRefusedError extends Error {
constructor(reason: string);
readonly reason: string;
}memorySource
A ViewSource over documents held in memory that answers each query the way a Wow service over MongoDB does — filter, sort, page, projection and aggregation — so a host's test sees the engine's real queries answered by the semantics the engine is built on, not a canned answer.
- The documents are the snapshots (or event streams) as the service returns them:
aggregateId,ownerIdanddeletedon the envelope, the state understate, times as epoch milliseconds. A document withdeleted: trueis left out unless the filter carries aDELETIONcondition. A cursor is an offset, as opaque to the caller as Wow's. - What it cannot answer — an operator or shape it has no reading of, such as
ID,TENANT_ID,SPACE_IDor the calendar filters (TODAY, …) the engine never sends — is refused with an error, so a query a test starts to send fails rather than getting a plausible wrong answer. The documents are read, never written; a test may change them between queries.
| Option | Role |
|---|---|
now | The service's clock, in epoch milliseconds, which BEFORE_NOW and AFTER_NOW compare against; a test pins it to the moment its data is built around, so "after now" answers the same on every run |
timeField | A time column every document holds as epoch milliseconds, the way a Wow snapshot keeps firstEventTime. The documents are kept in its order, and a range on it in the filter's top-level AND is cut out by binary search first — for large sets |
remember | Answers a repeated aggregation from memory, keyed by the query, each caller getting its own copy. Only for documents that never change; off by default, so a test that edits a document sees the edit in the next answer |
matches(document, filter, options?) asks whether one document matches a filter as memorySource reads it — a test compares a condition of its own (the one an old screen used, say) with what a view matched. Unlike a source, it assumes nothing about deletion: without a DELETION condition a deleted document is a candidate too.
export declare function memorySource(documents: readonly RecordData[], options?: MemorySourceOptions): ViewSource;
export interface MemorySourceOptions {
now?: () => number;
remember?: boolean;
timeField?: string;
}
export declare function matches(document: RecordData, filter: FilterExpression, options?: { now?: () => number }): boolean;resolveNavigation
to through the route of the definition it leads to, where it has one (bind's route, looked up by bindingOf): what the host's navigate is handed. Pure, and in /testing too, so a host's tests of its bindings run the engine's own resolution.
export declare function resolveNavigation(to: ViewNavigation, bindingOf: (definitionId: string) => { route?: ViewRouteOf } | undefined): ViewDestination;The ViewStore port conformance suite
A backend that implements ViewStore itself is held to the port's conformance suite: one set of cases every implementation passes — listing and reading, revision conflicts, requestId replays, audience changes, system views. MemoryViewStore runs it in the engine, WowViewStore against the view store server in typescript/integration-test.
It is a test file of the repository, not part of the published package: in /testing it would carry a test framework into a published entry. So it is imported by workspace path and depends only on vitest and on engine modules with no runtime imports; a backend outside the repository copies the file, or writes its own cases after it. What it assumes of the backend, and nothing more: every case works in a fresh definition id, so a shared server needs no reset; revisions are compared for change and no change only, never ordered or parsed — except that untouched preferences answer '0'. A capability the subject does not declare skips its cases, by name.
import { describeViewStoreConformance } from '../../wow-view-engine/test/conformance/viewStoreConformance.js';
describeViewStoreConformance({
name: 'WowViewStore',
capabilities: {
owners: true,
personalViews: true,
changeAudience: true,
idempotentCreate: true,
systemViews: { definitionId: 'conformance-system' },
},
// One server for the whole file is fine: every test uses fresh ids.
connect: () => ({ owner }) => new WowViewStore({ fetcher: actingAs(owner) }),
});| Capability | Meaning |
|---|---|
owners | Stores opened for two owners are two users: each sees the other's shared views and not their personal ones, and keeps preferences of their own. false for a single-user store, and the cases about two users are skipped |
personalViews | A personal view can be created at all (a host nobody signs in to has none) |
changeAudience | The store implements the optional changeAudience |
idempotentCreate | A create replayed under its requestId answers the first outcome rather than making a second view. The Wow server does not deduplicate create (it generates the id), and WowViewStore keeps it within one store by asking the replay route first; a store that keeps it not even so declares false, and that case is skipped — every other write's replay stays mandatory |
systemViews | A definition the backend serves at least one read-only system view for; left out when it serves none, and the case that every write to one is refused is skipped |
storedSystemViews | The store keeps system views of its own: create with scope: 'system' makes one, answered and listed with stored: true |
The full working version
- Storybook's integration walk-through's
integration.test.tsholds the walk-through's declarations toadmitandactionHarness. - The conformance suite:
test/conformance/viewStoreConformance.ts, andWowViewStore'swowViewStore.conformance.test.ts. - Source files:
src/testing/.