feat: T4 wave 4 - rules suggest, takeover, chains, autopilot A1-A3, suggestions page, varroa bands UI
- Automation rules propose suggestions instead of creating tasks; notify per rule config; corrected varroa measurements refresh or resolve their suggestion - Inspection values can be taken over into the colony card (occupiedFrames 0-40, strength deprecated); warning counts use the same visibility rule as the notification list - Biotech chains: swarm check after queen cells, queen acceptance check, split schedules (raise own, queen cell, queen) - Season autopilot core with A1 inspection due, A2 varroa measurement, A3 recheck after treatment; colony sync and daily step - Frontend: suggestions page with groups, bulk accept, undo, paging, focus management and permission gating - Frontend: varroa traffic light from seasonal bands, sample size, one range request for the colony chart - Notifications: mark-read answers 404 for rows the user may no longer see Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
64a2b7dad9
commit
02eb16924e
142 files changed
+11847
-1251
No files matched your search
@@ -0,0 +1,181 @@
|
||||
import type { Prisma } from '../../generated/prisma';
|
||||
import type {
|
||||
AnyDomainEventEnvelope,
|
||||
DomainEventType,
|
||||
} from '../domain-events/domain-event-types';
|
||||
import { domainEventHandlerMetadata } from '../domain-events/on-domain-event.decorator';
|
||||
import { AutopilotEventHandlers } from './autopilot.handlers';
|
||||
import type { AutopilotService } from './autopilot.service';
|
||||
|
||||
const EXPECTED_TYPES: DomainEventType[] = [
|
||||
'InspectionRecorded',
|
||||
'InspectionCorrected',
|
||||
'VarroaMeasured',
|
||||
'VarroaCorrected',
|
||||
'TreatmentRecorded',
|
||||
'TreatmentCorrected',
|
||||
'FeedingRecorded',
|
||||
'FeedingCorrected',
|
||||
'HarvestRecorded',
|
||||
'HarvestCorrected',
|
||||
'ColonyStatusChanged',
|
||||
'ColonySplit',
|
||||
'QueenAssigned',
|
||||
'TaskCompleted',
|
||||
'TaskUpdated',
|
||||
'WithdrawalEnded',
|
||||
'QueenAged',
|
||||
'InspectionOverdue',
|
||||
'VarroaMeasurementDue',
|
||||
];
|
||||
|
||||
function event(
|
||||
type: DomainEventType,
|
||||
aggregateId: string,
|
||||
payload: object,
|
||||
): AnyDomainEventEnvelope {
|
||||
return {
|
||||
id: 'evt-1',
|
||||
type,
|
||||
aggregateType: 'Colony',
|
||||
aggregateId,
|
||||
payload,
|
||||
actorUserId: null,
|
||||
correlationId: 'cor-1',
|
||||
occurredAt: new Date(),
|
||||
attempts: 0,
|
||||
} as AnyDomainEventEnvelope;
|
||||
}
|
||||
|
||||
describe('AutopilotEventHandlers', () => {
|
||||
let autopilot: { syncColonyIn: jest.Mock };
|
||||
let tx: { task: { findUnique: jest.Mock } };
|
||||
let handlers: AutopilotEventHandlers;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers({ now: new Date('2026-05-12T08:00:00.000Z') });
|
||||
autopilot = { syncColonyIn: jest.fn().mockResolvedValue({}) };
|
||||
tx = { task: { findUnique: jest.fn() } };
|
||||
handlers = new AutopilotEventHandlers(
|
||||
autopilot as unknown as AutopilotService,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
const run = (e: AnyDomainEventEnvelope) =>
|
||||
handlers.colonySync(
|
||||
e as Parameters<AutopilotEventHandlers['colonySync']>[0],
|
||||
tx as unknown as Prisma.TransactionClient,
|
||||
);
|
||||
const synced = () =>
|
||||
autopilot.syncColonyIn.mock.calls.map((call: unknown[]) => call[1]);
|
||||
|
||||
it('is registered as autopilot.colony-sync for the listed events', () => {
|
||||
const entries = domainEventHandlerMetadata(
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
AutopilotEventHandlers.prototype.colonySync,
|
||||
);
|
||||
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].handlerId).toBe('autopilot.colony-sync');
|
||||
expect([...entries[0].types].sort()).toEqual([...EXPECTED_TYPES].sort());
|
||||
});
|
||||
|
||||
it.each([
|
||||
['InspectionRecorded', { colonyId: 'c1' }],
|
||||
['InspectionCorrected', { colonyId: 'c1' }],
|
||||
['VarroaMeasured', { colonyId: 'c1' }],
|
||||
['VarroaCorrected', { colonyId: 'c1' }],
|
||||
['TreatmentRecorded', { colonyId: 'c1' }],
|
||||
['TreatmentCorrected', { colonyId: 'c1' }],
|
||||
['FeedingRecorded', { colonyId: 'c1' }],
|
||||
['FeedingCorrected', { colonyId: 'c1' }],
|
||||
['WithdrawalEnded', { colonyId: 'c1', endedOn: '2026-05-12' }],
|
||||
['QueenAged', { colonyId: 'c1', hatchYear: 2024 }],
|
||||
] as [DomainEventType, object][])(
|
||||
'%s syncs the colony of its payload for the Berlin day',
|
||||
async (type, payload) => {
|
||||
await run(event(type, 'aggregate-1', payload));
|
||||
|
||||
expect(autopilot.syncColonyIn).toHaveBeenCalledTimes(1);
|
||||
expect(autopilot.syncColonyIn).toHaveBeenCalledWith(
|
||||
tx,
|
||||
'c1',
|
||||
'2026-05-12',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
'ColonyStatusChanged',
|
||||
'InspectionOverdue',
|
||||
'VarroaMeasurementDue',
|
||||
] as const)('%s syncs the aggregate colony', async (type) => {
|
||||
await run(event(type, 'c9', {}));
|
||||
|
||||
expect(synced()).toEqual(['c9']);
|
||||
});
|
||||
|
||||
it('syncs every colony of a harvest, in id order', async () => {
|
||||
await run(
|
||||
event('HarvestRecorded', 'h1', {
|
||||
apiaryId: 'a',
|
||||
colonyIds: ['c3', 'c1', 'c2', 'c1'],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(synced()).toEqual(['c1', 'c2', 'c3']);
|
||||
});
|
||||
|
||||
it('syncs the new and the parent colony of a split', async () => {
|
||||
await run(event('ColonySplit', 'new', { parentColonyId: 'mother' }));
|
||||
|
||||
expect(synced()).toEqual(['mother', 'new']);
|
||||
});
|
||||
|
||||
it('syncs the colony and the previous colony of a queen assignment', async () => {
|
||||
await run(
|
||||
event('QueenAssigned', 'q1', { colonyId: 'c2', previousColonyId: 'c1' }),
|
||||
);
|
||||
await run(
|
||||
event('QueenAssigned', 'q1', { colonyId: 'c2', previousColonyId: null }),
|
||||
);
|
||||
|
||||
expect(synced()).toEqual(['c1', 'c2', 'c2']);
|
||||
});
|
||||
|
||||
it('syncs a colony task on completion and update, nothing for other tasks', async () => {
|
||||
await run(
|
||||
event('TaskCompleted', 't1', { relatedType: 'COLONY', relatedId: 'c1' }),
|
||||
);
|
||||
await run(
|
||||
event('TaskCompleted', 't2', { relatedType: 'GENERAL', relatedId: null }),
|
||||
);
|
||||
tx.task.findUnique
|
||||
.mockResolvedValueOnce({ relatedType: 'COLONY', relatedId: 'c4' })
|
||||
.mockResolvedValueOnce({ relatedType: 'QUEEN', relatedId: 'q1' })
|
||||
.mockResolvedValueOnce(null);
|
||||
await run(event('TaskUpdated', 't3', { status: 'OPEN', dueDate: null }));
|
||||
await run(event('TaskUpdated', 't4', { status: 'OPEN', dueDate: null }));
|
||||
await run(event('TaskUpdated', 't5', { status: 'OPEN', dueDate: null }));
|
||||
|
||||
expect(synced()).toEqual(['c1', 'c4']);
|
||||
});
|
||||
|
||||
it('does nothing for a queen event without a colony', async () => {
|
||||
await run(event('QueenAged', 'q1', { colonyId: null, hatchYear: 2023 }));
|
||||
|
||||
expect(autopilot.syncColonyIn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets errors propagate so the delivery is retried', async () => {
|
||||
autopilot.syncColonyIn.mockRejectedValue(new Error('db down'));
|
||||
|
||||
await expect(
|
||||
run(event('InspectionRecorded', 'i1', { colonyId: 'c1' })),
|
||||
).rejects.toThrow('db down');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user