lulz 1.0.2 → 2.0.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/test.js CHANGED
@@ -1,36 +1,107 @@
1
1
  /**
2
- * lulz Tests
2
+ * lulz - Test Suite
3
3
  */
4
4
 
5
- import { test } from 'node:test';
6
- import assert from 'node:assert';
7
5
  import {
8
6
  flow,
9
7
  subflow,
10
8
  compose,
9
+ parallel,
10
+ series,
11
11
  isOuter,
12
12
  isInner,
13
- Inject,
14
- Debug,
15
- Function as FunctionNode,
16
- Change,
17
- Switch,
18
- Template,
19
- Delay,
20
- Join,
21
- Split,
13
+ isFlow,
14
+ inject,
15
+ debug,
16
+ func,
17
+ change,
18
+ switchNode,
19
+ template,
20
+ delay,
21
+ split,
22
+ join,
23
+ map,
24
+ filter,
25
+ scan,
26
+ debounce,
27
+ throttle,
28
+ take,
29
+ skip,
30
+ distinct,
31
+ pairwise,
32
+ tap,
33
+ buffer,
34
+ combineLatest,
22
35
  } from './index.js';
23
36
 
24
- // ============ TESTS ============
37
+
38
+ // ─────────────────────────────────────────────────────────────
39
+ // Test Utilities
40
+ // ─────────────────────────────────────────────────────────────
41
+
42
+ let testCount = 0;
43
+ let passCount = 0;
44
+
45
+ const test = (name, fn) => {
46
+ testCount++;
47
+ try {
48
+ fn();
49
+ passCount++;
50
+ console.log(`✓ ${name}`);
51
+ } catch (err) {
52
+ console.log(`✗ ${name}`);
53
+ console.log(` Error: ${err.message}`);
54
+ }
55
+ };
56
+
57
+ const asyncTest = async (name, fn, timeout = 500) => {
58
+ testCount++;
59
+ try {
60
+ await Promise.race([
61
+ fn(),
62
+ new Promise((_, reject) =>
63
+ setTimeout(() => reject(new Error('Timeout')), timeout)
64
+ ),
65
+ ]);
66
+ passCount++;
67
+ console.log(`✓ ${name}`);
68
+ } catch (err) {
69
+ console.log(`✗ ${name}`);
70
+ console.log(` Error: ${err.message}`);
71
+ }
72
+ };
73
+
74
+ const assert = (condition, message = 'Assertion failed') => {
75
+ if (!condition) throw new Error(message);
76
+ };
77
+
78
+ const assertEqual = (actual, expected, message = '') => {
79
+ if (actual !== expected) {
80
+ throw new Error(`${message} Expected ${expected}, got ${actual}`);
81
+ }
82
+ };
83
+
84
+ const assertDeepEqual = (actual, expected, message = '') => {
85
+ if (JSON.stringify(actual) !== JSON.stringify(expected)) {
86
+ throw new Error(`${message} Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
87
+ }
88
+ };
89
+
90
+
91
+ // ─────────────────────────────────────────────────────────────
92
+ // Inner/Outer Detection Tests
93
+ // ─────────────────────────────────────────────────────────────
94
+
95
+ console.log('\n═══ Inner/Outer Detection ═══\n');
25
96
 
26
97
  test('Regular function is outer', () => {
27
98
  function outer() { return () => {}; }
28
- assert.ok(isOuter(outer), 'Should detect regular function as outer');
99
+ assert(isOuter(outer), 'Should detect regular function as outer');
29
100
  });
30
101
 
31
102
  test('Arrow function is inner', () => {
32
103
  const inner = () => {};
33
- assert.ok(isInner(inner), 'Should detect arrow function as inner');
104
+ assert(isInner(inner), 'Should detect arrow function as inner');
34
105
  });
35
106
 
36
107
  test('Pre-called outer returns inner', () => {
@@ -38,393 +109,548 @@ test('Pre-called outer returns inner', () => {
38
109
  return (send, packet) => send(packet);
39
110
  }
40
111
  const inner = factory({});
41
- assert.ok(isOuter(factory), 'Factory should be outer');
42
- assert.ok(isInner(inner), 'Result should be inner');
112
+ assert(isOuter(factory), 'Factory should be outer');
113
+ assert(isInner(inner), 'Result should be inner');
43
114
  });
44
115
 
45
- test('Simple pipe connection', (t, done) => {
46
- const results = [];
47
116
 
48
- function producer(options) {
49
- return (send) => {
50
- send({ payload: 'test' });
51
- };
52
- }
117
+ // ─────────────────────────────────────────────────────────────
118
+ // Basic Flow Tests
119
+ // ─────────────────────────────────────────────────────────────
53
120
 
54
- function consumer(options) {
55
- return (send, packet) => {
56
- results.push(packet);
57
- send(packet);
58
- };
59
- }
121
+ console.log('\n═══ Basic Flow ═══\n');
60
122
 
61
- const app = flow([
62
- [producer, 'out'],
63
- ['out', consumer],
64
- ]);
65
-
66
- app.start();
67
-
68
- // Give it a tick
69
- setTimeout(() => {
70
- assert.strictEqual(results.length, 1, 'Should receive one packet');
71
- assert.strictEqual(results[0].payload, 'test', 'Payload should match');
72
- done();
73
- }, 10);
123
+ test('Flow is an EventEmitter', () => {
124
+ const app = flow([]);
125
+ assert(typeof app.on === 'function', 'Should have on method');
126
+ assert(typeof app.emit === 'function', 'Should have emit method');
127
+ assert(app._isFlow === true, 'Should be marked as flow');
74
128
  });
75
129
 
76
- test('Direct inject into pipe', () => {
130
+ test('Direct inject into pipe via emit', () => {
77
131
  const results = [];
78
-
132
+
79
133
  function collector(options) {
80
134
  return (send, packet) => {
81
135
  results.push(packet.payload);
82
136
  send(packet);
83
137
  };
84
138
  }
85
-
139
+
86
140
  const app = flow([
87
141
  ['input', collector],
88
142
  ]);
143
+
144
+ app.emit('input', { payload: 'hello' });
145
+ app.emit('input', { payload: 'world' });
146
+
147
+ assertEqual(results.length, 2, 'Should receive two packets');
148
+ assertEqual(results[0], 'hello');
149
+ assertEqual(results[1], 'world');
150
+ });
89
151
 
90
- app.inject('input', { payload: 'hello' });
91
- app.inject('input', { payload: 'world' });
92
-
93
- assert.strictEqual(results.length, 2, 'Should receive two packets');
94
- assert.strictEqual(results[0], 'hello');
95
- assert.strictEqual(results[1], 'world');
152
+ test('Listen to pipe via on', () => {
153
+ const results = [];
154
+
155
+ const app = flow([
156
+ ['input', 'output'],
157
+ ]);
158
+
159
+ app.on('output', (packet) => {
160
+ results.push(packet.payload);
161
+ });
162
+
163
+ app.emit('input', { payload: 'test' });
164
+
165
+ assertEqual(results.length, 1);
166
+ assertEqual(results[0], 'test');
96
167
  });
97
168
 
98
- test('Fan-out sends to multiple transforms', () => {
99
- const results = { a: [], b: [], c: [] };
100
169
 
101
- function transformA(options) {
102
- return (send, packet) => {
103
- results.a.push(packet.payload);
104
- send({ ...packet, from: 'A' });
105
- };
106
- }
170
+ // ─────────────────────────────────────────────────────────────
171
+ // Series Processing (Default)
172
+ // ─────────────────────────────────────────────────────────────
107
173
 
108
- function transformB(options) {
109
- return (send, packet) => {
110
- results.b.push(packet.payload);
111
- send({ ...packet, from: 'B' });
112
- };
113
- }
174
+ console.log('\n═══ Series Processing (Default) ═══\n');
114
175
 
115
- function collector(options) {
116
- return (send, packet) => {
117
- results.c.push(packet.from);
118
- send(packet);
176
+ test('Default is series: a → b → c', () => {
177
+ const order = [];
178
+
179
+ function step(name) {
180
+ return function(options) {
181
+ return (send, packet) => {
182
+ order.push(name);
183
+ send({ ...packet, steps: [...(packet.steps || []), name] });
184
+ };
119
185
  };
120
186
  }
121
-
187
+
122
188
  const app = flow([
123
- ['input', transformA, transformB, 'output'],
124
- ['output', collector],
189
+ ['input', step('A'), step('B'), step('C'), 'output'],
125
190
  ]);
126
-
127
- app.inject('input', { payload: 42 });
128
-
129
- assert.strictEqual(results.a.length, 1, 'A should receive packet');
130
- assert.strictEqual(results.b.length, 1, 'B should receive packet');
131
- assert.strictEqual(results.c.length, 2, 'Collector should receive from both');
191
+
192
+ let finalPacket = null;
193
+ app.on('output', (packet) => { finalPacket = packet; });
194
+
195
+ app.emit('input', { payload: 'start' });
196
+
197
+ assertDeepEqual(order, ['A', 'B', 'C'], 'Should process in order');
198
+ assertDeepEqual(finalPacket.steps, ['A', 'B', 'C'], 'Packet should have all steps');
132
199
  });
133
200
 
134
- test('Series processes in order: [a, b, c]', () => {
201
+ test('Explicit series() helper', () => {
135
202
  const order = [];
136
-
203
+
137
204
  function step(name) {
138
- return function factory(options) {
205
+ return function(options) {
139
206
  return (send, packet) => {
140
207
  order.push(name);
141
- send({ ...packet, steps: [...(packet.steps || []), name] });
208
+ send(packet);
142
209
  };
143
210
  };
144
211
  }
145
-
212
+
146
213
  const app = flow([
147
- ['input', [step('A'), step('B'), step('C')], 'output'],
214
+ ['input', series(step('X'), step('Y'), step('Z')), 'output'],
148
215
  ]);
216
+
217
+ app.emit('input', { payload: 'test' });
218
+
219
+ assertDeepEqual(order, ['X', 'Y', 'Z']);
220
+ });
149
221
 
150
- let finalPacket = null;
151
- app.pipes['output'].connect({
152
- receive: (packet) => { finalPacket = packet; }
153
- });
154
222
 
155
- app.inject('input', { payload: 'start' });
223
+ // ─────────────────────────────────────────────────────────────
224
+ // Parallel Processing (Explicit)
225
+ // ─────────────────────────────────────────────────────────────
156
226
 
157
- assert.deepStrictEqual(order, ['A', 'B', 'C'], 'Should process in order');
158
- assert.deepStrictEqual(finalPacket.steps, ['A', 'B', 'C'], 'Packet should have all steps');
159
- });
227
+ console.log('\n═══ Parallel Processing (Explicit) ═══\n');
160
228
 
161
- test('Series with pre-configured function', () => {
162
- const order = [];
163
-
164
- function configurable(options) {
165
- return (send, packet) => {
166
- order.push(options.name);
167
- send(packet);
229
+ test('Parallel with [] syntax', () => {
230
+ const results = { a: false, b: false, c: false };
231
+
232
+ function setFlag(name) {
233
+ return function(options) {
234
+ return (send, packet) => {
235
+ results[name] = true;
236
+ send({ ...packet, from: name });
237
+ };
168
238
  };
169
239
  }
170
-
240
+
171
241
  const app = flow([
172
- ['input', [
173
- configurable({ name: 'first' }),
174
- configurable, // outer, will get empty config
175
- configurable({ name: 'third' }),
176
- ], 'output'],
242
+ ['input', [setFlag('a'), setFlag('b'), setFlag('c')], 'output'],
177
243
  ]);
178
-
179
- app.inject('input', { payload: 'test' });
180
-
181
- assert.strictEqual(order[0], 'first');
182
- assert.strictEqual(order[2], 'third');
244
+
245
+ const outputs = [];
246
+ app.on('output', (packet) => outputs.push(packet.from));
247
+
248
+ app.emit('input', { payload: 'parallel' });
249
+
250
+ assert(results.a && results.b && results.c, 'All should receive packet');
251
+ assertEqual(outputs.length, 3, 'Output should receive 3 packets');
183
252
  });
184
253
 
185
- test('Subflow can be embedded', () => {
254
+ test('Explicit parallel() helper', () => {
186
255
  const results = [];
187
-
188
- // Create a subflow that doubles the payload
189
- const doubler = subflow([
190
- ['in', FunctionNode({ func: (msg) => ({ ...msg, payload: msg.payload * 2 }) }), 'out'],
191
- ]);
192
-
193
- function collector(options) {
194
- return (send, packet) => {
195
- results.push(packet.payload);
196
- send(packet);
256
+
257
+ function addResult(value) {
258
+ return function(options) {
259
+ return (send, packet) => {
260
+ results.push(value);
261
+ send(packet);
262
+ };
197
263
  };
198
264
  }
199
-
200
- // Main flow: inject into subflow, subflow outputs to collector
201
- // We wire: input → subflow.in → (doubles) → subflow.out → collector
265
+
202
266
  const app = flow([
203
- ['output', collector],
267
+ ['input', parallel(addResult(1), addResult(2), addResult(3)), 'output'],
204
268
  ]);
269
+
270
+ app.emit('input', { payload: 'test' });
271
+
272
+ assertEqual(results.length, 3, 'All parallel branches should execute');
273
+ });
205
274
 
206
- // Connect: inject directly into subflow, subflow outputs to 'output' pipe
207
- doubler._output.connect(app.pipes['output']);
208
275
 
209
- // Inject into subflow's input
210
- doubler._input.receive({ payload: 21 });
276
+ // ─────────────────────────────────────────────────────────────
277
+ // Pre-configured Functions
278
+ // ─────────────────────────────────────────────────────────────
211
279
 
212
- assert.strictEqual(results[0], 42, 'Subflow should double the value');
213
- });
280
+ console.log('\n═══ Pre-configured Functions ═══\n');
214
281
 
215
- test('Inject node produces packets', (t, done) => {
216
- const results = [];
217
-
218
- function collector(options) {
282
+ test('Mixed pre-configured and auto-configured', () => {
283
+ const configs = [];
284
+
285
+ function configurable(options) {
219
286
  return (send, packet) => {
220
- results.push(packet);
287
+ configs.push(options.name || 'default');
221
288
  send(packet);
222
289
  };
223
290
  }
224
-
291
+
225
292
  const app = flow([
226
- [Inject({ payload: 'hello', once: true }), collector],
293
+ ['input', configurable({ name: 'first' }), configurable, configurable({ name: 'third' }), 'output'],
227
294
  ]);
295
+
296
+ app.emit('input', { payload: 'test' });
297
+
298
+ assertEqual(configs[0], 'first');
299
+ assertEqual(configs[1], 'default'); // Auto-configured with {}
300
+ assertEqual(configs[2], 'third');
301
+ });
228
302
 
229
- app.start();
230
303
 
231
- setTimeout(() => {
232
- assert.ok(results.length >= 1, 'Should produce at least one packet');
233
- assert.strictEqual(results[0].payload, 'hello');
234
- done();
235
- }, 50);
304
+ // ─────────────────────────────────────────────────────────────
305
+ // Subflows and Auto-compose
306
+ // ─────────────────────────────────────────────────────────────
307
+
308
+ console.log('\n═══ Subflows ═══\n');
309
+
310
+ test('Subflow with in/out pipes', () => {
311
+ const results = [];
312
+
313
+ const doubler = subflow([
314
+ ['in', func({ func: (msg) => ({ ...msg, payload: msg.payload * 2 }) }), 'out'],
315
+ ]);
316
+
317
+ doubler.on('out', (packet) => results.push(packet.payload));
318
+ doubler.emit('in', { payload: 21 });
319
+
320
+ assertEqual(results[0], 42);
236
321
  });
237
322
 
238
- test('Debug node logs and passes through', () => {
239
- const logs = [];
323
+ test('Compose multiple flows', () => {
324
+ const flow1 = subflow([
325
+ ['in', func({ func: (msg) => ({ ...msg, payload: msg.payload + 10 }) }), 'out'],
326
+ ]);
327
+
328
+ const flow2 = subflow([
329
+ ['in', func({ func: (msg) => ({ ...msg, payload: msg.payload * 2 }) }), 'out'],
330
+ ]);
331
+
332
+ const composed = compose(flow1, flow2);
333
+
240
334
  const results = [];
335
+ composed.on('out', (packet) => results.push(packet.payload));
336
+
337
+ composed.emit('in', { payload: 5 });
338
+
339
+ // (5 + 10) * 2 = 30
340
+ assertEqual(results[0], 30);
341
+ });
241
342
 
242
- const customLogger = (...args) => logs.push(args);
243
343
 
244
- function collector(options) {
245
- return (send, packet) => {
246
- results.push(packet);
247
- send(packet);
248
- };
249
- }
344
+ // ─────────────────────────────────────────────────────────────
345
+ // Node-RED Core Nodes
346
+ // ─────────────────────────────────────────────────────────────
347
+
348
+ console.log('\n═══ Node-RED Core Nodes ═══\n');
250
349
 
350
+ test('debug node logs and passes through', () => {
351
+ const logs = [];
352
+ const results = [];
353
+
251
354
  const app = flow([
252
- ['input', Debug({ name: 'test', logger: customLogger }), collector],
355
+ ['input', debug({ name: 'test', logger: (...args) => logs.push(args) }), 'output'],
253
356
  ]);
254
-
255
- app.inject('input', { payload: 42 });
256
-
257
- assert.strictEqual(logs.length, 1, 'Should log once');
258
- assert.strictEqual(logs[0][0], '[test]', 'Should have correct label');
259
- assert.strictEqual(logs[0][1], 42, 'Should log payload');
260
- assert.strictEqual(results.length, 1, 'Should pass through');
357
+
358
+ app.on('output', (packet) => results.push(packet));
359
+ app.emit('input', { payload: 42 });
360
+
361
+ assertEqual(logs.length, 1);
362
+ assertEqual(logs[0][0], '[test]');
363
+ assertEqual(logs[0][1], 42);
364
+ assertEqual(results.length, 1);
261
365
  });
262
366
 
263
- test('Function node transforms messages', () => {
367
+ test('func node transforms messages', () => {
264
368
  const results = [];
265
-
266
- function collector(options) {
267
- return (send, packet) => {
268
- results.push(packet);
269
- send(packet);
270
- };
271
- }
272
-
369
+
273
370
  const app = flow([
274
- ['input', FunctionNode({
275
- func: (msg) => ({
276
- ...msg,
277
- payload: msg.payload.toUpperCase()
278
- })
279
- }), collector],
371
+ ['input', func({ func: (msg) => ({ ...msg, payload: msg.payload.toUpperCase() }) }), 'output'],
280
372
  ]);
281
-
282
- app.inject('input', { payload: 'hello' });
283
-
284
- assert.strictEqual(results[0].payload, 'HELLO');
373
+
374
+ app.on('output', (packet) => results.push(packet));
375
+ app.emit('input', { payload: 'hello' });
376
+
377
+ assertEqual(results[0].payload, 'HELLO');
285
378
  });
286
379
 
287
- test('Change node sets properties', () => {
380
+ test('change node sets properties', () => {
288
381
  const results = [];
289
-
290
- function collector(options) {
291
- return (send, packet) => {
292
- results.push(packet);
293
- send(packet);
294
- };
295
- }
296
-
382
+
297
383
  const app = flow([
298
- ['input', Change({
384
+ ['input', change({
299
385
  rules: [
300
386
  { type: 'set', prop: 'payload', to: 'changed' },
301
387
  { type: 'set', prop: 'topic', to: 'test' },
302
388
  ]
303
- }), collector],
389
+ }), 'output'],
390
+ ]);
391
+
392
+ app.on('output', (packet) => results.push(packet));
393
+ app.emit('input', { payload: 'original' });
394
+
395
+ assertEqual(results[0].payload, 'changed');
396
+ assertEqual(results[0].topic, 'test');
397
+ });
398
+
399
+ test('switchNode routes messages', () => {
400
+ const high = [];
401
+
402
+ const app = flow([
403
+ ['input', switchNode({
404
+ property: 'payload',
405
+ rules: [{ type: 'gte', value: 50 }]
406
+ }), 'high'],
304
407
  ]);
408
+
409
+ app.on('high', (packet) => high.push(packet.payload));
410
+
411
+ app.emit('input', { payload: 75 });
412
+ app.emit('input', { payload: 25 });
413
+
414
+ assertEqual(high.length, 1);
415
+ assertEqual(high[0], 75);
416
+ });
305
417
 
306
- app.inject('input', { payload: 'original' });
418
+ test('template node renders mustache', () => {
419
+ const results = [];
420
+
421
+ const app = flow([
422
+ ['input', template({ template: 'Hello {{name}}!' }), 'output'],
423
+ ]);
424
+
425
+ app.on('output', (packet) => results.push(packet));
426
+ app.emit('input', { payload: '', name: 'Alice' });
427
+
428
+ assertEqual(results[0].payload, 'Hello Alice!');
429
+ });
307
430
 
308
- assert.strictEqual(results[0].payload, 'changed');
309
- assert.strictEqual(results[0].topic, 'test');
431
+ test('split node splits arrays', () => {
432
+ const results = [];
433
+
434
+ const app = flow([
435
+ ['input', split(), 'output'],
436
+ ]);
437
+
438
+ app.on('output', (packet) => results.push(packet.payload));
439
+ app.emit('input', { payload: [1, 2, 3] });
440
+
441
+ assertEqual(results.length, 3);
442
+ assertDeepEqual(results, [1, 2, 3]);
310
443
  });
311
444
 
312
- test('Switch node routes messages', () => {
313
- const results = { high: [], low: [] };
314
445
 
315
- function highCollector(options) {
316
- return (send, packet) => {
317
- results.high.push(packet.payload);
318
- send(packet);
319
- };
320
- }
446
+ // ─────────────────────────────────────────────────────────────
447
+ // RxJS-Style Operators
448
+ // ─────────────────────────────────────────────────────────────
321
449
 
322
- function lowCollector(options) {
323
- return (send, packet) => {
324
- results.low.push(packet.payload);
325
- send(packet);
326
- };
327
- }
450
+ console.log('\n═══ RxJS-Style Operators ═══\n');
328
451
 
452
+ test('map transforms values', () => {
453
+ const results = [];
454
+
329
455
  const app = flow([
330
- ['input', Switch({
331
- property: 'payload',
332
- rules: [
333
- { type: 'gte', value: 50 },
334
- ]
335
- }), 'high'],
336
- ['high', highCollector],
456
+ ['input', map({ fn: (x) => x * 2 }), 'output'],
337
457
  ]);
338
-
339
- app.inject('input', { payload: 75 });
340
- app.inject('input', { payload: 25 });
341
-
342
- assert.strictEqual(results.high.length, 1, 'Only high value should route');
343
- assert.strictEqual(results.high[0], 75);
458
+
459
+ app.on('output', (packet) => results.push(packet.payload));
460
+ app.emit('input', { payload: 21 });
461
+
462
+ assertEqual(results[0], 42);
344
463
  });
345
464
 
346
- test('Template node renders mustache', () => {
465
+ test('scan accumulates values', () => {
347
466
  const results = [];
467
+
468
+ const app = flow([
469
+ ['input', scan({ reducer: (acc, x) => acc + x, initial: 0 }), 'output'],
470
+ ]);
471
+
472
+ app.on('output', (packet) => results.push(packet.payload));
473
+
474
+ app.emit('input', { payload: 1 });
475
+ app.emit('input', { payload: 2 });
476
+ app.emit('input', { payload: 3 });
477
+
478
+ assertDeepEqual(results, [1, 3, 6]);
479
+ });
348
480
 
349
- function collector(options) {
350
- return (send, packet) => {
351
- results.push(packet);
352
- send(packet);
353
- };
354
- }
481
+ test('take only first N', () => {
482
+ const results = [];
483
+
484
+ const app = flow([
485
+ ['input', take({ count: 2 }), 'output'],
486
+ ]);
487
+
488
+ app.on('output', (packet) => results.push(packet.payload));
489
+
490
+ app.emit('input', { payload: 1 });
491
+ app.emit('input', { payload: 2 });
492
+ app.emit('input', { payload: 3 });
493
+
494
+ assertEqual(results.length, 2);
495
+ assertDeepEqual(results, [1, 2]);
496
+ });
355
497
 
498
+ test('skip first N', () => {
499
+ const results = [];
500
+
356
501
  const app = flow([
357
- ['input', Template({
358
- template: 'Hello {{name}}, you have {{count}} messages!'
359
- }), collector],
502
+ ['input', skip({ count: 2 }), 'output'],
360
503
  ]);
504
+
505
+ app.on('output', (packet) => results.push(packet.payload));
506
+
507
+ app.emit('input', { payload: 1 });
508
+ app.emit('input', { payload: 2 });
509
+ app.emit('input', { payload: 3 });
510
+
511
+ assertEqual(results.length, 1);
512
+ assertEqual(results[0], 3);
513
+ });
361
514
 
362
- app.inject('input', { payload: '', name: 'Alice', count: 5 });
515
+ test('distinct filters duplicates', () => {
516
+ const results = [];
517
+
518
+ const app = flow([
519
+ ['input', distinct(), 'output'],
520
+ ]);
521
+
522
+ app.on('output', (packet) => results.push(packet.payload));
523
+
524
+ app.emit('input', { payload: 1 });
525
+ app.emit('input', { payload: 2 });
526
+ app.emit('input', { payload: 1 });
527
+ app.emit('input', { payload: 3 });
528
+
529
+ assertDeepEqual(results, [1, 2, 3]);
530
+ });
363
531
 
364
- assert.strictEqual(results[0].payload, 'Hello Alice, you have 5 messages!');
532
+ test('pairwise emits pairs', () => {
533
+ const results = [];
534
+
535
+ const app = flow([
536
+ ['input', pairwise(), 'output'],
537
+ ]);
538
+
539
+ app.on('output', (packet) => results.push(packet.payload));
540
+
541
+ app.emit('input', { payload: 'a' });
542
+ app.emit('input', { payload: 'b' });
543
+ app.emit('input', { payload: 'c' });
544
+
545
+ assertEqual(results.length, 2);
546
+ assertDeepEqual(results[0], ['a', 'b']);
547
+ assertDeepEqual(results[1], ['b', 'c']);
365
548
  });
366
549
 
367
- test('Split node splits arrays', () => {
550
+ test('tap performs side effect', () => {
551
+ const sideEffects = [];
368
552
  const results = [];
553
+
554
+ const app = flow([
555
+ ['input', tap({ fn: (p) => sideEffects.push(p.payload) }), 'output'],
556
+ ]);
557
+
558
+ app.on('output', (packet) => results.push(packet.payload));
559
+ app.emit('input', { payload: 'test' });
560
+
561
+ assertEqual(sideEffects[0], 'test');
562
+ assertEqual(results[0], 'test');
563
+ });
369
564
 
370
- function collector(options) {
371
- return (send, packet) => {
372
- results.push(packet.payload);
373
- send(packet);
374
- };
375
- }
376
565
 
566
+ // ─────────────────────────────────────────────────────────────
567
+ // Node-RED Tutorial 1: Inject → Debug
568
+ // ─────────────────────────────────────────────────────────────
569
+
570
+ console.log('\n═══ Node-RED Tutorial 1 ═══\n');
571
+
572
+ await asyncTest('Tutorial 1: Inject timestamp to Debug', async () => {
573
+ const logs = [];
574
+
377
575
  const app = flow([
378
- ['input', Split(), collector],
576
+ [inject({ payload: () => Date.now(), once: true }), debug({ name: 'output', logger: (...args) => logs.push(args) })],
379
577
  ]);
578
+
579
+ app.start();
580
+
581
+ await new Promise(r => setTimeout(r, 50));
582
+
583
+ assert(logs.length >= 1, 'Should have logged');
584
+ assert(typeof logs[0][1] === 'number', 'Should be timestamp');
585
+ });
380
586
 
381
- app.inject('input', { payload: [1, 2, 3] });
382
587
 
383
- assert.strictEqual(results.length, 3);
384
- assert.deepStrictEqual(results, [1, 2, 3]);
385
- });
588
+ // ─────────────────────────────────────────────────────────────
589
+ // Node-RED Tutorial 2: Inject → Function → Debug
590
+ // ─────────────────────────────────────────────────────────────
386
591
 
387
- test('Tutorial 1: Inject timestamp to Debug', (t, done) => {
388
- const logs = [];
389
- const customLogger = (...args) => logs.push(args);
592
+ console.log('\n═══ Node-RED Tutorial 2 ═══\n');
390
593
 
391
- // Recreate first tutorial flow
594
+ await asyncTest('Tutorial 2: Modify payload with Function node', async () => {
595
+ const logs = [];
596
+
392
597
  const app = flow([
393
- [Inject({ payload: () => Date.now(), once: true }), Debug({ name: 'debug', logger: customLogger })],
598
+ [inject({ payload: 'Hello World!', once: true }), 'msg'],
599
+ ['msg', func({ func: (msg) => ({ ...msg, payload: msg.payload.toLowerCase() }) }), debug({ name: 'output', logger: (...args) => logs.push(args) })],
394
600
  ]);
395
-
601
+
396
602
  app.start();
397
-
398
- setTimeout(() => {
399
- assert.ok(logs.length >= 1, 'Should have logged');
400
- assert.strictEqual(typeof logs[0][1], 'number', 'Should be timestamp');
401
- done();
402
- }, 50);
603
+
604
+ await new Promise(r => setTimeout(r, 50));
605
+
606
+ assert(logs.length >= 1, 'Should have logged');
607
+ assertEqual(logs[0][1], 'hello world!');
403
608
  });
404
609
 
405
- test('Tutorial 2: Modify payload with Function node', (t, done) => {
406
- const logs = [];
407
- const customLogger = (...args) => logs.push(args);
408
610
 
409
- // Second tutorial: inject → function (modify payload) → debug
611
+ // ─────────────────────────────────────────────────────────────
612
+ // Complex Pipeline
613
+ // ─────────────────────────────────────────────────────────────
614
+
615
+ console.log('\n═══ Complex Pipeline ═══\n');
616
+
617
+ test('Series with parallel block', () => {
618
+ const order = [];
619
+
620
+ function step(name) {
621
+ return function(options) {
622
+ return (send, packet) => {
623
+ order.push(name);
624
+ send(packet);
625
+ };
626
+ };
627
+ }
628
+
629
+ // This should: A → (B,C parallel) → D
410
630
  const app = flow([
411
- [Inject({
412
- payload: 'Hello World!',
413
- once: true
414
- }), 'msg'],
415
- ['msg', FunctionNode({
416
- func: (msg) => ({
417
- ...msg,
418
- payload: msg.payload.toLowerCase()
419
- })
420
- }), Debug({ name: 'output', logger: customLogger })],
631
+ ['input', step('A'), 'stage1'],
632
+ ['stage1', [step('B'), step('C')], 'stage2'],
633
+ ['stage2', step('D'), 'output'],
421
634
  ]);
635
+
636
+ app.emit('input', { payload: 'test' });
637
+
638
+ assertEqual(order[0], 'A', 'A should be first');
639
+ assert(order.includes('B') && order.includes('C'), 'B and C should run');
640
+ // D runs twice (once for each parallel output)
641
+ });
422
642
 
423
- app.start();
424
643
 
425
- setTimeout(() => {
426
- assert.ok(logs.length >= 1, 'Should have logged');
427
- assert.strictEqual(logs[0][1], 'hello world!', 'Should be lowercase');
428
- done();
429
- }, 50);
430
- });
644
+ // ─────────────────────────────────────────────────────────────
645
+ // Results
646
+ // ─────────────────────────────────────────────────────────────
647
+
648
+ setTimeout(() => {
649
+ console.log(`\n═══ Results: ${passCount}/${testCount} tests passed ═══\n`);
650
+ if (passCount === testCount) {
651
+ console.log('All tests passed! ✓\n');
652
+ } else {
653
+ console.log(`${testCount - passCount} test(s) failed.\n`);
654
+ process.exit(1);
655
+ }
656
+ }, 300);