Files
ApiCore/ApiCore-Backend/src/automation/rule-config/rule-config.spec.ts
T
Nick SzaunigandClaude Opus 5.5 d99a697739 feat: T4 waves 1-2 - rule configs, schema, suggestions core, season, rule forms
- 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]>
2026-10-14 13:42:32 +02:00

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('');
}
});
});