node-red-contrib-timer-events 0.1.0 → 0.1.1
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-red-contrib-timer-events",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "An enhanced Node-RED timer node with variable delay, pause/resume, persistent state, cooldown and configurable reporting.",
|
|
5
5
|
"dependencies": {},
|
|
6
6
|
"repository": {
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Tests for the fractional-seconds status-label fix: rounding enforced at
|
|
2
|
+
// the single display boundary (displayTime). Reproduces the reported
|
|
3
|
+
// pause/resume case, sweeps the other fractional-sync paths, and confirms
|
|
4
|
+
// timing precision is untouched (labels round; clocks don't).
|
|
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
|
+
// A label is fraction-free if no decimal point appears in any numeric run
|
|
18
|
+
function hasFraction(text) { return typeof text === "string" && /\d+\.\d+/.test(text); }
|
|
19
|
+
|
|
20
|
+
function makeRED(userDir) {
|
|
21
|
+
const registered = {};
|
|
22
|
+
return {
|
|
23
|
+
nodes: {
|
|
24
|
+
registerType(name, ctor) { registered[name] = ctor; },
|
|
25
|
+
createNode(node, n) {
|
|
26
|
+
node.id = n.id;
|
|
27
|
+
node._handlers = {};
|
|
28
|
+
node.on = function(evt, fn) { node._handlers[evt] = fn; };
|
|
29
|
+
node.receive = function(msg) { node._handlers["input"](msg); };
|
|
30
|
+
node.close = function(removed) {
|
|
31
|
+
return new Promise(res => node._handlers["close"](removed, res));
|
|
32
|
+
};
|
|
33
|
+
node.sent = [];
|
|
34
|
+
node.send = function(arr) { node.sent.push(arr); };
|
|
35
|
+
node.statusCalls = [];
|
|
36
|
+
node.status = function(s) { node.statusCalls.push(s); };
|
|
37
|
+
node.warn = function() {};
|
|
38
|
+
node.error = function(e) { console.log("NODE ERROR:", e); };
|
|
39
|
+
},
|
|
40
|
+
get(name) { return registered[name]; }
|
|
41
|
+
},
|
|
42
|
+
util: {
|
|
43
|
+
cloneMessage(m) { return JSON.parse(JSON.stringify(m || {})); },
|
|
44
|
+
evaluateNodeProperty(v) { return v; }
|
|
45
|
+
},
|
|
46
|
+
settings: { userDir: userDir }
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function events(node, type) {
|
|
51
|
+
const out = [];
|
|
52
|
+
for (const arr of node.sent) for (const m of arr) if (m && m.timerEvent === type) out.push(m);
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
function lastEvent(node, type) { const e = events(node, type); return e.length ? e[e.length - 1] : null; }
|
|
56
|
+
function lastStatusText(node) {
|
|
57
|
+
const s = node.statusCalls[node.statusCalls.length - 1];
|
|
58
|
+
return s && s.text;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function makeNode(RED, cfg) {
|
|
62
|
+
const Ctor = RED.nodes.get("timer-events");
|
|
63
|
+
const node = {};
|
|
64
|
+
Ctor.call(node, Object.assign({
|
|
65
|
+
id: "r" + Math.random().toString(36).slice(2),
|
|
66
|
+
duration: "40", durationType: "num", units: "Second",
|
|
67
|
+
reporting: "every_second", reportingformat: "human",
|
|
68
|
+
persist: false, ignoretimerpass: false, donotresettimer: false,
|
|
69
|
+
thresholdaction: "donothing", thresholdcount: "0",
|
|
70
|
+
thresholdaddtime: "0", thresholdaddtimeunits: "Second",
|
|
71
|
+
heartbeatinterval: "0", heartbeatintervalunits: "Second",
|
|
72
|
+
cooldownduration: "0", cooldownunits: "Second"
|
|
73
|
+
}, cfg));
|
|
74
|
+
return node;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
(async function main() {
|
|
78
|
+
const userDir = fs.mkdtempSync(path.join(os.tmpdir(), "timerevents-frac-"));
|
|
79
|
+
const RED = makeRED(userDir);
|
|
80
|
+
require("/home/claude/timer-events.js")(RED);
|
|
81
|
+
|
|
82
|
+
// -- R1: the reported bug - pause then resume, label must be whole seconds -
|
|
83
|
+
{
|
|
84
|
+
const n = makeNode(RED, { reportingformat: "seconds" }); // 35.221-style leak was most visible here
|
|
85
|
+
n.receive({ payload: "go" });
|
|
86
|
+
await sleep(4780); // engineered so remaining is a messy fraction (~35.22s)
|
|
87
|
+
n.receive({ payload: "pause" });
|
|
88
|
+
check("R1a paused label has no fractional seconds", !hasFraction(lastStatusText(n)), lastStatusText(n));
|
|
89
|
+
n.receive({ payload: "resume" });
|
|
90
|
+
check("R1b resume label has no fractional seconds (the reported case)",
|
|
91
|
+
!hasFraction(lastStatusText(n)), lastStatusText(n));
|
|
92
|
+
await sleep(2300); // let the countdown interval repaint a few times
|
|
93
|
+
const fractional = n.statusCalls.filter(s => hasFraction(s && s.text));
|
|
94
|
+
check("R1c no subsequent countdown tick shows fractions",
|
|
95
|
+
fractional.length === 0, JSON.stringify(fractional.map(s => s.text)));
|
|
96
|
+
// Timing untouched: envelope still carries the exact frozen-derived ms
|
|
97
|
+
n.receive({ payload: "query" });
|
|
98
|
+
const q = lastEvent(n, "query");
|
|
99
|
+
check("R1d msg.remainingTime still exact ms (not rounded)",
|
|
100
|
+
q && q.remainingTime % 1000 !== 0, q && q.remainingTime);
|
|
101
|
+
n.receive({ payload: "stop" });
|
|
102
|
+
await n.close(false);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// -- R2: sweep the other fractional-sync paths ------------------------------
|
|
106
|
+
{
|
|
107
|
+
const n = makeNode(RED, { reportingformat: "human", donotresettimer: true,
|
|
108
|
+
thresholdaction: "addtime", thresholdcount: "2", thresholdaddtime: "5" });
|
|
109
|
+
n.receive({ payload: "go" });
|
|
110
|
+
await sleep(1370);
|
|
111
|
+
n.receive({ payload: "adjusttime", adjusttime: 2500 }); // fractional base + fractional add
|
|
112
|
+
check("R2a adjusttime label whole seconds", !hasFraction(lastStatusText(n)), lastStatusText(n));
|
|
113
|
+
n.receive({ payload: "settime", settime: 1500 }); // the flagged settime edge: now rounds
|
|
114
|
+
check("R2b settime 1500 label rounds (edge resolved per display principle)",
|
|
115
|
+
!hasFraction(lastStatusText(n)), lastStatusText(n));
|
|
116
|
+
n.receive({ payload: "poke1" });
|
|
117
|
+
n.receive({ payload: "poke2" }); // threshold Add Time fires on fractional base
|
|
118
|
+
check("R2c threshold Add Time label whole seconds", !hasFraction(lastStatusText(n)), lastStatusText(n));
|
|
119
|
+
n.receive({ payload: "stop" });
|
|
120
|
+
await n.close(false);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// -- R3: threshold Pause label whole seconds --------------------------------
|
|
124
|
+
{
|
|
125
|
+
const n = makeNode(RED, { donotresettimer: true, thresholdaction: "pause", thresholdcount: "2" });
|
|
126
|
+
n.receive({ payload: "go" });
|
|
127
|
+
await sleep(1430);
|
|
128
|
+
n.receive({ payload: "p1" });
|
|
129
|
+
n.receive({ payload: "p2" }); // threshold pause fires at fractional remaining
|
|
130
|
+
check("R3 threshold Pause label whole seconds", !hasFraction(lastStatusText(n)), lastStatusText(n));
|
|
131
|
+
n.receive({ payload: "stop" });
|
|
132
|
+
await n.close(false);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// -- R4: timing precision unchanged - resume runs the exact frozen time -----
|
|
136
|
+
{
|
|
137
|
+
const n = makeNode(RED, { duration: "8", reporting: "none" });
|
|
138
|
+
n.receive({ payload: "go" });
|
|
139
|
+
await sleep(2340); // frozen remaining will be ~5660ms, deliberately fractional
|
|
140
|
+
n.receive({ payload: "pause" });
|
|
141
|
+
const frozen = (n.receive({ payload: "query" }), lastEvent(n, "query")).remainingTime;
|
|
142
|
+
n.receive({ payload: "resume" });
|
|
143
|
+
const t0 = Date.now();
|
|
144
|
+
await new Promise(res => { const iv = setInterval(() => { if (lastEvent(n, "expired")) { clearInterval(iv); res(); } }, 25); });
|
|
145
|
+
const ranFor = Date.now() - t0;
|
|
146
|
+
check("R4 resume-to-expiry matches the exact frozen ms (labels round; clocks don't)",
|
|
147
|
+
near(ranFor, frozen, 150), ranFor + " vs frozen " + Math.round(frozen));
|
|
148
|
+
await n.close(false);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
console.log(failures === 0 ? "\nALL FRACTIONAL-LABEL TESTS PASSED" : "\n" + failures + " FAILURE(S)");
|
|
152
|
+
process.exit(failures === 0 ? 0 : 1);
|
|
153
|
+
})();
|
|
@@ -45,7 +45,19 @@
|
|
|
45
45
|
outputs:4,
|
|
46
46
|
icon: "stoptimer.png",
|
|
47
47
|
label: function() {
|
|
48
|
-
|
|
48
|
+
// A custom name replaces the generated label entirely
|
|
49
|
+
// (standard Node-RED convention). Otherwise: duration line,
|
|
50
|
+
// plus a cooldown line when a valid cooldown is configured -
|
|
51
|
+
// Node-RED 1.0+ renders \n in labels as separate lines.
|
|
52
|
+
if (this.name) {
|
|
53
|
+
return this.name;
|
|
54
|
+
}
|
|
55
|
+
let label = this.duration + " " + this.units + " Timer";
|
|
56
|
+
let cd = Number(this.cooldownduration);
|
|
57
|
+
if (!isNaN(cd) && cd > 0) {
|
|
58
|
+
label += "\n" + this.cooldownduration + " " + this.cooldownunits + " Cooldown";
|
|
59
|
+
}
|
|
60
|
+
return label;
|
|
49
61
|
},
|
|
50
62
|
labelStyle: function() {
|
|
51
63
|
return this.name?"node_label_italic":"";
|
|
@@ -479,13 +479,13 @@ module.exports = function(RED) {
|
|
|
479
479
|
}
|
|
480
480
|
|
|
481
481
|
/**
|
|
482
|
-
*
|
|
483
|
-
*
|
|
484
|
-
*
|
|
485
|
-
*
|
|
482
|
+
* Convenience wrapper: format the current authoritative remaining time
|
|
483
|
+
* for the status label. Rounding to whole seconds happens inside
|
|
484
|
+
* displayTime() itself - the single display boundary - so the raw ms
|
|
485
|
+
* value passes through untouched here.
|
|
486
486
|
*/
|
|
487
487
|
function displayRemaining(fmt) {
|
|
488
|
-
return displayTime(
|
|
488
|
+
return displayTime(getRemainingTime(), fmt);
|
|
489
489
|
}
|
|
490
490
|
|
|
491
491
|
function convertToMilliseconds(value, units) {
|
|
@@ -1450,7 +1450,13 @@ module.exports = function(RED) {
|
|
|
1450
1450
|
// -------------------------------------------------------------------------
|
|
1451
1451
|
|
|
1452
1452
|
function displayTime(delayToDisplay, reportingformat) {
|
|
1453
|
-
|
|
1453
|
+
// THE display boundary: every status label flows through here, and
|
|
1454
|
+
// nothing else does (message envelopes carry raw ms; timing never
|
|
1455
|
+
// reads formatted values). Rounding to whole seconds at this single
|
|
1456
|
+
// choke point guarantees no label ever shows fractional seconds -
|
|
1457
|
+
// e.g. the exact frozen value restored on resume (35221ms) displays
|
|
1458
|
+
// as 35, not 35.221.
|
|
1459
|
+
delayToDisplay = Math.round(delayToDisplay / 1000);
|
|
1454
1460
|
switch (reportingformat) {
|
|
1455
1461
|
case REPORTING_FORMAT.SECONDS: return delayToDisplay;
|
|
1456
1462
|
case REPORTING_FORMAT.MINUTES: return delayToDisplay / 60;
|
|
@@ -1525,4 +1531,4 @@ module.exports = function(RED) {
|
|
|
1525
1531
|
}
|
|
1526
1532
|
|
|
1527
1533
|
RED.nodes.registerType("timer-events", TimerEvents);
|
|
1528
|
-
}
|
|
1534
|
+
}
|