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.0",
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
- return this.name || this.duration + " " + this.units + " Timer";
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
- * Display-boundary rounding only: the raw ms value from
483
- * getRemainingTime() goes on outgoing messages untouched; the status
484
- * label gets the nearest whole second so displayTime()'s HH:MM:SS
485
- * formatting never renders fractional seconds.
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(Math.round(getRemainingTime() / 1000) * 1000, fmt);
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
- delayToDisplay = delayToDisplay / 1000;
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
+ }