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]>
This commit is contained in:
1 parent
7097fcd97c
commit
d99a697739
116 files changed
+11239
-801
No files matched your search
@@ -1,15 +1,11 @@
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SeasonService } from '../season/season.service';
|
||||
import {
|
||||
ACTIVE_SEASON_MONTHS,
|
||||
CATCH_UP_DAYS,
|
||||
catchUpEvents,
|
||||
DAILY_EVALUATION_RULES,
|
||||
DailyEvent,
|
||||
inCatchUpWindow,
|
||||
INSPECTION_INTERVAL_DAYS,
|
||||
latestCycleOccurrence,
|
||||
QUEEN_AGED_MIN_YEARS,
|
||||
VARROA_MEASUREMENT_INTERVAL_DAYS,
|
||||
withdrawalEndDay,
|
||||
} from './daily-evaluation.rules';
|
||||
|
||||
@@ -17,8 +13,6 @@ const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Midnight UTC of a 'YYYY-MM-DD' day. */
|
||||
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
|
||||
/** When the CronJob runs on `day` (04:30 Europe/Berlin). */
|
||||
const at = (day: string): Date => new Date(`${day}T02:30:00.000Z`);
|
||||
|
||||
/** Every day from `from` to `to`, both included. */
|
||||
function daysBetween(from: string, to: string): string[] {
|
||||
@@ -38,23 +32,25 @@ interface FakeRows {
|
||||
withdrawalPeriodDays: number | null;
|
||||
}[];
|
||||
tasks?: { id: string; dueDate: Date | null }[];
|
||||
queens?: {
|
||||
id: string;
|
||||
hatchYear: number | null;
|
||||
colony: { id: string } | null;
|
||||
}[];
|
||||
/** Living colonies, all in one apiary. */
|
||||
colonies?: { id: string; createdAt: Date }[];
|
||||
/** Living colonies with their current queen (rows the queen query returns). */
|
||||
queenedColonies?: {
|
||||
id: string;
|
||||
currentQueen: { id: string; hatchYear: number | null } | null;
|
||||
}[];
|
||||
/** The latest varroa measurement per colony. */
|
||||
varroa?: { colonyId: string; date: Date }[];
|
||||
varroa?: { id: string; colonyId: string; date: Date }[];
|
||||
/** The latest inspection per colony. */
|
||||
inspections?: { colonyId: string; date: Date }[];
|
||||
/** Stored rule configs by trigger type; others read as the built-in setting. */
|
||||
rules?: Record<string, object>;
|
||||
}
|
||||
|
||||
interface FakeDb {
|
||||
db: PrismaService;
|
||||
queryRaw: jest.Mock;
|
||||
taskFindMany: jest.Mock;
|
||||
queenFindMany: jest.Mock;
|
||||
colonyFindMany: jest.Mock;
|
||||
}
|
||||
|
||||
@@ -63,32 +59,60 @@ interface FakeDb {
|
||||
* day themselves, the database filters only narrow the candidates.
|
||||
*/
|
||||
function fakeDb(rows: FakeRows): FakeDb {
|
||||
const latest = (records: { colonyId: string; date: Date }[] = []) =>
|
||||
records.map(({ colonyId, date }) => ({ colonyId, _max: { date } }));
|
||||
const queryRaw = jest.fn().mockResolvedValue(rows.treatments ?? []);
|
||||
const taskFindMany = jest.fn().mockResolvedValue(rows.tasks ?? []);
|
||||
const queenFindMany = jest.fn().mockResolvedValue(rows.queens ?? []);
|
||||
const colonyFindMany = jest.fn().mockResolvedValue(rows.colonies ?? []);
|
||||
const colonyFindMany = jest.fn(
|
||||
({ select }: { select: Record<string, unknown> }) =>
|
||||
Promise.resolve(
|
||||
'currentQueen' in select
|
||||
? (rows.queenedColonies ?? [])
|
||||
: (rows.colonies ?? []).map((colony) => ({
|
||||
...colony,
|
||||
apiaryId: 'apiary-1',
|
||||
})),
|
||||
),
|
||||
);
|
||||
const db = {
|
||||
$queryRaw: queryRaw,
|
||||
task: { findMany: taskFindMany },
|
||||
queen: { findMany: queenFindMany },
|
||||
colony: { findMany: colonyFindMany },
|
||||
varroaMeasurement: {
|
||||
groupBy: jest.fn().mockResolvedValue(latest(rows.varroa)),
|
||||
findMany: jest.fn().mockResolvedValue(rows.varroa ?? []),
|
||||
},
|
||||
inspection: {
|
||||
groupBy: jest.fn().mockResolvedValue(latest(rows.inspections)),
|
||||
groupBy: jest.fn().mockResolvedValue(
|
||||
(rows.inspections ?? []).map(({ colonyId, date }) => ({
|
||||
colonyId,
|
||||
_max: { date },
|
||||
})),
|
||||
),
|
||||
},
|
||||
automationRule: {
|
||||
findUnique: jest.fn(({ where }: { where: { triggerType: string } }) => {
|
||||
const config = rows.rules?.[where.triggerType];
|
||||
return Promise.resolve(
|
||||
config === undefined
|
||||
? null
|
||||
: { id: 'rule-1', active: true, actionTemplate: null, config },
|
||||
);
|
||||
}),
|
||||
},
|
||||
} as unknown as PrismaService;
|
||||
return { db, queryRaw, taskFindMany, queenFindMany, colonyFindMany };
|
||||
return { db, queryRaw, taskFindMany, colonyFindMany };
|
||||
}
|
||||
|
||||
const seasons = new SeasonService();
|
||||
|
||||
/** One job run on `day`: every rule's events in the catch-up window. */
|
||||
async function eventsOn(db: PrismaService, day: string): Promise<DailyEvent[]> {
|
||||
const events: DailyEvent[] = [];
|
||||
for (const rule of DAILY_EVALUATION_RULES) {
|
||||
events.push(...catchUpEvents(await rule.occurrences(db, at(day)), at(day)));
|
||||
events.push(
|
||||
...catchUpEvents(
|
||||
await rule.occurrences(db, d(day), { seasons, day }),
|
||||
d(day),
|
||||
),
|
||||
);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
@@ -113,13 +137,12 @@ async function runDaily(
|
||||
return store;
|
||||
}
|
||||
|
||||
const keysOf = (store: Map<string, DailyEvent>, type: string): string[] =>
|
||||
[...store.keys()].filter((key) => key.startsWith(`${type}:`));
|
||||
|
||||
describe('daily evaluation rules', () => {
|
||||
it('keeps the values of the backbone plan', () => {
|
||||
it('keeps the catch-up window of the backbone plan', () => {
|
||||
expect(CATCH_UP_DAYS).toBe(7);
|
||||
expect(QUEEN_AGED_MIN_YEARS).toBe(2);
|
||||
expect(VARROA_MEASUREMENT_INTERVAL_DAYS).toBe(28);
|
||||
expect(INSPECTION_INTERVAL_DAYS).toBe(21);
|
||||
expect(ACTIVE_SEASON_MONTHS).toEqual([3, 4, 5, 6, 7, 8, 9, 10]);
|
||||
});
|
||||
|
||||
it('runs one rule per time-based event type', () => {
|
||||
@@ -170,50 +193,9 @@ describe('daily evaluation rules', () => {
|
||||
).toBeNull());
|
||||
});
|
||||
|
||||
describe('latestCycleOccurrence', () => {
|
||||
it('is the latest base + k × interval up to today, with k ≥ 1', () => {
|
||||
expect(latestCycleOccurrence(d('2026-05-01'), 21, at('2026-06-15'))).toBe(
|
||||
'2026-06-12',
|
||||
);
|
||||
expect(latestCycleOccurrence(d('2026-05-01'), 21, at('2026-05-22'))).toBe(
|
||||
'2026-05-22',
|
||||
);
|
||||
});
|
||||
|
||||
it('is null before the first full interval and for a base in the future', () => {
|
||||
expect(
|
||||
latestCycleOccurrence(d('2026-05-01'), 21, at('2026-05-21')),
|
||||
).toBeNull();
|
||||
expect(
|
||||
latestCycleOccurrence(d('2026-05-01'), 21, at('2026-05-01')),
|
||||
).toBeNull();
|
||||
expect(
|
||||
latestCycleOccurrence(d('2026-07-01'), 21, at('2026-05-01')),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('counts whole UTC days, not hours', () =>
|
||||
expect(
|
||||
latestCycleOccurrence(
|
||||
new Date('2026-05-01T20:00:00.000Z'),
|
||||
21,
|
||||
at('2026-05-22'),
|
||||
),
|
||||
).toBe('2026-05-22'));
|
||||
|
||||
it('refuses an interval that is not a positive whole number of days', () => {
|
||||
expect(() =>
|
||||
latestCycleOccurrence(d('2026-05-01'), 0, at('2026-06-01')),
|
||||
).toThrow(RangeError);
|
||||
expect(() =>
|
||||
latestCycleOccurrence(d('2026-05-01'), 1.5, at('2026-06-01')),
|
||||
).toThrow(RangeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('inCatchUpWindow', () => {
|
||||
it('spans today and the 7 days before it', () => {
|
||||
const today = at('2026-06-10');
|
||||
const today = d('2026-06-10');
|
||||
expect(inCatchUpWindow('2026-06-03', today)).toBe(true);
|
||||
expect(inCatchUpWindow('2026-06-07', today)).toBe(true);
|
||||
expect(inCatchUpWindow('2026-06-10', today)).toBe(true);
|
||||
@@ -222,8 +204,8 @@ describe('daily evaluation rules', () => {
|
||||
});
|
||||
|
||||
it('works across a year boundary', () => {
|
||||
expect(inCatchUpWindow('2026-12-29', at('2027-01-04'))).toBe(true);
|
||||
expect(inCatchUpWindow('2026-12-27', at('2027-01-04'))).toBe(false);
|
||||
expect(inCatchUpWindow('2026-12-29', d('2027-01-04'))).toBe(true);
|
||||
expect(inCatchUpWindow('2026-12-27', d('2027-01-04'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -245,10 +227,11 @@ describe('daily evaluation rules', () => {
|
||||
// Never measured or inspected: they count from its creation.
|
||||
{ id: 'colony-2', createdAt: d('2026-04-12') },
|
||||
],
|
||||
varroa: [{ colonyId: 'colony-1', date: d('2026-05-01') }],
|
||||
varroa: [{ id: 'm-1', colonyId: 'colony-1', date: d('2026-05-01') }],
|
||||
inspections: [{ colonyId: 'colony-1', date: d('2026-05-20') }],
|
||||
});
|
||||
|
||||
// 2026-05-29 is SCHWARMZEIT (inspection every 7 days).
|
||||
expect(await eventsOn(db, '2026-05-29')).toEqual([
|
||||
{
|
||||
type: 'WithdrawalEnded',
|
||||
@@ -269,16 +252,26 @@ describe('daily evaluation rules', () => {
|
||||
type: 'VarroaMeasurementDue',
|
||||
aggregateType: 'Colony',
|
||||
aggregateId: 'colony-1',
|
||||
payload: { dueOn: '2026-05-29', lastMeasuredOn: '2026-05-01' },
|
||||
payload: {
|
||||
dueOn: '2026-05-29',
|
||||
lastMeasuredOn: '2026-05-01',
|
||||
kind: 'INTERVAL',
|
||||
anchor: 'm-1',
|
||||
},
|
||||
dedupeKey: 'VarroaMeasurementDue:colony-1:2026-05-29',
|
||||
},
|
||||
// colony-1: 2026-05-20 + 21 = 2026-06-10, not yet due.
|
||||
// colony-1: 2026-05-20 + 7 = 2026-05-27 is due, overdue the day after.
|
||||
// colony-2: 2026-04-12 + 7, overdue on 2026-04-20, outside the window.
|
||||
{
|
||||
type: 'InspectionOverdue',
|
||||
aggregateType: 'Colony',
|
||||
aggregateId: 'colony-2',
|
||||
payload: { dueOn: '2026-05-24', lastInspectedOn: null },
|
||||
dedupeKey: 'InspectionOverdue:colony-2:2026-05-24',
|
||||
aggregateId: 'colony-1',
|
||||
payload: {
|
||||
dueOn: '2026-05-27',
|
||||
lastInspectedOn: '2026-05-20',
|
||||
firstOfYear: false,
|
||||
},
|
||||
dedupeKey: 'InspectionOverdue:colony-1:2026-05-28',
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -334,82 +327,300 @@ describe('daily evaluation rules', () => {
|
||||
expect(await eventsOn(db, '2026-06-22')).toEqual([]);
|
||||
});
|
||||
|
||||
it('emits no inspection or varroa event outside March–October', async () => {
|
||||
// Never measured or inspected: due every 28 resp. 21 days from 2026-01-10.
|
||||
const { db } = fakeDb({
|
||||
colonies: [{ id: 'colony-1', createdAt: d('2026-01-10') }],
|
||||
describe('QueenAged', () => {
|
||||
const queen = (hatchYear: number | null) => ({
|
||||
id: 'queen-1',
|
||||
hatchYear,
|
||||
});
|
||||
|
||||
const store = await runDaily(db, daysBetween('2026-01-01', '2026-12-31'));
|
||||
const dueDays = (type: string) =>
|
||||
[...store.values()]
|
||||
.filter((event) => event.type === type)
|
||||
.map((event) => event.dedupeKey.split(':')[2]);
|
||||
it('fires on the job day, once per queen and year under the January 1 key', async () => {
|
||||
const { db } = fakeDb({
|
||||
queenedColonies: [
|
||||
{ id: 'colony-1', currentQueen: queen(2024) },
|
||||
{ id: 'colony-2', currentQueen: { id: 'queen-2', hatchYear: 2025 } },
|
||||
{ id: 'colony-3', currentQueen: { id: 'queen-3', hatchYear: null } },
|
||||
],
|
||||
});
|
||||
|
||||
// Not 2026-02-07, 2026-11-14 or 2026-12-12.
|
||||
expect(dueDays('VarroaMeasurementDue')).toEqual([
|
||||
'2026-03-07',
|
||||
'2026-04-04',
|
||||
'2026-05-02',
|
||||
'2026-05-30',
|
||||
'2026-06-27',
|
||||
'2026-07-25',
|
||||
'2026-08-22',
|
||||
'2026-09-19',
|
||||
'2026-10-17',
|
||||
]);
|
||||
// Not 2026-01-31, 2026-02-21, 2026-11-21 or 2026-12-12.
|
||||
expect(dueDays('InspectionOverdue')).toEqual([
|
||||
'2026-03-14',
|
||||
'2026-04-04',
|
||||
'2026-04-25',
|
||||
'2026-05-16',
|
||||
'2026-06-06',
|
||||
'2026-06-27',
|
||||
'2026-07-18',
|
||||
'2026-08-08',
|
||||
'2026-08-29',
|
||||
'2026-09-19',
|
||||
'2026-10-10',
|
||||
'2026-10-31',
|
||||
]);
|
||||
expect(await eventsOn(db, '2026-09-09')).toEqual([
|
||||
{
|
||||
type: 'QueenAged',
|
||||
aggregateType: 'Queen',
|
||||
aggregateId: 'queen-1',
|
||||
payload: { hatchYear: 2024, colonyId: 'colony-1' },
|
||||
dedupeKey: 'QueenAged:queen-1:2026-01-01',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('QueenAged is not limited to January 1–8', async () => {
|
||||
const { db } = fakeDb({
|
||||
queenedColonies: [{ id: 'colony-1', currentQueen: queen(2024) }],
|
||||
});
|
||||
|
||||
for (const day of ['2026-01-01', '2026-03-15', '2026-11-30']) {
|
||||
expect((await eventsOn(db, day)).map((e) => e.dedupeKey)).toEqual([
|
||||
'QueenAged:queen-1:2026-01-01',
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it('QueenAged uses queenMaxAgeYears', async () => {
|
||||
const queened = [{ id: 'colony-1', currentQueen: queen(2025) }];
|
||||
const { db: byDefault } = fakeDb({ queenedColonies: queened });
|
||||
const { db: stricter, colonyFindMany } = fakeDb({
|
||||
queenedColonies: queened,
|
||||
rules: { SEASON_AUTOPILOT: { queenMaxAgeYears: 1 } },
|
||||
});
|
||||
|
||||
expect(await eventsOn(byDefault, '2026-06-10')).toEqual([]);
|
||||
expect(await eventsOn(stricter, '2026-06-10')).toHaveLength(1);
|
||||
expect(colonyFindMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
currentQueen: { status: 'ACTIVE', hatchYear: { lte: 2025 } },
|
||||
}) as object,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('QueenAged fires on the first job day a queen assigned in June is aged, and not a second time in the same year', async () => {
|
||||
const { db, colonyFindMany } = fakeDb({});
|
||||
// The queen (hatched 2024) is assigned to the colony on 2026-06-15.
|
||||
let assigned = false;
|
||||
colonyFindMany.mockImplementation(
|
||||
({ select }: { select: Record<string, unknown> }) =>
|
||||
Promise.resolve(
|
||||
'currentQueen' in select && assigned
|
||||
? [{ id: 'colony-1', currentQueen: queen(2024) }]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
const store = await runDaily(db, daysBetween('2026-06-01', '2026-06-14'));
|
||||
expect([...store.keys()]).toEqual([]);
|
||||
|
||||
assigned = true;
|
||||
expect(
|
||||
(await eventsOn(db, '2026-06-15')).map((event) => event.dedupeKey),
|
||||
).toEqual(['QueenAged:queen-1:2026-01-01']);
|
||||
|
||||
await runDaily(db, daysBetween('2026-06-15', '2026-12-31'), store);
|
||||
expect([...store.keys()]).toEqual(['QueenAged:queen-1:2026-01-01']);
|
||||
|
||||
// The next year is a new anchor.
|
||||
await runDaily(db, ['2027-01-02'], store);
|
||||
expect([...store.keys()]).toEqual([
|
||||
'QueenAged:queen-1:2026-01-01',
|
||||
'QueenAged:queen-1:2027-01-01',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('reports a queen once a year on January 1 from her second year after hatching', async () => {
|
||||
const { db } = fakeDb({
|
||||
queens: [
|
||||
{ id: 'queen-2026', hatchYear: 2026, colony: { id: 'colony-1' } },
|
||||
{ id: 'queen-2027', hatchYear: 2027, colony: null },
|
||||
{ id: 'queen-unknown', hatchYear: null, colony: null },
|
||||
],
|
||||
describe('InspectionOverdue', () => {
|
||||
it('emits InspectionOverdue on the day after inspectionDueOn by the segment interval', async () => {
|
||||
const { db } = fakeDb({
|
||||
colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }],
|
||||
// SCHWARMZEIT since 2026-04-15: 2026-04-28 + 7 = 2026-05-05.
|
||||
inspections: [{ colonyId: 'colony-1', date: d('2026-04-28') }],
|
||||
});
|
||||
|
||||
expect(
|
||||
(await eventsOn(db, '2026-05-05')).filter(
|
||||
(event) => event.type === 'InspectionOverdue',
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
(await eventsOn(db, '2026-05-06')).filter(
|
||||
(event) => event.type === 'InspectionOverdue',
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
type: 'InspectionOverdue',
|
||||
aggregateType: 'Colony',
|
||||
aggregateId: 'colony-1',
|
||||
payload: {
|
||||
dueOn: '2026-05-05',
|
||||
lastInspectedOn: '2026-04-28',
|
||||
firstOfYear: false,
|
||||
},
|
||||
dedupeKey: 'InspectionOverdue:colony-1:2026-05-06',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
const store = await runDaily(db, daysBetween('2027-12-20', '2029-01-20'));
|
||||
it('a due date inside the winter yields InspectionOverdue the day after the start of FRUEHJAHR and none inside WINTERRUHE', async () => {
|
||||
// Last inspected in autumn: the next one is due in the winter.
|
||||
const { db } = fakeDb({
|
||||
colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }],
|
||||
inspections: [{ colonyId: 'colony-1', date: d('2025-10-20') }],
|
||||
});
|
||||
|
||||
expect([...store.values()]).toEqual([
|
||||
{
|
||||
type: 'QueenAged',
|
||||
aggregateType: 'Queen',
|
||||
aggregateId: 'queen-2026',
|
||||
payload: { hatchYear: 2026, colonyId: 'colony-1' },
|
||||
dedupeKey: 'QueenAged:queen-2026:2028-01-01',
|
||||
},
|
||||
expect.objectContaining({
|
||||
dedupeKey: 'QueenAged:queen-2026:2029-01-01',
|
||||
}) as DailyEvent,
|
||||
{
|
||||
type: 'QueenAged',
|
||||
aggregateType: 'Queen',
|
||||
aggregateId: 'queen-2027',
|
||||
payload: { hatchYear: 2027, colonyId: null },
|
||||
dedupeKey: 'QueenAged:queen-2027:2029-01-01',
|
||||
},
|
||||
]);
|
||||
const winter = await runDaily(
|
||||
db,
|
||||
daysBetween('2026-01-01', '2026-03-19'),
|
||||
);
|
||||
expect([...winter.keys()]).toEqual([]);
|
||||
|
||||
// FRUEHJAHR begins 2026-03-20: due that day, overdue the day after.
|
||||
const spring = await runDaily(
|
||||
db,
|
||||
daysBetween('2026-03-20', '2026-04-10'),
|
||||
);
|
||||
expect(keysOf(spring, 'InspectionOverdue')).toEqual([
|
||||
'InspectionOverdue:colony-1:2026-03-21',
|
||||
]);
|
||||
expect(spring.get('InspectionOverdue:colony-1:2026-03-21')).toMatchObject(
|
||||
{
|
||||
payload: {
|
||||
dueOn: '2026-03-20',
|
||||
lastInspectedOn: '2025-10-20',
|
||||
firstOfYear: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('emits nothing for a due date more than a week back (one event per due date)', async () => {
|
||||
const { db } = fakeDb({
|
||||
colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }],
|
||||
inspections: [{ colonyId: 'colony-1', date: d('2026-04-28') }],
|
||||
});
|
||||
const inspections = async (day: string) =>
|
||||
(await eventsOn(db, day)).filter((e) => e.type === 'InspectionOverdue');
|
||||
|
||||
// Due 2026-05-05, overdue 2026-05-06, last caught up on 2026-05-13.
|
||||
expect(await inspections('2026-05-13')).toHaveLength(1);
|
||||
// Due + 9 days.
|
||||
expect(await inspections('2026-05-14')).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps the same dedupe key on consecutive days while nothing changes', async () => {
|
||||
const { db } = fakeDb({
|
||||
colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }],
|
||||
inspections: [{ colonyId: 'colony-1', date: d('2026-04-28') }],
|
||||
});
|
||||
const keys = async (day: string) =>
|
||||
(await eventsOn(db, day)).map((event) => event.dedupeKey);
|
||||
|
||||
expect(await keys('2026-05-08')).toEqual(
|
||||
expect.arrayContaining(['InspectionOverdue:colony-1:2026-05-06']),
|
||||
);
|
||||
expect(await keys('2026-05-09')).toEqual(await keys('2026-05-08'));
|
||||
});
|
||||
|
||||
it('a segment interval change gives a second event with another key', async () => {
|
||||
// Inspected 2026-06-05: SCHWARMZEIT (7 days) is overdue on 06-13; from
|
||||
// SOMMER on 2026-06-20 (14 days) the due date is 06-19, overdue 06-20.
|
||||
const { db } = fakeDb({
|
||||
colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }],
|
||||
inspections: [{ colonyId: 'colony-1', date: d('2026-06-05') }],
|
||||
});
|
||||
|
||||
const store = await runDaily(db, daysBetween('2026-06-06', '2026-06-25'));
|
||||
|
||||
expect(keysOf(store, 'InspectionOverdue')).toEqual([
|
||||
'InspectionOverdue:colony-1:2026-06-13',
|
||||
'InspectionOverdue:colony-1:2026-06-20',
|
||||
]);
|
||||
});
|
||||
|
||||
it('a HERBST due date inside the winter gives no event on WINTER and a first-of-year one in spring', async () => {
|
||||
// HERBST: 21 days. 2026-10-24 + 21 = 2026-11-14, overdue on 11-15 -
|
||||
// but 11-15 is WINTERRUHE, so nothing.
|
||||
const { db } = fakeDb({
|
||||
colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }],
|
||||
inspections: [{ colonyId: 'colony-1', date: d('2026-10-24') }],
|
||||
});
|
||||
|
||||
expect(
|
||||
keysOf(
|
||||
await runDaily(db, daysBetween('2026-11-10', '2027-03-19')),
|
||||
'InspectionOverdue',
|
||||
),
|
||||
).toEqual([]);
|
||||
|
||||
const spring = await runDaily(
|
||||
db,
|
||||
daysBetween('2027-03-20', '2027-03-25'),
|
||||
);
|
||||
expect(spring.get('InspectionOverdue:colony-1:2027-03-21')).toMatchObject(
|
||||
{ payload: { dueOn: '2027-03-20', firstOfYear: true } },
|
||||
);
|
||||
});
|
||||
|
||||
it('emits no inspection event inside WINTERRUHE although the due date passes', async () => {
|
||||
const { db } = fakeDb({
|
||||
colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }],
|
||||
inspections: [{ colonyId: 'colony-1', date: d('2026-11-01') }],
|
||||
});
|
||||
|
||||
const winter = await runDaily(
|
||||
db,
|
||||
daysBetween('2026-11-15', '2027-03-19'),
|
||||
);
|
||||
expect(keysOf(winter, 'InspectionOverdue')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('VarroaMeasurementDue', () => {
|
||||
it('is due on the winter window start without a measurement since 11-01 and not in the rest of WINTERRUHE', async () => {
|
||||
const { db } = fakeDb({
|
||||
colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }],
|
||||
varroa: [{ id: 'm-1', colonyId: 'colony-1', date: d('2026-09-20') }],
|
||||
});
|
||||
|
||||
const store = await runDaily(db, daysBetween('2026-11-10', '2027-02-28'));
|
||||
|
||||
expect(keysOf(store, 'VarroaMeasurementDue')).toEqual([
|
||||
'VarroaMeasurementDue:colony-1:2026-11-15',
|
||||
]);
|
||||
});
|
||||
|
||||
it('is not due in the winter window after a measurement since 11-01', async () => {
|
||||
const { db } = fakeDb({
|
||||
colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }],
|
||||
varroa: [{ id: 'm-2', colonyId: 'colony-1', date: d('2026-11-03') }],
|
||||
});
|
||||
|
||||
const store = await runDaily(db, daysBetween('2026-11-10', '2027-02-28'));
|
||||
|
||||
expect(keysOf(store, 'VarroaMeasurementDue')).toEqual([]);
|
||||
});
|
||||
|
||||
it('takes up a due date inside the winter at the start of FRUEHJAHR', async () => {
|
||||
const { db } = fakeDb({
|
||||
colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }],
|
||||
// Measured 2026-11-20 (inside the winter window of the year): the
|
||||
// next interval day, 12-18, lies in WINTERRUHE.
|
||||
varroa: [{ id: 'm-9', colonyId: 'colony-1', date: d('2026-11-20') }],
|
||||
});
|
||||
|
||||
const store = await runDaily(db, daysBetween('2026-11-21', '2027-03-25'));
|
||||
|
||||
expect(keysOf(store, 'VarroaMeasurementDue')).toEqual([
|
||||
'VarroaMeasurementDue:colony-1:2027-03-20',
|
||||
]);
|
||||
expect(
|
||||
store.get('VarroaMeasurementDue:colony-1:2027-03-20'),
|
||||
).toMatchObject({ payload: { kind: 'INTERVAL', anchor: 'm-9' } });
|
||||
});
|
||||
|
||||
it('follows varroaMeasureIntervalDays outside WINTERRUHE', async () => {
|
||||
const { db } = fakeDb({
|
||||
colonies: [{ id: 'colony-1', createdAt: d('2026-04-20') }],
|
||||
rules: { SEASON_AUTOPILOT: { varroaMeasureIntervalDays: 14 } },
|
||||
});
|
||||
|
||||
const store = await runDaily(db, daysBetween('2026-05-01', '2026-05-10'));
|
||||
|
||||
expect(keysOf(store, 'VarroaMeasurementDue')).toEqual([
|
||||
'VarroaMeasurementDue:colony-1:2026-05-04',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('asks the database only for the candidates of the catch-up window', async () => {
|
||||
const { db, queryRaw, taskFindMany, queenFindMany, colonyFindMany } =
|
||||
fakeDb({});
|
||||
const { db, queryRaw, taskFindMany, colonyFindMany } = fakeDb({});
|
||||
|
||||
await eventsOn(db, '2026-06-10');
|
||||
|
||||
@@ -434,14 +645,5 @@ describe('daily evaluation rules', () => {
|
||||
];
|
||||
expect(strings.join('?')).toContain('"withdrawalPeriodDays" IS NOT NULL');
|
||||
expect(values).toEqual(['2026-06-03', '2026-06-10']);
|
||||
// QueenAged only looks on January 1-8.
|
||||
expect(queenFindMany).not.toHaveBeenCalled();
|
||||
|
||||
await eventsOn(db, '2027-01-08');
|
||||
expect(queenFindMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { status: 'ACTIVE', hatchYear: { lte: 2025 } },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user