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,147 @@
1
+ // Tests for fix #3: running restore is a continuation of the same run.
2
+ // Simulates a Node-RED restart by constructing a node, running it, closing
3
+ // it (persist file survives), sleeping to simulate downtime, then
4
+ // reconstructing a node with the same id.
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 output1(node) { return node.sent.map(a => a[0]).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: "fixed-id", duration: "10", durationType: "num", units: "Second",
60
+ reporting: "none", reportingformat: "seconds",
61
+ persist: true, ignoretimerpass: false, donotresettimer: true,
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
+
70
+ (async function main() {
71
+ const userDir = fs.mkdtempSync(path.join(os.tmpdir(), "timerevents-t3-"));
72
+ const RED = makeRED(userDir);
73
+ require("/home/claude/timer-events.js")(RED);
74
+
75
+ // ---- Phase 1: run, accumulate ignored count, persist, "shut down" --------
76
+ const n1 = makeNode(RED, { reporting: "every_second" }); // persisted reporting differs from phase-2 config
77
+ n1.receive({ payload: "go", runTag: "original-run" });
78
+ await sleep(1500);
79
+ n1.receive({ payload: "poke1" }); // ignored (locked) -> ignoredCount 1
80
+ n1.receive({ payload: "poke2" }); // ignored -> ignoredCount 2
81
+ n1.receive({ payload: "adjusttime", adjusttime: 0 }); // triggers writeState with current bookkeeping
82
+ await sleep(1500); // ~3s elapsed total
83
+ const preQ = (n1.receive({ payload: "query" }), lastEvent(n1, "query"));
84
+ await n1.close(false); // persist file survives (not removed)
85
+
86
+ // ---- Phase 2: 2s of simulated Node-RED downtime, then restore ------------
87
+ await sleep(2000);
88
+ const restoreAt = Date.now();
89
+ const n2 = makeNode(RED, { reporting: "none" }); // freshly-deployed config differs from persisted
90
+ await sleep(200); // let restore-driven dispatch settle
91
+
92
+ const started = lastEvent(n2, "started");
93
+ check("V1a restore fires started, output 1, source internal",
94
+ output1(n2).length === 1 && started && started.source === "internal" && started.ignored === false,
95
+ output1(n2).length + "/" + (started && started.source));
96
+ check("V1b original timerDuration preserved: 10000 (was: remaining time)",
97
+ started && started.timerDuration === 10000, started && started.timerDuration);
98
+ check("V1c ignoredCount survives restore: 2 (was: reset to 0)",
99
+ started && started.ignoredCount === 2 && started.lastIgnoredTime !== null,
100
+ started && started.ignoredCount + "/" + started.lastIgnoredTime);
101
+ // ~3s original elapsed + ~2s downtime = ~5s elapsed; remaining ~5s
102
+ check("V1d elapsedTime reflects run + downtime (~5000ms)",
103
+ started && near(started.elapsedTime, 5000, 700), started && started.elapsedTime);
104
+ check("V1e elapsed + remaining reconciles with duration",
105
+ started && near(started.elapsedTime + started.remainingTime, started.timerDuration, 700),
106
+ started && Math.round(started.elapsedTime + started.remainingTime));
107
+ check("V1f remaining carried over minus downtime (~5000ms, was ~7000 pre-shutdown)",
108
+ started && near(started.remainingTime, 5000, 700) && near(preQ.remainingTime, 7000, 700),
109
+ started && Math.round(started.remainingTime) + " (pre: " + Math.round(preQ.remainingTime) + ")");
110
+
111
+ // ---- V2: fresh config wins over persisted reporting settings -------------
112
+ check("V2 freshly-deployed reporting config wins (was: persisted 'every_second')",
113
+ n2.reporting === "none", n2.reporting);
114
+
115
+ // ---- V3: restored run expires at the original wall-clock target ----------
116
+ await new Promise(res => {
117
+ const iv = setInterval(() => { if (lastEvent(n2, "expired")) { clearInterval(iv); res(); } }, 100);
118
+ });
119
+ const ranFor = Date.now() - restoreAt;
120
+ check("V3 restored run expires after ~remaining (~5s), not full duration",
121
+ near(ranFor, 5000, 800), ranFor);
122
+ const exp = lastEvent(n2, "expired");
123
+ check("V3b expired event still reports the original duration",
124
+ exp && exp.timerDuration === 10000, exp && exp.timerDuration);
125
+ await n2.close(true);
126
+
127
+ // ---- V4: regression - a normal start still resets run identity -----------
128
+ {
129
+ const n = makeNode(RED, { id: "fresh-id", persist: false });
130
+ n.receive({ payload: "go" });
131
+ await sleep(500);
132
+ n.receive({ payload: "poke" }); // ignored -> count 1
133
+ n.receive({ payload: "stop" });
134
+ n.receive({ payload: "unlock" }); // allow normal restart behavior
135
+ n.sent.length = 0;
136
+ n.receive({ payload: "go2" });
137
+ const s = lastEvent(n, "started");
138
+ check("V4 normal start resets ignoredCount and sets fresh duration",
139
+ s && s.ignoredCount === 0 && s.lastIgnoredTime === null && s.timerDuration === 10000 && s.elapsedTime <= 50,
140
+ s && s.ignoredCount + "/" + s.timerDuration + "/" + s.elapsedTime);
141
+ n.receive({ payload: "stop" });
142
+ await n.close(true);
143
+ }
144
+
145
+ console.log(failures === 0 ? "\nALL #3 TESTS PASSED" : "\n" + failures + " FAILURE(S)");
146
+ process.exit(failures === 0 ? 0 : 1);
147
+ })();
@@ -0,0 +1,143 @@
1
+ // Tests for the status-flicker fix: the unconditional node.status({}) at
2
+ // the top of handleInputEvent is gone. Verifies (a) a query against a
3
+ // running timer never emits an empty status call, and (b) every command
4
+ // path still ends with a non-empty status of its own (the invariant that
5
+ // replaced the blanket blank).
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
+ function isBlank(s) { return !s || Object.keys(s).length === 0 || (!s.text && !s.fill && !s.shape); }
18
+
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.statusCalls = []; // record every status call
35
+ node.status = function(s) { node.statusCalls.push(s); };
36
+ node.warn = function() {};
37
+ node.error = function(e) { console.log("NODE ERROR:", e); };
38
+ },
39
+ get(name) { return registered[name]; }
40
+ },
41
+ util: {
42
+ cloneMessage(m) { return JSON.parse(JSON.stringify(m || {})); },
43
+ evaluateNodeProperty(v) { return v; }
44
+ },
45
+ settings: { userDir: userDir }
46
+ };
47
+ }
48
+
49
+ function makeNode(RED, cfg) {
50
+ const Ctor = RED.nodes.get("timer-events");
51
+ const node = {};
52
+ Ctor.call(node, Object.assign({
53
+ id: "s" + Math.random().toString(36).slice(2),
54
+ duration: "10", durationType: "num", units: "Second",
55
+ reporting: "none", reportingformat: "seconds",
56
+ persist: false, ignoretimerpass: false, donotresettimer: false,
57
+ thresholdaction: "donothing", thresholdcount: "0",
58
+ thresholdaddtime: "0", thresholdaddtimeunits: "Second",
59
+ heartbeatinterval: "0", heartbeatintervalunits: "Second",
60
+ cooldownduration: "0", cooldownunits: "Second"
61
+ }, cfg));
62
+ return node;
63
+ }
64
+
65
+ (async function main() {
66
+ const userDir = fs.mkdtempSync(path.join(os.tmpdir(), "timerevents-flicker-"));
67
+ const RED = makeRED(userDir);
68
+ require("/home/claude/timer-events.js")(RED);
69
+
70
+ // -- F1: the flicker itself - query against a running timer ----------------
71
+ {
72
+ const n = makeNode(RED);
73
+ n.receive({ payload: "go" });
74
+ await sleep(300);
75
+ n.statusCalls.length = 0;
76
+ n.receive({ payload: "query" });
77
+ check("F1a query emits no blank status call (was: blank-then-repaint flicker)",
78
+ n.statusCalls.every(s => !isBlank(s)), JSON.stringify(n.statusCalls));
79
+ check("F1b query still repaints a non-empty status",
80
+ n.statusCalls.length >= 1 && !isBlank(n.statusCalls[n.statusCalls.length - 1]),
81
+ JSON.stringify(n.statusCalls[n.statusCalls.length - 1]));
82
+ n.receive({ payload: "stop" });
83
+ await n.close(false);
84
+ }
85
+
86
+ // -- F2: sweep - every command path ends with a non-empty status -----------
87
+ // Sequence chosen to hit genuine AND redundant/ignored branches of each
88
+ // handler, plus the gates, across running / paused / idle states.
89
+ {
90
+ const n = makeNode(RED, { donotresettimer: false });
91
+ const steps = [
92
+ { payload: "go" }, // start (startReporting paints)
93
+ { payload: "go" }, // restart
94
+ { payload: "lock" }, // genuine lock
95
+ { payload: "lock" }, // redundant lock
96
+ { payload: "poke" }, // lock gate (blocked restart)
97
+ { payload: "unlock" }, // genuine unlock
98
+ { payload: "unlock" }, // redundant unlock
99
+ { payload: "adjusttime", adjusttime: 1000 }, // genuine adjust
100
+ { payload: "adjusttime", adjusttime: "bad" }, // rejected adjust
101
+ { payload: "settime", settime: 5000 }, // genuine settime
102
+ { payload: "settime", settime: -1 }, // rejected settime
103
+ { payload: "setduration", setduration: 8000 }, // genuine setduration
104
+ { payload: "setduration", setduration: "bad" }, // rejected setduration
105
+ { payload: "pause" }, // genuine pause
106
+ { payload: "pause" }, // redundant pause
107
+ { payload: "poke" }, // paused gate
108
+ { payload: "resume" }, // genuine resume
109
+ { payload: "resume" }, // redundant resume
110
+ { payload: "disable" }, // genuine disable
111
+ { payload: "disable" }, // redundant disable
112
+ { payload: "stop" }, // genuine stop
113
+ { payload: "stop" }, // redundant stop (idle)
114
+ { payload: "go" }, // blocked start (disabled gate)
115
+ { payload: "enable" }, // genuine enable
116
+ { payload: "enable" }, // redundant enable
117
+ { payload: "query" } // query while idle
118
+ ];
119
+ let ok = true, failedAt = null;
120
+ for (const m of steps) {
121
+ n.statusCalls.length = 0;
122
+ n.receive(m);
123
+ const last = n.statusCalls[n.statusCalls.length - 1];
124
+ if (n.statusCalls.length === 0 || isBlank(last)) { ok = false; failedAt = JSON.stringify(m); break; }
125
+ }
126
+ check("F2 all 26 command paths end with a non-empty status (invariant holds)", ok, failedAt);
127
+ await n.close(false);
128
+ }
129
+
130
+ // -- F3: close handler still blanks (unrelated blank preserved) ------------
131
+ {
132
+ const n = makeNode(RED);
133
+ n.receive({ payload: "go" });
134
+ n.receive({ payload: "stop" });
135
+ n.statusCalls.length = 0;
136
+ await n.close(false);
137
+ check("F3 node close still blanks the status (deliberate blank preserved)",
138
+ n.statusCalls.length === 1 && isBlank(n.statusCalls[0]), JSON.stringify(n.statusCalls));
139
+ }
140
+
141
+ console.log(failures === 0 ? "\nALL FLICKER TESTS PASSED" : "\n" + failures + " FAILURE(S)");
142
+ process.exit(failures === 0 ? 0 : 1);
143
+ })();
@@ -0,0 +1,148 @@
1
+ // Tests for fix #2: threshold actions scoped to an active run (running/paused).
2
+ // U1-U3 verify the fixed idle behavior; U4-U5 verify no regression to the
3
+ // legitimate paused-gate and lock-gate threshold paths.
4
+ "use strict";
5
+ const os = require("os");
6
+ const fs = require("fs");
7
+ const path = require("path");
8
+
9
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
10
+ let failures = 0;
11
+ function check(label, cond, detail) {
12
+ if (cond) { console.log("PASS " + label); }
13
+ else { failures++; console.log("FAIL " + label + (detail !== undefined ? " [" + detail + "]" : "")); }
14
+ }
15
+
16
+ function makeRED(userDir) {
17
+ const registered = {};
18
+ return {
19
+ nodes: {
20
+ registerType(name, ctor) { registered[name] = ctor; },
21
+ createNode(node, n) {
22
+ node.id = n.id;
23
+ node._handlers = {};
24
+ node.on = function(evt, fn) { node._handlers[evt] = fn; };
25
+ node.receive = function(msg) { node._handlers["input"](msg); };
26
+ node.close = function(removed) {
27
+ return new Promise(res => node._handlers["close"](removed, res));
28
+ };
29
+ node.sent = [];
30
+ node.send = function(arr) { node.sent.push(arr); };
31
+ node.status = function() {};
32
+ node.warn = function() {};
33
+ node.error = function(e) { console.log("NODE ERROR:", e); };
34
+ },
35
+ get(name) { return registered[name]; }
36
+ },
37
+ util: {
38
+ cloneMessage(m) { return JSON.parse(JSON.stringify(m || {})); },
39
+ evaluateNodeProperty(v) { return v; }
40
+ },
41
+ settings: { userDir: userDir }
42
+ };
43
+ }
44
+
45
+ function events(node, type) {
46
+ const out = [];
47
+ for (const arr of node.sent) {
48
+ for (const m of arr) if (m && m.timerEvent === type) out.push(m);
49
+ }
50
+ return out;
51
+ }
52
+ function output2(node) { // messages that appeared on output 2 specifically
53
+ return node.sent.map(a => a[1]).filter(Boolean);
54
+ }
55
+ function lastEvent(node, type) { const e = events(node, type); return e.length ? e[e.length - 1] : null; }
56
+
57
+ function makeNode(RED, cfg) {
58
+ const Ctor = RED.nodes.get("timer-events");
59
+ const node = {};
60
+ Ctor.call(node, Object.assign({
61
+ id: "u" + Math.random().toString(36).slice(2),
62
+ duration: "5", durationType: "num", units: "Second",
63
+ reporting: "none", reportingformat: "seconds",
64
+ persist: false, ignoretimerpass: false, donotresettimer: false,
65
+ thresholdaction: "donothing", thresholdcount: "0",
66
+ thresholdaddtime: "0", thresholdaddtimeunits: "Second",
67
+ heartbeatinterval: "0", heartbeatintervalunits: "Second",
68
+ cooldownduration: "0", cooldownunits: "Second"
69
+ }, cfg));
70
+ return node;
71
+ }
72
+
73
+ (async function main() {
74
+ const userDir = fs.mkdtempSync(path.join(os.tmpdir(), "timerevents-t2-"));
75
+ const RED = makeRED(userDir);
76
+ require("/home/claude/timer-events.js")(RED);
77
+
78
+ // -- U1: idle + disabled + Add Time threshold must NOT start the timer -----
79
+ {
80
+ const n = makeNode(RED, { thresholdaction: "addtime", thresholdcount: "2", thresholdaddtime: "10", donotresettimer: true });
81
+ n.receive({ payload: "disable" });
82
+ for (let i = 0; i < 4; i++) n.receive({ payload: "go" }); // 4 blocked starts, threshold=2 hit twice
83
+ await sleep(300);
84
+ const q = (n.receive({ payload: "query" }), lastEvent(n, "query"));
85
+ check("U1a timer stays idle (was: Add Time started it)", q.timerState === "stopped", q.timerState);
86
+ check("U1b no timeadjusted fired from idle", events(n, "timeadjusted").length === 0, events(n, "timeadjusted").length);
87
+ check("U1c blocked starts still counted", q.ignoredCount === 4, q.ignoredCount);
88
+ const blocked = events(n, "started").filter(e => e.ignored === true);
89
+ check("U1d each block still observable on output 4", blocked.length === 4, blocked.length);
90
+ await n.close(false);
91
+ }
92
+
93
+ // -- U2: idle + disabled + Stop threshold -> no phantom stop on output 2 ---
94
+ {
95
+ const n = makeNode(RED, { thresholdaction: "stop", thresholdcount: "2" });
96
+ n.receive({ payload: "disable" });
97
+ for (let i = 0; i < 3; i++) n.receive({ payload: "go" });
98
+ check("U2 no phantom stopped on output 2", output2(n).length === 0, output2(n).length);
99
+ await n.close(false);
100
+ }
101
+
102
+ // -- U3: cooldown + Restart threshold -> still never fires (unchanged) -----
103
+ {
104
+ const n = makeNode(RED, { duration: "1", cooldownduration: "5", thresholdaction: "reset", thresholdcount: "1" });
105
+ n.receive({ payload: "go" });
106
+ await sleep(1300); // expire into cooldown
107
+ n.sent.length = 0;
108
+ for (let i = 0; i < 3; i++) n.receive({ payload: "go" }); // blocked by cooldown
109
+ check("U3a no restart fired during cooldown", events(n, "restarted").length === 0, events(n, "restarted").length);
110
+ const q = (n.receive({ payload: "query" }), lastEvent(n, "query"));
111
+ check("U3b still in cooldown", q.timerState === "cooldown", q.timerState);
112
+ n.receive({ payload: "stop" });
113
+ await n.close(false);
114
+ }
115
+
116
+ // -- U4: regression - lock gate + Add Time threshold still fires while running
117
+ {
118
+ const n = makeNode(RED, { duration: "10", donotresettimer: true, thresholdaction: "addtime", thresholdcount: "2", thresholdaddtime: "5" });
119
+ n.receive({ payload: "go" });
120
+ await sleep(500);
121
+ n.receive({ payload: "poke1" });
122
+ n.receive({ payload: "poke2" }); // hits threshold
123
+ const a = lastEvent(n, "timeadjusted");
124
+ check("U4a Add Time still fires for a running locked timer", !!a && a.source === "internal" && a.timeAdjusted === 5000, a && a.timeAdjusted);
125
+ check("U4b remaining reflects the added time (~14500ms)", a && Math.abs(a.remainingTime - 14500) <= 400, a && a.remainingTime);
126
+ n.receive({ payload: "stop" });
127
+ await n.close(false);
128
+ }
129
+
130
+ // -- U5: regression - paused gate + Restart threshold fires, stays paused --
131
+ {
132
+ const n = makeNode(RED, { duration: "10", thresholdaction: "reset", thresholdcount: "2" });
133
+ n.receive({ payload: "go" });
134
+ await sleep(2000);
135
+ n.receive({ payload: "pause" });
136
+ n.receive({ payload: "poke1" });
137
+ n.receive({ payload: "poke2" }); // hits threshold while paused
138
+ const r = events(n, "restarted").filter(e => e.ignored === false && e.source === "internal");
139
+ check("U5a Restart threshold still fires from the paused gate", r.length === 1, r.length);
140
+ const q = (n.receive({ payload: "query" }), lastEvent(n, "query"));
141
+ check("U5b stays paused at full duration", q.timerState === "paused" && q.remainingTime === 10000, q.timerState + "/" + q.remainingTime);
142
+ n.receive({ payload: "stop" });
143
+ await n.close(false);
144
+ }
145
+
146
+ console.log(failures === 0 ? "\nALL #2 TESTS PASSED" : "\n" + failures + " FAILURE(S)");
147
+ process.exit(failures === 0 ? 0 : 1);
148
+ })();
@@ -0,0 +1,198 @@
1
+ // Tests for fix #4: numeric validation of msg.delay, adjusttime, settime,
2
+ // setduration. Malformed values must be rejected cleanly (ignored:true or
3
+ // documented fallback) with zero state corruption; all previously-valid
4
+ // inputs must behave identically, including adjusttime: 0 (accepted per
5
+ // explicit decision) and fractional delays.
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
+ function near(a, e, tol) { return Math.abs(a - e) <= tol; }
18
+
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 events(node, type) {
49
+ const out = [];
50
+ for (const arr of node.sent) for (const m of arr) if (m && m.timerEvent === type) out.push(m);
51
+ return out;
52
+ }
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: "w" + Math.random().toString(36).slice(2),
60
+ duration: "10", 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-t4-"));
73
+ const RED = makeRED(userDir);
74
+ require("/home/claude/timer-events.js")(RED);
75
+
76
+ // -- W1: msg.delay unconvertible ("5s") -> documented fallback -------------
77
+ {
78
+ const n = makeNode(RED);
79
+ n.receive({ payload: "go", delay: "5s" });
80
+ const s = lastEvent(n, "started");
81
+ check("W1 delay '5s' falls back to configured 10000ms (was: NaN corruption)",
82
+ s && near(s.remainingTime, 10000, 50) && s.timerDuration === 10000, s && s.remainingTime);
83
+ n.receive({ payload: "stop" });
84
+ await n.close(false);
85
+ }
86
+
87
+ // -- W2: msg.delay negative -> documented clamp to 0 ------------------------
88
+ {
89
+ const n = makeNode(RED);
90
+ n.receive({ payload: "go", delay: -5 });
91
+ await sleep(300);
92
+ const e = lastEvent(n, "expired");
93
+ check("W2 negative delay clamps to 0 and expires immediately (was: negative remaining)",
94
+ !!e && e.timerDuration === 0, e && e.timerDuration);
95
+ await n.close(false);
96
+ }
97
+
98
+ // -- W3: fractional delay still works (regression) -------------------------
99
+ {
100
+ const n = makeNode(RED);
101
+ n.receive({ payload: "go", delay: 2.5, units: "seconds" });
102
+ const s = lastEvent(n, "started");
103
+ check("W3 fractional delay 2.5s -> 2500ms preserved", s && s.remainingTime === 2500, s && s.remainingTime);
104
+ n.receive({ payload: "stop" });
105
+ await n.close(false);
106
+ }
107
+
108
+ // -- W4: msg.delay empty string -> fallback, not zero -----------------------
109
+ {
110
+ const n = makeNode(RED);
111
+ n.receive({ payload: "go", delay: "" });
112
+ const s = lastEvent(n, "started");
113
+ check("W4 empty-string delay falls back to configured (Number('') is 0 trap)",
114
+ s && s.remainingTime === 10000, s && s.remainingTime);
115
+ n.receive({ payload: "stop" });
116
+ await n.close(false);
117
+ }
118
+
119
+ // -- W5: adjusttime missing / non-numeric -> ignored, timer unharmed --------
120
+ {
121
+ const n = makeNode(RED);
122
+ n.receive({ payload: "go" });
123
+ await sleep(1000);
124
+ n.receive({ payload: "adjusttime" }); // property missing entirely
125
+ let a1 = lastEvent(n, "timeadjusted");
126
+ check("W5a missing adjusttime -> ignored:true, null attempted value",
127
+ a1 && a1.ignored === true && a1.timeAdjusted === null, a1 && (a1.ignored + "/" + a1.timeAdjusted));
128
+ n.receive({ payload: "adjusttime", adjusttime: "abc" }); // non-numeric
129
+ let a2 = lastEvent(n, "timeadjusted");
130
+ check("W5b non-numeric adjusttime -> ignored:true, raw value attached",
131
+ a2 && a2.ignored === true && a2.timeAdjusted === "abc", a2 && a2.timeAdjusted);
132
+ n.receive({ payload: "query" });
133
+ const q = lastEvent(n, "query");
134
+ check("W5c remaining time unharmed (~9000ms, was: NaN)",
135
+ q && near(q.remainingTime, 9000, 300), q && q.remainingTime);
136
+ n.receive({ payload: "stop" });
137
+ await n.close(false);
138
+ }
139
+
140
+ // -- W6: settime NaN passes-as-valid hole closed; valid settime unchanged ---
141
+ {
142
+ const n = makeNode(RED);
143
+ n.receive({ payload: "go" });
144
+ await sleep(500);
145
+ n.receive({ payload: "settime", settime: "abc" });
146
+ let t1 = lastEvent(n, "timeset");
147
+ check("W6a non-numeric settime -> ignored:true (NaN<=0 hole closed)",
148
+ t1 && t1.ignored === true && t1.timeSet === "abc", t1 && t1.timeSet);
149
+ n.receive({ payload: "settime", settime: 4000 }); // regression: valid still works
150
+ let t2 = lastEvent(n, "timeset");
151
+ check("W6b valid settime 4000 still applies",
152
+ t2 && t2.ignored === false && near(t2.remainingTime, 4000, 50), t2 && t2.remainingTime);
153
+ n.receive({ payload: "settime", settime: -1 }); // regression: <=0 still rejected
154
+ let t3 = lastEvent(n, "timeset");
155
+ check("W6c settime <= 0 still rejected, attempted value attached (ms default)",
156
+ t3 && t3.ignored === true && t3.timeSet === -1, t3 && t3.timeSet);
157
+ n.receive({ payload: "stop" });
158
+ await n.close(false);
159
+ }
160
+
161
+ // -- W7: setduration NaN can no longer poison the next run ------------------
162
+ {
163
+ const n = makeNode(RED);
164
+ n.receive({ payload: "setduration", setduration: "abc" });
165
+ let d1 = lastEvent(n, "durationset");
166
+ check("W7a non-numeric setduration -> ignored:true",
167
+ d1 && d1.ignored === true && d1.durationSet === "abc", d1 && d1.durationSet);
168
+ n.receive({ payload: "go" });
169
+ let s1 = lastEvent(n, "started");
170
+ check("W7b next run unpoisoned: starts at configured 10000ms (was: NaN)",
171
+ s1 && near(s1.remainingTime, 10000, 50), s1 && s1.remainingTime);
172
+ n.receive({ payload: "stop" });
173
+ n.receive({ payload: "setduration", setduration: 3, setdurationunits: "seconds" }); // regression
174
+ n.receive({ payload: "go2" });
175
+ let s2 = lastEvent(n, "started");
176
+ check("W7c valid setduration still applies to next run (3000ms)",
177
+ s2 && near(s2.remainingTime, 3000, 50), s2 && s2.remainingTime);
178
+ n.receive({ payload: "stop" });
179
+ await n.close(false);
180
+ }
181
+
182
+ // -- W8: adjusttime 0 is processed, NOT flagged ignored (explicit decision) -
183
+ {
184
+ const n = makeNode(RED);
185
+ n.receive({ payload: "go" });
186
+ await sleep(500);
187
+ n.receive({ payload: "adjusttime", adjusttime: 0 });
188
+ const a = lastEvent(n, "timeadjusted");
189
+ check("W8 adjusttime 0 accepted as successful no-op",
190
+ a && a.ignored === false && a.timeAdjusted === 0 && near(a.remainingTime, 9500, 300),
191
+ a && (a.ignored + "/" + a.timeAdjusted + "/" + Math.round(a.remainingTime)));
192
+ n.receive({ payload: "stop" });
193
+ await n.close(false);
194
+ }
195
+
196
+ console.log(failures === 0 ? "\nALL #4 TESTS PASSED" : "\n" + failures + " FAILURE(S)");
197
+ process.exit(failures === 0 ? 0 : 1);
198
+ })();