aeon-test 0.1.0
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 +47 -0
- package/dist/assert.d.ts +24 -0
- package/dist/assert.d.ts.map +1 -0
- package/dist/collect.d.ts +59 -0
- package/dist/collect.d.ts.map +1 -0
- package/dist/index.cjs +521 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +8 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +513 -0
- package/dist/index.js.map +1 -0
- package/dist/marble.d.ts +47 -0
- package/dist/marble.d.ts.map +1 -0
- package/dist/testEvent.d.ts +20 -0
- package/dist/testEvent.d.ts.map +1 -0
- package/package.json +47 -0
package/README.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# aeon-test
|
|
2
|
+
|
|
3
|
+
Marble testing utilities and helpers for [Aeon](https://github.com/joshburgess/aeon).
|
|
4
|
+
|
|
5
|
+
This package provides:
|
|
6
|
+
|
|
7
|
+
- **`marble`** — RxJS-style marble syntax for declaratively constructing test events
|
|
8
|
+
- **`collect`** — synchronously collect all emitted values from an event into an array
|
|
9
|
+
- **`testEvent`** — helpers for building events that fire at specific virtual times
|
|
10
|
+
- **`assert`** — assertion helpers for comparing event sequences
|
|
11
|
+
|
|
12
|
+
For the underlying virtual scheduler, see [`aeon-scheduler`](https://www.npmjs.com/package/aeon-scheduler) (`VirtualScheduler`).
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pnpm add -D aeon-test aeon-scheduler
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Quick Start
|
|
21
|
+
|
|
22
|
+
```typescript
|
|
23
|
+
import { describe, it, expect } from "vitest";
|
|
24
|
+
import { map } from "aeon-core";
|
|
25
|
+
import { VirtualScheduler } from "aeon-scheduler";
|
|
26
|
+
import { collect } from "aeon-test";
|
|
27
|
+
|
|
28
|
+
describe("map", () => {
|
|
29
|
+
it("doubles each value", () => {
|
|
30
|
+
const scheduler = new VirtualScheduler();
|
|
31
|
+
const result = collect(
|
|
32
|
+
map((x: number) => x * 2, fromArray([1, 2, 3])),
|
|
33
|
+
scheduler,
|
|
34
|
+
);
|
|
35
|
+
expect(result).toEqual([2, 4, 6]);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Documentation
|
|
41
|
+
|
|
42
|
+
- [Main README](https://github.com/joshburgess/aeon#readme)
|
|
43
|
+
- [Getting Started](https://github.com/joshburgess/aeon/blob/main/docs/getting-started.md)
|
|
44
|
+
|
|
45
|
+
## License
|
|
46
|
+
|
|
47
|
+
MIT
|
package/dist/assert.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Assertion helpers for marble-based stream testing.
|
|
3
|
+
*/
|
|
4
|
+
import type { CollectedEntry } from "./collect.js";
|
|
5
|
+
import type { MarbleEntry } from "./marble.js";
|
|
6
|
+
/**
|
|
7
|
+
* Compare collected entries against expected marble entries.
|
|
8
|
+
*
|
|
9
|
+
* Returns `{ pass: true }` if they match, or `{ pass: false, message }`
|
|
10
|
+
* with a human-readable diff if they don't.
|
|
11
|
+
*
|
|
12
|
+
* This is framework-agnostic — use it with any assertion library:
|
|
13
|
+
* ```typescript
|
|
14
|
+
* const check = assertEvents(result.entries, expected);
|
|
15
|
+
* if (!check.pass) throw new Error(check.message);
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
export declare const assertEvents: <A, E>(actual: readonly CollectedEntry<A, E>[], expected: readonly MarbleEntry<A, E>[]) => {
|
|
19
|
+
pass: true;
|
|
20
|
+
} | {
|
|
21
|
+
pass: false;
|
|
22
|
+
message: string;
|
|
23
|
+
};
|
|
24
|
+
//# sourceMappingURL=assert.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"assert.d.ts","sourceRoot":"","sources":["../src/assert.ts"],"names":[],"mappings":"AAAA;;GAEG;AAGH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AACnD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAE/C;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,YAAY,GAAI,CAAC,EAAE,CAAC,EAC/B,QAAQ,SAAS,cAAc,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,EACvC,UAAU,SAAS,WAAW,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,KACrC;IAAE,IAAI,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,IAAI,EAAE,KAAK,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAuDjD,CAAC"}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test collection helpers.
|
|
3
|
+
*
|
|
4
|
+
* Helpers for running a pulse Event and collecting its output
|
|
5
|
+
* as a structured list of timed entries for assertion.
|
|
6
|
+
*/
|
|
7
|
+
import type { Disposable, Event as PulseEvent, Scheduler, Time } from "aeon-types";
|
|
8
|
+
/** A collected event entry (value, error, or end). */
|
|
9
|
+
export type CollectedEntry<A, E = never> = {
|
|
10
|
+
readonly type: "event";
|
|
11
|
+
readonly time: Time;
|
|
12
|
+
readonly value: A;
|
|
13
|
+
} | {
|
|
14
|
+
readonly type: "error";
|
|
15
|
+
readonly time: Time;
|
|
16
|
+
readonly error: E;
|
|
17
|
+
} | {
|
|
18
|
+
readonly type: "end";
|
|
19
|
+
readonly time: Time;
|
|
20
|
+
};
|
|
21
|
+
/** Result of collecting events from a stream. */
|
|
22
|
+
export interface CollectResult<A, E = never> {
|
|
23
|
+
/** All collected entries in order. */
|
|
24
|
+
readonly entries: CollectedEntry<A, E>[];
|
|
25
|
+
/** Just the values (convenience accessor). */
|
|
26
|
+
readonly values: A[];
|
|
27
|
+
/** Whether the stream ended. */
|
|
28
|
+
readonly ended: boolean;
|
|
29
|
+
/** Whether the stream errored. */
|
|
30
|
+
readonly errored: boolean;
|
|
31
|
+
/** The error value, if any. */
|
|
32
|
+
readonly error: E | undefined;
|
|
33
|
+
/** Disposable to stop collection. */
|
|
34
|
+
readonly disposable: Disposable;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Subscribe to a pulse Event and collect all emissions.
|
|
38
|
+
*
|
|
39
|
+
* Returns a CollectResult whose `entries`, `values`, `ended`, etc.
|
|
40
|
+
* update live as the scheduler advances. Use with VirtualScheduler:
|
|
41
|
+
*
|
|
42
|
+
* ```typescript
|
|
43
|
+
* const scheduler = new VirtualScheduler();
|
|
44
|
+
* const result = collectEvents(myEvent, scheduler);
|
|
45
|
+
* scheduler.advanceTo(toTime(100));
|
|
46
|
+
* expect(result.values).toEqual([1, 2, 3]);
|
|
47
|
+
* expect(result.ended).toBe(true);
|
|
48
|
+
* result.disposable.dispose();
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
export declare const collectEvents: <A, E = never>(event: PulseEvent<A, E>, scheduler: Scheduler) => CollectResult<A, E>;
|
|
52
|
+
/**
|
|
53
|
+
* Collect all values from a synchronous Event.
|
|
54
|
+
*
|
|
55
|
+
* For events backed by synchronous sources (fromArray, now, empty),
|
|
56
|
+
* all values are available immediately without advancing time.
|
|
57
|
+
*/
|
|
58
|
+
export declare const collectSync: <A>(event: PulseEvent<A, never>, scheduler: Scheduler) => A[];
|
|
59
|
+
//# sourceMappingURL=collect.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"collect.d.ts","sourceRoot":"","sources":["../src/collect.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,KAAK,IAAI,UAAU,EAAE,SAAS,EAAgB,IAAI,EAAE,MAAM,YAAY,CAAC;AAEjG,sDAAsD;AACtD,MAAM,MAAM,cAAc,CAAC,CAAC,EAAE,CAAC,GAAG,KAAK,IACnC;IAAE,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,CAAC,CAAA;CAAE,GAClE;IAAE,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,CAAC,CAAA;CAAE,GAClE;IAAE,QAAQ,CAAC,IAAI,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAA;CAAE,CAAC;AAElD,iDAAiD;AACjD,MAAM,WAAW,aAAa,CAAC,CAAC,EAAE,CAAC,GAAG,KAAK;IACzC,sCAAsC;IACtC,QAAQ,CAAC,OAAO,EAAE,cAAc,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC;IACzC,8CAA8C;IAC9C,QAAQ,CAAC,MAAM,EAAE,CAAC,EAAE,CAAC;IACrB,gCAAgC;IAChC,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;IACxB,kCAAkC;IAClC,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,+BAA+B;IAC/B,QAAQ,CAAC,KAAK,EAAE,CAAC,GAAG,SAAS,CAAC;IAC9B,qCAAqC;IACrC,QAAQ,CAAC,UAAU,EAAE,UAAU,CAAC;CACjC;AAED;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,aAAa,GAAI,CAAC,EAAE,CAAC,GAAG,KAAK,EACxC,OAAO,UAAU,CAAC,CAAC,EAAE,CAAC,CAAC,EACvB,WAAW,SAAS,KACnB,aAAa,CAAC,CAAC,EAAE,CAAC,CAiCpB,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,WAAW,GAAI,CAAC,EAAE,OAAO,UAAU,CAAC,CAAC,EAAE,KAAK,CAAC,EAAE,WAAW,SAAS,KAAG,CAAC,EAInF,CAAC"}
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Branded types for temporal values.
|
|
5
|
+
*
|
|
6
|
+
* Branding prevents accidental mixing of Time, Duration, and Offset
|
|
7
|
+
* at the type level while remaining plain numbers at runtime.
|
|
8
|
+
*/ // --- Constructors ---
|
|
9
|
+
/** Wrap a raw millisecond value as a Time. */ const toTime$1 = (ms)=>ms;
|
|
10
|
+
/** Advance a Time by a Duration. */ const timeAdd = (t, d)=>t + d;
|
|
11
|
+
// --- Constants ---
|
|
12
|
+
/** Time zero — the epoch. */ const TIME_ZERO = 0;
|
|
13
|
+
/** Manually advanceable clock for deterministic testing. */ let VirtualClock = class VirtualClock {
|
|
14
|
+
constructor(initialTime = toTime$1(0)){
|
|
15
|
+
this.time = initialTime;
|
|
16
|
+
}
|
|
17
|
+
now() {
|
|
18
|
+
return this.time;
|
|
19
|
+
}
|
|
20
|
+
setTime(time) {
|
|
21
|
+
this.time = time;
|
|
22
|
+
}
|
|
23
|
+
advance(ms) {
|
|
24
|
+
this.time = toTime$1(this.time + ms);
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Binary min-heap for the scheduler's timer queue.
|
|
29
|
+
*
|
|
30
|
+
* Keyed on a numeric priority (scheduled time). Uses a pre-allocated
|
|
31
|
+
* backing array to minimize per-node allocation.
|
|
32
|
+
*/ let BinaryHeap = class BinaryHeap {
|
|
33
|
+
constructor(initialCapacity = 64){
|
|
34
|
+
this.items = [];
|
|
35
|
+
// Pre-allocate hint (V8 will grow as needed)
|
|
36
|
+
if (initialCapacity > 0) {
|
|
37
|
+
this.items.length = 0;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
get size() {
|
|
41
|
+
return this.items.length;
|
|
42
|
+
}
|
|
43
|
+
peek() {
|
|
44
|
+
return this.items[0];
|
|
45
|
+
}
|
|
46
|
+
insert(value, priority) {
|
|
47
|
+
const entry = {
|
|
48
|
+
value,
|
|
49
|
+
priority,
|
|
50
|
+
index: this.items.length
|
|
51
|
+
};
|
|
52
|
+
this.items.push(entry);
|
|
53
|
+
this.siftUp(entry.index);
|
|
54
|
+
return entry;
|
|
55
|
+
}
|
|
56
|
+
extractMin() {
|
|
57
|
+
if (this.items.length === 0) return undefined;
|
|
58
|
+
const min = this.items[0];
|
|
59
|
+
this.removeAt(0);
|
|
60
|
+
return min;
|
|
61
|
+
}
|
|
62
|
+
remove(entry) {
|
|
63
|
+
if (entry.index < 0 || entry.index >= this.items.length) return false;
|
|
64
|
+
if (this.items[entry.index] !== entry) return false;
|
|
65
|
+
this.removeAt(entry.index);
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
removeAt(index) {
|
|
69
|
+
const last = this.items.length - 1;
|
|
70
|
+
if (index === last) {
|
|
71
|
+
this.items.pop();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const moved = this.items[last];
|
|
75
|
+
this.items[index] = moved;
|
|
76
|
+
moved.index = index;
|
|
77
|
+
this.items.pop();
|
|
78
|
+
// Restore heap property
|
|
79
|
+
const parent = index - 1 >>> 1;
|
|
80
|
+
if (index > 0 && moved.priority < this.items[parent].priority) {
|
|
81
|
+
this.siftUp(index);
|
|
82
|
+
} else {
|
|
83
|
+
this.siftDown(index);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
siftUp(index) {
|
|
87
|
+
const item = this.items[index];
|
|
88
|
+
while(index > 0){
|
|
89
|
+
const parentIndex = index - 1 >>> 1;
|
|
90
|
+
const parent = this.items[parentIndex];
|
|
91
|
+
if (item.priority >= parent.priority) break;
|
|
92
|
+
this.items[index] = parent;
|
|
93
|
+
parent.index = index;
|
|
94
|
+
index = parentIndex;
|
|
95
|
+
}
|
|
96
|
+
this.items[index] = item;
|
|
97
|
+
item.index = index;
|
|
98
|
+
}
|
|
99
|
+
siftDown(index) {
|
|
100
|
+
const item = this.items[index];
|
|
101
|
+
const halfLength = this.items.length >>> 1;
|
|
102
|
+
while(index < halfLength){
|
|
103
|
+
let childIndex = 2 * index + 1;
|
|
104
|
+
let child = this.items[childIndex];
|
|
105
|
+
const rightIndex = childIndex + 1;
|
|
106
|
+
if (rightIndex < this.items.length && this.items[rightIndex].priority < child.priority) {
|
|
107
|
+
childIndex = rightIndex;
|
|
108
|
+
child = this.items[rightIndex];
|
|
109
|
+
}
|
|
110
|
+
if (item.priority <= child.priority) break;
|
|
111
|
+
this.items[index] = child;
|
|
112
|
+
child.index = index;
|
|
113
|
+
index = childIndex;
|
|
114
|
+
}
|
|
115
|
+
this.items[index] = item;
|
|
116
|
+
item.index = index;
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
let VirtualScheduler = class VirtualScheduler {
|
|
120
|
+
constructor(initialTime = TIME_ZERO){
|
|
121
|
+
this.clock = new VirtualClock(initialTime);
|
|
122
|
+
this.heap = new BinaryHeap();
|
|
123
|
+
}
|
|
124
|
+
currentTime() {
|
|
125
|
+
return this.clock.now();
|
|
126
|
+
}
|
|
127
|
+
scheduleTask(delay, task) {
|
|
128
|
+
const time = timeAdd(this.clock.now(), delay);
|
|
129
|
+
const pending = {
|
|
130
|
+
task,
|
|
131
|
+
time,
|
|
132
|
+
cancelled: false,
|
|
133
|
+
heapEntry: undefined
|
|
134
|
+
};
|
|
135
|
+
pending.heapEntry = this.heap.insert(pending, time);
|
|
136
|
+
return {
|
|
137
|
+
task,
|
|
138
|
+
time,
|
|
139
|
+
dispose () {
|
|
140
|
+
if (!pending.cancelled) {
|
|
141
|
+
pending.cancelled = true;
|
|
142
|
+
if (pending.heapEntry) ;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
relative(offset) {
|
|
148
|
+
return new VirtualRelativeScheduler(offset, this);
|
|
149
|
+
}
|
|
150
|
+
cancelTask(st) {
|
|
151
|
+
st.dispose();
|
|
152
|
+
}
|
|
153
|
+
/** Advance time by a duration, executing all tasks that fall within range. */ advance(duration) {
|
|
154
|
+
const target = timeAdd(this.clock.now(), duration);
|
|
155
|
+
this.advanceTo(target);
|
|
156
|
+
}
|
|
157
|
+
/** Advance to an exact time, executing all tasks up to and including that time. */ advanceTo(time) {
|
|
158
|
+
while(this.heap.size > 0){
|
|
159
|
+
const next = this.heap.peek();
|
|
160
|
+
if (next.priority > time) break;
|
|
161
|
+
this.heap.extractMin();
|
|
162
|
+
const pending = next.value;
|
|
163
|
+
if (!pending.cancelled) {
|
|
164
|
+
this.clock.setTime(pending.time);
|
|
165
|
+
try {
|
|
166
|
+
pending.task.run(pending.time);
|
|
167
|
+
} catch (err) {
|
|
168
|
+
pending.task.error(pending.time, err);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
this.clock.setTime(time);
|
|
173
|
+
}
|
|
174
|
+
/** Execute all pending tasks regardless of time. */ flush() {
|
|
175
|
+
while(this.heap.size > 0){
|
|
176
|
+
const next = this.heap.extractMin();
|
|
177
|
+
const pending = next.value;
|
|
178
|
+
if (!pending.cancelled) {
|
|
179
|
+
this.clock.setTime(pending.time);
|
|
180
|
+
try {
|
|
181
|
+
pending.task.run(pending.time);
|
|
182
|
+
} catch (err) {
|
|
183
|
+
pending.task.error(pending.time, err);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/** Number of pending (non-cancelled) tasks. */ get pendingCount() {
|
|
189
|
+
return this.heap.size;
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
let VirtualRelativeScheduler = class VirtualRelativeScheduler {
|
|
193
|
+
constructor(offset, parent){
|
|
194
|
+
this.offset = offset;
|
|
195
|
+
this.parent = parent;
|
|
196
|
+
}
|
|
197
|
+
currentTime() {
|
|
198
|
+
return toTime$1(this.parent.currentTime() + this.offset);
|
|
199
|
+
}
|
|
200
|
+
scheduleTask(delay, task) {
|
|
201
|
+
return this.parent.scheduleTask(delay, task);
|
|
202
|
+
}
|
|
203
|
+
relative(offset) {
|
|
204
|
+
return new VirtualRelativeScheduler(toTime$1(this.offset + offset), this.parent);
|
|
205
|
+
}
|
|
206
|
+
cancelTask(st) {
|
|
207
|
+
this.parent.cancelTask(st);
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Branded types for temporal values.
|
|
213
|
+
*
|
|
214
|
+
* Branding prevents accidental mixing of Time, Duration, and Offset
|
|
215
|
+
* at the type level while remaining plain numbers at runtime.
|
|
216
|
+
*/ // --- Constructors ---
|
|
217
|
+
/** Wrap a raw millisecond value as a Time. */ const toTime = (ms)=>ms;
|
|
218
|
+
/** Wrap a raw millisecond value as a Duration. */ const toDuration = (ms)=>ms;
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Parse a marble string into a sequence of timed entries.
|
|
222
|
+
*
|
|
223
|
+
* @param marble - The marble notation string
|
|
224
|
+
* @param values - Map from single-character keys to values
|
|
225
|
+
* @param error - The error value for `#`
|
|
226
|
+
* @param timeUnit - Milliseconds per time unit (default: 1)
|
|
227
|
+
*/ const parseMarble = (marble, values, error, timeUnit = 1)=>{
|
|
228
|
+
const entries = [];
|
|
229
|
+
let time = 0;
|
|
230
|
+
let inGroup = false;
|
|
231
|
+
for(let i = 0; i < marble.length; i++){
|
|
232
|
+
const ch = marble[i];
|
|
233
|
+
switch(ch){
|
|
234
|
+
case "-":
|
|
235
|
+
if (!inGroup) time += timeUnit;
|
|
236
|
+
break;
|
|
237
|
+
case "(":
|
|
238
|
+
inGroup = true;
|
|
239
|
+
break;
|
|
240
|
+
case ")":
|
|
241
|
+
inGroup = false;
|
|
242
|
+
if (!inGroup) time += timeUnit;
|
|
243
|
+
break;
|
|
244
|
+
case "|":
|
|
245
|
+
entries.push({
|
|
246
|
+
type: "end",
|
|
247
|
+
time: toTime(time)
|
|
248
|
+
});
|
|
249
|
+
if (!inGroup) time += timeUnit;
|
|
250
|
+
break;
|
|
251
|
+
case "#":
|
|
252
|
+
entries.push({
|
|
253
|
+
type: "error",
|
|
254
|
+
time: toTime(time),
|
|
255
|
+
error: error
|
|
256
|
+
});
|
|
257
|
+
if (!inGroup) time += timeUnit;
|
|
258
|
+
break;
|
|
259
|
+
case " ":
|
|
260
|
+
break;
|
|
261
|
+
default:
|
|
262
|
+
{
|
|
263
|
+
const value = values[ch];
|
|
264
|
+
if (value === undefined) {
|
|
265
|
+
throw new Error(`Marble character '${ch}' not found in values map`);
|
|
266
|
+
}
|
|
267
|
+
entries.push({
|
|
268
|
+
type: "event",
|
|
269
|
+
time: toTime(time),
|
|
270
|
+
value
|
|
271
|
+
});
|
|
272
|
+
if (!inGroup) time += timeUnit;
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return entries;
|
|
278
|
+
};
|
|
279
|
+
/**
|
|
280
|
+
* Compute the total duration of a marble string (in time units).
|
|
281
|
+
*/ const marbleDuration = (marble, timeUnit = 1)=>{
|
|
282
|
+
let time = 0;
|
|
283
|
+
let inGroup = false;
|
|
284
|
+
for(let i = 0; i < marble.length; i++){
|
|
285
|
+
const ch = marble[i];
|
|
286
|
+
if (ch === "(") {
|
|
287
|
+
inGroup = true;
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (ch === ")") {
|
|
291
|
+
inGroup = false;
|
|
292
|
+
time += timeUnit;
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (ch === " ") continue;
|
|
296
|
+
if (!inGroup) time += timeUnit;
|
|
297
|
+
}
|
|
298
|
+
return time;
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
let MarbleSource = class MarbleSource {
|
|
302
|
+
constructor(entries){
|
|
303
|
+
this.entries = entries;
|
|
304
|
+
}
|
|
305
|
+
run(sink, scheduler) {
|
|
306
|
+
const disposables = [];
|
|
307
|
+
const currentTime = scheduler.currentTime();
|
|
308
|
+
for (const entry of this.entries){
|
|
309
|
+
const delay = toDuration(entry.time - currentTime);
|
|
310
|
+
switch(entry.type){
|
|
311
|
+
case "event":
|
|
312
|
+
{
|
|
313
|
+
const value = entry.value;
|
|
314
|
+
disposables.push(scheduler.scheduleTask(delay, {
|
|
315
|
+
run (t) {
|
|
316
|
+
sink.event(t, value);
|
|
317
|
+
},
|
|
318
|
+
error (t, err) {
|
|
319
|
+
sink.error(t, err);
|
|
320
|
+
},
|
|
321
|
+
dispose () {}
|
|
322
|
+
}));
|
|
323
|
+
break;
|
|
324
|
+
}
|
|
325
|
+
case "error":
|
|
326
|
+
{
|
|
327
|
+
const error = entry.error;
|
|
328
|
+
disposables.push(scheduler.scheduleTask(delay, {
|
|
329
|
+
run (t) {
|
|
330
|
+
sink.error(t, error);
|
|
331
|
+
},
|
|
332
|
+
error () {},
|
|
333
|
+
dispose () {}
|
|
334
|
+
}));
|
|
335
|
+
break;
|
|
336
|
+
}
|
|
337
|
+
case "end":
|
|
338
|
+
{
|
|
339
|
+
disposables.push(scheduler.scheduleTask(delay, {
|
|
340
|
+
run (t) {
|
|
341
|
+
sink.end(t);
|
|
342
|
+
},
|
|
343
|
+
error () {},
|
|
344
|
+
dispose () {}
|
|
345
|
+
}));
|
|
346
|
+
break;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return {
|
|
351
|
+
dispose () {
|
|
352
|
+
for (const d of disposables)d.dispose();
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
/**
|
|
358
|
+
* Create a pulse Event from a marble string.
|
|
359
|
+
*
|
|
360
|
+
* Events are scheduled on the provided scheduler. Use with VirtualScheduler
|
|
361
|
+
* and advance/flush to control time.
|
|
362
|
+
*
|
|
363
|
+
* @param marble - Marble notation string
|
|
364
|
+
* @param values - Map from single-character keys to values
|
|
365
|
+
* @param error - Error value for `#` in the marble string
|
|
366
|
+
* @param timeUnit - Milliseconds per time unit (default: 1)
|
|
367
|
+
*/ const testEvent = (marble, values, error, timeUnit)=>{
|
|
368
|
+
const entries = parseMarble(marble, values, error, timeUnit);
|
|
369
|
+
return new MarbleSource(entries);
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Test collection helpers.
|
|
374
|
+
*
|
|
375
|
+
* Helpers for running a pulse Event and collecting its output
|
|
376
|
+
* as a structured list of timed entries for assertion.
|
|
377
|
+
*/ /**
|
|
378
|
+
* Subscribe to a pulse Event and collect all emissions.
|
|
379
|
+
*
|
|
380
|
+
* Returns a CollectResult whose `entries`, `values`, `ended`, etc.
|
|
381
|
+
* update live as the scheduler advances. Use with VirtualScheduler:
|
|
382
|
+
*
|
|
383
|
+
* ```typescript
|
|
384
|
+
* const scheduler = new VirtualScheduler();
|
|
385
|
+
* const result = collectEvents(myEvent, scheduler);
|
|
386
|
+
* scheduler.advanceTo(toTime(100));
|
|
387
|
+
* expect(result.values).toEqual([1, 2, 3]);
|
|
388
|
+
* expect(result.ended).toBe(true);
|
|
389
|
+
* result.disposable.dispose();
|
|
390
|
+
* ```
|
|
391
|
+
*/ const collectEvents = (event, scheduler)=>{
|
|
392
|
+
const entries = [];
|
|
393
|
+
const values = [];
|
|
394
|
+
const result = {
|
|
395
|
+
entries,
|
|
396
|
+
values,
|
|
397
|
+
ended: false,
|
|
398
|
+
errored: false,
|
|
399
|
+
error: undefined,
|
|
400
|
+
disposable: {
|
|
401
|
+
dispose () {}
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
const sink = {
|
|
405
|
+
event (time, value) {
|
|
406
|
+
entries.push({
|
|
407
|
+
type: "event",
|
|
408
|
+
time,
|
|
409
|
+
value
|
|
410
|
+
});
|
|
411
|
+
values.push(value);
|
|
412
|
+
},
|
|
413
|
+
error (time, err) {
|
|
414
|
+
entries.push({
|
|
415
|
+
type: "error",
|
|
416
|
+
time,
|
|
417
|
+
error: err
|
|
418
|
+
});
|
|
419
|
+
result.errored = true;
|
|
420
|
+
result.error = err;
|
|
421
|
+
},
|
|
422
|
+
end (time) {
|
|
423
|
+
entries.push({
|
|
424
|
+
type: "end",
|
|
425
|
+
time
|
|
426
|
+
});
|
|
427
|
+
result.ended = true;
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
const source = event;
|
|
431
|
+
const disposable = source.run(sink, scheduler);
|
|
432
|
+
result.disposable = disposable;
|
|
433
|
+
return result;
|
|
434
|
+
};
|
|
435
|
+
/**
|
|
436
|
+
* Collect all values from a synchronous Event.
|
|
437
|
+
*
|
|
438
|
+
* For events backed by synchronous sources (fromArray, now, empty),
|
|
439
|
+
* all values are available immediately without advancing time.
|
|
440
|
+
*/ const collectSync = (event, scheduler)=>{
|
|
441
|
+
const result = collectEvents(event, scheduler);
|
|
442
|
+
result.disposable.dispose();
|
|
443
|
+
return result.values;
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Assertion helpers for marble-based stream testing.
|
|
448
|
+
*/ /**
|
|
449
|
+
* Compare collected entries against expected marble entries.
|
|
450
|
+
*
|
|
451
|
+
* Returns `{ pass: true }` if they match, or `{ pass: false, message }`
|
|
452
|
+
* with a human-readable diff if they don't.
|
|
453
|
+
*
|
|
454
|
+
* This is framework-agnostic — use it with any assertion library:
|
|
455
|
+
* ```typescript
|
|
456
|
+
* const check = assertEvents(result.entries, expected);
|
|
457
|
+
* if (!check.pass) throw new Error(check.message);
|
|
458
|
+
* ```
|
|
459
|
+
*/ const assertEvents = (actual, expected)=>{
|
|
460
|
+
if (actual.length !== expected.length) {
|
|
461
|
+
return {
|
|
462
|
+
pass: false,
|
|
463
|
+
message: `Expected ${expected.length} entries, got ${actual.length}.\n` + ` actual: ${formatEntries(actual)}\n` + ` expected: ${formatEntries(expected)}`
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
for(let i = 0; i < actual.length; i++){
|
|
467
|
+
const a = actual[i];
|
|
468
|
+
const e = expected[i];
|
|
469
|
+
if (a.type !== e.type) {
|
|
470
|
+
return {
|
|
471
|
+
pass: false,
|
|
472
|
+
message: `Entry ${i}: expected type '${e.type}', got '${a.type}'.\n` + ` actual: ${formatEntry(a)}\n` + ` expected: ${formatEntry(e)}`
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
if (a.time !== e.time) {
|
|
476
|
+
return {
|
|
477
|
+
pass: false,
|
|
478
|
+
message: `Entry ${i}: expected time ${e.time}, got ${a.time}.\n` + ` actual: ${formatEntry(a)}\n` + ` expected: ${formatEntry(e)}`
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
if (a.type === "event" && e.type === "event") {
|
|
482
|
+
if (!Object.is(a.value, e.value)) {
|
|
483
|
+
return {
|
|
484
|
+
pass: false,
|
|
485
|
+
message: `Entry ${i}: expected value ${JSON.stringify(e.value)}, got ${JSON.stringify(a.value)}.`
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
if (a.type === "error" && e.type === "error") {
|
|
490
|
+
if (!Object.is(a.error, e.error)) {
|
|
491
|
+
return {
|
|
492
|
+
pass: false,
|
|
493
|
+
message: `Entry ${i}: expected error ${JSON.stringify(e.error)}, got ${JSON.stringify(a.error)}.`
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return {
|
|
499
|
+
pass: true
|
|
500
|
+
};
|
|
501
|
+
};
|
|
502
|
+
const formatEntry = (e)=>{
|
|
503
|
+
switch(e.type){
|
|
504
|
+
case "event":
|
|
505
|
+
return `event(${e.time}, ${JSON.stringify(e.value)})`;
|
|
506
|
+
case "error":
|
|
507
|
+
return `error(${e.time}, ${JSON.stringify(e.error)})`;
|
|
508
|
+
case "end":
|
|
509
|
+
return `end(${e.time})`;
|
|
510
|
+
}
|
|
511
|
+
};
|
|
512
|
+
const formatEntries = (entries)=>`[${entries.map(formatEntry).join(", ")}]`;
|
|
513
|
+
|
|
514
|
+
exports.VirtualScheduler = VirtualScheduler;
|
|
515
|
+
exports.assertEvents = assertEvents;
|
|
516
|
+
exports.collectEvents = collectEvents;
|
|
517
|
+
exports.collectSync = collectSync;
|
|
518
|
+
exports.marbleDuration = marbleDuration;
|
|
519
|
+
exports.parseMarble = parseMarble;
|
|
520
|
+
exports.testEvent = testEvent;
|
|
521
|
+
//# sourceMappingURL=index.cjs.map
|