@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
text/typescript
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' },
)
})
})
})