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
@@ -0,0 +1,218 @@
|
||||
import {
|
||||
ScratchDatabase,
|
||||
createScratchDatabase,
|
||||
} from './utils/migration-harness';
|
||||
|
||||
const FEED_VOCABULARY = '20261014100200_t4_feed_vocabulary';
|
||||
const SPLIT_QUEEN_SOURCE = '20261014100300_t4_split_queen_source';
|
||||
const VARROA_SAMPLE_SIZE = '20261014100400_t4_varroa_sample_size';
|
||||
|
||||
const TIMEOUT_MS = 120_000;
|
||||
|
||||
interface InspectionSeed {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
feedStatus: string | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
/** Minimal FK targets, written as raw SQL (Prisma ids and updatedAt have no DB default). */
|
||||
async function seedColony(db: ScratchDatabase): Promise<void> {
|
||||
await db.query(
|
||||
`INSERT INTO "Role" ("id", "key", "name", "updatedAt")
|
||||
VALUES ('role-1', 'MIG_TEST', 'Migration test', now())`,
|
||||
);
|
||||
await db.query(
|
||||
`INSERT INTO "User" ("id", "email", "passwordHash", "name", "roleId", "updatedAt")
|
||||
VALUES ('user-1', '[email protected]', 'x', 'Mig', 'role-1', now())`,
|
||||
);
|
||||
await db.query(
|
||||
`INSERT INTO "Apiary" ("id", "name", "updatedAt")
|
||||
VALUES ('apiary-1', 'Stand', now())`,
|
||||
);
|
||||
await db.query(
|
||||
`INSERT INTO "Colony" ("id", "code", "apiaryId", "colonyType", "updatedAt")
|
||||
VALUES ('colony-1', 'V-1', 'apiary-1', 'PRODUCTION_COLONY', now())`,
|
||||
);
|
||||
}
|
||||
|
||||
async function seedInspections(
|
||||
db: ScratchDatabase,
|
||||
seeds: InspectionSeed[],
|
||||
): Promise<void> {
|
||||
for (const seed of seeds) {
|
||||
await db.query(
|
||||
`INSERT INTO "Inspection" ("id", "colonyId", "userId", "date", "feedStatus", "notes", "createdAt")
|
||||
VALUES ($1, 'colony-1', 'user-1', '2026-05-01', $2, $3, $4)`,
|
||||
[seed.id, seed.feedStatus, seed.notes, seed.createdAt],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
describe('T4 schema II migrations (e2e)', () => {
|
||||
let db: ScratchDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (db) {
|
||||
await db.drop();
|
||||
db = null;
|
||||
}
|
||||
});
|
||||
|
||||
it(
|
||||
'free-text feed moves into notes and known words map to the enum',
|
||||
async () => {
|
||||
db = await createScratchDatabase(FEED_VOCABULARY);
|
||||
await seedColony(db);
|
||||
await seedInspections(db, [
|
||||
{
|
||||
id: 'i-1',
|
||||
createdAt: '2026-05-01T10:00:00Z',
|
||||
feedStatus: 'niedrig',
|
||||
notes: null,
|
||||
},
|
||||
{
|
||||
id: 'i-2',
|
||||
createdAt: '2026-05-01T11:00:00Z',
|
||||
feedStatus: 'halb voll',
|
||||
notes: null,
|
||||
},
|
||||
{
|
||||
id: 'i-3',
|
||||
createdAt: '2026-05-01T12:00:00Z',
|
||||
feedStatus: 'halb voll',
|
||||
notes: 'x',
|
||||
},
|
||||
]);
|
||||
|
||||
await db.migrateThrough(FEED_VOCABULARY);
|
||||
|
||||
expect(
|
||||
await db.query(
|
||||
'SELECT "feedStatus", notes FROM "Inspection" ORDER BY "createdAt"',
|
||||
),
|
||||
).toEqual([
|
||||
{ feedStatus: 'WEAK', notes: null },
|
||||
{ feedStatus: null, notes: 'Futter (alt): halb voll' },
|
||||
{ feedStatus: null, notes: 'x\nFutter (alt): halb voll' },
|
||||
]);
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'leaves no inspection whose old feed text was lost',
|
||||
async () => {
|
||||
db = await createScratchDatabase(FEED_VOCABULARY);
|
||||
await seedColony(db);
|
||||
const rawSeeds: [string | null, string | null][] = [
|
||||
[' Ausreichend ', null],
|
||||
['KRITISCH', 'Hinweis'],
|
||||
['Niedrig', ''],
|
||||
['sehr viel', 'a'],
|
||||
[' Wabe voll ', null],
|
||||
['', 'leer'],
|
||||
[' ', null],
|
||||
[null, 'ohne'],
|
||||
['\tniedrig', null],
|
||||
['\r\n Halb\tvoll\n', null],
|
||||
];
|
||||
const seeds: InspectionSeed[] = rawSeeds.map(
|
||||
([feedStatus, notes], index) => ({
|
||||
id: `i-${index}`,
|
||||
createdAt: `2026-05-01T0${index}:00:00Z`,
|
||||
feedStatus,
|
||||
notes,
|
||||
}),
|
||||
);
|
||||
await seedInspections(db, seeds);
|
||||
|
||||
await db.migrateThrough(FEED_VOCABULARY);
|
||||
|
||||
const rows = await db.query<{
|
||||
id: string;
|
||||
feedStatus: string | null;
|
||||
notes: string | null;
|
||||
}>('SELECT id, "feedStatus", notes FROM "Inspection" ORDER BY id');
|
||||
const byId = new Map(rows.map((row) => [row.id, row]));
|
||||
|
||||
// Spec 4.12: every old text ends up as an enum value or in the notes.
|
||||
const known = ['ausreichend', 'niedrig', 'kritisch'];
|
||||
for (const seed of seeds) {
|
||||
const text = seed.feedStatus?.trim() ?? '';
|
||||
if (text === '') {
|
||||
continue;
|
||||
}
|
||||
const row = byId.get(seed.id)!;
|
||||
if (known.includes(text.toLowerCase())) {
|
||||
expect(row.feedStatus).not.toBeNull();
|
||||
} else {
|
||||
expect(row.feedStatus).toBeNull();
|
||||
expect(row.notes).toContain(`Futter (alt): ${text}`);
|
||||
}
|
||||
}
|
||||
expect(
|
||||
rows.filter(
|
||||
(row) => row.feedStatus === null && (row.notes ?? '') === '',
|
||||
),
|
||||
).toEqual([
|
||||
// Only the blank text with no notes remains empty - nothing was lost.
|
||||
{ id: 'i-6', feedStatus: null, notes: null },
|
||||
]);
|
||||
expect(byId.get('i-1')?.notes).toBe('Hinweis');
|
||||
expect(byId.get('i-3')?.notes).toBe('a\nFutter (alt): sehr viel');
|
||||
expect(byId.get('i-5')?.notes).toBe('leer');
|
||||
expect(byId.get('i-7')?.notes).toBe('ohne');
|
||||
expect(byId.get('i-8')).toMatchObject({
|
||||
feedStatus: 'WEAK',
|
||||
notes: null,
|
||||
});
|
||||
expect(byId.get('i-9')).toMatchObject({
|
||||
feedStatus: null,
|
||||
notes: 'Futter (alt): Halb\tvoll',
|
||||
});
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'adds the optional split queen source and varroa sample size without touching old rows',
|
||||
async () => {
|
||||
db = await createScratchDatabase(SPLIT_QUEEN_SOURCE);
|
||||
await seedColony(db);
|
||||
await db.query(
|
||||
`INSERT INTO "Colony" ("id", "code", "apiaryId", "colonyType", "updatedAt")
|
||||
VALUES ('colony-2', 'V-2', 'apiary-1', 'NUCLEUS_COLONY', now())`,
|
||||
);
|
||||
await db.query(
|
||||
`INSERT INTO "ColonyLineage" ("id", "type", "fromColonyId", "toColonyId", "date")
|
||||
VALUES ('l-1', 'SPLIT', 'colony-1', 'colony-2', '2026-05-01')`,
|
||||
);
|
||||
await db.query(
|
||||
`INSERT INTO "VarroaMeasurement" ("id", "colonyId", "userId", "date", "method", "miteCount")
|
||||
VALUES ('v-1', 'colony-1', 'user-1', '2026-05-02', 'WASHING_METHOD', 4)`,
|
||||
);
|
||||
|
||||
await db.migrateThrough(SPLIT_QUEEN_SOURCE);
|
||||
await db.migrateThrough(VARROA_SAMPLE_SIZE);
|
||||
|
||||
expect(
|
||||
await db.query('SELECT "queenSource" FROM "ColonyLineage"'),
|
||||
).toEqual([{ queenSource: null }]);
|
||||
expect(
|
||||
await db.query(
|
||||
'SELECT "sampleBees", "miteCount" FROM "VarroaMeasurement"',
|
||||
),
|
||||
).toEqual([{ sampleBees: null, miteCount: 4 }]);
|
||||
await db.query(
|
||||
`UPDATE "ColonyLineage" SET "queenSource" = 'RAISE_OWN' WHERE id = 'l-1'`,
|
||||
);
|
||||
await expect(
|
||||
db.query(
|
||||
`UPDATE "ColonyLineage" SET "queenSource" = 'BOUGHT' WHERE id = 'l-1'`,
|
||||
),
|
||||
).rejects.toThrow();
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user