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.
- package/.github/workflows/npm-publish.yml +18 -0
- package/LICENSE +201 -0
- package/README.md +69 -0
- package/package.json +29 -0
- package/test-scripts/test-elapsed.js +219 -0
- package/test-scripts/test-harness.js +203 -0
- package/test-scripts/test-paused-restore.js +157 -0
- package/test-scripts/test-redundant-stop.js +190 -0
- package/test-scripts/test-restore.js +147 -0
- package/test-scripts/test-status-flicker.js +143 -0
- package/test-scripts/test-threshold-scope.js +148 -0
- package/test-scripts/test-validation.js +198 -0
- package/timer-events/cycle.js +182 -0
- package/timer-events/timer-events.html +444 -0
- package/timer-events/timer-events.js +1528 -0
|
@@ -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
|
+
})();
|