@kairos-es/testing 1.0.0-alpha.0 → 1.0.0-alpha.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/README.md +25 -4
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/specification.js +56 -13
- package/dist/cjs/specification.js.map +1 -1
- package/dist/dts/index.d.ts +7 -4
- package/dist/dts/index.d.ts.map +1 -1
- package/dist/dts/specification.d.ts +75 -10
- package/dist/dts/specification.d.ts.map +1 -1
- package/dist/esm/index.js +7 -4
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/specification.js +55 -13
- package/dist/esm/specification.js.map +1 -1
- package/package.json +3 -3
- package/src/index.ts +7 -4
- package/src/specification.ts +155 -32
package/README.md
CHANGED
|
@@ -17,10 +17,31 @@ a peer for you.
|
|
|
17
17
|
Its other peers, `@kairos-es/core`, `@kairos-es/codec` and `effect`, are the ones
|
|
18
18
|
an application using the library already has.
|
|
19
19
|
|
|
20
|
-
A `DeciderSpecification`-equivalent
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
and
|
|
20
|
+
A `DeciderSpecification`-equivalent running the real read → decode → decide →
|
|
21
|
+
append cycle against the in-memory store rather than a mock, on
|
|
22
|
+
`@effect/vitest`. Over the codec's `buildDecisionModel` for a single-hop
|
|
23
|
+
decision, and over `modelAtHead` and the read ledger for a layered one.
|
|
24
|
+
`@kairos-es/core`, `@kairos-es/codec`, `effect` and `@effect/vitest` are peer
|
|
25
|
+
dependencies.
|
|
26
|
+
|
|
27
|
+
`specificationFor(decider)` is the ordinary entry point.
|
|
28
|
+
`specificationForCycle(run)` is the general one it is a one-line specialisation
|
|
29
|
+
of: it takes any `(command) => Effect<Position, …>`, so a `transact` bracket slots
|
|
30
|
+
into the same chain a decider does. Its `run` states its errors as `E | CycleError`
|
|
31
|
+
— the chain adds that union itself, so a naive `Effect<Position, E, R>`
|
|
32
|
+
double-counts it and will not unify.
|
|
33
|
+
|
|
34
|
+
⚠ `.thenError(tag)` is weaker on a bracket than on a decider, in three ways worth
|
|
35
|
+
knowing before you rely on it. Two different denials from one guard usually share
|
|
36
|
+
one tag, so a spec can pass on a refusal its author did not mean. An
|
|
37
|
+
`AppendConditionFailed` now also fires for an EARLIER hop, so the tag no longer says
|
|
38
|
+
which boundary moved. And `NoReadsRecorded` reads like a domain assertion while
|
|
39
|
+
being a statement about wiring. A layered or permission suite should assert more
|
|
40
|
+
than the tag.
|
|
41
|
+
|
|
42
|
+
`.thenUnchanged()` asserts the "already done" branch: the cycle succeeded with
|
|
43
|
+
the codec's `Unchanged` AND appended nothing. `.then()` and `.thenError(tag)` both
|
|
44
|
+
fail on an `Unchanged` cycle, so the three terminals cannot pass for one another.
|
|
24
45
|
|
|
25
46
|
The `Decider` a scenario exercises and the `runDecider` that runs it are
|
|
26
47
|
production symbols and belong to [`@kairos-es/codec`](../codec); this package
|
package/dist/cjs/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":["_specification","require","Object","keys","forEach","key","exports","defineProperty","enumerable","get"],"sources":["../../src/index.ts"],"sourcesContent":[null],"mappings":";;;;;
|
|
1
|
+
{"version":3,"file":"index.js","names":["_specification","require","Object","keys","forEach","key","exports","defineProperty","enumerable","get"],"sources":["../../src/index.ts"],"sourcesContent":[null],"mappings":";;;;;AA6BA,IAAAA,cAAA,GAAAC,OAAA;AAAAC,MAAA,CAAAC,IAAA,CAAAH,cAAA,EAAAI,OAAA,WAAAC,GAAA;EAAA,IAAAA,GAAA,kBAAAA,GAAA;EAAA,IAAAA,GAAA,IAAAC,OAAA,IAAAA,OAAA,CAAAD,GAAA,MAAAL,cAAA,CAAAK,GAAA;EAAAH,MAAA,CAAAK,cAAA,CAAAD,OAAA,EAAAD,GAAA;IAAAG,UAAA;IAAAC,GAAA,WAAAA,CAAA;MAAA,OAAAT,cAAA,CAAAK,GAAA;IAAA;EAAA;AAAA","ignoreList":[]}
|
|
@@ -3,15 +3,20 @@
|
|
|
3
3
|
Object.defineProperty(exports, "__esModule", {
|
|
4
4
|
value: true
|
|
5
5
|
});
|
|
6
|
-
exports.specificationFor = exports.scenario = void 0;
|
|
6
|
+
exports.specificationForCycle = exports.specificationFor = exports.scenario = void 0;
|
|
7
7
|
var _vitest = require("@effect/vitest");
|
|
8
8
|
var _codec = require("@kairos-es/codec");
|
|
9
9
|
var _core = require("@kairos-es/core");
|
|
10
10
|
var _effect = require("effect");
|
|
11
11
|
/**
|
|
12
|
-
* The Given/When/Then surface: a `DeciderSpecification`-equivalent over
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* The Given/When/Then surface: a `DeciderSpecification`-equivalent over a real
|
|
13
|
+
* fold and a `DcbEventStore` (the in-memory layer in practice) —
|
|
14
|
+
* `@kairos-es/codec`'s `buildDecisionModel` for a single-hop decision, and
|
|
15
|
+
* `modelAtHead` plus the read ledger for a layered one. `specificationForCycle`
|
|
16
|
+
* is the general entry point over either, named for the command CYCLE it takes
|
|
17
|
+
* rather than for the layered bracket that was its first caller — the shape it
|
|
18
|
+
* accepts is `(command) => Effect<Position, …>` and a decider is one instance of
|
|
19
|
+
* it. It is shaped so an Event Modeling
|
|
15
20
|
* `Specification` (given events, when a command, then events / thenError a tag)
|
|
16
21
|
* compiles onto it 1:1 — prior art: dcb-events `TestRunner` and Emmett's
|
|
17
22
|
* `DeciderSpecification`.
|
|
@@ -26,12 +31,17 @@ var _effect = require("effect");
|
|
|
26
31
|
* .when(command)
|
|
27
32
|
* .thenError('AppendConditionFailed') // failure branch (domain error or conflict)
|
|
28
33
|
*
|
|
34
|
+
* specificationFor(decider)
|
|
35
|
+
* .given(...pastEventProducers)
|
|
36
|
+
* .when(command)
|
|
37
|
+
* .thenUnchanged() // "already done" branch: nothing appended
|
|
38
|
+
*
|
|
29
39
|
* Events are supplied *effectfully*: a given/expected event is the producer an
|
|
30
40
|
* application authors via `toEvent` — an `Effect<DcbEvent, CodecError,
|
|
31
41
|
* Serializer | Uuid | R>` — so both sides materialise (encode -> serialise)
|
|
32
42
|
* under the same layers the cycle runs against, and their `data` bytes match.
|
|
33
43
|
*
|
|
34
|
-
* Each terminal (`then` / `thenError`) returns an `Effect` requiring
|
|
44
|
+
* Each terminal (`then` / `thenError` / `thenUnchanged`) returns an `Effect` requiring
|
|
35
45
|
* `DcbEventStore | Serializer | Uuid | R`, so a test provides the store,
|
|
36
46
|
* serialiser, and uuid layers (fresh store per case for isolation).
|
|
37
47
|
* `scenario(...)` wraps the whole thing in `@effect/vitest`'s `it.effect`
|
|
@@ -91,21 +101,32 @@ const appendedAfter = after => _effect.Effect.gen(function* () {
|
|
|
91
101
|
return yield* collect(events);
|
|
92
102
|
});
|
|
93
103
|
/**
|
|
94
|
-
* Begin a specification for
|
|
95
|
-
*
|
|
96
|
-
* `
|
|
104
|
+
* Begin a specification for ANY command cycle that returns the appended
|
|
105
|
+
* `Position` — a bracket for one command as readily as a decider — or
|
|
106
|
+
* `Unchanged`, for a cycle that can answer "already done".
|
|
107
|
+
*
|
|
108
|
+
* This is the general form and `specificationFor` below is a one-line
|
|
109
|
+
* specialisation of it, which is the result worth stating rather than the
|
|
110
|
+
* mechanism: a `Decider` is one particular `(command) => Effect<Position, …>`,
|
|
111
|
+
* and `runDecider` is the function that says so. Nothing downstream of `when`
|
|
112
|
+
* moves — the same seed boundary, the same read-back, the same shape comparison
|
|
113
|
+
* — because both cycles already agree on what they return.
|
|
114
|
+
*
|
|
115
|
+
* ⚠ `run`'s error channel is written `E | CycleError` and must be: the chain adds
|
|
116
|
+
* that union itself, so a naive `Effect<Position, E, R>` double-counts it and
|
|
117
|
+
* fails to unify. `CycleError` exists to be written here.
|
|
97
118
|
*/
|
|
98
|
-
const
|
|
119
|
+
const specificationForCycle = run => ({
|
|
99
120
|
given: (...pastEvents) => ({
|
|
100
121
|
when: command => {
|
|
101
|
-
// Run the seed +
|
|
122
|
+
// Run the seed + cycle, exposing the outcome as an Exit so both
|
|
102
123
|
// branches (then / thenError) can inspect success or failure uniformly. The
|
|
103
124
|
// seed is inside the Exit too, so a decode failure while materialising a
|
|
104
125
|
// given producer is captured as a failure rather than escaping the channel.
|
|
105
126
|
// The success value pairs the seed boundary (the last seeded position) with
|
|
106
|
-
// the
|
|
127
|
+
// the cycle's outcome, so `then` reads strictly after that boundary
|
|
107
128
|
// rather than dropping a fixed row count (correct on a gappy store).
|
|
108
|
-
const runToExit = seed(pastEvents).pipe(_effect.Effect.zip((
|
|
129
|
+
const runToExit = seed(pastEvents).pipe(_effect.Effect.zip(run(command)), _effect.Effect.exit);
|
|
109
130
|
return {
|
|
110
131
|
// biome-ignore lint/suspicious/noThenProperty: deliberate Given/When/Then terminal name (see WhenSpec.then); never awaited as a thenable.
|
|
111
132
|
then: (...expected) => _effect.Effect.gen(function* () {
|
|
@@ -116,7 +137,8 @@ const specificationFor = decider => ({
|
|
|
116
137
|
// it loud rather than silently passing.
|
|
117
138
|
return yield* _effect.Effect.failCause(exit.cause);
|
|
118
139
|
}
|
|
119
|
-
const [seedBoundary] = exit.value;
|
|
140
|
+
const [seedBoundary, result] = exit.value;
|
|
141
|
+
(0, _vitest.expect)((0, _codec.isUnchanged)(result)).toBe(false);
|
|
120
142
|
const appended = yield* appendedAfter(seedBoundary);
|
|
121
143
|
const expectedEvents = yield* _effect.Effect.all(expected);
|
|
122
144
|
(0, _vitest.expect)(appended.map(s => shapeOf(s.event))).toEqual(expectedEvents.map(shapeOf));
|
|
@@ -130,11 +152,32 @@ const specificationFor = decider => ({
|
|
|
130
152
|
const actualTag = typeof error === 'object' && error !== null && '_tag' in error ? error._tag : undefined;
|
|
131
153
|
(0, _vitest.expect)(actualTag).toBe(tag);
|
|
132
154
|
}
|
|
155
|
+
}),
|
|
156
|
+
thenUnchanged: () => _effect.Effect.gen(function* () {
|
|
157
|
+
const exit = yield* runToExit;
|
|
158
|
+
if (_effect.Exit.isFailure(exit)) {
|
|
159
|
+
return yield* _effect.Effect.failCause(exit.cause);
|
|
160
|
+
}
|
|
161
|
+
const [seedBoundary, result] = exit.value;
|
|
162
|
+
(0, _vitest.expect)((0, _codec.isUnchanged)(result)).toBe(true);
|
|
163
|
+
const appended = yield* appendedAfter(seedBoundary);
|
|
164
|
+
(0, _vitest.expect)(appended.map(s => shapeOf(s.event))).toEqual([]);
|
|
133
165
|
})
|
|
134
166
|
};
|
|
135
167
|
}
|
|
136
168
|
})
|
|
137
169
|
});
|
|
170
|
+
/**
|
|
171
|
+
* Begin a specification for `decider`. The returned `given(...)` starts the
|
|
172
|
+
* Given/When/Then chain; the terminal step yields an `Effect` requiring
|
|
173
|
+
* `DcbEventStore | Serializer | Uuid | R | RequirementOf<P>`.
|
|
174
|
+
*
|
|
175
|
+
* One line over the layered sibling above, and deliberately not a twin of it:
|
|
176
|
+
* a duplicated body would be two sets of tests and two things free to drift on
|
|
177
|
+
* the seed boundary, the read-back or the shape comparison.
|
|
178
|
+
*/
|
|
179
|
+
exports.specificationForCycle = specificationForCycle;
|
|
180
|
+
const specificationFor = decider => specificationForCycle(command => (0, _codec.runDecider)(decider, command));
|
|
138
181
|
/**
|
|
139
182
|
* `scenario(description, spec => spec.given(...).when(...).then(...))` — register
|
|
140
183
|
* an `@effect/vitest` `it.effect` case running `build` against a fresh in-memory
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"specification.js","names":["_vitest","require","_codec","_core","_effect","shapeOf","event","type","tags","data","collect","events","Stream","runCollect","pipe","Effect","map","Chunk","toReadonlyArray","seed","producers","gen","store","DcbEventStore","last","ORIGIN","producer","append","orDie","appendedAfter","after","read","Q","all","
|
|
1
|
+
{"version":3,"file":"specification.js","names":["_vitest","require","_codec","_core","_effect","shapeOf","event","type","tags","data","collect","events","Stream","runCollect","pipe","Effect","map","Chunk","toReadonlyArray","seed","producers","gen","store","DcbEventStore","last","ORIGIN","producer","append","orDie","appendedAfter","after","read","Q","all","specificationForCycle","run","given","pastEvents","when","command","runToExit","zip","exit","then","expected","Exit","isFailure","failCause","cause","seedBoundary","result","value","expect","isUnchanged","toBe","appended","expectedEvents","s","toEqual","thenError","tag","failure","Cause","failureOption","error","_tag","undefined","actualTag","thenUnchanged","exports","specificationFor","decider","runDecider","scenario","description","build","it","effect","provide","Layer","mergeAll","fresh","DcbEventStoreInMemory","SerializerDefault","UuidTest"],"sources":["../../src/specification.ts"],"sourcesContent":[null],"mappings":"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","ignoreList":[]}
|
package/dist/dts/index.d.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @kairos-es/testing — the Given/When/Then decision-model testing surface.
|
|
3
3
|
*
|
|
4
|
-
* A `DeciderSpecification`-equivalent over
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* A `DeciderSpecification`-equivalent over a real fold and a `DcbEventStore` (in
|
|
5
|
+
* practice the in-memory layer): `@kairos-es/codec`'s `buildDecisionModel` for
|
|
6
|
+
* a single-hop decision, and `modelAtHead` plus the read ledger for a layered
|
|
7
|
+
* one. Shaped so an Event Modeling `Specification` (given events / when a
|
|
8
|
+
* command / then events / thenError a tag) compiles onto it 1:1, plus
|
|
9
|
+
* `thenUnchanged` for a decision that answers "already done" and appends
|
|
10
|
+
* nothing.
|
|
8
11
|
*
|
|
9
12
|
* scenario('subscribe a student', subscribeDecider, (spec) =>
|
|
10
13
|
* spec
|
package/dist/dts/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,cAAc,iBAAiB,CAAA"}
|
|
@@ -1,9 +1,26 @@
|
|
|
1
|
-
import type { CodecError, Decider, RequirementOf, SliceProjection } from '@kairos-es/codec';
|
|
1
|
+
import type { CodecError, Decider, DecisionOutput, NoReadsRecorded, RequirementOf, SliceProjection, TaglessSliceInLedger, Unchanged } from '@kairos-es/codec';
|
|
2
2
|
import { type Serializer, type Uuid } from '@kairos-es/codec';
|
|
3
|
-
import { type AppendConditionFailed, type DcbEvent, DcbEventStore } from '@kairos-es/core';
|
|
4
|
-
import { Effect } from 'effect';
|
|
3
|
+
import { type AppendConditionFailed, type DcbEvent, DcbEventStore, type Position } from '@kairos-es/core';
|
|
4
|
+
import { type Array as Arr, Effect } from 'effect';
|
|
5
5
|
/** An effectful producer of a wire event (the `toEvent` result an app authors). */
|
|
6
6
|
export type EventProducer<R> = Effect.Effect<DcbEvent, CodecError, Serializer | Uuid | R>;
|
|
7
|
+
/**
|
|
8
|
+
* The errors a command CYCLE contributes, beyond whatever `E` the decision
|
|
9
|
+
* itself fails with.
|
|
10
|
+
*
|
|
11
|
+
* Named once and exported because it is what makes the layered entry point below
|
|
12
|
+
* UNIFY. `WhenSpec.then` adds this union itself, so a `run` written as a naive
|
|
13
|
+
* `Effect<Position, E, R>` double-counts every member of it and fails to
|
|
14
|
+
* typecheck against a chain that has already added them — a trap with an
|
|
15
|
+
* unhelpful error message, met once and closed here by giving the union a name a
|
|
16
|
+
* caller can write.
|
|
17
|
+
*
|
|
18
|
+
* `NoReadsRecorded` is reachable only on the layered path and `TaglessSliceInLedger`
|
|
19
|
+
* only where a bracket is open, but both are members here rather than in a second
|
|
20
|
+
* union, because the two paths share every step downstream of `when` and a second
|
|
21
|
+
* union would be a second thing to keep in step for no caller's benefit.
|
|
22
|
+
*/
|
|
23
|
+
export type CycleError = CodecError | AppendConditionFailed | TaglessSliceInLedger | NoReadsRecorded;
|
|
7
24
|
/**
|
|
8
25
|
* The entry point of the Given/When/Then chain: what `specificationFor` returns
|
|
9
26
|
* and what `scenario` hands its `build` callback. It is NAMED rather than
|
|
@@ -35,30 +52,78 @@ export interface GivenSpec<Command, E, R, PR> {
|
|
|
35
52
|
/** Supply the command to decide. */
|
|
36
53
|
readonly when: (command: Command) => WhenSpec<E, R, PR>;
|
|
37
54
|
}
|
|
38
|
-
/** A spec that has a command and awaits a `then`/`thenError` assertion. */
|
|
55
|
+
/** A spec that has a command and awaits a `then`/`thenError`/`thenUnchanged` assertion. */
|
|
39
56
|
export interface WhenSpec<E, R, PR> {
|
|
40
57
|
/**
|
|
41
58
|
* Assert the success branch: the decider appended exactly these events
|
|
42
59
|
* (compared structurally by type/tags/data, in order). The expected events are
|
|
43
60
|
* effectful producers, materialised under the provided layers. The error
|
|
44
|
-
* channel keeps `E |
|
|
45
|
-
*
|
|
46
|
-
*
|
|
61
|
+
* channel keeps `E | CycleError` because a genuinely failing cycle re-raises
|
|
62
|
+
* its cause (a `.then` on a failing decision is a test-authoring mistake, made
|
|
63
|
+
* loud rather than silently green).
|
|
64
|
+
*
|
|
65
|
+
* A cycle that answered `Unchanged` FAILS this assertion, even with no
|
|
66
|
+
* expected events. `.then()` with an empty list and `.thenUnchanged()` both
|
|
67
|
+
* see nothing appended, but they claim different things — *decided an empty
|
|
68
|
+
* change* is not a decision this library can make, while *already done* is —
|
|
69
|
+
* so the one that does not match the cycle's answer must not pass.
|
|
47
70
|
*/
|
|
48
|
-
readonly then: (...expected: ReadonlyArray<EventProducer<R>>) => Effect.Effect<void, E |
|
|
71
|
+
readonly then: (...expected: ReadonlyArray<EventProducer<R>>) => Effect.Effect<void, E | CycleError, DcbEventStore | Serializer | Uuid | R | PR>;
|
|
49
72
|
/**
|
|
50
73
|
* Assert the failure branch: the cycle failed with a tagged error whose
|
|
51
74
|
* `_tag` equals `tag` (a domain error from `decide`, a `CodecError` from
|
|
52
75
|
* decode, or `AppendConditionFailed` from a moved consistency boundary).
|
|
76
|
+
*
|
|
77
|
+
* ⚠ THREE WAYS THIS PASSES FOR THE WRONG REASON, and a layered or permission
|
|
78
|
+
* suite must assert more than the tag. An opaque refusal is correct on the wire
|
|
79
|
+
* and is exactly what blinds this assertion — two different denials share one
|
|
80
|
+
* tag, so a spec can pass on a refusal its author did not mean. An
|
|
81
|
+
* `AppendConditionFailed` now also fires for an EARLIER hop, so the tag no
|
|
82
|
+
* longer says which boundary moved. And `NoReadsRecorded` is a reachable spec
|
|
83
|
+
* whose shape does not mark it as structural: it reads like a domain assertion
|
|
84
|
+
* and is a statement about wiring.
|
|
53
85
|
*/
|
|
54
86
|
readonly thenError: (tag: string) => Effect.Effect<void, never, DcbEventStore | Serializer | Uuid | R | PR>;
|
|
87
|
+
/**
|
|
88
|
+
* Assert the "already done" branch: the cycle SUCCEEDED with `Unchanged`
|
|
89
|
+
* (recognised by `isUnchanged`, never by its tag) and appended nothing past
|
|
90
|
+
* the seed. A cycle that appended — even an empty-looking one — or that
|
|
91
|
+
* returned a `Position` fails it, and a failing cycle re-raises its cause, for
|
|
92
|
+
* the reason `then` gives.
|
|
93
|
+
*
|
|
94
|
+
* Both halves are asserted, not just the result, because they are different
|
|
95
|
+
* claims: a runner that returned `Unchanged` AND appended would be exactly the
|
|
96
|
+
* defect this step exists to catch, and the result alone would not see it.
|
|
97
|
+
*/
|
|
98
|
+
readonly thenUnchanged: () => Effect.Effect<void, E | CycleError, DcbEventStore | Serializer | Uuid | R | PR>;
|
|
55
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* Begin a specification for ANY command cycle that returns the appended
|
|
102
|
+
* `Position` — a bracket for one command as readily as a decider — or
|
|
103
|
+
* `Unchanged`, for a cycle that can answer "already done".
|
|
104
|
+
*
|
|
105
|
+
* This is the general form and `specificationFor` below is a one-line
|
|
106
|
+
* specialisation of it, which is the result worth stating rather than the
|
|
107
|
+
* mechanism: a `Decider` is one particular `(command) => Effect<Position, …>`,
|
|
108
|
+
* and `runDecider` is the function that says so. Nothing downstream of `when`
|
|
109
|
+
* moves — the same seed boundary, the same read-back, the same shape comparison
|
|
110
|
+
* — because both cycles already agree on what they return.
|
|
111
|
+
*
|
|
112
|
+
* ⚠ `run`'s error channel is written `E | CycleError` and must be: the chain adds
|
|
113
|
+
* that union itself, so a naive `Effect<Position, E, R>` double-counts it and
|
|
114
|
+
* fails to unify. `CycleError` exists to be written here.
|
|
115
|
+
*/
|
|
116
|
+
export declare const specificationForCycle: <Command, E, R, PR = never>(run: (command: Command) => Effect.Effect<Position | Unchanged, E | CycleError, DcbEventStore | Serializer | Uuid | R | PR>) => Specification<Command, E, R, PR>;
|
|
56
117
|
/**
|
|
57
118
|
* Begin a specification for `decider`. The returned `given(...)` starts the
|
|
58
119
|
* Given/When/Then chain; the terminal step yields an `Effect` requiring
|
|
59
120
|
* `DcbEventStore | Serializer | Uuid | R | RequirementOf<P>`.
|
|
121
|
+
*
|
|
122
|
+
* One line over the layered sibling above, and deliberately not a twin of it:
|
|
123
|
+
* a duplicated body would be two sets of tests and two things free to drift on
|
|
124
|
+
* the seed boundary, the read-back or the shape comparison.
|
|
60
125
|
*/
|
|
61
|
-
export declare const specificationFor: <P extends Record<string, SliceProjection>, Command, E, R
|
|
126
|
+
export declare const specificationFor: <P extends Record<string, SliceProjection>, Command, E, R, O extends DecisionOutput = Arr.NonEmptyReadonlyArray<DcbEvent>>(decider: Decider<P, Command, E, R, O>) => Specification<Command, E, R, RequirementOf<P>>;
|
|
62
127
|
/**
|
|
63
128
|
* `scenario(description, spec => spec.given(...).when(...).then(...))` — register
|
|
64
129
|
* an `@effect/vitest` `it.effect` case running `build` against a fresh in-memory
|
|
@@ -72,5 +137,5 @@ export declare const specificationFor: <P extends Record<string, SliceProjection
|
|
|
72
137
|
* encryption requirement provides its cipher layer inside `build` before
|
|
73
138
|
* returning.
|
|
74
139
|
*/
|
|
75
|
-
export declare const scenario: <P extends Record<string, SliceProjection>, Command, E
|
|
140
|
+
export declare const scenario: <P extends Record<string, SliceProjection>, Command, E, O extends DecisionOutput = Arr.NonEmptyReadonlyArray<DcbEvent>>(description: string, decider: Decider<P, Command, E, never, O>, build: (spec: Specification<Command, E, never, RequirementOf<P>>) => Effect.Effect<void, E | CycleError, DcbEventStore | Serializer | Uuid>) => void;
|
|
76
141
|
//# sourceMappingURL=specification.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"specification.d.ts","sourceRoot":"","sources":["../../src/specification.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"specification.d.ts","sourceRoot":"","sources":["../../src/specification.ts"],"names":[],"mappings":"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"}
|
package/dist/esm/index.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @kairos-es/testing — the Given/When/Then decision-model testing surface.
|
|
3
3
|
*
|
|
4
|
-
* A `DeciderSpecification`-equivalent over
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* A `DeciderSpecification`-equivalent over a real fold and a `DcbEventStore` (in
|
|
5
|
+
* practice the in-memory layer): `@kairos-es/codec`'s `buildDecisionModel` for
|
|
6
|
+
* a single-hop decision, and `modelAtHead` plus the read ledger for a layered
|
|
7
|
+
* one. Shaped so an Event Modeling `Specification` (given events / when a
|
|
8
|
+
* command / then events / thenError a tag) compiles onto it 1:1, plus
|
|
9
|
+
* `thenUnchanged` for a decision that answers "already done" and appends
|
|
10
|
+
* nothing.
|
|
8
11
|
*
|
|
9
12
|
* scenario('subscribe a student', subscribeDecider, (spec) =>
|
|
10
13
|
* spec
|
package/dist/esm/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../../src/index.ts"],"sourcesContent":[null],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../../src/index.ts"],"sourcesContent":[null],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA6BA,cAAc,oBAAiB","ignoreList":[]}
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The Given/When/Then surface: a `DeciderSpecification`-equivalent over
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* The Given/When/Then surface: a `DeciderSpecification`-equivalent over a real
|
|
3
|
+
* fold and a `DcbEventStore` (the in-memory layer in practice) —
|
|
4
|
+
* `@kairos-es/codec`'s `buildDecisionModel` for a single-hop decision, and
|
|
5
|
+
* `modelAtHead` plus the read ledger for a layered one. `specificationForCycle`
|
|
6
|
+
* is the general entry point over either, named for the command CYCLE it takes
|
|
7
|
+
* rather than for the layered bracket that was its first caller — the shape it
|
|
8
|
+
* accepts is `(command) => Effect<Position, …>` and a decider is one instance of
|
|
9
|
+
* it. It is shaped so an Event Modeling
|
|
5
10
|
* `Specification` (given events, when a command, then events / thenError a tag)
|
|
6
11
|
* compiles onto it 1:1 — prior art: dcb-events `TestRunner` and Emmett's
|
|
7
12
|
* `DeciderSpecification`.
|
|
@@ -16,12 +21,17 @@
|
|
|
16
21
|
* .when(command)
|
|
17
22
|
* .thenError('AppendConditionFailed') // failure branch (domain error or conflict)
|
|
18
23
|
*
|
|
24
|
+
* specificationFor(decider)
|
|
25
|
+
* .given(...pastEventProducers)
|
|
26
|
+
* .when(command)
|
|
27
|
+
* .thenUnchanged() // "already done" branch: nothing appended
|
|
28
|
+
*
|
|
19
29
|
* Events are supplied *effectfully*: a given/expected event is the producer an
|
|
20
30
|
* application authors via `toEvent` — an `Effect<DcbEvent, CodecError,
|
|
21
31
|
* Serializer | Uuid | R>` — so both sides materialise (encode -> serialise)
|
|
22
32
|
* under the same layers the cycle runs against, and their `data` bytes match.
|
|
23
33
|
*
|
|
24
|
-
* Each terminal (`then` / `thenError`) returns an `Effect` requiring
|
|
34
|
+
* Each terminal (`then` / `thenError` / `thenUnchanged`) returns an `Effect` requiring
|
|
25
35
|
* `DcbEventStore | Serializer | Uuid | R`, so a test provides the store,
|
|
26
36
|
* serialiser, and uuid layers (fresh store per case for isolation).
|
|
27
37
|
* `scenario(...)` wraps the whole thing in `@effect/vitest`'s `it.effect`
|
|
@@ -35,7 +45,7 @@
|
|
|
35
45
|
* deterministic, so equal payloads produce equal `data` bytes.
|
|
36
46
|
*/
|
|
37
47
|
import { expect, it } from '@effect/vitest';
|
|
38
|
-
import { runDecider, SerializerDefault, UuidTest } from '@kairos-es/codec';
|
|
48
|
+
import { isUnchanged, runDecider, SerializerDefault, UuidTest } from '@kairos-es/codec';
|
|
39
49
|
import { DcbEventStore, DcbEventStoreInMemory, ORIGIN, query as Q } from '@kairos-es/core';
|
|
40
50
|
import { Cause, Chunk, Effect, Exit, Layer, Stream } from 'effect';
|
|
41
51
|
const shapeOf = event => ({
|
|
@@ -84,21 +94,32 @@ const appendedAfter = after => Effect.gen(function* () {
|
|
|
84
94
|
return yield* collect(events);
|
|
85
95
|
});
|
|
86
96
|
/**
|
|
87
|
-
* Begin a specification for
|
|
88
|
-
*
|
|
89
|
-
* `
|
|
97
|
+
* Begin a specification for ANY command cycle that returns the appended
|
|
98
|
+
* `Position` — a bracket for one command as readily as a decider — or
|
|
99
|
+
* `Unchanged`, for a cycle that can answer "already done".
|
|
100
|
+
*
|
|
101
|
+
* This is the general form and `specificationFor` below is a one-line
|
|
102
|
+
* specialisation of it, which is the result worth stating rather than the
|
|
103
|
+
* mechanism: a `Decider` is one particular `(command) => Effect<Position, …>`,
|
|
104
|
+
* and `runDecider` is the function that says so. Nothing downstream of `when`
|
|
105
|
+
* moves — the same seed boundary, the same read-back, the same shape comparison
|
|
106
|
+
* — because both cycles already agree on what they return.
|
|
107
|
+
*
|
|
108
|
+
* ⚠ `run`'s error channel is written `E | CycleError` and must be: the chain adds
|
|
109
|
+
* that union itself, so a naive `Effect<Position, E, R>` double-counts it and
|
|
110
|
+
* fails to unify. `CycleError` exists to be written here.
|
|
90
111
|
*/
|
|
91
|
-
export const
|
|
112
|
+
export const specificationForCycle = run => ({
|
|
92
113
|
given: (...pastEvents) => ({
|
|
93
114
|
when: command => {
|
|
94
|
-
// Run the seed +
|
|
115
|
+
// Run the seed + cycle, exposing the outcome as an Exit so both
|
|
95
116
|
// branches (then / thenError) can inspect success or failure uniformly. The
|
|
96
117
|
// seed is inside the Exit too, so a decode failure while materialising a
|
|
97
118
|
// given producer is captured as a failure rather than escaping the channel.
|
|
98
119
|
// The success value pairs the seed boundary (the last seeded position) with
|
|
99
|
-
// the
|
|
120
|
+
// the cycle's outcome, so `then` reads strictly after that boundary
|
|
100
121
|
// rather than dropping a fixed row count (correct on a gappy store).
|
|
101
|
-
const runToExit = seed(pastEvents).pipe(Effect.zip(
|
|
122
|
+
const runToExit = seed(pastEvents).pipe(Effect.zip(run(command)), Effect.exit);
|
|
102
123
|
return {
|
|
103
124
|
// biome-ignore lint/suspicious/noThenProperty: deliberate Given/When/Then terminal name (see WhenSpec.then); never awaited as a thenable.
|
|
104
125
|
then: (...expected) => Effect.gen(function* () {
|
|
@@ -109,7 +130,8 @@ export const specificationFor = decider => ({
|
|
|
109
130
|
// it loud rather than silently passing.
|
|
110
131
|
return yield* Effect.failCause(exit.cause);
|
|
111
132
|
}
|
|
112
|
-
const [seedBoundary] = exit.value;
|
|
133
|
+
const [seedBoundary, result] = exit.value;
|
|
134
|
+
expect(isUnchanged(result)).toBe(false);
|
|
113
135
|
const appended = yield* appendedAfter(seedBoundary);
|
|
114
136
|
const expectedEvents = yield* Effect.all(expected);
|
|
115
137
|
expect(appended.map(s => shapeOf(s.event))).toEqual(expectedEvents.map(shapeOf));
|
|
@@ -123,11 +145,31 @@ export const specificationFor = decider => ({
|
|
|
123
145
|
const actualTag = typeof error === 'object' && error !== null && '_tag' in error ? error._tag : undefined;
|
|
124
146
|
expect(actualTag).toBe(tag);
|
|
125
147
|
}
|
|
148
|
+
}),
|
|
149
|
+
thenUnchanged: () => Effect.gen(function* () {
|
|
150
|
+
const exit = yield* runToExit;
|
|
151
|
+
if (Exit.isFailure(exit)) {
|
|
152
|
+
return yield* Effect.failCause(exit.cause);
|
|
153
|
+
}
|
|
154
|
+
const [seedBoundary, result] = exit.value;
|
|
155
|
+
expect(isUnchanged(result)).toBe(true);
|
|
156
|
+
const appended = yield* appendedAfter(seedBoundary);
|
|
157
|
+
expect(appended.map(s => shapeOf(s.event))).toEqual([]);
|
|
126
158
|
})
|
|
127
159
|
};
|
|
128
160
|
}
|
|
129
161
|
})
|
|
130
162
|
});
|
|
163
|
+
/**
|
|
164
|
+
* Begin a specification for `decider`. The returned `given(...)` starts the
|
|
165
|
+
* Given/When/Then chain; the terminal step yields an `Effect` requiring
|
|
166
|
+
* `DcbEventStore | Serializer | Uuid | R | RequirementOf<P>`.
|
|
167
|
+
*
|
|
168
|
+
* One line over the layered sibling above, and deliberately not a twin of it:
|
|
169
|
+
* a duplicated body would be two sets of tests and two things free to drift on
|
|
170
|
+
* the seed boundary, the read-back or the shape comparison.
|
|
171
|
+
*/
|
|
172
|
+
export const specificationFor = decider => specificationForCycle(command => runDecider(decider, command));
|
|
131
173
|
/**
|
|
132
174
|
* `scenario(description, spec => spec.given(...).when(...).then(...))` — register
|
|
133
175
|
* an `@effect/vitest` `it.effect` case running `build` against a fresh in-memory
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"specification.js","names":["expect","it","runDecider","SerializerDefault","UuidTest","DcbEventStore","DcbEventStoreInMemory","ORIGIN","query","Q","Cause","Chunk","Effect","Exit","Layer","Stream","shapeOf","event","type","tags","data","collect","events","runCollect","pipe","map","toReadonlyArray","seed","producers","gen","store","last","producer","append","orDie","appendedAfter","after","read","all","
|
|
1
|
+
{"version":3,"file":"specification.js","names":["expect","it","isUnchanged","runDecider","SerializerDefault","UuidTest","DcbEventStore","DcbEventStoreInMemory","ORIGIN","query","Q","Cause","Chunk","Effect","Exit","Layer","Stream","shapeOf","event","type","tags","data","collect","events","runCollect","pipe","map","toReadonlyArray","seed","producers","gen","store","last","producer","append","orDie","appendedAfter","after","read","all","specificationForCycle","run","given","pastEvents","when","command","runToExit","zip","exit","then","expected","isFailure","failCause","cause","seedBoundary","result","value","toBe","appended","expectedEvents","s","toEqual","thenError","tag","failure","failureOption","error","_tag","undefined","actualTag","thenUnchanged","specificationFor","decider","scenario","description","build","effect","provide","mergeAll","fresh"],"sources":["../../src/specification.ts"],"sourcesContent":[null],"mappings":"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","ignoreList":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kairos-es/testing",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.2",
|
|
4
4
|
"description": "Given/When/Then decision-model testing surface for kairos-es",
|
|
5
5
|
"license": "BSD-3-Clause",
|
|
6
6
|
"repository": {
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
"peerDependencies": {
|
|
14
14
|
"@effect/vitest": "^0.30.0",
|
|
15
15
|
"effect": "^3.22.0",
|
|
16
|
-
"@kairos-es/codec": "^1.0.0-alpha.
|
|
17
|
-
"@kairos-es/core": "^1.0.0-alpha.
|
|
16
|
+
"@kairos-es/codec": "^1.0.0-alpha.2",
|
|
17
|
+
"@kairos-es/core": "^1.0.0-alpha.2"
|
|
18
18
|
},
|
|
19
19
|
"main": "./dist/cjs/index.js",
|
|
20
20
|
"module": "./dist/esm/index.js",
|
package/src/index.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @kairos-es/testing — the Given/When/Then decision-model testing surface.
|
|
3
3
|
*
|
|
4
|
-
* A `DeciderSpecification`-equivalent over
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* A `DeciderSpecification`-equivalent over a real fold and a `DcbEventStore` (in
|
|
5
|
+
* practice the in-memory layer): `@kairos-es/codec`'s `buildDecisionModel` for
|
|
6
|
+
* a single-hop decision, and `modelAtHead` plus the read ledger for a layered
|
|
7
|
+
* one. Shaped so an Event Modeling `Specification` (given events / when a
|
|
8
|
+
* command / then events / thenError a tag) compiles onto it 1:1, plus
|
|
9
|
+
* `thenUnchanged` for a decision that answers "already done" and appends
|
|
10
|
+
* nothing.
|
|
8
11
|
*
|
|
9
12
|
* scenario('subscribe a student', subscribeDecider, (spec) =>
|
|
10
13
|
* spec
|
package/src/specification.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The Given/When/Then surface: a `DeciderSpecification`-equivalent over
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* The Given/When/Then surface: a `DeciderSpecification`-equivalent over a real
|
|
3
|
+
* fold and a `DcbEventStore` (the in-memory layer in practice) —
|
|
4
|
+
* `@kairos-es/codec`'s `buildDecisionModel` for a single-hop decision, and
|
|
5
|
+
* `modelAtHead` plus the read ledger for a layered one. `specificationForCycle`
|
|
6
|
+
* is the general entry point over either, named for the command CYCLE it takes
|
|
7
|
+
* rather than for the layered bracket that was its first caller — the shape it
|
|
8
|
+
* accepts is `(command) => Effect<Position, …>` and a decider is one instance of
|
|
9
|
+
* it. It is shaped so an Event Modeling
|
|
5
10
|
* `Specification` (given events, when a command, then events / thenError a tag)
|
|
6
11
|
* compiles onto it 1:1 — prior art: dcb-events `TestRunner` and Emmett's
|
|
7
12
|
* `DeciderSpecification`.
|
|
@@ -16,12 +21,17 @@
|
|
|
16
21
|
* .when(command)
|
|
17
22
|
* .thenError('AppendConditionFailed') // failure branch (domain error or conflict)
|
|
18
23
|
*
|
|
24
|
+
* specificationFor(decider)
|
|
25
|
+
* .given(...pastEventProducers)
|
|
26
|
+
* .when(command)
|
|
27
|
+
* .thenUnchanged() // "already done" branch: nothing appended
|
|
28
|
+
*
|
|
19
29
|
* Events are supplied *effectfully*: a given/expected event is the producer an
|
|
20
30
|
* application authors via `toEvent` — an `Effect<DcbEvent, CodecError,
|
|
21
31
|
* Serializer | Uuid | R>` — so both sides materialise (encode -> serialise)
|
|
22
32
|
* under the same layers the cycle runs against, and their `data` bytes match.
|
|
23
33
|
*
|
|
24
|
-
* Each terminal (`then` / `thenError`) returns an `Effect` requiring
|
|
34
|
+
* Each terminal (`then` / `thenError` / `thenUnchanged`) returns an `Effect` requiring
|
|
25
35
|
* `DcbEventStore | Serializer | Uuid | R`, so a test provides the store,
|
|
26
36
|
* serialiser, and uuid layers (fresh store per case for isolation).
|
|
27
37
|
* `scenario(...)` wraps the whole thing in `@effect/vitest`'s `it.effect`
|
|
@@ -38,10 +48,15 @@ import { expect, it } from '@effect/vitest'
|
|
|
38
48
|
import type {
|
|
39
49
|
CodecError,
|
|
40
50
|
Decider,
|
|
51
|
+
DecisionOutput,
|
|
52
|
+
NoReadsRecorded,
|
|
41
53
|
RequirementOf,
|
|
42
54
|
SliceProjection,
|
|
55
|
+
TaglessSliceInLedger,
|
|
56
|
+
Unchanged,
|
|
43
57
|
} from '@kairos-es/codec'
|
|
44
58
|
import {
|
|
59
|
+
isUnchanged,
|
|
45
60
|
runDecider,
|
|
46
61
|
type Serializer,
|
|
47
62
|
SerializerDefault,
|
|
@@ -58,7 +73,15 @@ import {
|
|
|
58
73
|
query as Q,
|
|
59
74
|
type SequencedEvent,
|
|
60
75
|
} from '@kairos-es/core'
|
|
61
|
-
import {
|
|
76
|
+
import {
|
|
77
|
+
type Array as Arr,
|
|
78
|
+
Cause,
|
|
79
|
+
Chunk,
|
|
80
|
+
Effect,
|
|
81
|
+
Exit,
|
|
82
|
+
Layer,
|
|
83
|
+
Stream,
|
|
84
|
+
} from 'effect'
|
|
62
85
|
|
|
63
86
|
/** An effectful producer of a wire event (the `toEvent` result an app authors). */
|
|
64
87
|
export type EventProducer<R> = Effect.Effect<
|
|
@@ -128,6 +151,28 @@ const appendedAfter = (
|
|
|
128
151
|
return yield* collect(events)
|
|
129
152
|
})
|
|
130
153
|
|
|
154
|
+
/**
|
|
155
|
+
* The errors a command CYCLE contributes, beyond whatever `E` the decision
|
|
156
|
+
* itself fails with.
|
|
157
|
+
*
|
|
158
|
+
* Named once and exported because it is what makes the layered entry point below
|
|
159
|
+
* UNIFY. `WhenSpec.then` adds this union itself, so a `run` written as a naive
|
|
160
|
+
* `Effect<Position, E, R>` double-counts every member of it and fails to
|
|
161
|
+
* typecheck against a chain that has already added them — a trap with an
|
|
162
|
+
* unhelpful error message, met once and closed here by giving the union a name a
|
|
163
|
+
* caller can write.
|
|
164
|
+
*
|
|
165
|
+
* `NoReadsRecorded` is reachable only on the layered path and `TaglessSliceInLedger`
|
|
166
|
+
* only where a bracket is open, but both are members here rather than in a second
|
|
167
|
+
* union, because the two paths share every step downstream of `when` and a second
|
|
168
|
+
* union would be a second thing to keep in step for no caller's benefit.
|
|
169
|
+
*/
|
|
170
|
+
export type CycleError =
|
|
171
|
+
| CodecError
|
|
172
|
+
| AppendConditionFailed
|
|
173
|
+
| TaglessSliceInLedger
|
|
174
|
+
| NoReadsRecorded
|
|
175
|
+
|
|
131
176
|
/**
|
|
132
177
|
* The entry point of the Given/When/Then chain: what `specificationFor` returns
|
|
133
178
|
* and what `scenario` hands its `build` callback. It is NAMED rather than
|
|
@@ -163,57 +208,100 @@ export interface GivenSpec<Command, E, R, PR> {
|
|
|
163
208
|
readonly when: (command: Command) => WhenSpec<E, R, PR>
|
|
164
209
|
}
|
|
165
210
|
|
|
166
|
-
/** A spec that has a command and awaits a `then`/`thenError` assertion. */
|
|
211
|
+
/** A spec that has a command and awaits a `then`/`thenError`/`thenUnchanged` assertion. */
|
|
167
212
|
export interface WhenSpec<E, R, PR> {
|
|
168
213
|
/**
|
|
169
214
|
* Assert the success branch: the decider appended exactly these events
|
|
170
215
|
* (compared structurally by type/tags/data, in order). The expected events are
|
|
171
216
|
* effectful producers, materialised under the provided layers. The error
|
|
172
|
-
* channel keeps `E |
|
|
173
|
-
*
|
|
174
|
-
*
|
|
217
|
+
* channel keeps `E | CycleError` because a genuinely failing cycle re-raises
|
|
218
|
+
* its cause (a `.then` on a failing decision is a test-authoring mistake, made
|
|
219
|
+
* loud rather than silently green).
|
|
220
|
+
*
|
|
221
|
+
* A cycle that answered `Unchanged` FAILS this assertion, even with no
|
|
222
|
+
* expected events. `.then()` with an empty list and `.thenUnchanged()` both
|
|
223
|
+
* see nothing appended, but they claim different things — *decided an empty
|
|
224
|
+
* change* is not a decision this library can make, while *already done* is —
|
|
225
|
+
* so the one that does not match the cycle's answer must not pass.
|
|
175
226
|
*/
|
|
176
227
|
readonly then: (
|
|
177
228
|
...expected: ReadonlyArray<EventProducer<R>>
|
|
178
229
|
) => Effect.Effect<
|
|
179
230
|
void,
|
|
180
|
-
E |
|
|
231
|
+
E | CycleError,
|
|
181
232
|
DcbEventStore | Serializer | Uuid | R | PR
|
|
182
233
|
>
|
|
183
234
|
/**
|
|
184
235
|
* Assert the failure branch: the cycle failed with a tagged error whose
|
|
185
236
|
* `_tag` equals `tag` (a domain error from `decide`, a `CodecError` from
|
|
186
237
|
* decode, or `AppendConditionFailed` from a moved consistency boundary).
|
|
238
|
+
*
|
|
239
|
+
* ⚠ THREE WAYS THIS PASSES FOR THE WRONG REASON, and a layered or permission
|
|
240
|
+
* suite must assert more than the tag. An opaque refusal is correct on the wire
|
|
241
|
+
* and is exactly what blinds this assertion — two different denials share one
|
|
242
|
+
* tag, so a spec can pass on a refusal its author did not mean. An
|
|
243
|
+
* `AppendConditionFailed` now also fires for an EARLIER hop, so the tag no
|
|
244
|
+
* longer says which boundary moved. And `NoReadsRecorded` is a reachable spec
|
|
245
|
+
* whose shape does not mark it as structural: it reads like a domain assertion
|
|
246
|
+
* and is a statement about wiring.
|
|
187
247
|
*/
|
|
188
248
|
readonly thenError: (
|
|
189
249
|
tag: string,
|
|
190
250
|
) => Effect.Effect<void, never, DcbEventStore | Serializer | Uuid | R | PR>
|
|
251
|
+
/**
|
|
252
|
+
* Assert the "already done" branch: the cycle SUCCEEDED with `Unchanged`
|
|
253
|
+
* (recognised by `isUnchanged`, never by its tag) and appended nothing past
|
|
254
|
+
* the seed. A cycle that appended — even an empty-looking one — or that
|
|
255
|
+
* returned a `Position` fails it, and a failing cycle re-raises its cause, for
|
|
256
|
+
* the reason `then` gives.
|
|
257
|
+
*
|
|
258
|
+
* Both halves are asserted, not just the result, because they are different
|
|
259
|
+
* claims: a runner that returned `Unchanged` AND appended would be exactly the
|
|
260
|
+
* defect this step exists to catch, and the result alone would not see it.
|
|
261
|
+
*/
|
|
262
|
+
readonly thenUnchanged: () => Effect.Effect<
|
|
263
|
+
void,
|
|
264
|
+
E | CycleError,
|
|
265
|
+
DcbEventStore | Serializer | Uuid | R | PR
|
|
266
|
+
>
|
|
191
267
|
}
|
|
192
268
|
|
|
193
269
|
/**
|
|
194
|
-
* Begin a specification for
|
|
195
|
-
*
|
|
196
|
-
* `
|
|
270
|
+
* Begin a specification for ANY command cycle that returns the appended
|
|
271
|
+
* `Position` — a bracket for one command as readily as a decider — or
|
|
272
|
+
* `Unchanged`, for a cycle that can answer "already done".
|
|
273
|
+
*
|
|
274
|
+
* This is the general form and `specificationFor` below is a one-line
|
|
275
|
+
* specialisation of it, which is the result worth stating rather than the
|
|
276
|
+
* mechanism: a `Decider` is one particular `(command) => Effect<Position, …>`,
|
|
277
|
+
* and `runDecider` is the function that says so. Nothing downstream of `when`
|
|
278
|
+
* moves — the same seed boundary, the same read-back, the same shape comparison
|
|
279
|
+
* — because both cycles already agree on what they return.
|
|
280
|
+
*
|
|
281
|
+
* ⚠ `run`'s error channel is written `E | CycleError` and must be: the chain adds
|
|
282
|
+
* that union itself, so a naive `Effect<Position, E, R>` double-counts it and
|
|
283
|
+
* fails to unify. `CycleError` exists to be written here.
|
|
197
284
|
*/
|
|
198
|
-
export const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
285
|
+
export const specificationForCycle = <Command, E, R, PR = never>(
|
|
286
|
+
run: (
|
|
287
|
+
command: Command,
|
|
288
|
+
) => Effect.Effect<
|
|
289
|
+
Position | Unchanged,
|
|
290
|
+
E | CycleError,
|
|
291
|
+
DcbEventStore | Serializer | Uuid | R | PR
|
|
292
|
+
>,
|
|
293
|
+
): Specification<Command, E, R, PR> => ({
|
|
206
294
|
given: (...pastEvents) => ({
|
|
207
295
|
when: (command) => {
|
|
208
|
-
// Run the seed +
|
|
296
|
+
// Run the seed + cycle, exposing the outcome as an Exit so both
|
|
209
297
|
// branches (then / thenError) can inspect success or failure uniformly. The
|
|
210
298
|
// seed is inside the Exit too, so a decode failure while materialising a
|
|
211
299
|
// given producer is captured as a failure rather than escaping the channel.
|
|
212
300
|
// The success value pairs the seed boundary (the last seeded position) with
|
|
213
|
-
// the
|
|
301
|
+
// the cycle's outcome, so `then` reads strictly after that boundary
|
|
214
302
|
// rather than dropping a fixed row count (correct on a gappy store).
|
|
215
303
|
const runToExit = seed(pastEvents).pipe(
|
|
216
|
-
Effect.zip(
|
|
304
|
+
Effect.zip(run(command)),
|
|
217
305
|
Effect.exit,
|
|
218
306
|
)
|
|
219
307
|
|
|
@@ -228,7 +316,8 @@ export const specificationFor = <
|
|
|
228
316
|
// it loud rather than silently passing.
|
|
229
317
|
return yield* Effect.failCause(exit.cause)
|
|
230
318
|
}
|
|
231
|
-
const [seedBoundary] = exit.value
|
|
319
|
+
const [seedBoundary, result] = exit.value
|
|
320
|
+
expect(isUnchanged(result)).toBe(false)
|
|
232
321
|
const appended = yield* appendedAfter(seedBoundary)
|
|
233
322
|
const expectedEvents = yield* Effect.all(expected)
|
|
234
323
|
expect(appended.map((s) => shapeOf(s.event))).toEqual(
|
|
@@ -249,11 +338,44 @@ export const specificationFor = <
|
|
|
249
338
|
expect(actualTag).toBe(tag)
|
|
250
339
|
}
|
|
251
340
|
}),
|
|
341
|
+
thenUnchanged: () =>
|
|
342
|
+
Effect.gen(function* () {
|
|
343
|
+
const exit = yield* runToExit
|
|
344
|
+
if (Exit.isFailure(exit)) {
|
|
345
|
+
return yield* Effect.failCause(exit.cause)
|
|
346
|
+
}
|
|
347
|
+
const [seedBoundary, result] = exit.value
|
|
348
|
+
expect(isUnchanged(result)).toBe(true)
|
|
349
|
+
const appended = yield* appendedAfter(seedBoundary)
|
|
350
|
+
expect(appended.map((s) => shapeOf(s.event))).toEqual([])
|
|
351
|
+
}),
|
|
252
352
|
}
|
|
253
353
|
},
|
|
254
354
|
}),
|
|
255
355
|
})
|
|
256
356
|
|
|
357
|
+
/**
|
|
358
|
+
* Begin a specification for `decider`. The returned `given(...)` starts the
|
|
359
|
+
* Given/When/Then chain; the terminal step yields an `Effect` requiring
|
|
360
|
+
* `DcbEventStore | Serializer | Uuid | R | RequirementOf<P>`.
|
|
361
|
+
*
|
|
362
|
+
* One line over the layered sibling above, and deliberately not a twin of it:
|
|
363
|
+
* a duplicated body would be two sets of tests and two things free to drift on
|
|
364
|
+
* the seed boundary, the read-back or the shape comparison.
|
|
365
|
+
*/
|
|
366
|
+
export const specificationFor = <
|
|
367
|
+
P extends Record<string, SliceProjection>,
|
|
368
|
+
Command,
|
|
369
|
+
E,
|
|
370
|
+
R,
|
|
371
|
+
O extends DecisionOutput = Arr.NonEmptyReadonlyArray<DcbEvent>,
|
|
372
|
+
>(
|
|
373
|
+
decider: Decider<P, Command, E, R, O>,
|
|
374
|
+
): Specification<Command, E, R, RequirementOf<P>> =>
|
|
375
|
+
specificationForCycle<Command, E, R, RequirementOf<P>>((command) =>
|
|
376
|
+
runDecider(decider, command),
|
|
377
|
+
)
|
|
378
|
+
|
|
257
379
|
/**
|
|
258
380
|
* `scenario(description, spec => spec.given(...).when(...).then(...))` — register
|
|
259
381
|
* an `@effect/vitest` `it.effect` case running `build` against a fresh in-memory
|
|
@@ -267,16 +389,17 @@ export const specificationFor = <
|
|
|
267
389
|
* encryption requirement provides its cipher layer inside `build` before
|
|
268
390
|
* returning.
|
|
269
391
|
*/
|
|
270
|
-
export const scenario = <
|
|
392
|
+
export const scenario = <
|
|
393
|
+
P extends Record<string, SliceProjection>,
|
|
394
|
+
Command,
|
|
395
|
+
E,
|
|
396
|
+
O extends DecisionOutput = Arr.NonEmptyReadonlyArray<DcbEvent>,
|
|
397
|
+
>(
|
|
271
398
|
description: string,
|
|
272
|
-
decider: Decider<P, Command, E>,
|
|
399
|
+
decider: Decider<P, Command, E, never, O>,
|
|
273
400
|
build: (
|
|
274
401
|
spec: Specification<Command, E, never, RequirementOf<P>>,
|
|
275
|
-
) => Effect.Effect<
|
|
276
|
-
void,
|
|
277
|
-
E | CodecError | AppendConditionFailed,
|
|
278
|
-
DcbEventStore | Serializer | Uuid
|
|
279
|
-
>,
|
|
402
|
+
) => Effect.Effect<void, E | CycleError, DcbEventStore | Serializer | Uuid>,
|
|
280
403
|
): void => {
|
|
281
404
|
it.effect(description, () =>
|
|
282
405
|
build(specificationFor(decider)).pipe(
|