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,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
|
+
})();
|