UNPKG

@tbowmo/node-red-small-timer

Version:

Small timer node for Node-RED with support for sunrise, sunset etc. timers

667 lines (563 loc) 24.5 kB
import { expect } from 'chai' import { useSinonSandbox } from '../../test' import { ISmallTimerProperties } from '../nodes/common' import { SmallTimerRunner } from './small-timer-runner' import * as timeCalc from './time-calculation' import * as Timer from './timer' describe('lib/small-timer-runner', () => { const sinon = useSinonSandbox() function setupTest(config?: Partial<ISmallTimerProperties>) { const send = sinon.stub().named('node-send') const status = sinon.stub().named('node-status') const error = sinon.stub().named('node-error') // eslint-disable-next-line @typescript-eslint/no-explicit-any const node = { send, status, error } as any const position = { latitude: 56.00, longitude: 10.00 } const configuration: ISmallTimerProperties = { position: '', startTime: 0, endTime: 0, startOffset: 0, endOffset: 0, onMsg: '', offMsg: '', topic: '', injectOnStartup: false, repeat: false, repeatInterval: 60, disable: false, rules: [{ type: 'include', month: 0, day: 0 }], onTimeout: 1440, offTimeout: 1440, offMsgType: 'str', onMsgType: 'str', wrapMidnight: false, debugEnable: false, minimumOnTime: 0, sendEmptyPayload: true, id: '', type: '', name: '', z: '', ...config, } const stubbedTimeCalc = { getTimeToNextStartEvent: sinon.stub(), getTimeToNextEndEvent: sinon.stub(), getOnState: sinon.stub().returns(false), operationToday: sinon.stub().returns('normal'), debug: sinon.stub().returns({debug: 'this is debug'}), } const stubbedTimer = { stop: sinon.stub(), start: sinon.stub(), active: sinon.stub().returns(false), timeLeft: sinon.stub().returns(0), } return { send, status, TimeCalc: sinon.stub(timeCalc, 'TimeCalc').returns(stubbedTimeCalc), Timer: sinon.stub(Timer, 'Timer').returns(stubbedTimer), node, position, configuration, stubbedTimeCalc, stubbedTimer, } } it('should handle no action today, due to negative on interval', () => { const stubs = setupTest() stubs.stubbedTimeCalc.getTimeToNextStartEvent.returns(10) stubs.stubbedTimeCalc.getTimeToNextEndEvent.returns(20) stubs.stubbedTimeCalc.operationToday.returns('noMidnightWrap') const runner = new SmallTimerRunner(stubs.position, stubs.configuration, stubs.node) runner.onMessage({payload: 'sync', _msgid: ''}) sinon.assert.calledWith(stubs.node.status, { fill: 'yellow', shape: 'dot', text: 'No action today - off time is before on time', }) }) it('should handle no action today, due to minimum on time not met', () => { const stubs = setupTest() stubs.stubbedTimeCalc.getTimeToNextStartEvent.returns(10) stubs.stubbedTimeCalc.getTimeToNextEndEvent.returns(20) stubs.stubbedTimeCalc.operationToday.returns('minimumOnTimeNotMet') const runner = new SmallTimerRunner(stubs.position, stubs.configuration, stubs.node) runner.onMessage({payload: 'sync', _msgid: ''}) sinon.assert.calledWith(stubs.node.status, { fill: 'yellow', shape: 'dot', text: 'No action today - minimum on time not met', }) }) it('should handle temporary on and use timeout to calculate next change', () => { const stubs = setupTest({ onTimeout: 5, }) stubs.stubbedTimeCalc.getTimeToNextStartEvent.returns(20) stubs.stubbedTimeCalc.getTimeToNextEndEvent.returns(30) stubs.stubbedTimer.timeLeft.returns(5) stubs.stubbedTimer.active.returns(true) const runner = new SmallTimerRunner(stubs.position, stubs.configuration, stubs.node) runner.onMessage({ payload: 'on', _msgid: 'some-id' }) sinon.assert.calledWithExactly(stubs.status, { fill: 'green', shape: 'ring', text: 'Temporary ON for 05mins' }) sinon.assert.calledWith(stubs.stubbedTimer.start, 5) }) it('should handle temporary off and use nextStartEvent to calculate next change', async () => { const stubs = setupTest() stubs.stubbedTimeCalc.getTimeToNextStartEvent.returns(20) stubs.stubbedTimeCalc.getTimeToNextEndEvent.returns(30) stubs.stubbedTimeCalc.getOnState.returns(true) const runner = new SmallTimerRunner(stubs.position, stubs.configuration, stubs.node) runner.onMessage({ payload: 'off', _msgid: 'some-id' }) sinon.assert.calledWith( stubs.status.lastCall, { fill: 'red', shape: 'ring', text: 'Temporary OFF for 20mins' }, ) runner.onMessage({ payload: 'auto', _msgid: 'some-id' }) sinon.assert.calledWithExactly( stubs.status.lastCall, { fill: 'green', shape: 'dot', text: 'ON for 30mins' }, ) }) it('should send a new change msg when timer tick kicks in and state has changed', async () => { const stubs = setupTest({ topic: 'test-topic', onMsg: 'on-msg', offMsg: '0', injectOnStartup: true, }) stubs.stubbedTimeCalc.getTimeToNextStartEvent.returns(0) stubs.stubbedTimeCalc.getTimeToNextEndEvent.returns(120.6) stubs.stubbedTimeCalc.getOnState.returns(false) const runner = new SmallTimerRunner(stubs.position, stubs.configuration, stubs.node) runner.onMessage({payload: 'sync', _msgid: ''}) sinon.clock.tick(60000) sinon.assert.calledWithExactly(stubs.status, { fill: 'red', shape: 'dot', text: 'OFF for 00secs' }) sinon.assert.calledWithExactly(stubs.send, { state: 'auto', stamp: 2000, autoState: true, duration: 0, temporaryManual: false, timeout: 0, payload: '0', topic: 'test-topic', trigger: 'timer', }) stubs.stubbedTimeCalc.getOnState.returns(true) sinon.clock.tick(60000) sinon.assert.calledWithExactly( stubs.status.lastCall, { fill: 'green', shape: 'dot', text: 'ON for 02hrs 01mins' }, ) sinon.assert.calledWithExactly(stubs.send.lastCall, { state: 'auto', stamp: 61000, autoState: true, duration: 0, temporaryManual: false, timeout: 0, payload: 'on-msg', topic: 'test-topic', trigger: 'timer', }) }) it('should send update together with an debug message when debug is enabled', () => { const stubs = setupTest({ topic: 'test-topic', onMsg:'on', offMsg: 'off', injectOnStartup: true, debugEnable: true, }) stubs.stubbedTimeCalc.getTimeToNextStartEvent.returns(0) stubs.stubbedTimeCalc.getTimeToNextEndEvent.returns(120.6) stubs.stubbedTimeCalc.getOnState.returns(false) const runner = new SmallTimerRunner(stubs.position, stubs.configuration, stubs.node) runner.onMessage({payload: 'sync', _msgid: ''}) sinon.clock.tick(2000) sinon.assert.calledWith(stubs.send, [ { state: 'auto', stamp: 2000, autoState: true, duration: 0, temporaryManual: false, timeout: 0, payload: 'off', topic: 'test-topic', trigger: 'timer', }, { debug: 'this is debug', override: 'auto', topic: 'debug', weekNumber: 1, }, ]) }) it('should not send message with empty payload', () => { const stubs = setupTest({ topic: 'test-topic', onMsg:'on', offMsg: '', // empty string should not be sendt injectOnStartup: true, debugEnable: true, sendEmptyPayload: false, }) stubs.stubbedTimeCalc.getTimeToNextStartEvent.returns(0) stubs.stubbedTimeCalc.getTimeToNextEndEvent.returns(120.6) stubs.stubbedTimeCalc.getOnState.returns(false) const runner = new SmallTimerRunner(stubs.position, stubs.configuration, stubs.node) runner.onMessage({payload: 'sync', _msgid: ''}) sinon.clock.tick(2000) sinon.assert.calledWith(stubs.send, [ null, { debug: 'this is debug', override: 'auto', topic: 'debug', weekNumber: 1, }, ]) }) it('should stop timer, and not advance anything after cleanup has been called', async () => { const stubs = setupTest({ topic: 'test-topic', onMsg: 'on-msg', offMsg: '0', injectOnStartup: true, }) stubs.stubbedTimeCalc.getTimeToNextStartEvent.returns(0) stubs.stubbedTimeCalc.getTimeToNextEndEvent.returns(20) stubs.stubbedTimeCalc.getOnState.returns(false) const runner = new SmallTimerRunner(stubs.position, stubs.configuration, stubs.node) sinon.clock.tick(5000) runner.cleanup() sinon.assert.calledWithExactly(stubs.status, { fill: 'red', shape: 'dot', text: 'OFF for 00secs' }) sinon.assert.calledWithExactly(stubs.send, { state: 'auto', stamp: 2000, autoState: true, duration: 0, temporaryManual: false, timeout: 0, payload: '0', topic: 'test-topic', trigger: 'timer', }) stubs.stubbedTimeCalc.getOnState.returns(true) sinon.clock.tick(1200000) await Promise.resolve() expect(stubs.status.callCount).to.equal(1) expect(stubs.send.callCount).to.equal(1) }) describe('repeat functionality', () => { it('should not repeat output when not configured to', () => { const stubs = setupTest({ topic: 'test-topic', onMsg: 'on-msg', offMsg: '0', injectOnStartup: false, }) stubs.stubbedTimeCalc.getTimeToNextStartEvent.returns(0) stubs.stubbedTimeCalc.getTimeToNextEndEvent.returns(20) stubs.stubbedTimeCalc.getOnState.returns(false) new SmallTimerRunner(stubs.position, stubs.configuration, stubs.node) const initialCallCount = stubs.status.callCount sinon.clock.tick(60000) expect(stubs.send.callCount).to.equal(0) expect(stubs.status.callCount).to.be.greaterThan(initialCallCount) }) it('should repeat twice in 60 seconds when configured to 30 second repeat interval', () => { const stubs = setupTest({ topic: 'test-topic', onMsg: 'on-msg', offMsg: '0', repeat: true, repeatInterval: 30, injectOnStartup: false, }) stubs.stubbedTimeCalc.getTimeToNextStartEvent.returns(0) stubs.stubbedTimeCalc.getTimeToNextEndEvent.returns(20) stubs.stubbedTimeCalc.getOnState.returns(false) new SmallTimerRunner(stubs.position, stubs.configuration, stubs.node) sinon.clock.tick(60000) expect(stubs.send.callCount).to.equal(2) }) it('should repeat maximum 60 times during a minute, even with repeat interval set to 0.5 seconds', () => { const stubs = setupTest({ topic: 'test-topic', onMsg: 'on-msg', offMsg: '0', repeat: true, repeatInterval: 0.5, injectOnStartup: false, }) stubs.stubbedTimeCalc.getTimeToNextStartEvent.returns(0) stubs.stubbedTimeCalc.getTimeToNextEndEvent.returns(20) stubs.stubbedTimeCalc.getOnState.returns(false) new SmallTimerRunner(stubs.position, stubs.configuration, stubs.node) sinon.clock.tick(60000) expect(stubs.send.callCount).to.equal(60) }) }) describe('message input', () => { it('should toggle output when toggle message received', async () => { const stubs = setupTest({ topic: 'test-topic', onMsg: 'on-msg', offMsg: '0', injectOnStartup: true, onTimeout: 30, }) stubs.stubbedTimeCalc.getTimeToNextStartEvent.returns(0) stubs.stubbedTimeCalc.getTimeToNextEndEvent.returns(20) stubs.stubbedTimeCalc.getOnState.returns(false) const runner = new SmallTimerRunner(stubs.position, stubs.configuration, stubs.node) sinon.clock.tick(80000) sinon.assert.calledWithExactly(stubs.status, { fill: 'red', shape: 'dot', text: 'OFF for 00secs' }) sinon.assert.calledWithExactly(stubs.send, { state: 'auto', stamp: 2000, autoState: true, duration: 0, temporaryManual: false, timeout: 0, payload: '0', topic: 'test-topic', trigger: 'timer', }) runner.onMessage({ payload: 'toggle', _msgid: 'some-msg' }) sinon.assert.calledWith(stubs.stubbedTimer.start, 30) sinon.assert.calledWithExactly( stubs.status.lastCall, { fill: 'green', shape: 'ring', text: 'Temporary ON for 20mins' }, ) sinon.assert.calledWithExactly(stubs.send.lastCall, { state: 'tempOn', stamp: 80000, autoState: false, duration: 0, temporaryManual: true, timeout: 0, payload: 'on-msg', topic: 'test-topic', trigger: 'input', }) runner.onMessage({ payload: 'toggle', _msgid: 'some-msg' }) sinon.assert.calledWithExactly( stubs.status.lastCall, { fill: 'red', shape: 'dot', text: 'OFF for 00secs' }, ) sinon.assert.calledWithExactly(stubs.send.lastCall, { state: 'auto', stamp: 80000, autoState: true, duration: 0, temporaryManual: false, timeout: 0, payload: '0', topic: 'test-topic', trigger: 'input', }) }) it('should output node status when sync message is received, without changing properties', () => { const stubs = setupTest({ topic: 'test-topic', onMsg: 'on-msg', offMsg: '0', injectOnStartup: true, }) stubs.stubbedTimeCalc.getTimeToNextStartEvent.returns(0) stubs.stubbedTimeCalc.getTimeToNextEndEvent.returns(20) stubs.stubbedTimeCalc.getOnState.returns(false) const runner = new SmallTimerRunner(undefined, stubs.configuration, stubs.node) sinon.clock.tick(80000) sinon.assert.calledWithExactly(stubs.status, { fill: 'red', shape: 'dot', text: 'OFF for 00secs' }) sinon.assert.calledWithExactly(stubs.send, { state: 'auto', stamp: 2000, autoState: true, duration: 0, temporaryManual: false, timeout: 0, payload: '0', topic: 'test-topic', trigger: 'timer', }) runner.onMessage({ payload: 'sync', _msgid: 'some-id' }) runner.onMessage({ payload: 'sync', _msgid: 'some-id' }) runner.onMessage({ payload: 'sync', _msgid: 'some-id' }) runner.onMessage({ payload: 'sync', _msgid: 'some-id' }) expect(stubs.send.callCount).to.equal(5) expect(stubs.send.lastCall.args).to.deep.equal([{ state: 'auto', stamp: 80000, autoState: true, duration: 0, temporaryManual: false, timeout: 0, payload: '0', topic: 'test-topic', trigger: 'input', }]) }) it('should use timeout property to override timeout', () => { const stubs = setupTest({ topic: 'test-topic', onMsg: 'on-msg', offMsg: '0', injectOnStartup: true, }) stubs.stubbedTimeCalc.getTimeToNextStartEvent.returns(0) stubs.stubbedTimeCalc.getTimeToNextEndEvent.returns(20) stubs.stubbedTimeCalc.getOnState.returns(false) const runner = new SmallTimerRunner(stubs.position, stubs.configuration, stubs.node) sinon.clock.tick(80000) sinon.assert.calledWithExactly(stubs.status, { fill: 'red', shape: 'dot', text: 'OFF for 00secs' }) sinon.assert.calledWithExactly(stubs.send, { state: 'auto', stamp: 2000, autoState: true, duration: 0, temporaryManual: false, timeout: 0, payload: '0', topic: 'test-topic', trigger: 'timer', }) runner.onMessage({ payload: 'toggle', timeout: 10, _msgid: 'some-msg' }) sinon.assert.calledWith(stubs.stubbedTimer.start, 10) }) it('should throw error if timeout property cannot be converted to number', () => { const stubs = setupTest({ topic: 'test-topic', onMsg: 'on-msg', offMsg: '0', injectOnStartup: false, }) stubs.stubbedTimeCalc.getTimeToNextStartEvent.returns(0) stubs.stubbedTimeCalc.getTimeToNextEndEvent.returns(20) stubs.stubbedTimeCalc.getOnState.returns(false) const runner = new SmallTimerRunner(stubs.position, stubs.configuration, stubs.node) sinon.clock.tick(80000) sinon.assert.calledWithExactly(stubs.status, { fill: 'red', shape: 'dot', text: 'OFF for 00secs' }) // eslint-disable-next-line @typescript-eslint/no-explicit-any expect(runner.onMessage.bind(runner.onMessage, { payload: 'invalid', timeout: 'notANumber', _msgid: 'some-id' } as any)) .to.throw('Timeout value "notANumber" can not be converted to a number') expect(stubs.send.callCount).to.equal(0) }) it('should signal error if invalid message is received', () => { const stubs = setupTest({ topic: 'test-topic', onMsg: 'on-msg', offMsg: '0', injectOnStartup: false, }) stubs.stubbedTimeCalc.getTimeToNextStartEvent.returns(0) stubs.stubbedTimeCalc.getTimeToNextEndEvent.returns(20) stubs.stubbedTimeCalc.getOnState.returns(false) const runner = new SmallTimerRunner(stubs.position, stubs.configuration, stubs.node) sinon.clock.tick(80000) sinon.assert.calledWithExactly(stubs.status, { fill: 'red', shape: 'dot', text: 'OFF for 00secs' }) expect(runner.onMessage.bind(runner.onMessage, { payload: 'invalid', _msgid: 'some-id' })) .to.throw('Did not understand the command \'invalid\' supplied in payload') expect(stubs.send.callCount).to.equal(0) }) it('should handle reset', () => { const stubs = setupTest({ topic: 'test-topic', onMsg: 'on-msg', offMsg: '0', injectOnStartup: false, }) stubs.stubbedTimeCalc.getTimeToNextStartEvent.returns(0) stubs.stubbedTimeCalc.getTimeToNextEndEvent.returns(20) stubs.stubbedTimeCalc.getOnState.returns(false) const runner = new SmallTimerRunner(stubs.position, stubs.configuration, stubs.node) sinon.clock.tick(80000) sinon.assert.calledWithExactly(stubs.status, { fill: 'red', shape: 'dot', text: 'OFF for 00secs' }) runner.onMessage({ payload: 'on', _msgid: 'some-id' }) runner.onMessage({ reset: true, _msgid: 'some-id' }) expect(stubs.send.callCount).to.equal(2) sinon.assert.calledWith(stubs.send.firstCall, { state: 'tempOn', stamp: 80000, autoState: false, duration: 0, temporaryManual: true, timeout: 0, payload: 'on-msg', topic: 'test-topic', trigger: 'input', }) sinon.assert.calledWith(stubs.send.lastCall, { state: 'auto', stamp: 80000, autoState: true, duration: 0, temporaryManual: false, timeout: 0, payload: '0', topic: 'test-topic', trigger: 'input', }) }) }) describe('rules check', () => { it('should exclude a specific day of week', () => { sinon.clock.setSystemTime(new Date('2020-12-30')) // December 30th 2020 is wednesday in week 53 const stubs = setupTest({ rules: [ { type: 'include', month: 0, day: 0 }, // Exclude wednesday (103) in week 53 { type: 'exclude', month: 153, day: 103 }, ], }) stubs.stubbedTimeCalc.getTimeToNextStartEvent.returns(0) stubs.stubbedTimeCalc.getTimeToNextEndEvent.returns(20) stubs.stubbedTimeCalc.getOnState.returns(false) const runner = new SmallTimerRunner(stubs.position, stubs.configuration, stubs.node) runner.onMessage({ payload: 'sync', _msgid: '' }) sinon.assert.calledWithExactly( stubs.status.lastCall, { fill: 'yellow', shape: 'dot', text: 'No action today' }, ) sinon.clock.setSystemTime(new Date('2023-12-31')) runner.onMessage({ payload: 'sync', _msgid: '' }) sinon.assert.calledWithExactly( stubs.status.lastCall, { fill: 'red', shape: 'dot', text: 'OFF for 00secs' }, ) }) it('should include a day in a excluded month', () => { const stubs = setupTest({ rules: [ { type: 'include', month: 0, day: 0 }, { type: 'exclude', month: 5, day: 0 }, { type: 'include', month: 5, day: 11 }, ], }) stubs.stubbedTimeCalc.getTimeToNextStartEvent.returns(0) stubs.stubbedTimeCalc.getTimeToNextEndEvent.returns(20) stubs.stubbedTimeCalc.getOnState.returns(false) const runner = new SmallTimerRunner(stubs.position, stubs.configuration, stubs.node) sinon.clock.setSystemTime(new Date('2023-05-04')) runner.onMessage({ payload: 'sync', _msgid: '' }) sinon.assert.calledWithExactly( stubs.status.lastCall, { fill: 'yellow', shape: 'dot', text: 'No action today' }, ) sinon.clock.setSystemTime(new Date('2023-05-11')) runner.onMessage({ payload: 'sync', _msgid: '' }) sinon.assert.calledWithExactly( stubs.status.lastCall, { fill: 'red', shape: 'dot', text: 'OFF for 00secs' }, ) }) }) })