- Rule configs with typed parsers and registry; Berlin day helpers - Schema: T4 enums, Suggestion table, feed vocabulary (feedStatus as ColonyCondition with legacy text mapping), split queen source, varroa sample size; migration harness for data migrations - SuggestionsService: propose/refresh/resolve/obsolete/expire, accept/dismiss/reopen incl. bulk, lock order, concurrency e2e - SeasonService: phenology-based segments, inspection/varroa due dates, queen age; TimeEventsStep with JobPartialFailure - Frontend: per-rule settings forms with visible value sources Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
441 lines
15 KiB
TypeScript
441 lines
15 KiB
TypeScript
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('');
|
|
}
|
|
});
|
|
});
|