import { Logger } from '@nestjs/common'; import { AutomationTriggerType } from '../../../generated/prisma'; import { DEFAULT_RULES } from './default-rules'; import { ruleConfigOf, RULE_CONFIG_PARSERS, validateRuleConfig } from './index'; const ALL_TRIGGERS = Object.values(AutomationTriggerType); function fieldsOf(trigger: AutomationTriggerType, config: unknown): string[] { return validateRuleConfig(trigger, config).map((issue) => issue.field); } describe('rule config', () => { beforeEach(() => { jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); }); afterEach(() => jest.restoreAllMocks()); it('every trigger has defaults and a parser', () => { for (const t of ALL_TRIGGERS) { expect(DEFAULT_RULES[t]).toBeDefined(); expect(RULE_CONFIG_PARSERS[t]).toBeDefined(); } }); it.each(ALL_TRIGGERS)( 'the default config of %s parses to itself and validates without issues', (trigger) => { const { config } = DEFAULT_RULES[trigger]; expect(RULE_CONFIG_PARSERS[trigger].parse(config)).toEqual(config); expect(RULE_CONFIG_PARSERS[trigger].defaults).toEqual(config); expect(validateRuleConfig(trigger, config)).toEqual([]); }, ); it.each(ALL_TRIGGERS)( '%s falls back to its defaults for null, arrays and scalars', (trigger) => { const { config } = DEFAULT_RULES[trigger]; for (const junk of [null, undefined, [], 'x', 5]) { expect(ruleConfigOf(trigger, junk)).toEqual(config); expect(validateRuleConfig(trigger, junk)).toEqual([]); } }, ); it('reads a legacy thresholdMitesPerDay as naturalFall.redAbove', () => expect( ruleConfigOf('VARROA_THRESHOLD', { thresholdMitesPerDay: 7 }).naturalFall, ).toEqual({ seasonMonths: [3, 4, 5, 6, 7, 8, 9, 10], yellowFrom: 5, redAbove: 7, winterRedAbove: 0.5, })); it('lets the band format win over the legacy key', () => expect( ruleConfigOf('VARROA_THRESHOLD', { thresholdMitesPerDay: 7, naturalFall: { redAbove: 9 }, }).naturalFall.redAbove, ).toBe(9)); it('ignores an invalid legacy threshold', () => expect( ruleConfigOf('VARROA_THRESHOLD', { thresholdMitesPerDay: 0 }).naturalFall .redAbove, ).toBe(10)); it('reports an invalid legacy threshold with field and message', () => { const issues = validateRuleConfig('VARROA_THRESHOLD', { thresholdMitesPerDay: 0, }); expect(issues).toEqual([ { field: 'thresholdMitesPerDay', message: expect.any(String) as string, }, ]); expect(fieldsOf('VARROA_THRESHOLD', { thresholdMitesPerDay: 7 })).toEqual( [], ); }); it('keeps natural-fall redAbove above zero and bounded, percent bands up to 100', () => { const fields = (config: unknown) => fieldsOf('VARROA_THRESHOLD', config); expect(fields({ naturalFall: { redAbove: 0 } })).toEqual([ 'naturalFall.redAbove', ]); expect(fields({ naturalFall: { redAbove: 1001 } })).toEqual([ 'naturalFall.redAbove', ]); expect( fields({ infestationPercent: { summer: { redAbove: 101 } } }), ).toEqual(['infestationPercent.summer.redAbove']); expect( fields({ infestationPercent: { autumn: { redAbove: 100 } } }), ).toEqual([]); }); it('reports an explicit null on a known key', () => { expect(fieldsOf('SEASON_AUTOPILOT', { queenMaxAgeYears: null })).toEqual([ 'queenMaxAgeYears', ]); expect(fieldsOf('PHENOLOGY', { swarmSeason: null })).toEqual([ 'swarmSeason', ]); }); it('accepts null on optional keys as clearing them', () => { expect(fieldsOf('TREATMENT_FOLLOW_UP', { dueInDays: null })).toEqual([]); expect(ruleConfigOf('TREATMENT_FOLLOW_UP', { dueInDays: null })).toEqual({ notify: false, }); expect( ruleConfigOf('RAW_MATERIAL_LOW', { minGrams: { BEESWAX: null, PROPOLIS: 50 }, }), ).toEqual({ notify: false, minGrams: { PROPOLIS: 50 } }); expect(ruleConfigOf('RAW_MATERIAL_LOW', { minGrams: null })).toEqual({ notify: false, }); expect( ruleConfigOf('SPLIT_SCHEDULE', { QUEEN: [{ step: 'ACCEPTANCE_CHECK', offsetDays: 7, windowDays: null }], }).QUEEN, ).toEqual([{ step: 'ACCEPTANCE_CHECK', offsetDays: 7 }]); expect( fieldsOf('PHENOLOGY', { gtsThresholds: { SALWEIDE: null } }), ).toEqual([]); expect( ruleConfigOf('PHENOLOGY', { gtsThresholds: { SALWEIDE: null } }) .gtsThresholds, ).toEqual({}); }); it('lets a client remove a gts threshold by leaving it out', () => { expect( ruleConfigOf('PHENOLOGY', { gtsThresholds: { KIRSCHE: 400 } }) .gtsThresholds, ).toEqual({ KIRSCHE: 400 }); expect( ruleConfigOf('PHENOLOGY', { gtsThresholds: {} }).gtsThresholds, ).toEqual({}); }); it('keeps yellowFrom at or below a legacy threshold under five', () => { const { naturalFall } = ruleConfigOf('VARROA_THRESHOLD', { thresholdMitesPerDay: 3, }); expect(naturalFall.redAbove).toBe(3); expect(naturalFall.yellowFrom).toBeLessThanOrEqual(3); }); it('falls back per invalid key and keeps the valid ones', () => expect( ruleConfigOf('SEASON_AUTOPILOT', { queenMaxAgeYears: 'x', honeySuperMinFrames: 10, }), ).toMatchObject({ queenMaxAgeYears: 2, honeySuperMinFrames: 10 })); it('logs a warning with trigger and field but without the value', () => { const warn = jest.spyOn(Logger.prototype, 'warn'); ruleConfigOf('SEASON_AUTOPILOT', { queenMaxAgeYears: 'secret-value' }); const logged = warn.mock.calls.map((call) => String(call[0])).join('\n'); expect(logged).toContain('SEASON_AUTOPILOT'); expect(logged).toContain('queenMaxAgeYears'); expect(logged).not.toContain('secret-value'); }); it('rejects SCHWARMZEIT 10 days with the field name', () => expect( validateRuleConfig('INSPECTION_INTERVAL', { intervalDays: { SCHWARMZEIT: 10 }, }), ).toEqual([ { field: 'intervalDays.SCHWARMZEIT', message: expect.stringContaining('7') as string, }, ])); it('allows 7 to 35 days outside the swarm season and 7 to 9 inside', () => { expect( validateRuleConfig('INSPECTION_INTERVAL', { intervalDays: { SOMMER: 35, FRUEHJAHR: 7, SCHWARMZEIT: 9 }, }), ).toEqual([]); expect( fieldsOf('INSPECTION_INTERVAL', { intervalDays: { SOMMER: 36 } }), ).toEqual(['intervalDays.SOMMER']); expect( fieldsOf('INSPECTION_INTERVAL', { intervalDays: { HERBST: 6.5 } }), ).toEqual(['intervalDays.HERBST']); }); it('merges a partial interval config over the defaults', () => expect( ruleConfigOf('INSPECTION_INTERVAL', { intervalDays: { SOMMER: 10 } }), ).toEqual({ intervalDays: { FRUEHJAHR: 14, SCHWARMZEIT: 7, SOMMER: 10, SPAETSOMMER: 21, HERBST: 21, }, })); it('rejects yellowFrom above redAbove', () => expect( fieldsOf('VARROA_THRESHOLD', { naturalFall: { yellowFrom: 12, redAbove: 8 }, }), ).toEqual(['naturalFall.yellowFrom'])); it('rejects percent bands where yellowFrom is above redAbove', () => expect( fieldsOf('VARROA_THRESHOLD', { infestationPercent: { summer: { yellowFrom: 6, redAbove: 5 } }, }), ).toEqual(['infestationPercent.summer.yellowFrom'])); it('rejects duplicate or out-of-range months', () => { expect( fieldsOf('VARROA_THRESHOLD', { naturalFall: { seasonMonths: [3, 3] } }), ).toEqual(['naturalFall.seasonMonths']); expect( fieldsOf('VARROA_THRESHOLD', { infestationPercent: { summerMonths: [0, 13] }, }), ).toEqual(['infestationPercent.summerMonths']); }); it('checks the varroa limits', () => { const fields = (config: unknown) => fieldsOf('VARROA_THRESHOLD', config); expect(fields({ youngColonyFactor: 0 })).toEqual(['youngColonyFactor']); expect(fields({ youngColonyFactor: 1.5 })).toEqual(['youngColonyFactor']); expect(fields({ youngColonyFactor: 1 })).toEqual([]); expect(fields({ recheckAfterDays: 61 })).toEqual(['recheckAfterDays']); expect(fields({ recheckAfterDays: 0 })).toEqual([]); expect(fields({ youngColonyTypes: ['NOPE'] })).toEqual([ 'youngColonyTypes', ]); expect(fields({ naturalFall: { redAbove: -1 } })).toEqual([ 'naturalFall.redAbove', ]); expect(fields({ naturalFall: { redAbove: Infinity } })).toEqual([ 'naturalFall.redAbove', ]); expect(fields({ notify: 'yes' })).toEqual(['notify']); }); it('reports no issue for keys it does not know', () => expect( validateRuleConfig('SEASON_AUTOPILOT', { somethingElse: 'x' }), ).toEqual([])); it('rejects a calendar fallback that runs backwards', () => { expect( fieldsOf('PHENOLOGY', { calendarFallback: { KIRSCHE: '03-01' } }), ).toEqual(['calendarFallback.KIRSCHE']); // The whole map falls back so the phases stay in order. expect( ruleConfigOf('PHENOLOGY', { calendarFallback: { KIRSCHE: '03-01' } }) .calendarFallback, ).toEqual(DEFAULT_RULES.PHENOLOGY.config.calendarFallback); }); it('rejects an invalid month-day', () => { expect( fieldsOf('PHENOLOGY', { calendarFallback: { SALWEIDE: '13-01' } }), ).toEqual(['calendarFallback.SALWEIDE']); expect( fieldsOf('PHENOLOGY', { calendarFallback: { SALWEIDE: '02-30' } }), ).toEqual(['calendarFallback.SALWEIDE']); expect( fieldsOf('PHENOLOGY', { calendarFallback: { SALWEIDE: '03-20' } }), ).toEqual([]); }); it('checks the phenology limits', () => { const fields = (config: unknown) => fieldsOf('PHENOLOGY', config); expect(fields({ maxGapShare: 0.6 })).toEqual(['maxGapShare']); expect(fields({ maxGapShare: 0.5 })).toEqual([]); expect(fields({ gtsThresholds: { SALWEIDE: 0 } })).toEqual([ 'gtsThresholds.SALWEIDE', ]); expect(fields({ gtsThresholds: { NOPE: 5 } })).toEqual([]); expect(fields({ calibratePhases: ['KIRSCHE', 'X'] })).toEqual([ 'calibratePhases', ]); expect( fields({ swarmSeason: { from: 'LINDE', until: 'KIRSCHE' } }), ).toEqual(['swarmSeason.from']); }); it('defaults gtsThresholds to SALWEIDE 200 only', () => expect(ruleConfigOf('PHENOLOGY', {}).gtsThresholds).toEqual({ SALWEIDE: 200, })); it('checks the season autopilot limits', () => { const fields = (config: unknown) => fieldsOf('SEASON_AUTOPILOT', config); expect(fields({ honeySuperMinFrames: 0 })).toEqual(['honeySuperMinFrames']); expect(fields({ honeySuperMinFrames: 41 })).toEqual([ 'honeySuperMinFrames', ]); expect(fields({ varroaMeasureIntervalDays: 6 })).toEqual([ 'varroaMeasureIntervalDays', ]); expect(fields({ queenMaxAgeYears: 0 })).toEqual(['queenMaxAgeYears']); expect(fields({ queenMaxAgeYears: 11 })).toEqual(['queenMaxAgeYears']); expect(fields({ suggestionTtlDays: 366 })).toEqual(['suggestionTtlDays']); expect(fields({ maxStepsPerColony: 4 })).toEqual(['maxStepsPerColony']); expect( fields({ winterVarroaWindow: { from: '12-20', to: '11-15' } }), ).toEqual(['winterVarroaWindow.from']); expect(fields({ rules: { A3: 'no' } })).toEqual(['rules.A3']); expect(fields({ rules: { A3: false } })).toEqual([]); expect( ruleConfigOf('SEASON_AUTOPILOT', { rules: { A3: false } }).rules, ).toEqual(expect.objectContaining({ A1: true, A3: false, A10: true })); }); it('checks the water content limits', () => { const fields = (config: unknown) => fieldsOf('HONEY_WATER_CONTENT', config); expect(fields({ warnAbovePercent: 9 })).toEqual(['warnAbovePercent']); expect(fields({ confirmAbovePercent: 31 })).toEqual([ 'confirmAbovePercent', ]); expect(fields({ warnAbovePercent: 21 })).toEqual(['warnAbovePercent']); expect( fields({ byHoneyType: { Heidehonig: { confirmAbovePercent: 23 } } }), ).toEqual([]); expect( fields({ byHoneyType: { Heidehonig: { confirmAbovePercent: 5 } } }), ).toEqual(['byHoneyType.Heidehonig.confirmAbovePercent']); }); it('checks the split schedule steps', () => { const fields = (config: unknown) => fieldsOf('SPLIT_SCHEDULE', config); expect( fields({ QUEEN: [{ step: 'ACCEPTANCE_CHECK', offsetDays: 121 }] }), ).toEqual(['QUEEN[0].offsetDays']); expect( fields({ QUEEN_CELL: [{ step: 'EGGS_CHECK', offsetDays: 21, windowDays: 31 }], }), ).toEqual(['QUEEN_CELL[0].windowDays']); expect(fields({ RAISE_OWN: [{ offsetDays: 3 }] })).toEqual([ 'RAISE_OWN[0].step', ]); expect(fields({ RAISE_OWN: 'nope' })).toEqual(['RAISE_OWN']); expect( fields({ RAISE_OWN: [{ step: 'MATING_CHECK', offsetDays: 21, windowDays: 3 }], }), ).toEqual([]); }); it('checks dueInDays of chains and simple rules', () => { expect(fieldsOf('QUEEN_CELLS_SWARM_CHECK', { dueInDays: 366 })).toEqual([ 'dueInDays', ]); expect(fieldsOf('TREATMENT_FOLLOW_UP', { dueInDays: -1 })).toEqual([ 'dueInDays', ]); expect(ruleConfigOf('TREATMENT_FOLLOW_UP', { dueInDays: 14 })).toEqual({ notify: false, dueInDays: 14, }); }); it('reads minGrams per raw material kind', () => { expect( ruleConfigOf('RAW_MATERIAL_LOW', { minGrams: { BEESWAX: 500 } }), ).toEqual({ notify: false, minGrams: { BEESWAX: 500 } }); expect( fieldsOf('RAW_MATERIAL_LOW', { minGrams: { BEESWAX: -5, PROPOLIS: 'x' }, }), ).toEqual(['minGrams.BEESWAX', 'minGrams.PROPOLIS']); }); it('keeps a number format and rejects an empty or oversized one', () => { expect( ruleConfigOf('HONEY_BATCH_NUMBER_FORMAT', { format: 'HB-{SEQ:3}' }), ).toEqual({ format: 'HB-{SEQ:3}' }); expect(fieldsOf('HONEY_BATCH_NUMBER_FORMAT', { format: '' })).toEqual([ 'format', ]); expect( fieldsOf('HONEY_BATCH_NUMBER_FORMAT', { format: 'x'.repeat(101) }), ).toEqual(['format']); }); it('defaults notify to true only for QUEENLESS_NO_EGGS, VARROA_THRESHOLD and COLONY_HEALTH_CRITICAL (and WITHDRAWAL_ENDED)', () => { const withNotify = ALL_TRIGGERS.filter( (trigger) => (ruleConfigOf(trigger, {}) as { notify?: boolean }).notify === true, ); expect([...withNotify].sort()).toEqual( [ 'COLONY_HEALTH_CRITICAL', 'QUEENLESS_NO_EGGS', 'VARROA_THRESHOLD', 'WITHDRAWAL_ENDED', ].sort(), ); }); it('starts the optional rules switched off', () => { const off = ALL_TRIGGERS.filter( (trigger) => DEFAULT_RULES[trigger].active === false, ).sort(); expect(off).toEqual([ 'FEEDING_FOLLOW_UP', 'RAW_MATERIAL_LOW', 'TREATMENT_FOLLOW_UP', ]); }); it('gives every new rule an empty action template', () => { for (const trigger of [ 'INSPECTION_INTERVAL', 'QUEEN_CELLS_SWARM_CHECK', 'QUEEN_ACCEPTANCE_CHECK', 'SPLIT_SCHEDULE', 'COLONY_ENDED_CLEANUP', 'WITHDRAWAL_ENDED', 'HONEY_WATER_CONTENT', 'SEASON_AUTOPILOT', 'PHENOLOGY', ] as const) { expect(DEFAULT_RULES[trigger].actionTemplate).toBe(''); } }); });