'use strict'; const test = require('node:test'); const assert = require('node:assert/strict'); const { StatusUpdater } = require('../../src/nodered/statusUpdater'); function makeNode() { const calls = []; return { calls, status(badge) { calls.push(badge); }, }; } function makeSource(initial) { return { badge: initial, throwOnNext: false, getStatusBadge() { if (this.throwOnNext) { this.throwOnNext = false; throw new Error('boom'); } return this.badge; }, }; } function makeLogger() { const errors = []; return { errors, error(msg) { errors.push(msg); }, }; } test('start() schedules a tick that applies the source badge', (t) => { t.mock.timers.enable({ apis: ['setInterval'] }); const node = makeNode(); const source = makeSource({ fill: 'green', shape: 'dot', text: 'OK' }); const u = new StatusUpdater({ node, source, intervalMs: 1000 }); u.start(); assert.equal(node.calls.length, 0); t.mock.timers.tick(1000); assert.equal(node.calls.length, 1); assert.deepEqual(node.calls[0], { fill: 'green', shape: 'dot', text: 'OK' }); u.stop(); }); test('multiple ticks reflect the latest badge from the source', (t) => { t.mock.timers.enable({ apis: ['setInterval'] }); const node = makeNode(); const source = makeSource({ fill: 'green', shape: 'dot', text: 'A' }); const u = new StatusUpdater({ node, source, intervalMs: 500 }); u.start(); t.mock.timers.tick(500); source.badge = { fill: 'yellow', shape: 'dot', text: 'B' }; t.mock.timers.tick(500); source.badge = { fill: 'red', shape: 'ring', text: 'C' }; t.mock.timers.tick(500); assert.equal(node.calls.length, 3); assert.equal(node.calls[0].text, 'A'); assert.equal(node.calls[1].text, 'B'); assert.equal(node.calls[2].text, 'C'); u.stop(); }); test('source returns null → node.status({}) is called', (t) => { t.mock.timers.enable({ apis: ['setInterval'] }); const node = makeNode(); const source = makeSource(null); const u = new StatusUpdater({ node, source, intervalMs: 100 }); u.start(); t.mock.timers.tick(100); assert.equal(node.calls.length, 1); assert.deepEqual(node.calls[0], {}); u.stop(); }); test('source throw → error logged, error badge applied, next tick still runs', (t) => { t.mock.timers.enable({ apis: ['setInterval'] }); const node = makeNode(); const logger = makeLogger(); const source = makeSource({ fill: 'green', shape: 'dot', text: 'OK' }); source.throwOnNext = true; const u = new StatusUpdater({ node, source, intervalMs: 1000, logger }); u.start(); t.mock.timers.tick(1000); assert.equal(logger.errors.length, 1, 'error logged once'); assert.match(logger.errors[0], /boom/); assert.deepEqual(node.calls[0], { fill: 'red', shape: 'ring', text: '⚠ boom' }); // Subsequent tick: source recovers, normal badge resumes. t.mock.timers.tick(1000); assert.equal(node.calls.length, 2); assert.deepEqual(node.calls[1], { fill: 'green', shape: 'dot', text: 'OK' }); u.stop(); }); test('stop() halts the interval AND clears the badge', (t) => { t.mock.timers.enable({ apis: ['setInterval'] }); const node = makeNode(); const source = makeSource({ fill: 'green', shape: 'dot', text: 'OK' }); const u = new StatusUpdater({ node, source, intervalMs: 500 }); u.start(); t.mock.timers.tick(500); assert.equal(node.calls.length, 1); u.stop(); assert.equal(u.isRunning, false); // stop() pushes a clear-badge call. assert.equal(node.calls.length, 2); assert.deepEqual(node.calls[1], {}); // No further ticks after stop. t.mock.timers.tick(5000); assert.equal(node.calls.length, 2); }); test('start() called twice does not schedule two intervals', (t) => { t.mock.timers.enable({ apis: ['setInterval'] }); const node = makeNode(); const source = makeSource({ fill: 'green', shape: 'dot', text: 'OK' }); const u = new StatusUpdater({ node, source, intervalMs: 1000 }); u.start(); u.start(); u.start(); t.mock.timers.tick(1000); assert.equal(node.calls.length, 1, 'one tick per interval period'); t.mock.timers.tick(1000); assert.equal(node.calls.length, 2); u.stop(); }); test('intervalMs: 0 makes start() a no-op', (t) => { t.mock.timers.enable({ apis: ['setInterval'] }); const node = makeNode(); const source = makeSource({ fill: 'green', shape: 'dot', text: 'OK' }); const u = new StatusUpdater({ node, source, intervalMs: 0 }); u.start(); assert.equal(u.isRunning, false); t.mock.timers.tick(10000); assert.equal(node.calls.length, 0); }); test('intervalMs omitted is also treated as a no-op', (t) => { t.mock.timers.enable({ apis: ['setInterval'] }); const node = makeNode(); const source = makeSource({ fill: 'green', shape: 'dot', text: 'OK' }); const u = new StatusUpdater({ node, source }); u.start(); assert.equal(u.isRunning, false); t.mock.timers.tick(10000); assert.equal(node.calls.length, 0); }); test('constructor throws if node.status is missing', () => { const source = makeSource(null); assert.throws( () => new StatusUpdater({ node: {}, source, intervalMs: 1000 }), /node must expose a \.status/, ); assert.throws( () => new StatusUpdater({ node: null, source, intervalMs: 1000 }), /node must expose a \.status/, ); }); test('constructor throws if source.getStatusBadge is missing', () => { const node = makeNode(); assert.throws( () => new StatusUpdater({ node, source: {}, intervalMs: 1000 }), /source must expose a \.getStatusBadge/, ); assert.throws( () => new StatusUpdater({ node, source: null, intervalMs: 1000 }), /source must expose a \.getStatusBadge/, ); }); test('isRunning getter reflects timer lifecycle', (t) => { t.mock.timers.enable({ apis: ['setInterval'] }); const node = makeNode(); const source = makeSource(null); const u = new StatusUpdater({ node, source, intervalMs: 1000 }); assert.equal(u.isRunning, false); u.start(); assert.equal(u.isRunning, true); u.stop(); assert.equal(u.isRunning, false); });