node-red-contrib-timer-events 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.
@@ -0,0 +1,203 @@
1
+ // Test harness for the #1 fix: authoritative wall-clock remaining time.
2
+ // Stubs enough of the Node-RED runtime to instantiate the node, then
3
+ // exercises the exact scenarios that were broken, all with Status
4
+ // Reporting = "none" (the default, and the previously-broken case).
5
+ "use strict";
6
+ const os = require("os");
7
+ const fs = require("fs");
8
+ const path = require("path");
9
+
10
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
11
+ let failures = 0;
12
+ function check(label, cond, detail) {
13
+ if (cond) { console.log("PASS " + label); }
14
+ else { failures++; console.log("FAIL " + label + (detail !== undefined ? " [" + detail + "]" : "")); }
15
+ }
16
+ function near(actual, expected, tol) { return Math.abs(actual - expected) <= tol; }
17
+
18
+ // ---- RED stub --------------------------------------------------------------
19
+ function makeRED(userDir) {
20
+ const registered = {};
21
+ return {
22
+ nodes: {
23
+ registerType(name, ctor) { registered[name] = ctor; },
24
+ createNode(node, n) {
25
+ node.id = n.id;
26
+ node._handlers = {};
27
+ node.on = function(evt, fn) { node._handlers[evt] = fn; };
28
+ node.receive = function(msg) { node._handlers["input"](msg); };
29
+ node.close = function(removed) {
30
+ return new Promise(res => node._handlers["close"](removed, res));
31
+ };
32
+ node.sent = [];
33
+ node.send = function(arr) { node.sent.push(arr); };
34
+ node.status = function() {};
35
+ node.warn = function() {};
36
+ node.error = function(e) { console.log("NODE ERROR:", e); };
37
+ },
38
+ get(name) { return registered[name]; }
39
+ },
40
+ util: {
41
+ cloneMessage(m) { return JSON.parse(JSON.stringify(m || {})); },
42
+ evaluateNodeProperty(v) { return v; }
43
+ },
44
+ settings: { userDir: userDir }
45
+ };
46
+ }
47
+
48
+ function lastEvent(node, type) {
49
+ for (let i = node.sent.length - 1; i >= 0; i--) {
50
+ const m = node.sent[i].find(x => x && x.timerEvent === type);
51
+ if (m) return m;
52
+ }
53
+ return null;
54
+ }
55
+
56
+ function makeNode(RED, cfg) {
57
+ const Ctor = RED.nodes.get("timer-events");
58
+ const node = {};
59
+ Ctor.call(node, Object.assign({
60
+ id: "t" + Math.random().toString(36).slice(2),
61
+ duration: "5", durationType: "num", units: "Second",
62
+ reporting: "none", reportingformat: "seconds",
63
+ persist: false, ignoretimerpass: false, donotresettimer: false,
64
+ thresholdaction: "donothing", thresholdcount: "0",
65
+ thresholdaddtime: "0", thresholdaddtimeunits: "Second",
66
+ heartbeatinterval: "0", heartbeatintervalunits: "Second",
67
+ cooldownduration: "0", cooldownunits: "Second"
68
+ }, cfg));
69
+ return node;
70
+ }
71
+
72
+ (async function main() {
73
+ const userDir = fs.mkdtempSync(path.join(os.tmpdir(), "timerevents-test-"));
74
+ const RED = makeRED(userDir);
75
+ require("/home/claude/timer-events.js")(RED);
76
+
77
+ // -- T1: pause/resume accuracy with reporting off ("Never") ---------------
78
+ {
79
+ const n = makeNode(RED, { duration: "10" }); // 10s
80
+ n.receive({ payload: "go" });
81
+ await sleep(3000);
82
+ n.receive({ payload: "pause" });
83
+ let p = lastEvent(n, "paused");
84
+ check("T1a pause freezes at ~7000ms (was: full 10000ms)", p && near(p.remainingTime, 7000, 300), p && p.remainingTime);
85
+ await sleep(1500); // frozen time must not move
86
+ n.receive({ payload: "query" });
87
+ let q = lastEvent(n, "query");
88
+ check("T1b remaining unchanged while paused", q && near(q.remainingTime, 7000, 300), q && q.remainingTime);
89
+ n.receive({ payload: "resume" });
90
+ const resumeAt = Date.now();
91
+ await new Promise(res => {
92
+ const iv = setInterval(() => { if (lastEvent(n, "expired")) { clearInterval(iv); res(); } }, 100);
93
+ });
94
+ const ranFor = Date.now() - resumeAt;
95
+ check("T1c resume runs ~7s more, not 10s (was: full duration back)", near(ranFor, 7000, 500), ranFor);
96
+ await n.close(false);
97
+ }
98
+
99
+ // -- T2: query mid-run reports live remaining time ------------------------
100
+ {
101
+ const n = makeNode(RED, { duration: "10" });
102
+ n.receive({ payload: "go" });
103
+ await sleep(4000);
104
+ n.receive({ payload: "query" });
105
+ const q = lastEvent(n, "query");
106
+ check("T2 query at t+4s reports ~6000ms (was: 10000ms)", q && near(q.remainingTime, 6000, 300), q && q.remainingTime);
107
+ n.receive({ payload: "stop" });
108
+ await n.close(false);
109
+ }
110
+
111
+ // -- T3: adjusttime computes from true remaining, and never leaks into next run
112
+ {
113
+ const n = makeNode(RED, { duration: "10" });
114
+ n.receive({ payload: "go" });
115
+ await sleep(2000);
116
+ n.receive({ payload: "adjusttime", adjusttime: 5000 });
117
+ const a = lastEvent(n, "timeadjusted");
118
+ check("T3a adjust +5s at t+2s -> ~13000ms (was: 15000ms)", a && near(a.remainingTime, 13000, 300), a && a.remainingTime);
119
+ n.receive({ payload: "stop" });
120
+ n.sent.length = 0;
121
+ n.receive({ payload: "go2" }); // fresh run: original duration must return
122
+ const s = lastEvent(n, "started");
123
+ check("T3b next run starts at original 10000ms (no adjustment leak)", s && near(s.remainingTime, 10000, 50), s && s.remainingTime);
124
+ n.receive({ payload: "stop" });
125
+ await n.close(false);
126
+ }
127
+
128
+ // -- T4: settime while paused updates the frozen snapshot ------------------
129
+ {
130
+ const n = makeNode(RED, { duration: "10" });
131
+ n.receive({ payload: "go" });
132
+ await sleep(1000);
133
+ n.receive({ payload: "pause" });
134
+ n.receive({ payload: "settime", settime: 4000 });
135
+ n.receive({ payload: "query" });
136
+ const q = lastEvent(n, "query");
137
+ check("T4 settime while paused -> frozen at exactly 4000ms", q && q.remainingTime === 4000, q && q.remainingTime);
138
+ n.receive({ payload: "stop" });
139
+ await n.close(false);
140
+ }
141
+
142
+ // -- T5: persistence writes the real target (reporting off) ----------------
143
+ {
144
+ const n = makeNode(RED, { duration: "10", persist: true });
145
+ n.receive({ payload: "go" });
146
+ await sleep(3000);
147
+ n.receive({ payload: "lock" }); // any mid-run writeState trigger
148
+ const dir = path.join(userDir, "timerevents-timers");
149
+ const file = fs.readdirSync(dir).map(f => path.join(dir, f))
150
+ .sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs)[0];
151
+ const saved = JSON.parse(fs.readFileSync(file));
152
+ const persistedRemaining = new Date(saved.time).getTime() - Date.now();
153
+ check("T5 persisted target reflects ~7000ms left (was: ~10000ms)", near(persistedRemaining, 7000, 400), Math.round(persistedRemaining));
154
+ n.receive({ payload: "stop" });
155
+ await n.close(true);
156
+ }
157
+
158
+ // -- T6: cooldown query reports live cooldown remaining --------------------
159
+ {
160
+ const n = makeNode(RED, { duration: "1", cooldownduration: "6" });
161
+ n.receive({ payload: "go" });
162
+ await sleep(1300); // expire -> cooldown starts
163
+ check("T6a cooldownstarted fired", !!lastEvent(n, "cooldownstarted"));
164
+ await sleep(2000); // 2s into the 6s cooldown
165
+ n.receive({ payload: "query" });
166
+ const q = lastEvent(n, "query");
167
+ check("T6b cooldown query state", q && q.timerState === "cooldown", q && q.timerState);
168
+ check("T6c cooldown remaining ~4000ms (was: full 6000ms)", q && near(q.remainingTime, 3900, 400), q && q.remainingTime);
169
+ n.receive({ payload: "stop" }); // cancel cooldown
170
+ const st = lastEvent(n, "stopped");
171
+ check("T6d stop after cooldown reports remainingTime 0", st && st.remainingTime === 0, st && st.remainingTime);
172
+ await n.close(false);
173
+ }
174
+
175
+ // -- T7: regression - reporting ON still behaves (natural expiry timing) ---
176
+ {
177
+ const n = makeNode(RED, { duration: "4", reporting: "every_second" });
178
+ const t0 = Date.now();
179
+ n.receive({ payload: "go" });
180
+ await new Promise(res => {
181
+ const iv = setInterval(() => { if (lastEvent(n, "expired")) { clearInterval(iv); res(); } }, 100);
182
+ });
183
+ check("T7a expiry fires at ~4s with reporting on", near(Date.now() - t0, 4000, 500), Date.now() - t0);
184
+ const e = lastEvent(n, "expired");
185
+ check("T7b expired reports remainingTime 0", e && e.remainingTime === 0, e && e.remainingTime);
186
+ await n.close(false);
187
+ }
188
+
189
+ // -- T8: regression - blocked restart while locked, envelope still sane ----
190
+ {
191
+ const n = makeNode(RED, { duration: "10", donotresettimer: true });
192
+ n.receive({ payload: "go" });
193
+ await sleep(2000);
194
+ n.receive({ payload: "again" }); // ignored restart
195
+ const r = lastEvent(n, "restarted");
196
+ check("T8 ignored restart carries live remaining ~8000ms", r && r.ignored === true && near(r.remainingTime, 8000, 300), r && r.remainingTime);
197
+ n.receive({ payload: "stop" });
198
+ await n.close(false);
199
+ }
200
+
201
+ console.log(failures === 0 ? "\nALL TESTS PASSED" : "\n" + failures + " FAILURE(S)");
202
+ process.exit(failures === 0 ? 0 : 1);
203
+ })();
@@ -0,0 +1,157 @@
1
+ // Tests for open-item 1 (Option A): a paused timer restores at the SAME
2
+ // frozen remaining and elapsed values regardless of Node-RED downtime.
3
+ // Running/cooldown restores remain wall-clock (downtime absorbed). Legacy
4
+ // persist files without the new fields fall back to the old calculation.
5
+ "use strict";
6
+ const os = require("os");
7
+ const fs = require("fs");
8
+ const path = require("path");
9
+
10
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
11
+ let failures = 0;
12
+ function check(label, cond, detail) {
13
+ if (cond) { console.log("PASS " + label); }
14
+ else { failures++; console.log("FAIL " + label + (detail !== undefined ? " [" + detail + "]" : "")); }
15
+ }
16
+ function near(a, e, tol) { return Math.abs(a - e) <= tol; }
17
+
18
+ function makeRED(userDir) {
19
+ const registered = {};
20
+ return {
21
+ nodes: {
22
+ registerType(name, ctor) { registered[name] = ctor; },
23
+ createNode(node, n) {
24
+ node.id = n.id;
25
+ node._handlers = {};
26
+ node.on = function(evt, fn) { node._handlers[evt] = fn; };
27
+ node.receive = function(msg) { node._handlers["input"](msg); };
28
+ node.close = function(removed) {
29
+ return new Promise(res => node._handlers["close"](removed, res));
30
+ };
31
+ node.sent = [];
32
+ node.send = function(arr) { node.sent.push(arr); };
33
+ node.status = function() {};
34
+ node.warn = function() {};
35
+ node.error = function(e) { console.log("NODE ERROR:", e); };
36
+ },
37
+ get(name) { return registered[name]; }
38
+ },
39
+ util: {
40
+ cloneMessage(m) { return JSON.parse(JSON.stringify(m || {})); },
41
+ evaluateNodeProperty(v) { return v; }
42
+ },
43
+ settings: { userDir: userDir }
44
+ };
45
+ }
46
+
47
+ function events(node, type) {
48
+ const out = [];
49
+ for (const arr of node.sent) for (const m of arr) if (m && m.timerEvent === type) out.push(m);
50
+ return out;
51
+ }
52
+ function lastEvent(node, type) { const e = events(node, type); return e.length ? e[e.length - 1] : null; }
53
+ function query(n) { n.receive({ payload: "query" }); return lastEvent(n, "query"); }
54
+
55
+ function makeNode(RED, cfg) {
56
+ const Ctor = RED.nodes.get("timer-events");
57
+ const node = {};
58
+ Ctor.call(node, Object.assign({
59
+ id: "z-fixed", duration: "10", durationType: "num", units: "Second",
60
+ reporting: "none", reportingformat: "seconds",
61
+ persist: true, ignoretimerpass: false, donotresettimer: false,
62
+ thresholdaction: "donothing", thresholdcount: "0",
63
+ thresholdaddtime: "0", thresholdaddtimeunits: "Second",
64
+ heartbeatinterval: "0", heartbeatintervalunits: "Second",
65
+ cooldownduration: "0", cooldownunits: "Second"
66
+ }, cfg));
67
+ return node;
68
+ }
69
+ function persistFile(userDir, id) { return path.join(userDir, "timerevents-timers", id); }
70
+
71
+ (async function main() {
72
+ const userDir = fs.mkdtempSync(path.join(os.tmpdir(), "timerevents-oi1-"));
73
+ const RED = makeRED(userDir);
74
+ require("/home/claude/timer-events.js")(RED);
75
+
76
+ // -- Z1: paused restore ignores downtime -----------------------------------
77
+ {
78
+ const n1 = makeNode(RED, { id: "z1" });
79
+ n1.receive({ payload: "go" });
80
+ await sleep(3000);
81
+ n1.receive({ payload: "pause" }); // frozen: ~7000 remaining / ~3000 elapsed
82
+ await n1.close(false);
83
+ await sleep(2000); // 2s of downtime - must NOT be deducted
84
+ const n2 = makeNode(RED, { id: "z1" });
85
+ const q = query(n2);
86
+ check("Z1a remaining unchanged across downtime (~7000, was: ~5000)",
87
+ q && q.timerState === "paused" && near(q.remainingTime, 7000, 300), q && Math.round(q.remainingTime));
88
+ check("Z1b elapsed unchanged across downtime (~3000, was: ~5000)",
89
+ q && near(q.elapsedTime, 3000, 300), q && Math.round(q.elapsedTime));
90
+ // resume must honor the frozen value end-to-end
91
+ n2.receive({ payload: "resume" });
92
+ const t0 = Date.now();
93
+ await new Promise(res => { const iv = setInterval(() => { if (lastEvent(n2, "expired")) { clearInterval(iv); res(); } }, 100); });
94
+ check("Z1c resume after restore runs the full frozen ~7s", near(Date.now() - t0, 7000, 600), Date.now() - t0);
95
+ await n2.close(true);
96
+ }
97
+
98
+ // -- Z2: frozenElapsed independence survives (settime while paused) --------
99
+ {
100
+ const n1 = makeNode(RED, { id: "z2" });
101
+ n1.receive({ payload: "go" });
102
+ await sleep(3000);
103
+ n1.receive({ payload: "pause" }); // elapsed frozen ~3000
104
+ n1.receive({ payload: "settime", settime: 9000 }); // remaining -> 9000, elapsed untouched
105
+ await n1.close(false);
106
+ await sleep(1000);
107
+ const n2 = makeNode(RED, { id: "z2" });
108
+ const q = query(n2);
109
+ check("Z2a settime'd remaining restores exactly (9000)",
110
+ q && q.remainingTime === 9000, q && q.remainingTime);
111
+ check("Z2b frozen elapsed restores independently (~3000, not derived 1000)",
112
+ q && near(q.elapsedTime, 3000, 300), q && Math.round(q.elapsedTime));
113
+ n2.receive({ payload: "stop" });
114
+ await n2.close(true);
115
+ }
116
+
117
+ // -- Z3: legacy persist file (no new fields) -> old fallback behavior ------
118
+ {
119
+ const n1 = makeNode(RED, { id: "z3" });
120
+ n1.receive({ payload: "go" });
121
+ await sleep(3000);
122
+ n1.receive({ payload: "pause" });
123
+ await n1.close(false);
124
+ // Strip the new fields to simulate a pre-upgrade persist file
125
+ const file = persistFile(userDir, "z3");
126
+ const saved = JSON.parse(fs.readFileSync(file));
127
+ delete saved.remaining;
128
+ delete saved.frozenElapsed;
129
+ fs.writeFileSync(file, JSON.stringify(saved));
130
+ await sleep(2000);
131
+ const n2 = makeNode(RED, { id: "z3" });
132
+ const q = query(n2);
133
+ check("Z3 legacy file falls back to target-minus-now (~5000 after 2s downtime)",
134
+ q && q.timerState === "paused" && near(q.remainingTime, 5000, 400), q && Math.round(q.remainingTime));
135
+ n2.receive({ payload: "stop" });
136
+ await n2.close(true);
137
+ }
138
+
139
+ // -- Z4: regression - RUNNING restore still absorbs downtime ----------------
140
+ {
141
+ const n1 = makeNode(RED, { id: "z4" });
142
+ n1.receive({ payload: "go" });
143
+ await sleep(3000); // ~7000 remaining
144
+ await n1.close(false);
145
+ await sleep(2000); // downtime SHOULD be absorbed for a running timer
146
+ const n2 = makeNode(RED, { id: "z4" });
147
+ await sleep(150);
148
+ const s = lastEvent(n2, "started");
149
+ check("Z4 running restore still deducts downtime (~5000 remaining)",
150
+ s && near(s.remainingTime, 5000, 700), s && Math.round(s.remainingTime));
151
+ n2.receive({ payload: "stop" });
152
+ await n2.close(true);
153
+ }
154
+
155
+ console.log(failures === 0 ? "\nALL OPEN-ITEM-1 TESTS PASSED" : "\n" + failures + " FAILURE(S)");
156
+ process.exit(failures === 0 ? 0 : 1);
157
+ })();
@@ -0,0 +1,190 @@
1
+ // Tests for fix #5 (Option A): stop while truly idle (stopped/expired) is a
2
+ // redundant command - ignored:true on output 4, zero state change, no
3
+ // _timerpass arming. Stop stays genuine for running/paused/cooldown.
4
+ // Redundant disable harmonized to no-increment. Blocked idle starts still
5
+ // count (command-redundancy-only scope).
6
+ "use strict";
7
+ const os = require("os");
8
+ const fs = require("fs");
9
+ const path = require("path");
10
+
11
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
12
+ let failures = 0;
13
+ function check(label, cond, detail) {
14
+ if (cond) { console.log("PASS " + label); }
15
+ else { failures++; console.log("FAIL " + label + (detail !== undefined ? " [" + detail + "]" : "")); }
16
+ }
17
+
18
+ function makeRED(userDir) {
19
+ const registered = {};
20
+ return {
21
+ nodes: {
22
+ registerType(name, ctor) { registered[name] = ctor; },
23
+ createNode(node, n) {
24
+ node.id = n.id;
25
+ node._handlers = {};
26
+ node.on = function(evt, fn) { node._handlers[evt] = fn; };
27
+ node.receive = function(msg) { node._handlers["input"](msg); };
28
+ node.close = function(removed) {
29
+ return new Promise(res => node._handlers["close"](removed, res));
30
+ };
31
+ node.sent = [];
32
+ node.send = function(arr) { node.sent.push(arr); };
33
+ node.status = function() {};
34
+ node.warn = function() {};
35
+ node.error = function(e) { console.log("NODE ERROR:", e); };
36
+ },
37
+ get(name) { return registered[name]; }
38
+ },
39
+ util: {
40
+ cloneMessage(m) { return JSON.parse(JSON.stringify(m || {})); },
41
+ evaluateNodeProperty(v) { return v; }
42
+ },
43
+ settings: { userDir: userDir }
44
+ };
45
+ }
46
+
47
+ function events(node, type) {
48
+ const out = [];
49
+ for (const arr of node.sent) for (const m of arr) if (m && m.timerEvent === type) out.push(m);
50
+ return out;
51
+ }
52
+ function output2(node) { return node.sent.map(a => a[1]).filter(Boolean); }
53
+ function lastEvent(node, type) { const e = events(node, type); return e.length ? e[e.length - 1] : null; }
54
+
55
+ function makeNode(RED, cfg) {
56
+ const Ctor = RED.nodes.get("timer-events");
57
+ const node = {};
58
+ Ctor.call(node, Object.assign({
59
+ id: "x" + Math.random().toString(36).slice(2),
60
+ duration: "5", durationType: "num", units: "Second",
61
+ reporting: "none", reportingformat: "seconds",
62
+ persist: false, ignoretimerpass: false, donotresettimer: false,
63
+ thresholdaction: "donothing", thresholdcount: "0",
64
+ thresholdaddtime: "0", thresholdaddtimeunits: "Second",
65
+ heartbeatinterval: "0", heartbeatintervalunits: "Second",
66
+ cooldownduration: "0", cooldownunits: "Second"
67
+ }, cfg));
68
+ return node;
69
+ }
70
+
71
+ (async function main() {
72
+ const userDir = fs.mkdtempSync(path.join(os.tmpdir(), "timerevents-t5-"));
73
+ const RED = makeRED(userDir);
74
+ require("/home/claude/timer-events.js")(RED);
75
+
76
+ // -- X1: stop on a never-started node -> ignored, output 4 only ------------
77
+ {
78
+ const n = makeNode(RED);
79
+ n.receive({ payload: "stop" });
80
+ const st = lastEvent(n, "stopped");
81
+ check("X1a stop while stopped -> ignored:true on output 4",
82
+ st && st.ignored === true && st.timerState === "stopped", st && st.ignored + "/" + st.timerState);
83
+ check("X1b nothing on output 2 (was: phantom stop)",
84
+ output2(n).length === 0, output2(n).length);
85
+ await n.close(false);
86
+ }
87
+
88
+ // -- X2: stop after natural expiry -> ignored, state stays expired ---------
89
+ {
90
+ const n = makeNode(RED, { duration: "1", donotresettimer: true });
91
+ n.receive({ payload: "go" });
92
+ await sleep(500);
93
+ n.receive({ payload: "poke" }); // ignoredCount 1 during the run
94
+ await sleep(800); // expire (expiry resets count to 0)
95
+ n.sent.length = 0;
96
+ n.receive({ payload: "stop" });
97
+ const st = lastEvent(n, "stopped");
98
+ check("X2a stop after expiry -> ignored:true, state stays 'expired' (was: flip to stopped)",
99
+ st && st.ignored === true && st.timerState === "expired", st && st.timerState);
100
+ check("X2b no phantom stop on output 2", output2(n).length === 0, output2(n).length);
101
+ check("X2c zero counter touch", st && st.ignoredCount === 0 && st.lastIgnoredTime === null,
102
+ st && st.ignoredCount + "/" + st.lastIgnoredTime);
103
+ n.receive({ payload: "stop" }); // second redundant stop, same treatment
104
+ check("X2d repeated stops all ignored", events(n, "stopped").every(e => e.ignored === true) && events(n, "stopped").length === 2, events(n, "stopped").length);
105
+ await n.close(false);
106
+ }
107
+
108
+ // -- X3: post-expiry stop no longer arms the _timerpass filter -------------
109
+ {
110
+ const n = makeNode(RED, { duration: "1" });
111
+ n.receive({ payload: "go" });
112
+ await sleep(1300); // expire
113
+ n.receive({ payload: "stop" }); // ignored - must NOT arm the filter
114
+ n.sent.length = 0;
115
+ n.receive({ payload: "go", _timerpass: true }); // would die if filter were armed
116
+ const s = lastEvent(n, "started");
117
+ check("X3 _timerpass msg starts normally after ignored stop (filter not armed)",
118
+ s && s.ignored === false, s && (s.timerEvent + "/" + s.ignored));
119
+ n.receive({ payload: "stop" });
120
+ await n.close(false);
121
+ }
122
+
123
+ // -- X4: regression - genuine stop still arms _timerpass filter ------------
124
+ {
125
+ const n = makeNode(RED);
126
+ n.receive({ payload: "go" });
127
+ await sleep(300);
128
+ n.receive({ payload: "stop" }); // genuine stop -> filter armed
129
+ check("X4a genuine stop fires on output 2", output2(n).length === 1 && output2(n)[0].timerEvent === "stopped", output2(n).length);
130
+ n.sent.length = 0;
131
+ n.receive({ payload: "go", _timerpass: true }); // must die silently
132
+ check("X4b armed filter still swallows _timerpass msg (no output at all)",
133
+ n.sent.length === 0, n.sent.length);
134
+ n.receive({ payload: "stop", _timerpass: true }); // stop variant also swallowed
135
+ check("X4c armed filter swallows _timerpass stop too (preserved edge)",
136
+ n.sent.length === 0, n.sent.length);
137
+ n.receive({ payload: "go" }); // plain msg disarms and starts
138
+ check("X4d plain msg still starts normally", !!lastEvent(n, "started"));
139
+ n.receive({ payload: "stop" });
140
+ await n.close(false);
141
+ }
142
+
143
+ // -- X5: regression - stop stays genuine for paused and cooldown -----------
144
+ {
145
+ const n = makeNode(RED, { duration: "5" });
146
+ n.receive({ payload: "go" });
147
+ await sleep(300);
148
+ n.receive({ payload: "pause" });
149
+ n.sent.length = 0;
150
+ n.receive({ payload: "stop" });
151
+ check("X5a stop while paused is genuine (output 2)",
152
+ output2(n).length === 1 && lastEvent(n, "stopped").ignored === false, output2(n).length);
153
+ await n.close(false);
154
+
155
+ const c = makeNode(RED, { duration: "1", cooldownduration: "10" });
156
+ c.receive({ payload: "go" });
157
+ await sleep(1300); // expire into cooldown
158
+ c.sent.length = 0;
159
+ c.receive({ payload: "stop" });
160
+ const st = lastEvent(c, "stopped");
161
+ check("X5b stop during cooldown is genuine and cancels it",
162
+ output2(c).length === 1 && st.ignored === false && st.timerState === "stopped", st && st.timerState);
163
+ await c.close(false);
164
+ }
165
+
166
+ // -- X6: disable harmonized; blocked idle starts still count ---------------
167
+ {
168
+ const n = makeNode(RED);
169
+ n.receive({ payload: "disable" }); // genuine
170
+ n.receive({ payload: "disable" }); // redundant - no longer increments
171
+ n.receive({ payload: "disable" }); // redundant
172
+ let d = lastEvent(n, "disabled");
173
+ check("X6a redundant disable no longer increments (was: lone oddball)",
174
+ d && d.ignored === true && d.ignoredCount === 0 && d.lastIgnoredTime === null,
175
+ d && d.ignoredCount);
176
+ n.receive({ payload: "go" }); // blocked idle start - MUST still count
177
+ n.receive({ payload: "go" });
178
+ let s = lastEvent(n, "started");
179
+ check("X6b blocked idle starts still increment (scope: command redundancy only)",
180
+ s && s.ignored === true && s.ignoredCount === 2 && s.lastIgnoredTime !== null, s && s.ignoredCount);
181
+ n.receive({ payload: "stop" }); // stop while idle+disabled: redundant, zero touch
182
+ let st = lastEvent(n, "stopped");
183
+ check("X6c redundant stop while disabled leaves blocked-start count intact",
184
+ st && st.ignored === true && st.ignoredCount === 2, st && st.ignoredCount);
185
+ await n.close(false);
186
+ }
187
+
188
+ console.log(failures === 0 ? "\nALL #5 TESTS PASSED" : "\n" + failures + " FAILURE(S)");
189
+ process.exit(failures === 0 ? 0 : 1);
190
+ })();