-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_protocol_emitter.mjs
More file actions
405 lines (336 loc) · 12.2 KB
/
Copy pathtest_protocol_emitter.mjs
File metadata and controls
405 lines (336 loc) · 12.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
// tests/test_protocol_emitter.mjs
//
// Node --test suite for ProtocolShellEmitter.
// Tests: subscribe + emit + get_snapshot happy path, multiple subscribers,
// unsubscribe stops delivery, reducer is applied before notification,
// and snapshot immutability (readonly annotations).
import { test, describe } from "node:test";
import assert from "node:assert";
// Import the emitter factory and types from the compiled output.
// Since this is a .mjs test, we import from the TypeScript output.
// The build system (tsc or esbuild) will have compiled src/scene_runtime/protocol/emitter.ts
// to dist/ or an equivalent output location. For Node --test in development,
// we use --import tsx to handle TypeScript on the fly.
//
// However, since the instructions say "node --import tsx --test", we need to
// import the TS source directly. tsx handles the transpilation.
import { createProtocolShellEmitter } from "../src/scene_runtime/protocol/emitter.ts";
//============================================
// Test fixture: initial snapshot and reducer
//============================================
function createFixtureSnapshot() {
return {
protocol_name: "test_protocol",
current_step_name: null,
current_prompt: null,
active_interaction: null,
current_tip: null,
progress: {
completed_step_count: 0,
total_step_count: 3,
},
last_outcome: null,
pending_validator_kind: null,
modal: {
is_open: false,
kind: null,
prompt: null,
choices: [],
invoking_target: null,
},
help: {
is_open: false,
topic: null,
},
tray: {
items: [],
},
active_scene_name: null,
is_complete: false,
pending_timed_wait: null,
};
}
function createFixtureReducer() {
return (prev, event) => {
// Simple reducer: update current_step_name on step_started,
// increment completed_step_count on step_completed.
// For most events, return snapshot unchanged.
if (event.kind === "step_started") {
return {
...prev,
current_step_name: event.step_name,
current_prompt: event.prompt,
};
}
if (event.kind === "step_completed") {
if (event.resolution === "complete") {
return {
...prev,
progress: {
...prev.progress,
completed_step_count: prev.progress.completed_step_count + 1,
},
};
}
}
if (event.kind === "protocol_completed") {
return {
...prev,
current_step_name: null,
is_complete: true,
};
}
return prev;
};
}
//============================================
// Tests
//============================================
describe("ProtocolShellEmitter", () => {
test("subscribe + emit + get_snapshot happy path", () => {
const initial = createFixtureSnapshot();
const reducer = createFixtureReducer();
const emitter = createProtocolShellEmitter(initial, reducer);
// Initial snapshot should match what we passed in.
const snap0 = emitter.get_snapshot();
assert.strictEqual(snap0.protocol_name, "test_protocol");
assert.strictEqual(snap0.current_step_name, null);
// Emit a step_started event.
const stepStartedEvent = {
kind: "step_started",
step_name: "step_1",
prompt: "Do the first step",
interaction_count: 2,
};
let event_received = null;
const unsubscribe = emitter.subscribe((event) => {
event_received = event;
});
emitter.emit(stepStartedEvent);
// The subscriber should have received the event.
assert.deepStrictEqual(event_received, stepStartedEvent);
// The snapshot should be updated by the reducer.
const snap1 = emitter.get_snapshot();
assert.strictEqual(snap1.current_step_name, "step_1");
assert.strictEqual(snap1.current_prompt, "Do the first step");
// Progress should still be 0 (only step_completed increments it).
assert.strictEqual(snap1.progress.completed_step_count, 0);
unsubscribe();
});
test("multiple subscribers all receive the event", () => {
const initial = createFixtureSnapshot();
const reducer = createFixtureReducer();
const emitter = createProtocolShellEmitter(initial, reducer);
const events1 = [];
const events2 = [];
const events3 = [];
emitter.subscribe((event) => {
events1.push(event);
});
emitter.subscribe((event) => {
events2.push(event);
});
emitter.subscribe((event) => {
events3.push(event);
});
const event1 = {
kind: "step_started",
step_name: "step_a",
prompt: "Start A",
interaction_count: 1,
};
const event2 = {
kind: "protocol_completed",
protocol_name: "test_protocol",
};
emitter.emit(event1);
emitter.emit(event2);
// All three subscribers should have received both events in order.
assert.strictEqual(events1.length, 2);
assert.strictEqual(events2.length, 2);
assert.strictEqual(events3.length, 2);
assert.deepStrictEqual(events1[0], event1);
assert.deepStrictEqual(events1[1], event2);
assert.deepStrictEqual(events2[0], event1);
assert.deepStrictEqual(events2[1], event2);
assert.deepStrictEqual(events3[0], event1);
assert.deepStrictEqual(events3[1], event2);
});
test("unsubscribe stops further delivery", () => {
const initial = createFixtureSnapshot();
const reducer = createFixtureReducer();
const emitter = createProtocolShellEmitter(initial, reducer);
const events1 = [];
const events2 = [];
const unsub1 = emitter.subscribe((event) => {
events1.push(event);
});
const _unsub2 = emitter.subscribe((event) => {
events2.push(event);
});
const event1 = {
kind: "step_started",
step_name: "step_1",
prompt: "Do step 1",
interaction_count: 1,
};
emitter.emit(event1);
assert.strictEqual(events1.length, 1);
assert.strictEqual(events2.length, 1);
// Unsubscribe the first listener.
unsub1();
const event2 = {
kind: "step_completed",
step_name: "step_1",
resolution: "complete",
};
emitter.emit(event2);
// events1 should still have only 1 event (unsubscribed).
// events2 should have 2 events (still subscribed).
assert.strictEqual(events1.length, 1);
assert.strictEqual(events2.length, 2);
assert.deepStrictEqual(events2[1], event2);
});
test("reducer is applied before subscribers notified", () => {
const initial = createFixtureSnapshot();
const reducer = createFixtureReducer();
const emitter = createProtocolShellEmitter(initial, reducer);
let snapshot_during_event = null;
emitter.subscribe(() => {
// Capture the snapshot when the listener is called.
snapshot_during_event = emitter.get_snapshot();
});
const event = {
kind: "step_completed",
step_name: "step_1",
resolution: "complete",
};
// Before emit, progress is 0.
assert.strictEqual(emitter.get_snapshot().progress.completed_step_count, 0);
emitter.emit(event);
// The listener's captured snapshot should show the reducer's result
// (completed_step_count incremented).
assert.strictEqual(snapshot_during_event.progress.completed_step_count, 1);
// The current snapshot should also reflect the change.
assert.strictEqual(emitter.get_snapshot().progress.completed_step_count, 1);
});
test("snapshot is readonly (TS-level; fresh object on each emit)", () => {
const initial = createFixtureSnapshot();
const reducer = createFixtureReducer();
const emitter = createProtocolShellEmitter(initial, reducer);
const snap1 = emitter.get_snapshot();
// TypeScript enforces readonly at compile time. At runtime, JavaScript
// does not prevent mutations on plain objects. This test documents the
// intended behavior: the reducer produces a new snapshot object on each
// emit, so even if someone mutates a stale snapshot, it does not affect
// the current snapshot held by the emitter.
// Get the snapshot, then emit an event to produce a new one.
const event = {
kind: "step_started",
step_name: "step_1",
prompt: "Do it",
interaction_count: 1,
};
emitter.emit(event);
const snap2 = emitter.get_snapshot();
// snap1 and snap2 are different objects (different identity).
assert.notStrictEqual(snap1, snap2);
// Now mutate the old snapshot (this violates the readonly contract,
// but we verify the emitter is not affected).
snap1.current_step_name = "hacked";
// snap1 is now mutated, but the emitter's current snapshot should
// reflect the reducer's result, not the mutation.
assert.strictEqual(snap2.current_step_name, "step_1");
assert.strictEqual(emitter.get_snapshot().current_step_name, "step_1");
// The old snapshot is garbage now, but that's the caller's problem
// (they violated the readonly contract).
assert.strictEqual(snap1.current_step_name, "hacked");
});
test("snapshot identity changes on every emit (new object each time)", () => {
const initial = createFixtureSnapshot();
const reducer = createFixtureReducer();
const emitter = createProtocolShellEmitter(initial, reducer);
const snap1 = emitter.get_snapshot();
const event = {
kind: "step_started",
step_name: "step_1",
prompt: "Do it",
interaction_count: 1,
};
emitter.emit(event);
const snap2 = emitter.get_snapshot();
// Even though the reducer might return a new object, snap2 should be
// a different object reference than snap1 (because the reducer creates
// a new object via spread or explicit construction).
// Note: this depends on the reducer's implementation. Our fixture reducer
// uses spread syntax, so objects should differ.
assert.notStrictEqual(snap1, snap2);
});
test("multiple emits accumulate reducer state", () => {
const initial = createFixtureSnapshot();
const reducer = createFixtureReducer();
const emitter = createProtocolShellEmitter(initial, reducer);
// Emit step_completed three times.
emitter.emit({
kind: "step_completed",
step_name: "step_1",
resolution: "complete",
});
assert.strictEqual(emitter.get_snapshot().progress.completed_step_count, 1);
emitter.emit({
kind: "step_completed",
step_name: "step_2",
resolution: "complete",
});
assert.strictEqual(emitter.get_snapshot().progress.completed_step_count, 2);
emitter.emit({
kind: "step_completed",
step_name: "step_3",
resolution: "complete",
});
assert.strictEqual(emitter.get_snapshot().progress.completed_step_count, 3);
});
test("step_completed with retry does not increment progress", () => {
const initial = createFixtureSnapshot();
const reducer = createFixtureReducer();
const emitter = createProtocolShellEmitter(initial, reducer);
// Emit step_completed with retry (not complete).
emitter.emit({
kind: "step_completed",
step_name: "step_1",
resolution: "retry",
});
// Progress should remain 0.
assert.strictEqual(emitter.get_snapshot().progress.completed_step_count, 0);
// Now emit a complete one.
emitter.emit({
kind: "step_completed",
step_name: "step_1",
resolution: "complete",
});
// Progress should be 1.
assert.strictEqual(emitter.get_snapshot().progress.completed_step_count, 1);
});
test("protocol_completed clears current_step_name and sets is_complete", () => {
const initial = createFixtureSnapshot();
const reducer = createFixtureReducer();
const emitter = createProtocolShellEmitter(initial, reducer);
// Set a current step.
emitter.emit({
kind: "step_started",
step_name: "step_1",
prompt: "Do it",
interaction_count: 1,
});
assert.strictEqual(emitter.get_snapshot().current_step_name, "step_1");
assert.strictEqual(emitter.get_snapshot().is_complete, false);
// Emit protocol_completed.
emitter.emit({
kind: "protocol_completed",
protocol_name: "test_protocol",
});
// current_step_name should be cleared, is_complete should be true.
assert.strictEqual(emitter.get_snapshot().current_step_name, null);
assert.strictEqual(emitter.get_snapshot().is_complete, true);
});
});