@wardic/test 0.0.0-stage → 1.0.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/input.ts ADDED
@@ -0,0 +1,104 @@
1
+ import * as InputSource from "@wardic/core/InputSource"
2
+ import * as Bool from "effect/Boolean"
3
+ import * as Effect from "effect/Effect"
4
+ import * as Predicate from "effect/Predicate"
5
+ import * as Layer from "effect/Layer"
6
+ import * as UndefinedOr from "effect/UndefinedOr"
7
+ import * as Option from "effect/Option"
8
+ import * as Feeds from "./feeds.ts"
9
+
10
+ /**
11
+ * An InputSource that replays fixed scripts of events, as **Input feeds** that end after their
12
+ * script: a Stream over one ends too. `pointer(target)` replays the same `pointerEvents`,
13
+ * `focus(target)` the same `focusEvents` and `keyboard(target)` the same `keyEvents` to each
14
+ * subscriber (each ignores `target` — tests address a single surface). The first positional arg is
15
+ * pointer events for back-compat; focus and key events are optional, defaulting to empty so existing
16
+ * call sites emit nothing on them. Each script plays whole when its feed is subscribed, so scripts of
17
+ * two kinds don't interleave: a test that needs them to pushes them ({@link pushable}). Called with no
18
+ * args it yields an InputSource that emits nothing — for tests that don't exercise gestures. It adopts
19
+ * no pointer: `adoptPointer` delivers nothing.
20
+ */
21
+ // Input feeds: #318. adoptPointer: #431.
22
+ export const layer = (
23
+ pointerEvents: ReadonlyArray<InputSource.PointerLikeEvent> = [],
24
+ focusEvents: ReadonlyArray<InputSource.FocusLikeEvent> = [],
25
+ keyEvents: ReadonlyArray<InputSource.KeyboardLikeEvent> = [],
26
+ ): Layer.Layer<InputSource.InputSource> =>
27
+ Layer.succeed(InputSource.InputSource, {
28
+ pointer: () => Feeds.script(pointerEvents),
29
+ keyboard: () => Feeds.script(keyEvents),
30
+ focus: () => Feeds.script(focusEvents),
31
+ adoptPointer: () => Option.none(),
32
+ })
33
+
34
+ /**
35
+ * A push-based InputSource for tests that need to interleave pointer events with
36
+ * other effects (e.g. advancing the Clock between a release and a re-grab). Unlike
37
+ * {@link layer} — which replays a fixed script that drains immediately — this
38
+ * form lets the test `push` events one at a time, on its own schedule.
39
+ *
40
+ * Backed by a hub (`feeds.ts`) so that every subscription to `pointer(target)`
41
+ * hears every event: a consumer may subscribe to `input.pointer(target)` more than once
42
+ * (a binding's drag track reads the drag fold and the grabs). There is no replay buffer,
43
+ * so only events pushed *after* a subscription is live reach it.
44
+ *
45
+ * `push` doesn't yield: a subscriber handles the event inside the push, as an
46
+ * Environment's consumers handle a real event inside its dispatch, so a pipeline that hops
47
+ * through Effect's scheduler on its way to its sink hasn't got there when `push` returns,
48
+ * and its test sees it.
49
+ *
50
+ * Returns `{ layer, push }`. Resolve it inside an `Effect.gen`, provide `layer`,
51
+ * and drive the surface with `push`:
52
+ *
53
+ * @example
54
+ * const input = yield* TestInput.pushable()
55
+ * yield* Stream.runForEach(drag(undefined), …).pipe(Effect.fork…) // subscribe
56
+ * yield* input.push(ev("down", 0, 0, 0))
57
+ *
58
+ * Returns `pushFocus` and `pushKey` alongside `push` so focus/blur transitions and keys can be
59
+ * driven on the same schedule; `focus(target)` and `keyboard(target)` mirror `pointer(target)`, each
60
+ * with its own hub.
61
+ *
62
+ * `adoptPointer(target, raw)` takes a pointer event as its `raw`: a pressed one is pushed on
63
+ * the pointer hub as an adopted down, inside the call, as the DOM's is. The hub holds no pointer, so
64
+ * the test pushes that pointer's later events itself.
65
+ *
66
+ * `options.focusWithin` is the source's `focusWithin`, for a test of a **Keyboard press** that
67
+ * holds its keys only while focus is within its target. Without it the source says nothing of focus,
68
+ * and a press hears every key pushed.
69
+ */
70
+ // The hub: #318. No yield: ADR-0032. Keys: #427. adoptPointer: #431. focusWithin: #460.
71
+ export const pushable = (options?: { readonly focusWithin?: ((target: unknown) => boolean) | undefined }): Effect.Effect<{
72
+ readonly layer: Layer.Layer<InputSource.InputSource>
73
+ readonly push: (event: InputSource.PointerLikeEvent) => Effect.Effect<void>
74
+ readonly pushFocus: (event: InputSource.FocusLikeEvent) => Effect.Effect<void>
75
+ readonly pushKey: (event: InputSource.KeyboardLikeEvent) => Effect.Effect<void>
76
+ }> =>
77
+ Effect.sync(() => {
78
+ const pointer = Feeds.hub<InputSource.PointerLikeEvent>()
79
+ const focus = Feeds.hub<InputSource.FocusLikeEvent>()
80
+ const keys = Feeds.hub<InputSource.KeyboardLikeEvent>()
81
+ return {
82
+ layer: Layer.succeed(InputSource.InputSource, {
83
+ pointer: () => pointer.feed,
84
+ keyboard: () => keys.feed,
85
+ focus: () => focus.feed,
86
+ focusWithin: UndefinedOr.map(options, (o) => o.focusWithin),
87
+ adoptPointer: (_target, raw) => {
88
+ if (isPressedPointer(raw)) {
89
+ const down: InputSource.PointerLikeEvent = { ...raw, type: "down", adopted: true }
90
+ Effect.runSync(pointer.push(down))
91
+ return Option.some(down)
92
+ }
93
+ return Option.none()
94
+ },
95
+ }),
96
+ push: pointer.push,
97
+ pushFocus: focus.push,
98
+ pushKey: keys.push,
99
+ }
100
+ })
101
+
102
+ /** Whether `raw` is a pointer event with a button pressed: one the pushable InputSource adopts (#431). */
103
+ const isPressedPointer = (raw: unknown): raw is InputSource.PointerLikeEvent =>
104
+ Bool.and(Predicate.hasProperty(raw, "pointerId"), Predicate.hasProperty(raw, "pressed")) && (raw as InputSource.PointerLikeEvent).pressed
package/src/layer.ts ADDED
@@ -0,0 +1,78 @@
1
+ import * as Announcer from "@wardic/core/Announcer"
2
+ import type * as Clock from "@wardic/core/Clock"
3
+ import * as DefaultTransition from "@wardic/core/DefaultTransition"
4
+ import * as DragActivity from "@wardic/core/DragActivity"
5
+ import * as FrameLoop from "@wardic/core/FrameLoop"
6
+ import type * as InputSource from "@wardic/core/InputSource"
7
+ import * as InterpolatorRegistry from "@wardic/core/InterpolatorRegistry"
8
+ import * as PressClaims from "@wardic/core/PressClaims"
9
+ import type * as Probe from "@wardic/core/Probe"
10
+ import * as ReducedMotion from "@wardic/core/ReducedMotion"
11
+ import * as RefreshFrameSource from "@wardic/core/RefreshFrameSource"
12
+ import type * as Renderer from "@wardic/core/Renderer"
13
+ import { constUndefined, constVoid } from "effect/Function"
14
+ import * as Layer from "effect/Layer"
15
+ import { AtomRegistry } from "effect/reactivity"
16
+ import * as TestClock from "./clock.ts"
17
+ import * as TestInput from "./input.ts"
18
+ import * as TestProbe from "./probe.ts"
19
+ import * as TestRenderer from "./renderer.ts"
20
+
21
+ /**
22
+ * The headless Environment's Refresh frame source: it never calls back, as its Clock never ticks
23
+ * on its own, so a flush gate built on it never flushes by itself. A test that fires frames
24
+ * provides `TestClock.pushable()`'s layer over it (ADR-0031, decision 6).
25
+ */
26
+ const stillFrames: RefreshFrameSource.Shape = { requestFrame: constUndefined, cancelFrame: constVoid }
27
+
28
+ /**
29
+ * The full headless environment: deterministic Clock (driven by `frames`), a Refresh frame
30
+ * source that never calls back, recording Renderer, static Probe, silent InputSource, core's real
31
+ * interpolator registry, a fresh AtomRegistry for reading Probe atoms
32
+ * (`viewport`, `prefersReducedMotion`), and a fresh Drag activity, so one test's
33
+ * drag never blocks another's hover or press, and core's Frame loop over that
34
+ * Clock. Provide this in place of `DomLive` to test animation code without a
35
+ * page. It draws nothing on one: an element rendered under it keeps its Starting style, so an
36
+ * app's components are tested with `DomLive` under the test runner's fake timers instead
37
+ * (https://wardic.dev/guides/testing).
38
+ */
39
+ // The Drag activity: ADR-0019. The Frame loop: ADR-0027. The testing guide: #558.
40
+ export const testLayer = (
41
+ frames: ReadonlyArray<number> = [],
42
+ ): Layer.Layer<
43
+ | Clock.Clock
44
+ | RefreshFrameSource.RefreshFrameSource
45
+ | FrameLoop.FrameLoop
46
+ | Renderer.Renderer
47
+ | Probe.Probe
48
+ | InputSource.InputSource
49
+ | InterpolatorRegistry.InterpolatorRegistry
50
+ | ReducedMotion.PositionalKeys
51
+ | DefaultTransition.DefaultTransition
52
+ | Announcer.Announcer
53
+ | AtomRegistry.AtomRegistry
54
+ > =>
55
+ FrameLoop.layer.pipe(
56
+ Layer.provideMerge(
57
+ Layer.mergeAll(
58
+ TestClock.layer(frames),
59
+ RefreshFrameSource.layer(stillFrames),
60
+ TestRenderer.layer,
61
+ TestProbe.layer(),
62
+ TestInput.layer(),
63
+ InterpolatorRegistry.layer,
64
+ // The transforms only: the Test Environment names no box keys (ADR-0036).
65
+ ReducedMotion.transformsOnly,
66
+ // No opinion on a key's Default transition: every key on one spring (ADR-0051).
67
+ DefaultTransition.layerOneSpring,
68
+ // No assistive technology to reach headless (ADR-0037).
69
+ Announcer.layerSilent,
70
+ AtomRegistry.layer,
71
+ Layer.sync(DragActivity.DragActivity, DragActivity.make),
72
+ Layer.sync(PressClaims.PressClaims, PressClaims.make),
73
+ ),
74
+ ),
75
+ )
76
+
77
+ /** `testLayer()` with an empty clock — for tests that drive their own frames. It never ticks on its own. */
78
+ export const TestLive = testLayer()
package/src/probe.ts ADDED
@@ -0,0 +1,87 @@
1
+ import * as Probe from "@wardic/core/Probe"
2
+ import * as Arr from "effect/Array"
3
+ import * as Effect from "effect/Effect"
4
+ import * as Layer from "effect/Layer"
5
+ import * as Stream from "effect/Stream"
6
+ import { Atom, AtomRegistry } from "effect/reactivity"
7
+ import * as Feeds from "./feeds.ts"
8
+
9
+ const ZERO_BOUNDS: Probe.Bounds = { x: 0, y: 0, width: 0, height: 0, top: 0, right: 0, bottom: 0, left: 0 }
10
+ const VIEWPORT = { width: 1024, height: 768 }
11
+ /** A page that doesn't scroll: no range, and progress 1, as Motion reads a container with none. */
12
+ const STILL_SCROLL: Probe.Scroll = { x: { current: 0, length: 0, progress: 1 }, y: { current: 0, length: 0, progress: 1 } }
13
+
14
+ /** A scripted in-view boolean as the Probe reports it, with no platform record. */
15
+ const toIntersection = (intersecting: boolean): Probe.Intersection => ({ intersecting })
16
+
17
+ /** Options for the static test Probe. */
18
+ // pageVisible: #443. scroll: #234. intersection, an Input feed: #318.
19
+ export interface Options {
20
+ readonly bounds?: Probe.Bounds
21
+ readonly viewport?: { readonly width: number; readonly height: number }
22
+ readonly prefersReducedMotion?: boolean
23
+ /** Whether the page is shown: `true` when left out. */
24
+ readonly pageVisible?: boolean
25
+ /** Every container's scroll, as `scroll` gives it: a page that doesn't scroll when left out. */
26
+ readonly scroll?: Probe.Scroll
27
+ /**
28
+ * Script of intersection (in-view) booleans replayed by `observeIntersection`, an **Input feed**
29
+ * that ends after it. Mirrors the InputSource test harness: omitted (or empty) means it
30
+ * emits nothing and ends.
31
+ */
32
+ readonly intersection?: ReadonlyArray<boolean>
33
+ }
34
+
35
+ /**
36
+ * A static Probe: fixed bounds/viewport. `observeIntersection` replays the
37
+ * optional `intersection` script to each subscriber (empty by default, so it
38
+ * emits nothing); `observeResize` stays empty.
39
+ */
40
+ export const layer = (options: Options = {}): Layer.Layer<Probe.Probe> => {
41
+ const { intersection = [], scroll = STILL_SCROLL } = options
42
+ return Layer.succeed(Probe.Probe, {
43
+ ...fixed(options, Atom.make(scroll)),
44
+ observeIntersection: () => Feeds.script(Arr.map(intersection, toIntersection)),
45
+ })
46
+ }
47
+
48
+ /**
49
+ * Both Probes' fixed part: the bounds, the viewport, the preference and the page's visibility, each defaulted, no resizes,
50
+ * and one scroll Atom for every container, target and offset (#234).
51
+ */
52
+ const fixed = ({ bounds = ZERO_BOUNDS, viewport = VIEWPORT, prefersReducedMotion = false, pageVisible = true }: Options, scroll: Atom.Atom<Probe.Scroll>) => ({
53
+ measure: () => Effect.sync(() => bounds),
54
+ viewport: Atom.make(viewport),
55
+ prefersReducedMotion: Atom.make(prefersReducedMotion),
56
+ pageVisible: Atom.make(pageVisible),
57
+ scroll: () => Effect.succeed(scroll),
58
+ observeResize: () => Stream.empty,
59
+ })
60
+
61
+ /**
62
+ * A push-based Probe for tests that need to interleave intersection
63
+ * (in-view/out-of-view) transitions with other effects on their own schedule —
64
+ * mirroring {@link TestInput.pushable}. `observeIntersection(target)` subscribes
65
+ * to a hub, so every subscriber sees the same transitions and only
66
+ * events pushed *after* a subscription is live reach it. `pushIntersection`
67
+ * doesn't yield: a subscriber handles the transition inside the push,
68
+ * as `TestInput.pushable`'s do. `pushScroll` sets the scroll every container reads,
69
+ * through the Environment's registry, as a scroll event would.
70
+ * measure/viewport/prefersReducedMotion/observeResize behave as in {@link layer}.
71
+ */
72
+ // The hub: #318. No yield: ADR-0032. Scroll: #234.
73
+ export const pushable = (options: Options = {}): Effect.Effect<{
74
+ readonly layer: Layer.Layer<Probe.Probe>
75
+ readonly pushIntersection: (inView: boolean) => Effect.Effect<void>
76
+ readonly pushScroll: (scroll: Probe.Scroll) => Effect.Effect<void, never, AtomRegistry.AtomRegistry>
77
+ }> =>
78
+ Effect.sync(() => {
79
+ const intersections = Feeds.hub<Probe.Intersection>()
80
+ const { scroll = STILL_SCROLL } = options
81
+ const scrollAtom = Atom.make(scroll)
82
+ return {
83
+ layer: Layer.succeed(Probe.Probe, { ...fixed(options, scrollAtom), observeIntersection: () => intersections.feed }),
84
+ pushIntersection: (inView: boolean) => intersections.push(toIntersection(inView)),
85
+ pushScroll: (next: Probe.Scroll) => AtomRegistry.AtomRegistry.use((registry) => Effect.sync(() => registry.set(scrollAtom, next))),
86
+ }
87
+ })
@@ -0,0 +1,63 @@
1
+ import * as Renderer from "@wardic/core/Renderer"
2
+ import { constVoid } from "effect/Function"
3
+ import * as Layer from "effect/Layer"
4
+
5
+ /** A single recorded `project` call. */
6
+ export interface ProjectCall {
7
+ readonly values: Record<string, unknown>
8
+ }
9
+
10
+ /** A single recorded `commit` call. */
11
+ export interface CommitCall {
12
+ readonly target: unknown
13
+ readonly patch: Renderer.Patch
14
+ }
15
+
16
+ /**
17
+ * A Renderer that records every `project` and `commit` invocation into
18
+ * separate arrays instead of touching an environment — lets a test assert
19
+ * both the pure projection step and the imperative writes that would have
20
+ * been issued.
21
+ *
22
+ * Defaults:
23
+ * - `project` is identity (`(values) => values as Patch`).
24
+ * - `commit` pushes onto `commitCalls`.
25
+ */
26
+ export const make = (): {
27
+ readonly service: {
28
+ readonly project: (values: Record<string, unknown>) => Renderer.Patch
29
+ readonly commit: (target: unknown, patch: Renderer.Patch) => void
30
+ }
31
+ readonly projectCalls: ReadonlyArray<ProjectCall>
32
+ readonly commitCalls: ReadonlyArray<CommitCall>
33
+ } => {
34
+ const projectCalls: Array<ProjectCall> = []
35
+ const commitCalls: Array<CommitCall> = []
36
+ return {
37
+ service: {
38
+ project: (values: Record<string, unknown>): Renderer.Patch => {
39
+ projectCalls.push({ values })
40
+ return values as Renderer.Patch
41
+ },
42
+ commit: (target: unknown, patch: Renderer.Patch): void => {
43
+ commitCalls.push({ target, patch })
44
+ },
45
+ },
46
+ projectCalls,
47
+ commitCalls,
48
+ }
49
+ }
50
+
51
+ /**
52
+ * A Renderer that draws nothing and keeps nothing: `project` hands its values back as the patch, and
53
+ * `commit` drops it. A test that reads the calls makes its own with {@link make}.
54
+ */
55
+ // #618 (decision 7): this was one recording renderer made at module load, whose calls every test in a
56
+ // file added to and nothing read.
57
+ const DISCARDING: Renderer.Renderer["Service"] = {
58
+ project: (values) => values as Renderer.Patch,
59
+ commit: constVoid,
60
+ }
61
+
62
+ /** The {@link DISCARDING} Renderer's Layer. */
63
+ export const layer: Layer.Layer<Renderer.Renderer> = Layer.succeed(Renderer.Renderer, DISCARDING)