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
@@ -1,6 +1,9 @@
|
||||
import { INestApplicationContext } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { Prisma } from '../generated/prisma';
|
||||
import { AutopilotStep } from '../src/autopilot/autopilot.step';
|
||||
import { SuggestionsExpiryStep } from '../src/autopilot/suggestions-expiry.step';
|
||||
import { AutopilotService } from '../src/autopilot/autopilot.service';
|
||||
import { JobsModule } from '../src/jobs/jobs.module';
|
||||
import { TimeEventsStep } from '../src/jobs/steps/time-events.step';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
@@ -27,7 +30,11 @@ describe('daily-evaluation (e2e)', () => {
|
||||
let app: INestApplicationContext;
|
||||
let prisma: PrismaService;
|
||||
let step: TimeEventsStep;
|
||||
let autopilotStep: AutopilotStep;
|
||||
let expiryStep: SuggestionsExpiryStep;
|
||||
const ids: Record<string, string> = {};
|
||||
/** Colonies of the autopilot tests; the steps only see these. */
|
||||
const autopilotColonyIds: string[] = [];
|
||||
|
||||
const eventsOf = (aggregateId: string) =>
|
||||
prisma.domainEvent.findMany({
|
||||
@@ -49,6 +56,25 @@ describe('daily-evaluation (e2e)', () => {
|
||||
app = await moduleRef.init();
|
||||
prisma = app.get(PrismaService);
|
||||
step = app.get(TimeEventsStep);
|
||||
expiryStep = app.get(SuggestionsExpiryStep);
|
||||
// The real AutopilotStep, but on the colonies of this file only: other
|
||||
// files share the database and must not be reconciled on a 2001 day.
|
||||
autopilotStep = new AutopilotStep(
|
||||
{
|
||||
colony: {
|
||||
findMany: (args: Prisma.ColonyFindManyArgs) =>
|
||||
prisma.colony.findMany({
|
||||
...args,
|
||||
where: {
|
||||
AND: [args.where ?? {}, { id: { in: autopilotColonyIds } }],
|
||||
},
|
||||
}),
|
||||
},
|
||||
automationRule: prisma.automationRule,
|
||||
$transaction: prisma.$transaction.bind(prisma) as unknown,
|
||||
} as unknown as PrismaService,
|
||||
app.get(AutopilotService),
|
||||
);
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
@@ -155,7 +181,15 @@ describe('daily-evaluation (e2e)', () => {
|
||||
await prisma.domainEvent.deleteMany({
|
||||
where: { aggregateId: { in: aggregateIds } },
|
||||
});
|
||||
const colonyIds = [ids.living, ids.dead].filter(Boolean);
|
||||
const colonyIds = [ids.living, ids.dead, ...autopilotColonyIds].filter(
|
||||
Boolean,
|
||||
);
|
||||
await prisma.suggestion.deleteMany({
|
||||
where: { colonyId: { in: colonyIds } },
|
||||
});
|
||||
await prisma.varroaMeasurement.deleteMany({
|
||||
where: { colonyId: { in: colonyIds } },
|
||||
});
|
||||
await prisma.treatment.deleteMany({
|
||||
where: { colonyId: { in: colonyIds } },
|
||||
});
|
||||
@@ -305,4 +339,128 @@ describe('daily-evaluation (e2e)', () => {
|
||||
expect(await eventsOf(ids['queen:young'])).toEqual([]);
|
||||
expect(await eventsOf(ids['queen:reserve'])).toEqual([]);
|
||||
});
|
||||
|
||||
describe('autopilot and suggestions-expiry steps', () => {
|
||||
/** Living colony, inspected 2001-05-03 and measured 2001-05-10. */
|
||||
async function lateColony(name: string): Promise<string> {
|
||||
const colony = await prisma.colony.create({
|
||||
data: {
|
||||
code: `DE-E2E-${suffix}-ap-${name}`,
|
||||
apiaryId: ids.apiary,
|
||||
colonyType: 'PRODUCTION_COLONY',
|
||||
createdAt: d('2001-04-20'),
|
||||
},
|
||||
});
|
||||
autopilotColonyIds.push(colony.id);
|
||||
await prisma.inspection.create({
|
||||
data: { colonyId: colony.id, userId: ids.user, date: d('2001-05-03') },
|
||||
});
|
||||
await prisma.varroaMeasurement.create({
|
||||
data: {
|
||||
colonyId: colony.id,
|
||||
userId: ids.user,
|
||||
date: d('2001-05-10'),
|
||||
method: 'NATURAL_MITE_FALL',
|
||||
},
|
||||
});
|
||||
return colony.id;
|
||||
}
|
||||
|
||||
const inspectSuggestions = (colonyId: string) =>
|
||||
prisma.suggestion.findMany({
|
||||
where: { colonyId, intent: 'INSPECT', source: 'AUTOPILOT' },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
it('running autopilot and expiry twice yields the same suggestion counts', async () => {
|
||||
const colonyId = await lateColony('twice');
|
||||
|
||||
const first = await autopilotStep.run('2001-05-12');
|
||||
const firstExpiry = await expiryStep.run('2001-05-12');
|
||||
const second = await autopilotStep.run('2001-05-12');
|
||||
const secondExpiry = await expiryStep.run('2001-05-12');
|
||||
|
||||
expect(first).toMatchObject({
|
||||
autopilotColonies: 1,
|
||||
suggestionsOpened: 1,
|
||||
suggestionsResolved: 0,
|
||||
});
|
||||
expect(second).toMatchObject({
|
||||
autopilotColonies: 1,
|
||||
suggestionsOpened: 0,
|
||||
suggestionsResolved: 0,
|
||||
});
|
||||
expect(firstExpiry).toEqual({ suggestionsExpired: 0 });
|
||||
expect(secondExpiry).toEqual({ suggestionsExpired: 0 });
|
||||
expect(await inspectSuggestions(colonyId)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('an OPEN suggestion past expiresAt becomes EXPIRED', async () => {
|
||||
const colonyId = await lateColony('expires');
|
||||
await autopilotStep.run('2001-05-12');
|
||||
|
||||
// expiresAt = 2001-06-11
|
||||
expect(await expiryStep.run('2001-06-11')).toEqual({
|
||||
suggestionsExpired: 0,
|
||||
});
|
||||
expect(await expiryStep.run('2001-06-12')).toEqual({
|
||||
suggestionsExpired: expect.any(Number) as number,
|
||||
});
|
||||
|
||||
const [suggestion] = await inspectSuggestions(colonyId);
|
||||
expect(suggestion).toMatchObject({ status: 'EXPIRED', dedupeKey: null });
|
||||
});
|
||||
|
||||
it('an expired INSPECT suggestion of the same anchor is proposed again on the next sync (dedupeKey was released)', async () => {
|
||||
const colonyId = await lateColony('again');
|
||||
await autopilotStep.run('2001-05-12');
|
||||
await expiryStep.run('2001-06-12');
|
||||
|
||||
const result = await autopilotStep.run('2001-06-12');
|
||||
|
||||
expect(result.suggestionsOpened).toBeGreaterThanOrEqual(1);
|
||||
expect((await inspectSuggestions(colonyId)).map((s) => s.status)).toEqual(
|
||||
['EXPIRED', 'OPEN'],
|
||||
);
|
||||
});
|
||||
|
||||
it('a colony overdue for 20+ days gets its step from the daily step although no new event is recorded', async () => {
|
||||
const colonyId = await lateColony('overdue20');
|
||||
await prisma.inspection.updateMany({
|
||||
where: { colonyId },
|
||||
data: { date: d('2001-04-10') },
|
||||
});
|
||||
|
||||
await step.run('2001-05-12');
|
||||
await autopilotStep.run('2001-05-12');
|
||||
|
||||
// Due 2001-04-17: the event of 04-18 is outside the 7-day catch-up.
|
||||
expect(
|
||||
await prisma.domainEvent.count({
|
||||
where: { aggregateId: colonyId, type: 'InspectionOverdue' },
|
||||
}),
|
||||
).toBe(0);
|
||||
const [suggestion] = await inspectSuggestions(colonyId);
|
||||
expect(suggestion).toMatchObject({ status: 'OPEN', priority: 'HIGH' });
|
||||
expect(suggestion.dueDate?.toISOString().slice(0, 10)).toBe('2001-04-17');
|
||||
});
|
||||
|
||||
it('a RESOLVED autopilot suggestion whose condition returns is proposed again', async () => {
|
||||
const colonyId = await lateColony('returns');
|
||||
await autopilotStep.run('2001-05-12');
|
||||
const fresh = await prisma.inspection.create({
|
||||
data: { colonyId, userId: ids.user, date: d('2001-05-12') },
|
||||
});
|
||||
const resolved = await autopilotStep.run('2001-05-12');
|
||||
await prisma.inspection.delete({ where: { id: fresh.id } });
|
||||
|
||||
const returned = await autopilotStep.run('2001-05-12');
|
||||
|
||||
expect(resolved.suggestionsResolved).toBeGreaterThanOrEqual(1);
|
||||
expect(returned.suggestionsOpened).toBeGreaterThanOrEqual(1);
|
||||
expect((await inspectSuggestions(colonyId)).map((s) => s.status)).toEqual(
|
||||
['RESOLVED', 'OPEN'],
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user