feat: hive setup data model and API (Beute L1)
Each colony can now hold its hive setup (Ist-Zustand) as validated JSON with an append-only change log: - catalog: hive systems and part types with dimensions, placement rules and state fields; Liebig-Zander 10 seeded with fixed ids (owner's equipment incl. Bausperre, Futterzarge, Nassenheider) - pure domain module (operations, validator, derived facts) with shared JSON fixtures the frontend runs too - API: catalog (hive-systems:manage), setup create/change/history/ state at a day (colonies:read/update), 409 HIVE_CONFLICT with the current state, idempotent writes, HiveChanged event - inspections can carry hive ops saved in the same transaction - backup export/import of the new tables (backup wins per colony) - docs: ARCHITECTURE section 13, Annahmen 95-100 Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
8982d0796a
commit
b48fb157c8
141 files changed
+15211
-17
No files matched your search
@@ -0,0 +1,109 @@
|
||||
-- Beute in 3D: hive systems, part types, per-colony setup and its change
|
||||
-- history (spec 2026-10-15, section 5.1).
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "HivePartKind" AS ENUM ('FLOOR', 'BOX', 'DIVIDER_BOARD', 'QUEEN_EXCLUDER', 'FEEDER', 'COVER_SHEET', 'PROPOLIS_SCREEN', 'INNER_LID', 'INSULATION', 'ROOF', 'FLOOR_INSERT', 'EVAPORATOR');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "HiveBoxDepth" AS ENUM ('FULL', 'SHALLOW');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "HiveChangeSource" AS ENUM ('INIT', 'CARD', 'EDITOR', 'INSPECTION', 'AI_SUGGESTION');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "HiveSystem" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"frameWidthMm" INTEGER NOT NULL,
|
||||
"frameHeightMm" INTEGER NOT NULL,
|
||||
"footprintLMm" INTEGER NOT NULL,
|
||||
"footprintWMm" INTEGER NOT NULL,
|
||||
"template" JSONB NOT NULL,
|
||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "HiveSystem_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "HivePartType" (
|
||||
"id" TEXT NOT NULL,
|
||||
"systemId" TEXT NOT NULL,
|
||||
"kind" "HivePartKind" NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"articleRef" TEXT,
|
||||
"lengthMm" INTEGER NOT NULL,
|
||||
"widthMm" INTEGER NOT NULL,
|
||||
"heightMm" INTEGER NOT NULL,
|
||||
"boxDepth" "HiveBoxDepth",
|
||||
"frameSlots" INTEGER,
|
||||
"stateSchema" JSONB NOT NULL,
|
||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "HivePartType_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "HiveSetup" (
|
||||
"colonyId" TEXT NOT NULL,
|
||||
"systemId" TEXT NOT NULL,
|
||||
"version" INTEGER NOT NULL DEFAULT 1,
|
||||
"state" JSONB NOT NULL,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "HiveSetup_pkey" PRIMARY KEY ("colonyId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "HiveChange" (
|
||||
"id" TEXT NOT NULL,
|
||||
"colonyId" TEXT NOT NULL,
|
||||
"version" INTEGER NOT NULL,
|
||||
"occurredOn" TIMESTAMP(3) NOT NULL,
|
||||
"source" "HiveChangeSource" NOT NULL,
|
||||
"inspectionId" TEXT,
|
||||
"userId" TEXT NOT NULL,
|
||||
"ops" JSONB NOT NULL,
|
||||
"stateAfter" JSONB NOT NULL,
|
||||
"note" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "HiveChange_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "HiveSystem_name_key" ON "HiveSystem"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "HivePartType_systemId_name_key" ON "HivePartType"("systemId", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "HiveChange_inspectionId_key" ON "HiveChange"("inspectionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "HiveChange_colonyId_occurredOn_idx" ON "HiveChange"("colonyId", "occurredOn");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "HiveChange_colonyId_version_key" ON "HiveChange"("colonyId", "version");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "HivePartType" ADD CONSTRAINT "HivePartType_systemId_fkey" FOREIGN KEY ("systemId") REFERENCES "HiveSystem"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "HiveSetup" ADD CONSTRAINT "HiveSetup_colonyId_fkey" FOREIGN KEY ("colonyId") REFERENCES "Colony"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "HiveSetup" ADD CONSTRAINT "HiveSetup_systemId_fkey" FOREIGN KEY ("systemId") REFERENCES "HiveSystem"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "HiveChange" ADD CONSTRAINT "HiveChange_colonyId_fkey" FOREIGN KEY ("colonyId") REFERENCES "HiveSetup"("colonyId") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "HiveChange" ADD CONSTRAINT "HiveChange_inspectionId_fkey" FOREIGN KEY ("inspectionId") REFERENCES "Inspection"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "HiveChange" ADD CONSTRAINT "HiveChange_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
-- Beute in 3D: start catalog for the Liebig-Zander equipment (spec 2026-10-15,
|
||||
-- sections 4.3 and 4.4) under fixed ids, so the system template has stable
|
||||
-- references and a restore into a fresh install creates no duplicate names.
|
||||
-- Dimensions are catalog data and editable in the settings.
|
||||
|
||||
-- Hive system
|
||||
INSERT INTO "HiveSystem" ("id", "name", "frameWidthMm", "frameHeightMm", "footprintLMm", "footprintWMm", "template", "active", "updatedAt")
|
||||
VALUES (
|
||||
'hive-system-zander-liebig-10',
|
||||
'Zander nach Dr. Liebig, 10 Rähmchen',
|
||||
477,
|
||||
220,
|
||||
525,
|
||||
430,
|
||||
'{"schemaVersion":1,"stack":[{"partTypeId":"hive-part-zl10-floor","state":{}},{"partTypeId":"hive-part-zl10-box-10","state":{},"frames":[{"slot":1,"type":"FOUNDATION"},{"slot":2,"type":"FOUNDATION"},{"slot":3,"type":"FOUNDATION"},{"slot":4,"type":"FOUNDATION"},{"slot":5,"type":"FOUNDATION"},{"slot":6,"type":"FOUNDATION"},{"slot":7,"type":"FOUNDATION"},{"slot":8,"type":"FOUNDATION"},{"slot":9,"type":"FOUNDATION"},{"slot":10,"type":"FOUNDATION"}]},{"partTypeId":"hive-part-zl10-cover-abdeckfix","state":{}},{"partTypeId":"hive-part-zl10-inner-lid","state":{}},{"partTypeId":"hive-part-zl10-insulation","state":{}},{"partTypeId":"hive-part-zl10-roof","state":{}}]}'::jsonb,
|
||||
true,
|
||||
CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Part types
|
||||
INSERT INTO "HivePartType" ("id", "systemId", "kind", "name", "lengthMm", "widthMm", "heightMm", "boxDepth", "frameSlots", "stateSchema", "active", "updatedAt")
|
||||
VALUES
|
||||
('hive-part-zl10-floor', 'hive-system-zander-liebig-10', 'FLOOR', 'Liebig Wanderboden', 525, 430, 120, NULL, NULL, '[{"key":"entrance","label":"Flugloch","kind":"choice","options":["OPEN","NARROW","CLOSED"],"optionLabels":["offen","eng","zu"]},{"key":"varroaTray","label":"Varroa-Schublade","kind":"choice","options":["IN","OUT"],"optionLabels":["drin","draußen"]}]'::jsonb, true, CURRENT_TIMESTAMP),
|
||||
('hive-part-zl10-floor-insert', 'hive-system-zander-liebig-10', 'FLOOR_INSERT', 'Liebig Bausperre', 475, 375, 5, NULL, NULL, '[]'::jsonb, true, CURRENT_TIMESTAMP),
|
||||
('hive-part-zl10-box-10', 'hive-system-zander-liebig-10', 'BOX', 'Liebig Zander Ganzzarge, 10 Rähmchen', 525, 430, 230, 'FULL', 10, '[]'::jsonb, true, CURRENT_TIMESTAMP),
|
||||
('hive-part-zl10-box-empty', 'hive-system-zander-liebig-10', 'BOX', 'Leerzarge (Liebig Zander Ganzzarge ohne Rähmchen)', 525, 430, 230, 'FULL', 0, '[]'::jsonb, true, CURRENT_TIMESTAMP),
|
||||
('hive-part-zl10-divider-board', 'hive-system-zander-liebig-10', 'DIVIDER_BOARD', 'Liebig Zwischenboden', 525, 430, 50, NULL, NULL, '[{"key":"bungHoles","label":"Spundlöcher","kind":"choice","options":["OPEN","CLOSED","BEE_ESCAPE"],"optionLabels":["offen","zu","Bienenflucht"]}]'::jsonb, true, CURRENT_TIMESTAMP),
|
||||
('hive-part-zl10-queen-excluder', 'hive-system-zander-liebig-10', 'QUEEN_EXCLUDER', 'Niro-Absperrgitter Zander Liebig', 478, 376, 2, NULL, NULL, '[]'::jsonb, true, CURRENT_TIMESTAMP),
|
||||
('hive-part-zl10-feeder', 'hive-system-zander-liebig-10', 'FEEDER', 'Liebig Zweikammer-Futterzarge', 520, 420, 70, NULL, NULL, '[{"key":"syrupLiters","label":"Flüssigfutter","kind":"level","unit":"l","max":3,"step":0.5,"feedType":"SUGAR_WATER"},{"key":"fondantKg","label":"Futterteig","kind":"level","unit":"kg","max":2.5,"step":0.5,"feedType":"FEED_PASTE"}]'::jsonb, true, CURRENT_TIMESTAMP),
|
||||
('hive-part-zl10-cover-abdeckfix', 'hive-system-zander-liebig-10', 'COVER_SHEET', 'Abdeck-Fix (Folie, klar, 0,4 mm)', 478, 378, 1, NULL, NULL, '[]'::jsonb, true, CURRENT_TIMESTAMP),
|
||||
('hive-part-zl10-cover-lotus', 'hive-system-zander-liebig-10', 'COVER_SHEET', 'Imgut Lotus-Abdeckung (0,5 mm)', 478, 378, 1, NULL, NULL, '[]'::jsonb, true, CURRENT_TIMESTAMP),
|
||||
('hive-part-zl10-propolis-screen', 'hive-system-zander-liebig-10', 'PROPOLIS_SCREEN', 'Imgut Propolisgitter', 478, 378, 3, NULL, NULL, '[]'::jsonb, true, CURRENT_TIMESTAMP),
|
||||
('hive-part-zl10-inner-lid', 'hive-system-zander-liebig-10', 'INNER_LID', 'Liebig Holzdeckel', 525, 430, 35, NULL, NULL, '[{"key":"feedHole","label":"Futterloch","kind":"choice","options":["OPEN","CLOSED"],"optionLabels":["offen","zu"]}]'::jsonb, true, CURRENT_TIMESTAMP),
|
||||
('hive-part-zl10-insulation', 'hive-system-zander-liebig-10', 'INSULATION', 'Styropor-Einlage (selbst gebaut)', 465, 365, 20, NULL, NULL, '[]'::jsonb, true, CURRENT_TIMESTAMP),
|
||||
('hive-part-zl10-roof', 'hive-system-zander-liebig-10', 'ROOF', 'Edelstahldeckel konisch Liebig', 525, 430, 60, NULL, NULL, '[]'::jsonb, true, CURRENT_TIMESTAMP),
|
||||
('hive-part-zl10-evaporator', 'hive-system-zander-liebig-10', 'EVAPORATOR', 'Nassenheider Verdunster Professional', 200, 140, 120, NULL, NULL, '[{"key":"acidMl","label":"Ameisensäure","kind":"level","unit":"ml","max":290,"step":10}]'::jsonb, true, CURRENT_TIMESTAMP);
|
||||
@@ -104,6 +104,7 @@ model User {
|
||||
refreshTokens RefreshToken[]
|
||||
|
||||
inspections Inspection[]
|
||||
hiveChanges HiveChange[]
|
||||
varroaMeasurements VarroaMeasurement[]
|
||||
swarmControlChecks SwarmControlCheck[]
|
||||
winterChecks WinterCheck[]
|
||||
@@ -331,6 +332,7 @@ model Colony {
|
||||
harvests HarvestColony[]
|
||||
queenEvaluations QueenEvaluation[]
|
||||
suggestions Suggestion[]
|
||||
hiveSetup HiveSetup?
|
||||
|
||||
@@index([apiaryId])
|
||||
@@index([status])
|
||||
@@ -501,7 +503,8 @@ model Inspection {
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
tasks Task[]
|
||||
tasks Task[]
|
||||
hiveChange HiveChange?
|
||||
|
||||
@@index([colonyId, date])
|
||||
}
|
||||
@@ -2182,3 +2185,101 @@ model ComplianceAcknowledgement {
|
||||
|
||||
@@index([itemKey, subjectId])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Beute in 3D: Beutensysteme, Teile-Arten, Aufbau je Volk (Spec 2026-10-15)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
model HiveSystem {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
frameWidthMm Int // Oberträger
|
||||
frameHeightMm Int
|
||||
footprintLMm Int
|
||||
footprintWMm Int
|
||||
template Json // Aufbau-JSON ohne Ids, Schema 5.2
|
||||
active Boolean @default(true)
|
||||
partTypes HivePartType[]
|
||||
setups HiveSetup[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model HivePartType {
|
||||
id String @id @default(cuid())
|
||||
systemId String
|
||||
system HiveSystem @relation(fields: [systemId], references: [id])
|
||||
kind HivePartKind
|
||||
name String
|
||||
articleRef String? // z. B. Holtermann-Art.-Nr.
|
||||
lengthMm Int
|
||||
widthMm Int
|
||||
heightMm Int
|
||||
boxDepth HiveBoxDepth?
|
||||
frameSlots Int? // nur BOX
|
||||
stateSchema Json // Abschnitt 4.2
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([systemId, name])
|
||||
}
|
||||
|
||||
model HiveSetup {
|
||||
colonyId String @id
|
||||
colony Colony @relation(fields: [colonyId], references: [id])
|
||||
systemId String
|
||||
system HiveSystem @relation(fields: [systemId], references: [id])
|
||||
version Int @default(1)
|
||||
state Json // Abschnitt 5.2, immer vollständig
|
||||
updatedAt DateTime @updatedAt
|
||||
changes HiveChange[]
|
||||
}
|
||||
|
||||
model HiveChange {
|
||||
id String @id @default(cuid())
|
||||
colonyId String
|
||||
setup HiveSetup @relation(fields: [colonyId], references: [colonyId])
|
||||
version Int // Version nach der Änderung
|
||||
occurredOn DateTime // fachlicher Tag (Durchsicht oder Eingabe)
|
||||
source HiveChangeSource
|
||||
inspectionId String? @unique
|
||||
inspection Inspection? @relation(fields: [inspectionId], references: [id])
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
ops Json // angewandte Operationen, Abschnitt 6
|
||||
stateAfter Json
|
||||
note String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([colonyId, version])
|
||||
@@index([colonyId, occurredOn])
|
||||
}
|
||||
|
||||
enum HivePartKind {
|
||||
FLOOR
|
||||
BOX
|
||||
DIVIDER_BOARD
|
||||
QUEEN_EXCLUDER
|
||||
FEEDER
|
||||
COVER_SHEET
|
||||
PROPOLIS_SCREEN
|
||||
INNER_LID
|
||||
INSULATION
|
||||
ROOF
|
||||
FLOOR_INSERT
|
||||
EVAPORATOR
|
||||
}
|
||||
|
||||
enum HiveBoxDepth {
|
||||
FULL
|
||||
SHALLOW
|
||||
}
|
||||
|
||||
enum HiveChangeSource {
|
||||
INIT
|
||||
CARD
|
||||
EDITOR
|
||||
INSPECTION
|
||||
AI_SUGGESTION
|
||||
}
|
||||
@@ -60,6 +60,7 @@ import { SuggestionsModule } from './suggestions/suggestions.module';
|
||||
import { AutopilotModule } from './autopilot/autopilot.module';
|
||||
import { SystemModule } from './system/system.module';
|
||||
import { WithdrawalModule } from './withdrawal/withdrawal.module';
|
||||
import { HiveModule } from './hive/hive.module';
|
||||
import { VeterinaryMedicinesModule } from './veterinary-medicines/veterinary-medicines.module';
|
||||
import { ComplianceModule } from './compliance/compliance.module';
|
||||
import { ReadinessModule } from './compliance/readiness/readiness.module';
|
||||
@@ -136,6 +137,7 @@ import { IdempotencyInterceptor } from './common/idempotency/idempotency.interce
|
||||
ApiKeysModule,
|
||||
SystemModule,
|
||||
VeterinaryMedicinesModule,
|
||||
HiveModule,
|
||||
ComplianceModule,
|
||||
ReadinessModule,
|
||||
],
|
||||
|
||||
@@ -412,6 +412,9 @@ export const PERMISSION_CATALOG = [
|
||||
],
|
||||
3800,
|
||||
),
|
||||
// Beute in 3D: hive systems and their part-type catalog. Reading the
|
||||
// catalog and writing a colony's setup use the colonies permissions.
|
||||
...group('Beutensysteme', 'hive-systems', [['manage', 'Pflegen']], 3900),
|
||||
] as const satisfies readonly PermissionCatalogEntry[];
|
||||
|
||||
export type PermissionKey = (typeof PERMISSION_CATALOG)[number]['key'];
|
||||
@@ -2,6 +2,7 @@ import { BadRequestException, Logger } from '@nestjs/common';
|
||||
import * as path from 'node:path';
|
||||
import { Prisma } from '../../generated/prisma';
|
||||
import {
|
||||
BackupBundle,
|
||||
BackupService,
|
||||
LEGACY_UNSEALED_TABLES,
|
||||
MODELS_EXCLUDED_FROM_BACKUP,
|
||||
@@ -113,7 +114,15 @@ describe('BackupService', () => {
|
||||
document: { findMany: jest.Mock; count: jest.Mock };
|
||||
$transaction: jest.Mock;
|
||||
};
|
||||
let tx: Record<string, { upsert: jest.Mock; update?: jest.Mock }> & {
|
||||
let tx: Record<
|
||||
string,
|
||||
{
|
||||
upsert: jest.Mock;
|
||||
update?: jest.Mock;
|
||||
create?: jest.Mock;
|
||||
deleteMany?: jest.Mock;
|
||||
}
|
||||
> & {
|
||||
permission: { findUnique: jest.Mock };
|
||||
user: { upsert: jest.Mock; findMany: jest.Mock };
|
||||
queen: { upsert: jest.Mock; update: jest.Mock; findMany: jest.Mock };
|
||||
@@ -139,6 +148,10 @@ describe('BackupService', () => {
|
||||
(tx as unknown as { $queryRaw: jest.Mock }).$queryRaw = jest
|
||||
.fn()
|
||||
.mockResolvedValue([]);
|
||||
for (const key of ['hiveChange', 'hiveSetup']) {
|
||||
tx[key].create = jest.fn().mockResolvedValue({});
|
||||
tx[key].deleteMany = jest.fn().mockResolvedValue({ count: 0 });
|
||||
}
|
||||
tx.queen.update = jest.fn().mockResolvedValue({});
|
||||
tx.queen.findMany = jest.fn().mockResolvedValue([]);
|
||||
tx.document.findUnique = jest.fn().mockResolvedValue(null);
|
||||
@@ -1948,6 +1961,80 @@ describe('BackupService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('importAll hive models', () => {
|
||||
const hiveBundle = (): BackupBundle => ({
|
||||
version: 3,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: {
|
||||
hiveSystem: [{ id: 'hive-system-1', name: 'System' }],
|
||||
hivePartType: [{ id: 'hive-part-1', systemId: 'hive-system-1' }],
|
||||
colony: [{ id: 'colony-1' }, { id: 'colony-2' }],
|
||||
hiveSetup: [
|
||||
{ colonyId: 'colony-1', systemId: 'hive-system-1', version: 2 },
|
||||
{ colonyId: 'colony-other', systemId: 'hive-system-1', version: 1 },
|
||||
],
|
||||
hiveChange: [
|
||||
{ id: 'change-1', colonyId: 'colony-1', userId: 'known-user' },
|
||||
{ id: 'change-2', colonyId: 'colony-1', userId: 'gone-user' },
|
||||
{ id: 'change-3', colonyId: 'colony-other', userId: 'known-user' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
it('upserts the catalog by id so seeded rows are neither duplicated nor rejected', async () => {
|
||||
await service.importAll(hiveBundle(), 'admin-1', TRUSTED);
|
||||
|
||||
expect(tx.hiveSystem.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: 'hive-system-1' } }),
|
||||
);
|
||||
expect(tx.hivePartType.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: 'hive-part-1' } }),
|
||||
);
|
||||
});
|
||||
|
||||
it('replaces setup and history of each restored colony, remapping the history user', async () => {
|
||||
const summary = await service.importAll(hiveBundle(), 'admin-1', TRUSTED);
|
||||
|
||||
expect(tx.hiveChange.deleteMany).toHaveBeenCalledWith({
|
||||
where: { colonyId: { in: ['colony-1'] } },
|
||||
});
|
||||
expect(tx.hiveSetup.deleteMany).toHaveBeenCalledWith({
|
||||
where: { colonyId: { in: ['colony-1'] } },
|
||||
});
|
||||
expect(tx.hiveSetup.create).toHaveBeenCalledTimes(1);
|
||||
const created = tx.hiveChange.create!.mock.calls.map(
|
||||
([arg]: [{ data: { id: string; userId: string } }]) => arg.data,
|
||||
);
|
||||
expect(created).toEqual([
|
||||
expect.objectContaining({ id: 'change-1', userId: 'known-user' }),
|
||||
expect.objectContaining({ id: 'change-2', userId: 'admin-1' }),
|
||||
]);
|
||||
expect(summary.imported).toMatchObject({
|
||||
hiveSystem: 1,
|
||||
hivePartType: 1,
|
||||
hiveSetup: 2,
|
||||
hiveChange: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('takes no hive table from an unsealed file and reports them', async () => {
|
||||
const summary = await service.importAll(hiveBundle(), 'admin-1', {
|
||||
trusted: false,
|
||||
});
|
||||
|
||||
expect(tx.hiveSetup.create).not.toHaveBeenCalled();
|
||||
expect(tx.hiveSystem.upsert).not.toHaveBeenCalled();
|
||||
expect(summary.skipped).toEqual(
|
||||
expect.arrayContaining([
|
||||
'hiveSystem',
|
||||
'hivePartType',
|
||||
'hiveSetup',
|
||||
'hiveChange',
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Drift guard: a model added to the schema has to be backed up or listed
|
||||
// in MODELS_EXCLUDED_FROM_BACKUP with its reason - never silently missing.
|
||||
describe('model coverage', () => {
|
||||
|
||||
@@ -329,9 +329,13 @@ export class BackupService {
|
||||
data.colony = await this.prisma.colony.findMany();
|
||||
data.colonyApiaryHistory = await this.prisma.colonyApiaryHistory.findMany();
|
||||
data.colonyLineage = await this.prisma.colonyLineage.findMany();
|
||||
data.hiveSystem = await this.prisma.hiveSystem.findMany();
|
||||
data.hivePartType = await this.prisma.hivePartType.findMany();
|
||||
data.hiveSetup = await this.prisma.hiveSetup.findMany();
|
||||
data.queenEvaluation = await this.prisma.queenEvaluation.findMany();
|
||||
data.queenColonyHistory = await this.prisma.queenColonyHistory.findMany();
|
||||
data.inspection = await this.prisma.inspection.findMany();
|
||||
data.hiveChange = await this.prisma.hiveChange.findMany();
|
||||
data.taskTemplate = await this.prisma.taskTemplate.findMany();
|
||||
data.suggestion = await this.prisma.suggestion.findMany();
|
||||
data.task = await this.prisma.task.findMany();
|
||||
@@ -608,6 +612,26 @@ export class BackupService {
|
||||
}
|
||||
imported.queen = rowsOf('queen').length;
|
||||
|
||||
// Catalog first: a fresh install already carries the seeded rows with
|
||||
// the same fixed ids, so upsert by id neither fails nor duplicates.
|
||||
for (const row of rowsOf('hiveSystem')) {
|
||||
await tx.hiveSystem.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: row as never,
|
||||
update: updateWith(row) as never,
|
||||
});
|
||||
}
|
||||
imported.hiveSystem = rowsOf('hiveSystem').length;
|
||||
|
||||
for (const row of rowsOf('hivePartType')) {
|
||||
await tx.hivePartType.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: row as never,
|
||||
update: updateWith(row) as never,
|
||||
});
|
||||
}
|
||||
imported.hivePartType = rowsOf('hivePartType').length;
|
||||
|
||||
// Restore deliberately emits no ColonyCreated (no domain events).
|
||||
for (const row of rowsOf('colony')) {
|
||||
await tx.colony.upsert({
|
||||
@@ -618,6 +642,33 @@ export class BackupService {
|
||||
}
|
||||
imported.colony = rowsOf('colony').length;
|
||||
|
||||
// A restore replaces, never merges: the backup's setup wins, so the
|
||||
// existing setup and history of each restored colony go first (the
|
||||
// history rows reference the setup). Their backup history follows
|
||||
// after the inspections.
|
||||
const hiveSetupRows = rowsOf('hiveSetup');
|
||||
const restoredColonyIds = new Set(
|
||||
rowsOf('colony').map((row) => row.id as string),
|
||||
);
|
||||
const setupsToReplace = hiveSetupRows.filter((row) =>
|
||||
restoredColonyIds.has(row.colonyId as string),
|
||||
);
|
||||
const replacedColonyIds = setupsToReplace.map(
|
||||
(row) => row.colonyId as string,
|
||||
);
|
||||
if (replacedColonyIds.length > 0) {
|
||||
await tx.hiveChange.deleteMany({
|
||||
where: { colonyId: { in: replacedColonyIds } },
|
||||
});
|
||||
await tx.hiveSetup.deleteMany({
|
||||
where: { colonyId: { in: replacedColonyIds } },
|
||||
});
|
||||
}
|
||||
for (const row of setupsToReplace) {
|
||||
await tx.hiveSetup.create({ data: row as never });
|
||||
}
|
||||
imported.hiveSetup = hiveSetupRows.length;
|
||||
|
||||
for (const row of rowsOf('queen')) {
|
||||
if (row.motherQueenId && !preexistingQueenIds.has(row.id as string)) {
|
||||
await tx.queen.update({
|
||||
@@ -692,6 +743,21 @@ export class BackupService {
|
||||
}
|
||||
imported.inspection = rowsOf('inspection').length;
|
||||
|
||||
const replacedColonies = new Set(replacedColonyIds);
|
||||
const hiveChangeRows = rowsOf('hiveChange');
|
||||
for (const row of hiveChangeRows) {
|
||||
if (!replacedColonies.has(row.colonyId as string)) {
|
||||
continue;
|
||||
}
|
||||
await tx.hiveChange.create({
|
||||
data: {
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
});
|
||||
}
|
||||
imported.hiveChange = hiveChangeRows.length;
|
||||
|
||||
for (const row of rowsOf('taskTemplate')) {
|
||||
await tx.taskTemplate.upsert({
|
||||
where: { id: row.id as string },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
ColonyCondition,
|
||||
ColonyStatus,
|
||||
HiveChangeSource,
|
||||
PhaseSource,
|
||||
PhenologyPhase,
|
||||
Prisma,
|
||||
@@ -144,6 +145,15 @@ export interface DomainEventPayloads {
|
||||
// reference day (referenceDay, 'YYYY-MM-DD') was reached. Not forwarded to
|
||||
// webhooks.
|
||||
TskReferenceDayReached: { year: number; referenceDay: string };
|
||||
// Beute in 3D, aggregate Colony: the hive setup of a colony changed (INIT
|
||||
// or a change). Ids and the source only, no content. Not forwarded to
|
||||
// webhooks.
|
||||
HiveChanged: {
|
||||
colonyId: string;
|
||||
version: number;
|
||||
source: HiveChangeSource;
|
||||
inspectionId: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export type DomainEventType = keyof DomainEventPayloads;
|
||||
@@ -188,6 +198,7 @@ const CATALOG: Record<DomainEventType, true> = {
|
||||
ColonyMoved: true,
|
||||
ApiaryChanged: true,
|
||||
TskReferenceDayReached: true,
|
||||
HiveChanged: true,
|
||||
};
|
||||
|
||||
export const DOMAIN_EVENT_TYPES: readonly DomainEventType[] = Object.freeze(
|
||||
|
||||
@@ -183,7 +183,7 @@ describe('DomainEventsService', () => {
|
||||
|
||||
describe('domain event catalog', () => {
|
||||
it('lists every event type exactly once', () => {
|
||||
expect(DOMAIN_EVENT_TYPES).toHaveLength(38);
|
||||
expect(DOMAIN_EVENT_TYPES).toHaveLength(39);
|
||||
expect(new Set(DOMAIN_EVENT_TYPES).size).toBe(DOMAIN_EVENT_TYPES.length);
|
||||
expect(DOMAIN_EVENT_TYPES).toEqual(
|
||||
expect.arrayContaining([
|
||||
@@ -206,6 +206,7 @@ describe('domain event catalog', () => {
|
||||
'ColonyMoved',
|
||||
'ApiaryChanged',
|
||||
'TskReferenceDayReached',
|
||||
'HiveChanged',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { ArgumentMetadata, ValidationPipe } from '@nestjs/common';
|
||||
import { CreateHiveChangeDto } from './create-hive-change.dto';
|
||||
|
||||
const pipe = new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
});
|
||||
const metadata: ArgumentMetadata = {
|
||||
type: 'body',
|
||||
metatype: CreateHiveChangeDto,
|
||||
};
|
||||
|
||||
const body = (overrides: Record<string, unknown> = {}) => ({
|
||||
occurredOn: '2026-10-15',
|
||||
source: 'CARD',
|
||||
ops: [{ op: 'setLabel', id: 'p1', label: 'Brutraum' }],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const accepted = (value: unknown) => pipe.transform(value, metadata);
|
||||
const rejected = async (value: unknown) => {
|
||||
await expect(accepted(value)).rejects.toMatchObject({ status: 400 });
|
||||
};
|
||||
|
||||
describe('CreateHiveChangeDto', () => {
|
||||
it('accepts every op shape', async () => {
|
||||
const ops = [
|
||||
{ op: 'addPart', part: { id: 'a', partTypeId: 't', state: {} }, at: 1 },
|
||||
{ op: 'addInsert', hostId: 'h', part: { id: 'a' } },
|
||||
{ op: 'removePart', id: 'a' },
|
||||
{ op: 'movePart', id: 'a', to: 0 },
|
||||
{ op: 'setState', id: 'a', key: 'k', value: 0.5 },
|
||||
{ op: 'setState', id: 'a', key: 'k', value: 'OPEN' },
|
||||
{ op: 'setFrame', boxId: 'b', slot: 1, type: 'HONEY', name: null },
|
||||
{ op: 'setFrames', boxId: 'b', frames: [{ slot: 1, type: 'BROOD' }] },
|
||||
{ op: 'setLabel', id: 'a', label: null },
|
||||
];
|
||||
await expect(accepted(body({ ops }))).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('refuses AI_SUGGESTION, INIT and INSPECTION as source', async () => {
|
||||
for (const source of ['AI_SUGGESTION', 'INIT', 'INSPECTION']) {
|
||||
await rejected(body({ source }));
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses an unknown op name', async () => {
|
||||
await rejected(body({ ops: [{ op: 'explode' }] }));
|
||||
});
|
||||
|
||||
it('refuses wrong primitive types', async () => {
|
||||
await rejected(body({ ops: [{ op: 'removePart', id: 5 }] }));
|
||||
await rejected(body({ ops: [{ op: 'movePart', id: 'a', to: '1' }] }));
|
||||
await rejected(
|
||||
body({ ops: [{ op: 'setState', id: 'a', key: 'k', value: true }] }),
|
||||
);
|
||||
await rejected(body({ ops: [{ op: 'addPart', part: 'x', at: 0 }] }));
|
||||
});
|
||||
|
||||
it('refuses a missing required field of an op', async () => {
|
||||
await rejected(body({ ops: [{ op: 'removePart' }] }));
|
||||
});
|
||||
|
||||
it('refuses an empty ops list', async () => {
|
||||
await rejected(body({ ops: [] }));
|
||||
});
|
||||
|
||||
it('refuses more than 100 ops and a non-array', async () => {
|
||||
const op = { op: 'setLabel', id: 'p1', label: 'x' };
|
||||
await expect(
|
||||
accepted(body({ ops: Array.from({ length: 100 }, () => op) })),
|
||||
).resolves.toBeDefined();
|
||||
await rejected(body({ ops: Array.from({ length: 101 }, () => op) }));
|
||||
await rejected(body({ ops: 'nope' }));
|
||||
});
|
||||
|
||||
it('refuses a malformed day and unknown properties', async () => {
|
||||
await rejected(body({ occurredOn: '15.10.2026' }));
|
||||
await rejected(body({ extra: 1 }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { DAY_PATTERN } from './create-hive-setup.dto';
|
||||
import { HiveOpDto, MAX_OPS_PER_REQUEST } from './hive-op.dto';
|
||||
|
||||
// INIT and INSPECTION are set by the server; AI_SUGGESTION is not accepted yet.
|
||||
export const CLIENT_CHANGE_SOURCES = ['CARD', 'EDITOR'] as const;
|
||||
const MAX_NOTE_LENGTH = 500;
|
||||
|
||||
export class CreateHiveChangeDto {
|
||||
@ApiProperty({ description: 'Berlin calendar day, YYYY-MM-DD' })
|
||||
@Matches(DAY_PATTERN)
|
||||
occurredOn!: string;
|
||||
|
||||
@ApiProperty({ enum: CLIENT_CHANGE_SOURCES })
|
||||
@IsIn(CLIENT_CHANGE_SOURCES)
|
||||
source!: (typeof CLIENT_CHANGE_SOURCES)[number];
|
||||
|
||||
@ApiProperty({ type: [HiveOpDto], maxItems: MAX_OPS_PER_REQUEST })
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(MAX_OPS_PER_REQUEST)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => HiveOpDto)
|
||||
ops!: HiveOpDto[];
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(MAX_NOTE_LENGTH)
|
||||
note?: string;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export const HIVE_PART_KINDS = [
|
||||
'FLOOR',
|
||||
'BOX',
|
||||
'DIVIDER_BOARD',
|
||||
'QUEEN_EXCLUDER',
|
||||
'FEEDER',
|
||||
'COVER_SHEET',
|
||||
'PROPOLIS_SCREEN',
|
||||
'INNER_LID',
|
||||
'INSULATION',
|
||||
'ROOF',
|
||||
'FLOOR_INSERT',
|
||||
'EVAPORATOR',
|
||||
] as const;
|
||||
export const HIVE_BOX_DEPTHS = ['FULL', 'SHALLOW'] as const;
|
||||
|
||||
const MAX_MM = 5000;
|
||||
const MAX_SCHEMA_FIELDS = 30;
|
||||
const MAX_FRAME_SLOTS = 20;
|
||||
|
||||
export class CreateHivePartTypeDto {
|
||||
@ApiProperty({ enum: HIVE_PART_KINDS })
|
||||
@IsIn(HIVE_PART_KINDS)
|
||||
kind!: (typeof HIVE_PART_KINDS)[number];
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
articleRef?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(MAX_MM)
|
||||
lengthMm!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(MAX_MM)
|
||||
widthMm!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(MAX_MM)
|
||||
heightMm!: number;
|
||||
|
||||
@ApiPropertyOptional({ enum: HIVE_BOX_DEPTHS })
|
||||
@IsOptional()
|
||||
@IsIn(HIVE_BOX_DEPTHS)
|
||||
boxDepth?: (typeof HIVE_BOX_DEPTHS)[number];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Required for kind BOX' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(MAX_FRAME_SLOTS)
|
||||
frameSlots?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'State fields, spec 4.2' })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(MAX_SCHEMA_FIELDS)
|
||||
@IsObject({ each: true })
|
||||
stateSchema?: Record<string, unknown>[];
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsObject, IsString, Matches, MaxLength } from 'class-validator';
|
||||
|
||||
export const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
export class CreateHiveSetupDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
systemId!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Complete hive state (schemaVersion 1); validated server side',
|
||||
})
|
||||
@IsObject()
|
||||
state!: Record<string, unknown>;
|
||||
|
||||
@ApiProperty({ description: 'Berlin calendar day, YYYY-MM-DD' })
|
||||
@Matches(DAY_PATTERN)
|
||||
occurredOn!: string;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
IsInt,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
const MAX_MM = 5000;
|
||||
|
||||
export class CreateHiveSystemDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ description: 'Top bar width in mm' })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(MAX_MM)
|
||||
frameWidthMm!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(MAX_MM)
|
||||
frameHeightMm!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(MAX_MM)
|
||||
footprintLMm!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(MAX_MM)
|
||||
footprintWMm!: number;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
ValidateBy,
|
||||
ValidateIf,
|
||||
buildMessage,
|
||||
} from 'class-validator';
|
||||
import { FRAME_TYPES, MAX_OPS } from '../hive-types';
|
||||
|
||||
export const HIVE_OP_NAMES = [
|
||||
'addPart',
|
||||
'addInsert',
|
||||
'removePart',
|
||||
'movePart',
|
||||
'setState',
|
||||
'setFrame',
|
||||
'setFrames',
|
||||
'setLabel',
|
||||
] as const;
|
||||
|
||||
export const MAX_OPS_PER_REQUEST = MAX_OPS;
|
||||
|
||||
const MAX_ID_LENGTH = 100;
|
||||
const MAX_TEXT = 200;
|
||||
const MAX_FRAMES = 100;
|
||||
|
||||
type OpName = (typeof HIVE_OP_NAMES)[number];
|
||||
|
||||
/** The property applies only to the listed operations. */
|
||||
const onlyFor =
|
||||
(...names: OpName[]) =>
|
||||
(dto: HiveOpDto) =>
|
||||
names.includes(dto.op);
|
||||
|
||||
function IsStringOrFiniteNumber() {
|
||||
return ValidateBy(
|
||||
{
|
||||
name: 'isStringOrFiniteNumber',
|
||||
validator: {
|
||||
validate: (value: unknown) =>
|
||||
typeof value === 'string' ||
|
||||
(typeof value === 'number' && Number.isFinite(value)),
|
||||
defaultMessage: buildMessage(
|
||||
(each) => `${each}$property must be a string or a finite number`,
|
||||
),
|
||||
},
|
||||
},
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One hive operation. Only the shape is checked here (discriminator and
|
||||
* primitive types); the deep check is `applyHiveOps` (spec 6.2).
|
||||
*/
|
||||
export class HiveOpDto {
|
||||
@ApiProperty({ enum: HIVE_OP_NAMES })
|
||||
@IsIn(HIVE_OP_NAMES)
|
||||
op!: OpName;
|
||||
|
||||
@ApiPropertyOptional({ description: 'addPart, addInsert: the part' })
|
||||
@ValidateIf(onlyFor('addPart', 'addInsert'))
|
||||
@IsObject()
|
||||
part?: Record<string, unknown>;
|
||||
|
||||
@ApiPropertyOptional({ description: 'addPart: stack index' })
|
||||
@ValidateIf(onlyFor('addPart'))
|
||||
@IsInt()
|
||||
at?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'addInsert: id of the host part' })
|
||||
@ValidateIf(onlyFor('addInsert'))
|
||||
@IsString()
|
||||
@MaxLength(MAX_ID_LENGTH)
|
||||
hostId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'removePart, movePart, setState, setLabel: part id',
|
||||
})
|
||||
@ValidateIf(onlyFor('removePart', 'movePart', 'setState', 'setLabel'))
|
||||
@IsString()
|
||||
@MaxLength(MAX_ID_LENGTH)
|
||||
id?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'movePart: target stack index' })
|
||||
@ValidateIf(onlyFor('movePart'))
|
||||
@IsInt()
|
||||
to?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'setState: state field key' })
|
||||
@ValidateIf(onlyFor('setState'))
|
||||
@IsString()
|
||||
@MaxLength(MAX_ID_LENGTH)
|
||||
key?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'setState: number or string' })
|
||||
@ValidateIf(onlyFor('setState'))
|
||||
@IsStringOrFiniteNumber()
|
||||
value?: number | string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'setFrame, setFrames: box id' })
|
||||
@ValidateIf(onlyFor('setFrame', 'setFrames'))
|
||||
@IsString()
|
||||
@MaxLength(MAX_ID_LENGTH)
|
||||
boxId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'setFrame: slot number' })
|
||||
@ValidateIf(onlyFor('setFrame'))
|
||||
@IsInt()
|
||||
slot?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'setFrame: frame type' })
|
||||
@ValidateIf(onlyFor('setFrame'))
|
||||
@IsIn(FRAME_TYPES)
|
||||
type?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'setFrame: optional frame name' })
|
||||
@ValidateIf(onlyFor('setFrame'))
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(MAX_TEXT)
|
||||
name?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ description: 'setFrames: all frames of the box' })
|
||||
@ValidateIf(onlyFor('setFrames'))
|
||||
@IsArray()
|
||||
@ArrayMaxSize(MAX_FRAMES)
|
||||
@IsObject({ each: true })
|
||||
frames?: Record<string, unknown>[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'setLabel: new label or null' })
|
||||
@ValidateIf(onlyFor('setLabel'))
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(MAX_TEXT)
|
||||
label?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { DAY_PATTERN } from './create-hive-setup.dto';
|
||||
|
||||
export const MAX_HISTORY_LIMIT = 100;
|
||||
|
||||
export class HiveHistoryQueryDto {
|
||||
@ApiPropertyOptional({ description: 'Opaque cursor of the previous page' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
cursor?: string;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 1, maximum: MAX_HISTORY_LIMIT })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(MAX_HISTORY_LIMIT)
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export class HiveAtQueryDto {
|
||||
@ApiProperty({ description: 'Berlin calendar day, YYYY-MM-DD' })
|
||||
@Matches(DAY_PATTERN)
|
||||
day!: string;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ApiPropertyOptional, OmitType, PartialType } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsOptional } from 'class-validator';
|
||||
import { CreateHivePartTypeDto } from './create-hive-part-type.dto';
|
||||
|
||||
// `kind` cannot change after creation, so it is not part of the update DTO.
|
||||
export class UpdateHivePartTypeDto extends PartialType(
|
||||
OmitType(CreateHivePartTypeDto, ['kind'] as const),
|
||||
) {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
active?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsObject, IsOptional } from 'class-validator';
|
||||
import { CreateHiveSystemDto } from './create-hive-system.dto';
|
||||
|
||||
export class UpdateHiveSystemDto extends PartialType(CreateHiveSystemDto) {
|
||||
@ApiPropertyOptional({ description: 'Hive state JSON without ids (5.2)' })
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
template?: Record<string, unknown>;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
active?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { HiveOp } from './hive-types';
|
||||
|
||||
/**
|
||||
* A copy of the op with only the fields its name defines, so stray client
|
||||
* fields never reach the state logic or the stored history.
|
||||
*/
|
||||
export function canonicalOp(op: HiveOp): HiveOp {
|
||||
switch (op.op) {
|
||||
case 'addPart':
|
||||
return { op: op.op, part: op.part, at: op.at };
|
||||
case 'addInsert':
|
||||
return { op: op.op, hostId: op.hostId, part: op.part };
|
||||
case 'removePart':
|
||||
return { op: op.op, id: op.id };
|
||||
case 'movePart':
|
||||
return { op: op.op, id: op.id, to: op.to };
|
||||
case 'setState':
|
||||
return { op: op.op, id: op.id, key: op.key, value: op.value };
|
||||
case 'setFrame':
|
||||
return {
|
||||
op: op.op,
|
||||
boxId: op.boxId,
|
||||
slot: op.slot,
|
||||
type: op.type,
|
||||
...(op.name !== undefined ? { name: op.name } : {}),
|
||||
};
|
||||
case 'setFrames':
|
||||
return { op: op.op, boxId: op.boxId, frames: op.frames };
|
||||
case 'setLabel':
|
||||
return { op: op.op, id: op.id, label: op.label };
|
||||
default:
|
||||
// Unknown names stay as they are; applyHiveOps reports UNKNOWN_OP.
|
||||
return { ...(op as object) } as HiveOp;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
|
||||
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||
import { CreateHivePartTypeDto } from './dto/create-hive-part-type.dto';
|
||||
import { CreateHiveSystemDto } from './dto/create-hive-system.dto';
|
||||
import { UpdateHivePartTypeDto } from './dto/update-hive-part-type.dto';
|
||||
import { UpdateHiveSystemDto } from './dto/update-hive-system.dto';
|
||||
import { HiveCatalogService } from './hive-catalog.service';
|
||||
|
||||
@ApiTags('hive-catalog')
|
||||
@ApiBearerAuth()
|
||||
@Controller()
|
||||
export class HiveCatalogController {
|
||||
constructor(private readonly catalog: HiveCatalogService) {}
|
||||
|
||||
@RequirePermission('colonies:read')
|
||||
@Get('hive-systems')
|
||||
listSystems() {
|
||||
return this.catalog.listSystems();
|
||||
}
|
||||
|
||||
@RequirePermission('colonies:read')
|
||||
@Get('hive-systems/:id/template')
|
||||
template(@Param('id') id: string) {
|
||||
return this.catalog.template(id);
|
||||
}
|
||||
|
||||
@RequirePermission('hive-systems:manage')
|
||||
@Post('hive-systems')
|
||||
createSystem(
|
||||
@Body() dto: CreateHiveSystemDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.catalog.createSystem(dto, actor.id);
|
||||
}
|
||||
|
||||
@RequirePermission('hive-systems:manage')
|
||||
@Patch('hive-systems/:id')
|
||||
updateSystem(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateHiveSystemDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.catalog.updateSystem(id, dto, actor.id);
|
||||
}
|
||||
|
||||
@RequirePermission('hive-systems:manage')
|
||||
@Post('hive-systems/:id/part-types')
|
||||
createPartType(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: CreateHivePartTypeDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.catalog.createPartType(id, dto, actor.id);
|
||||
}
|
||||
|
||||
@RequirePermission('hive-systems:manage')
|
||||
@Patch('hive-part-types/:id')
|
||||
updatePartType(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateHivePartTypeDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.catalog.updatePartType(id, dto, actor.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '../../generated/prisma';
|
||||
import { HiveCatalogService } from './hive-catalog.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { validateHiveState } from './hive-validate';
|
||||
import type { HiveState } from './hive-types';
|
||||
import { loadFixtureCatalog } from './hive-test-utils';
|
||||
|
||||
const SYSTEM_ID = 'hive-system-zander-liebig-10';
|
||||
const USER = 'user-1';
|
||||
|
||||
const fixtureRows = [...loadFixtureCatalog().partTypes.values()].map((t) => ({
|
||||
...t,
|
||||
systemId: SYSTEM_ID,
|
||||
}));
|
||||
|
||||
const FLOOR = 'hive-part-zl10-floor';
|
||||
const BOX = 'hive-part-zl10-box-10';
|
||||
const ROOF = 'hive-part-zl10-roof';
|
||||
const EVAPORATOR = 'hive-part-zl10-evaporator';
|
||||
|
||||
const frames = Array.from({ length: 10 }, (_, i) => ({
|
||||
slot: i + 1,
|
||||
type: 'FOUNDATION',
|
||||
}));
|
||||
|
||||
// Stored templates carry no ids (spec 4.4); one insert covers the insert path.
|
||||
const storedTemplate = {
|
||||
schemaVersion: 1,
|
||||
stack: [
|
||||
{ partTypeId: FLOOR, state: {} },
|
||||
{
|
||||
partTypeId: BOX,
|
||||
state: {},
|
||||
frames,
|
||||
inserts: [{ partTypeId: EVAPORATOR, state: {} }],
|
||||
},
|
||||
{ partTypeId: ROOF, state: {} },
|
||||
],
|
||||
};
|
||||
|
||||
const levelField = {
|
||||
key: 'syrupLiters',
|
||||
label: 'Fluessigfutter',
|
||||
kind: 'level',
|
||||
unit: 'l',
|
||||
max: 3,
|
||||
step: 0.5,
|
||||
};
|
||||
const choiceField = {
|
||||
key: 'entrance',
|
||||
label: 'Flugloch',
|
||||
kind: 'choice',
|
||||
options: ['OPEN', 'CLOSED'],
|
||||
optionLabels: ['offen', 'zu'],
|
||||
};
|
||||
|
||||
describe('HiveCatalogService', () => {
|
||||
let service: HiveCatalogService;
|
||||
let tx: {
|
||||
$queryRaw: jest.Mock;
|
||||
hiveSystem: {
|
||||
findMany: jest.Mock;
|
||||
findUnique: jest.Mock;
|
||||
create: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
hivePartType: {
|
||||
findMany: jest.Mock;
|
||||
findUnique: jest.Mock;
|
||||
create: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
};
|
||||
let prisma: { $transaction: jest.Mock } & typeof tx;
|
||||
let auditLog: { record: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
tx = {
|
||||
$queryRaw: jest.fn().mockResolvedValue([{ id: 'x' }]),
|
||||
hiveSystem: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
findUnique: jest.fn().mockResolvedValue({
|
||||
id: SYSTEM_ID,
|
||||
name: 'Zander',
|
||||
template: storedTemplate,
|
||||
active: true,
|
||||
}),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
hivePartType: {
|
||||
findMany: jest.fn().mockResolvedValue(fixtureRows),
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
};
|
||||
prisma = {
|
||||
...tx,
|
||||
$transaction: jest.fn((cb: (t: typeof tx) => unknown) => cb(tx)),
|
||||
};
|
||||
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||
service = new HiveCatalogService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
);
|
||||
});
|
||||
|
||||
describe('listSystems', () => {
|
||||
it('returns systems with all part types ordered by name', async () => {
|
||||
await service.listSystems();
|
||||
expect(prisma.hiveSystem.findMany).toHaveBeenCalledWith({
|
||||
include: { partTypes: { orderBy: { name: 'asc' } } },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadCatalog', () => {
|
||||
it('maps rows including inactive part types', async () => {
|
||||
tx.hivePartType.findMany.mockResolvedValue([
|
||||
{ ...fixtureRows[0], active: false },
|
||||
]);
|
||||
const catalog = await service.loadCatalog(SYSTEM_ID);
|
||||
expect(catalog.systemId).toBe(SYSTEM_ID);
|
||||
expect(catalog.partTypes.get(fixtureRows[0].id)?.active).toBe(false);
|
||||
});
|
||||
|
||||
it('uses the given transaction client', async () => {
|
||||
const other = {
|
||||
hiveSystem: {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: SYSTEM_ID }),
|
||||
},
|
||||
hivePartType: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
};
|
||||
await service.loadCatalog(
|
||||
SYSTEM_ID,
|
||||
other as unknown as Prisma.TransactionClient,
|
||||
);
|
||||
expect(other.hivePartType.findMany).toHaveBeenCalled();
|
||||
expect(tx.hivePartType.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a part type row with a malformed stateSchema', async () => {
|
||||
tx.hivePartType.findMany.mockResolvedValue([
|
||||
{ ...fixtureRows[0], stateSchema: { not: 'a list' } },
|
||||
]);
|
||||
await expect(service.loadCatalog(SYSTEM_ID)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws NotFound for an unknown system', async () => {
|
||||
tx.hiveSystem.findUnique.mockResolvedValue(null);
|
||||
await expect(service.loadCatalog('nope')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('template', () => {
|
||||
const ids = (state: HiveState): string[] =>
|
||||
state.stack.flatMap((p) => [p.id, ...(p.inserts ?? []).map((i) => i.id)]);
|
||||
|
||||
it('gives fresh ids on every call and is a valid state', async () => {
|
||||
const first = await service.template(SYSTEM_ID);
|
||||
const second = await service.template(SYSTEM_ID);
|
||||
const firstIds = ids(first);
|
||||
expect(firstIds).toHaveLength(4);
|
||||
expect(new Set(firstIds).size).toBe(4);
|
||||
expect(ids(second).some((id) => firstIds.includes(id))).toBe(false);
|
||||
expect(validateHiveState(first, loadFixtureCatalog())).toEqual([]);
|
||||
});
|
||||
|
||||
it('throws 409 TEMPLATE_NOT_SET for an empty stored stack', async () => {
|
||||
tx.hiveSystem.findUnique.mockResolvedValue({
|
||||
id: SYSTEM_ID,
|
||||
template: { schemaVersion: 1, stack: [] },
|
||||
});
|
||||
await expect(service.template(SYSTEM_ID)).rejects.toMatchObject({
|
||||
status: 409,
|
||||
response: expect.objectContaining({
|
||||
code: 'TEMPLATE_NOT_SET',
|
||||
}) as unknown,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not mutate the stored template', async () => {
|
||||
await service.template(SYSTEM_ID);
|
||||
expect(storedTemplate.stack[0]).not.toHaveProperty('id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateSystem', () => {
|
||||
it('rejects a template that fails validation with the messages', async () => {
|
||||
const bad = { schemaVersion: 1, stack: [{ partTypeId: BOX, state: {} }] };
|
||||
const error = await service
|
||||
.updateSystem(SYSTEM_ID, { template: bad }, USER)
|
||||
.catch((e: unknown) => e);
|
||||
expect(error).toBeInstanceOf(BadRequestException);
|
||||
const body = (error as BadRequestException).getResponse() as {
|
||||
messages: string[];
|
||||
};
|
||||
expect(body.messages.length).toBeGreaterThan(0);
|
||||
expect(tx.hiveSystem.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an explicit empty-stack template with TEMPLATE_INVALID', async () => {
|
||||
await expect(
|
||||
service.updateSystem(
|
||||
SYSTEM_ID,
|
||||
{ template: { schemaVersion: 1, stack: [] } },
|
||||
USER,
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
response: expect.objectContaining({
|
||||
code: 'TEMPLATE_INVALID',
|
||||
}) as unknown,
|
||||
});
|
||||
});
|
||||
|
||||
it('stores a valid template and audits the update', async () => {
|
||||
tx.hiveSystem.update.mockResolvedValue({ id: SYSTEM_ID });
|
||||
await service.updateSystem(SYSTEM_ID, { template: storedTemplate }, USER);
|
||||
expect(tx.hiveSystem.update).toHaveBeenCalled();
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityType: 'HiveSystem',
|
||||
action: 'UPDATE',
|
||||
entityId: SYSTEM_ID,
|
||||
}),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
it('deactivates without deleting', async () => {
|
||||
tx.hiveSystem.update.mockResolvedValue({ id: SYSTEM_ID, active: false });
|
||||
await service.updateSystem(SYSTEM_ID, { active: false }, USER);
|
||||
expect(tx.hiveSystem.update).toHaveBeenCalledWith({
|
||||
where: { id: SYSTEM_ID },
|
||||
data: { active: false },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSystem', () => {
|
||||
it('creates with an empty stack and audits CREATE', async () => {
|
||||
tx.hiveSystem.create.mockResolvedValue({ id: 'new' });
|
||||
await service.createSystem(
|
||||
{
|
||||
name: 'Neu',
|
||||
frameWidthMm: 1,
|
||||
frameHeightMm: 1,
|
||||
footprintLMm: 1,
|
||||
footprintWMm: 1,
|
||||
},
|
||||
USER,
|
||||
);
|
||||
expect(tx.hiveSystem.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
template: { schemaVersion: 1, stack: [] },
|
||||
}) as unknown,
|
||||
});
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ entityType: 'HiveSystem', action: 'CREATE' }),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('part types', () => {
|
||||
const existing = {
|
||||
id: 'pt-1',
|
||||
systemId: SYSTEM_ID,
|
||||
kind: 'FEEDER',
|
||||
name: 'Futter',
|
||||
stateSchema: [levelField, choiceField],
|
||||
active: true,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
tx.hivePartType.findUnique.mockResolvedValue(existing);
|
||||
tx.hivePartType.update.mockResolvedValue(existing);
|
||||
});
|
||||
|
||||
it('rejects removing a state field with SCHEMA_NOT_ADDITIVE', async () => {
|
||||
const error = await service
|
||||
.updatePartType('pt-1', { stateSchema: [levelField] }, USER)
|
||||
.catch((e: unknown) => e);
|
||||
expect(error).toBeInstanceOf(BadRequestException);
|
||||
expect((error as BadRequestException).getResponse()).toMatchObject({
|
||||
code: 'SCHEMA_NOT_ADDITIVE',
|
||||
});
|
||||
expect(tx.hivePartType.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects changing an existing field definition', async () => {
|
||||
const changed = { ...levelField, max: 5 };
|
||||
await expect(
|
||||
service.updatePartType(
|
||||
'pt-1',
|
||||
{ stateSchema: [changed, choiceField] },
|
||||
USER,
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
response: expect.objectContaining({
|
||||
code: 'SCHEMA_NOT_ADDITIVE',
|
||||
}) as unknown,
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts appending a field and audits the update', async () => {
|
||||
const extra = { ...levelField, key: 'extra' };
|
||||
await service.updatePartType(
|
||||
'pt-1',
|
||||
{ stateSchema: [levelField, choiceField, extra] },
|
||||
USER,
|
||||
);
|
||||
expect(tx.hivePartType.update).toHaveBeenCalled();
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityType: 'HivePartType',
|
||||
action: 'UPDATE',
|
||||
entityId: 'pt-1',
|
||||
}),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a duplicate key in the appended schema', async () => {
|
||||
await expect(
|
||||
service.updatePartType(
|
||||
'pt-1',
|
||||
{ stateSchema: [levelField, choiceField, levelField] },
|
||||
USER,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('locks the part type row before reading it', async () => {
|
||||
await service.updatePartType('pt-1', { active: false }, USER);
|
||||
expect(tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
tx.hivePartType.findUnique.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['unknown feedType', { ...levelField, key: 'f', feedType: 'NOPE' }],
|
||||
[
|
||||
'duplicate options',
|
||||
{
|
||||
...choiceField,
|
||||
key: 'c',
|
||||
options: ['A', 'A'],
|
||||
optionLabels: ['a', 'b'],
|
||||
},
|
||||
],
|
||||
['key over 40', { ...levelField, key: 'k'.repeat(41) }],
|
||||
['label over 60', { ...levelField, key: 'l', label: 'x'.repeat(61) }],
|
||||
['unit over 10', { ...levelField, key: 'u', unit: 'x'.repeat(11) }],
|
||||
[
|
||||
'option over 40',
|
||||
{
|
||||
...choiceField,
|
||||
key: 'o',
|
||||
options: ['x'.repeat(41), 'B'],
|
||||
optionLabels: ['a', 'b'],
|
||||
},
|
||||
],
|
||||
[
|
||||
'option label over 40',
|
||||
{
|
||||
...choiceField,
|
||||
key: 'p',
|
||||
options: ['A', 'B'],
|
||||
optionLabels: ['x'.repeat(41), 'b'],
|
||||
},
|
||||
],
|
||||
])('rejects a new field with %s', async (_name, field) => {
|
||||
await expect(
|
||||
service.updatePartType(
|
||||
'pt-1',
|
||||
{ stateSchema: [levelField, choiceField, field] },
|
||||
USER,
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
response: expect.objectContaining({
|
||||
code: 'SCHEMA_INVALID',
|
||||
}) as unknown,
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts a valid feedType', async () => {
|
||||
const ok = { ...levelField, key: 'fed', feedType: 'FEED_PASTE' };
|
||||
await service.updatePartType(
|
||||
'pt-1',
|
||||
{ stateSchema: [levelField, choiceField, ok] },
|
||||
USER,
|
||||
);
|
||||
expect(tx.hivePartType.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deactivates by setting active false and never deletes', async () => {
|
||||
await service.updatePartType('pt-1', { active: false }, USER);
|
||||
expect(tx.hivePartType.update).toHaveBeenCalledWith({
|
||||
where: { id: 'pt-1' },
|
||||
data: { active: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('throws NotFound for an unknown part type', async () => {
|
||||
tx.hivePartType.findUnique.mockResolvedValue(null);
|
||||
await expect(
|
||||
service.updatePartType('x', { active: false }, USER),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('creates a part type with audit CREATE', async () => {
|
||||
tx.hivePartType.create.mockResolvedValue({ id: 'new' });
|
||||
await service.createPartType(
|
||||
SYSTEM_ID,
|
||||
{
|
||||
kind: 'ROOF',
|
||||
name: 'Dach',
|
||||
lengthMm: 1,
|
||||
widthMm: 1,
|
||||
heightMm: 1,
|
||||
stateSchema: [],
|
||||
},
|
||||
USER,
|
||||
);
|
||||
expect(tx.hivePartType.create).toHaveBeenCalled();
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityType: 'HivePartType',
|
||||
action: 'CREATE',
|
||||
}),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
it('requires frameSlots for a BOX', async () => {
|
||||
await expect(
|
||||
service.createPartType(
|
||||
SYSTEM_ID,
|
||||
{
|
||||
kind: 'BOX',
|
||||
name: 'Zarge',
|
||||
lengthMm: 1,
|
||||
widthMm: 1,
|
||||
heightMm: 1,
|
||||
},
|
||||
USER,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,296 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { HivePartType, HiveSystem, Prisma } from '../../generated/prisma';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
import { CreateHivePartTypeDto } from './dto/create-hive-part-type.dto';
|
||||
import { CreateHiveSystemDto } from './dto/create-hive-system.dto';
|
||||
import { UpdateHivePartTypeDto } from './dto/update-hive-part-type.dto';
|
||||
import { UpdateHiveSystemDto } from './dto/update-hive-system.dto';
|
||||
import {
|
||||
isAdditiveSchema,
|
||||
parseStateSchema,
|
||||
stateSchemaProblems,
|
||||
} from './hive-state-schema';
|
||||
import { catalogFromRows } from './hive-types';
|
||||
import type { HiveCatalog, HiveState } from './hive-types';
|
||||
import { validateHiveState } from './hive-validate';
|
||||
|
||||
export type HiveSystemDto = HiveSystem & { partTypes: HivePartType[] };
|
||||
|
||||
const EMPTY_TEMPLATE = { schemaVersion: 1, stack: [] as never[] };
|
||||
const UNIQUE_VIOLATION = 'P2002';
|
||||
|
||||
type Obj = Record<string, unknown>;
|
||||
|
||||
const isObj = (value: unknown): value is Obj =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
|
||||
/** Copy of a stored part (no id) with a fresh id on it and on its inserts. */
|
||||
function stampPart(part: unknown): unknown {
|
||||
if (!isObj(part)) return part;
|
||||
const stamped: Obj = { ...part, id: randomUUID() };
|
||||
if (Array.isArray(part.inserts)) {
|
||||
stamped.inserts = part.inserts.map(stampPart);
|
||||
}
|
||||
return stamped;
|
||||
}
|
||||
|
||||
function stampTemplate(template: unknown): unknown {
|
||||
if (!isObj(template) || !Array.isArray(template.stack)) return template;
|
||||
return { ...template, stack: template.stack.map(stampPart) };
|
||||
}
|
||||
|
||||
function conflictOnDuplicate(error: unknown, what: string): never {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === UNIQUE_VIOLATION
|
||||
) {
|
||||
throw new ConflictException(`${what} name already exists`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class HiveCatalogService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
) {}
|
||||
|
||||
async listSystems(): Promise<HiveSystemDto[]> {
|
||||
return this.prisma.hiveSystem.findMany({
|
||||
include: { partTypes: { orderBy: { name: 'asc' } } },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
/** Part types of a system incl. inactive ones (`active: false`). */
|
||||
async loadCatalog(
|
||||
systemId: string,
|
||||
tx?: Prisma.TransactionClient,
|
||||
): Promise<HiveCatalog> {
|
||||
const db = tx ?? this.prisma;
|
||||
const system = await db.hiveSystem.findUnique({
|
||||
where: { id: systemId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!system) throw new NotFoundException('Hive system not found');
|
||||
const rows = await db.hivePartType.findMany({ where: { systemId } });
|
||||
return catalogFromRows(
|
||||
rows.map((row) => ({
|
||||
id: row.id,
|
||||
kind: row.kind,
|
||||
name: row.name,
|
||||
lengthMm: row.lengthMm,
|
||||
widthMm: row.widthMm,
|
||||
heightMm: row.heightMm,
|
||||
frameSlots: row.frameSlots,
|
||||
stateSchema: parseStateSchema(row.stateSchema),
|
||||
active: row.active,
|
||||
})),
|
||||
systemId,
|
||||
);
|
||||
}
|
||||
|
||||
/** The system template as a hive state with fresh ids on every part. */
|
||||
async template(systemId: string): Promise<HiveState> {
|
||||
const system = await this.prisma.hiveSystem.findUnique({
|
||||
where: { id: systemId },
|
||||
});
|
||||
if (!system) throw new NotFoundException('Hive system not found');
|
||||
const stored = system.template;
|
||||
if (
|
||||
isObj(stored) &&
|
||||
Array.isArray(stored.stack) &&
|
||||
stored.stack.length === 0
|
||||
) {
|
||||
throw new ConflictException({
|
||||
code: 'TEMPLATE_NOT_SET',
|
||||
message: 'The hive system has no template yet',
|
||||
});
|
||||
}
|
||||
return stampTemplate(stored) as HiveState;
|
||||
}
|
||||
|
||||
async createSystem(
|
||||
dto: CreateHiveSystemDto,
|
||||
actingUserId: string,
|
||||
): Promise<HiveSystem> {
|
||||
return this.prisma
|
||||
.$transaction(async (tx) => {
|
||||
// A template needs part types of the system, so it starts empty and
|
||||
// is set with PATCH once the catalog exists.
|
||||
const system = await tx.hiveSystem.create({
|
||||
data: { ...dto, template: EMPTY_TEMPLATE },
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'HiveSystem',
|
||||
entityId: system.id,
|
||||
action: 'CREATE',
|
||||
userId: actingUserId,
|
||||
afterData: system,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return system;
|
||||
})
|
||||
.catch((error: unknown) => conflictOnDuplicate(error, 'System'));
|
||||
}
|
||||
|
||||
async updateSystem(
|
||||
id: string,
|
||||
dto: UpdateHiveSystemDto,
|
||||
actingUserId: string,
|
||||
): Promise<HiveSystem> {
|
||||
return this.prisma
|
||||
.$transaction(async (tx) => {
|
||||
const before = await tx.hiveSystem.findUnique({ where: { id } });
|
||||
if (!before) throw new NotFoundException('Hive system not found');
|
||||
if (dto.template !== undefined) {
|
||||
await this.assertValidTemplate(id, dto.template, tx);
|
||||
}
|
||||
const { template, ...rest } = dto;
|
||||
const system = await tx.hiveSystem.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...rest,
|
||||
...(template !== undefined
|
||||
? { template: template as Prisma.InputJsonValue }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'HiveSystem',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: system,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return system;
|
||||
})
|
||||
.catch((error: unknown) => conflictOnDuplicate(error, 'System'));
|
||||
}
|
||||
|
||||
async createPartType(
|
||||
systemId: string,
|
||||
dto: CreateHivePartTypeDto,
|
||||
actingUserId: string,
|
||||
): Promise<HivePartType> {
|
||||
if (dto.kind === 'BOX' && dto.frameSlots === undefined) {
|
||||
throw new BadRequestException('frameSlots is required for a BOX');
|
||||
}
|
||||
const stateSchema = dto.stateSchema ?? [];
|
||||
this.assertSchemaShape(stateSchema);
|
||||
return this.prisma
|
||||
.$transaction(async (tx) => {
|
||||
const system = await tx.hiveSystem.findUnique({
|
||||
where: { id: systemId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!system) throw new NotFoundException('Hive system not found');
|
||||
const partType = await tx.hivePartType.create({
|
||||
data: {
|
||||
...dto,
|
||||
systemId,
|
||||
stateSchema: stateSchema as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'HivePartType',
|
||||
entityId: partType.id,
|
||||
action: 'CREATE',
|
||||
userId: actingUserId,
|
||||
afterData: partType,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return partType;
|
||||
})
|
||||
.catch((error: unknown) => conflictOnDuplicate(error, 'Part type'));
|
||||
}
|
||||
|
||||
async updatePartType(
|
||||
id: string,
|
||||
dto: UpdateHivePartTypeDto,
|
||||
actingUserId: string,
|
||||
): Promise<HivePartType> {
|
||||
return this.prisma
|
||||
.$transaction(async (tx) => {
|
||||
await lockRowForUpdate(tx, 'HivePartType', id);
|
||||
const before = await tx.hivePartType.findUnique({ where: { id } });
|
||||
if (!before) throw new NotFoundException('Hive part type not found');
|
||||
const { stateSchema, ...rest } = dto;
|
||||
if (stateSchema !== undefined) {
|
||||
this.assertSchemaShape(stateSchema);
|
||||
this.assertAdditive(before.stateSchema, stateSchema);
|
||||
}
|
||||
const partType = await tx.hivePartType.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...rest,
|
||||
...(stateSchema !== undefined
|
||||
? { stateSchema: stateSchema as Prisma.InputJsonValue }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'HivePartType',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: partType,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return partType;
|
||||
})
|
||||
.catch((error: unknown) => conflictOnDuplicate(error, 'Part type'));
|
||||
}
|
||||
|
||||
private assertSchemaShape(stateSchema: unknown): void {
|
||||
const problems = stateSchemaProblems(stateSchema);
|
||||
if (problems.length > 0) {
|
||||
throw new BadRequestException({
|
||||
code: 'SCHEMA_INVALID',
|
||||
messages: problems,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private assertAdditive(current: unknown, next: unknown): void {
|
||||
if (!isAdditiveSchema(parseStateSchema(current), parseStateSchema(next))) {
|
||||
throw new BadRequestException({
|
||||
code: 'SCHEMA_NOT_ADDITIVE',
|
||||
message:
|
||||
'The state schema may only append fields; existing fields stay unchanged',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async assertValidTemplate(
|
||||
systemId: string,
|
||||
template: unknown,
|
||||
tx: Prisma.TransactionClient,
|
||||
): Promise<void> {
|
||||
const catalog = await this.loadCatalog(systemId, tx);
|
||||
const messages = validateHiveState(stampTemplate(template), catalog);
|
||||
if (messages.length > 0) {
|
||||
throw new BadRequestException({ code: 'TEMPLATE_INVALID', messages });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type { HiveState } from './hive-types';
|
||||
|
||||
/** 409: an operation failed against the current state (spec 6.2). */
|
||||
export class HiveConflictException extends ConflictException {
|
||||
constructor(
|
||||
opIndex: number,
|
||||
reason: string,
|
||||
current: { version: number; state: HiveState },
|
||||
) {
|
||||
super({
|
||||
statusCode: 409,
|
||||
code: 'HIVE_CONFLICT',
|
||||
message: 'Die Änderung passt nicht mehr zum aktuellen Aufbau.',
|
||||
opIndex,
|
||||
reason,
|
||||
current,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function hiveNotFound(): NotFoundException {
|
||||
return new NotFoundException({
|
||||
statusCode: 404,
|
||||
code: 'HIVE_NOT_FOUND',
|
||||
message: 'Für dieses Volk gibt es noch keinen Aufbau.',
|
||||
});
|
||||
}
|
||||
|
||||
export function hiveExists(): ConflictException {
|
||||
return new ConflictException({
|
||||
statusCode: 409,
|
||||
code: 'HIVE_EXISTS',
|
||||
message: 'Für dieses Volk gibt es schon einen Aufbau.',
|
||||
});
|
||||
}
|
||||
|
||||
export function invalidDay(field: string): BadRequestException {
|
||||
return new BadRequestException(`${field} must be a valid YYYY-MM-DD day`);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { deriveHiveFacts } from './hive-facts';
|
||||
import type { FrameType, HivePart, HiveState } from './hive-types';
|
||||
import { loadFixtureCatalog } from './hive-test-utils';
|
||||
|
||||
const T = (slug: string) => `hive-part-zl10-${slug}`;
|
||||
const catalog = loadFixtureCatalog();
|
||||
|
||||
const makeBox = (types: FrameType[]): HivePart => ({
|
||||
id: 'b1',
|
||||
partTypeId: T('box-10'),
|
||||
state: {},
|
||||
frames: types.map((type, i) => ({ slot: i + 1, type })),
|
||||
});
|
||||
|
||||
const stackOf = (...parts: HivePart[]): HiveState => ({
|
||||
schemaVersion: 1,
|
||||
stack: [{ id: 'f1', partTypeId: T('floor'), state: {} }, ...parts],
|
||||
});
|
||||
|
||||
describe('deriveHiveFacts', () => {
|
||||
it('derives zero facts for the system template', () => {
|
||||
const state = stackOf(
|
||||
makeBox(Array<FrameType>(10).fill('FOUNDATION')),
|
||||
{ id: 'c1', partTypeId: T('cover-abdeckfix'), state: {} },
|
||||
{ id: 'l1', partTypeId: T('inner-lid'), state: {} },
|
||||
{ id: 'i1', partTypeId: T('insulation'), state: {} },
|
||||
{ id: 'r1', partTypeId: T('roof'), state: {} },
|
||||
);
|
||||
const facts = deriveHiveFacts(state, catalog);
|
||||
expect(facts.boxCount).toBe(1);
|
||||
expect(facts.broodFrames).toBe(0);
|
||||
expect(facts.honeyFrames).toBe(0);
|
||||
expect(facts.builtFrames).toBe(0);
|
||||
expect(facts.hasFloorInsert).toBe(false);
|
||||
expect(facts.evaporatorAcidMl).toBeNull();
|
||||
});
|
||||
|
||||
it('counts brood, honey and built frames', () => {
|
||||
const types: FrameType[] = [
|
||||
...Array<FrameType>(6).fill('BROOD'),
|
||||
'HONEY',
|
||||
'HONEY',
|
||||
'DIVIDER',
|
||||
'NONE',
|
||||
];
|
||||
const facts = deriveHiveFacts(stackOf(makeBox(types)), catalog);
|
||||
expect(facts.broodFrames).toBe(6);
|
||||
expect(facts.honeyFrames).toBe(2);
|
||||
expect(facts.builtFrames).toBe(8);
|
||||
});
|
||||
|
||||
it('reports a feeder level with its feed type', () => {
|
||||
const state = stackOf({
|
||||
id: 'fd1',
|
||||
partTypeId: T('feeder'),
|
||||
state: { syrupLiters: 2.5 },
|
||||
});
|
||||
const facts = deriveHiveFacts(state, catalog);
|
||||
const syrup = facts.levels.filter((l) => l.key === 'syrupLiters');
|
||||
expect(syrup).toEqual([
|
||||
{
|
||||
partId: 'fd1',
|
||||
key: 'syrupLiters',
|
||||
label: 'Flüssigfutter',
|
||||
unit: 'l',
|
||||
value: 2.5,
|
||||
max: 3,
|
||||
feedType: 'SUGAR_WATER',
|
||||
},
|
||||
]);
|
||||
// Missing level fields count as 0.
|
||||
expect(facts.levels.find((l) => l.key === 'fondantKg')?.value).toBe(0);
|
||||
});
|
||||
|
||||
it('detects a floor insert and reads evaporator acid', () => {
|
||||
const state: HiveState = {
|
||||
schemaVersion: 1,
|
||||
stack: [
|
||||
{
|
||||
id: 'f1',
|
||||
partTypeId: T('floor'),
|
||||
state: {},
|
||||
inserts: [{ id: 'fi1', partTypeId: T('floor-insert'), state: {} }],
|
||||
},
|
||||
{
|
||||
id: 'e1',
|
||||
partTypeId: T('box-empty'),
|
||||
state: {},
|
||||
frames: [],
|
||||
inserts: [
|
||||
{ id: 'ev1', partTypeId: T('evaporator'), state: { acidMl: 250 } },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const facts = deriveHiveFacts(state, catalog);
|
||||
expect(facts.hasFloorInsert).toBe(true);
|
||||
expect(facts.evaporatorAcidMl).toBe(250);
|
||||
expect(facts.boxCount).toBe(1);
|
||||
});
|
||||
|
||||
it('reports 0 ml for an evaporator without acid state', () => {
|
||||
const state: HiveState = {
|
||||
schemaVersion: 1,
|
||||
stack: [
|
||||
{ id: 'f1', partTypeId: T('floor'), state: {} },
|
||||
{
|
||||
id: 'e1',
|
||||
partTypeId: T('box-empty'),
|
||||
state: {},
|
||||
frames: [],
|
||||
inserts: [{ id: 'ev1', partTypeId: T('evaporator'), state: {} }],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(deriveHiveFacts(state, catalog).evaporatorAcidMl).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import type {
|
||||
HiveCatalog,
|
||||
HiveFacts,
|
||||
HiveLevelFact,
|
||||
HivePart,
|
||||
HiveState,
|
||||
} from './hive-types';
|
||||
|
||||
const UNBUILT_FRAME_TYPES = new Set(['NONE', 'FOUNDATION', 'DIVIDER']);
|
||||
|
||||
function allParts(state: HiveState): HivePart[] {
|
||||
return state.stack.flatMap((part) => [part, ...(part.inserts ?? [])]);
|
||||
}
|
||||
|
||||
function levelsOf(part: HivePart, catalog: HiveCatalog): HiveLevelFact[] {
|
||||
const type = catalog.partTypes.get(part.partTypeId);
|
||||
if (!type) return [];
|
||||
return type.stateSchema.flatMap((field) => {
|
||||
if (field.kind !== 'level') return [];
|
||||
const raw = part.state[field.key];
|
||||
return [
|
||||
{
|
||||
partId: part.id,
|
||||
key: field.key,
|
||||
label: field.label,
|
||||
unit: field.unit,
|
||||
value: typeof raw === 'number' ? raw : 0,
|
||||
max: field.max,
|
||||
...(field.feedType ? { feedType: field.feedType } : {}),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/** Pure derivation of display facts (spec 5.3). Never written back to a colony. */
|
||||
export function deriveHiveFacts(
|
||||
state: HiveState,
|
||||
catalog: HiveCatalog,
|
||||
): HiveFacts {
|
||||
const parts = allParts(state);
|
||||
const kindOf = (part: HivePart) =>
|
||||
catalog.partTypes.get(part.partTypeId)?.kind;
|
||||
const frames = state.stack
|
||||
.filter((part) => kindOf(part) === 'BOX')
|
||||
.flatMap((part) => part.frames ?? []);
|
||||
const evaporators = parts.filter((part) => kindOf(part) === 'EVAPORATOR');
|
||||
const acid = evaporators.reduce((sum, part) => {
|
||||
const value = part.state.acidMl;
|
||||
return sum + (typeof value === 'number' ? value : 0);
|
||||
}, 0);
|
||||
return {
|
||||
boxCount: state.stack.filter((part) => kindOf(part) === 'BOX').length,
|
||||
broodFrames: frames.filter((f) => f.type === 'BROOD').length,
|
||||
honeyFrames: frames.filter((f) => f.type === 'HONEY').length,
|
||||
builtFrames: frames.filter((f) => !UNBUILT_FRAME_TYPES.has(f.type)).length,
|
||||
levels: parts.flatMap((part) => levelsOf(part, catalog)),
|
||||
hasFloorInsert: parts.some((part) => kindOf(part) === 'FLOOR_INSERT'),
|
||||
evaporatorAcidMl: evaporators.length > 0 ? acid : null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { readdirSync, readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { applyHiveOps } from './hive-ops';
|
||||
import { validateHiveState } from './hive-validate';
|
||||
import type { HiveOp, HiveState } from './hive-types';
|
||||
import { FIXTURE_DIR, deepFreeze, loadFixtureCatalog } from './hive-test-utils';
|
||||
|
||||
interface FixtureCase {
|
||||
name: string;
|
||||
inactivePartTypeIds?: string[];
|
||||
state: HiveState;
|
||||
ops: HiveOp[];
|
||||
expect:
|
||||
| { ok: true; state: HiveState }
|
||||
| { ok: false; opIndex: number; reasonCode: string };
|
||||
}
|
||||
|
||||
const cases = readdirSync(FIXTURE_DIR)
|
||||
.filter((file) => file.endsWith('.json') && file !== 'catalog.json')
|
||||
.sort()
|
||||
.map((file) => ({
|
||||
file,
|
||||
data: JSON.parse(
|
||||
readFileSync(join(FIXTURE_DIR, file), 'utf8'),
|
||||
) as FixtureCase,
|
||||
}));
|
||||
|
||||
function findCase(name: string): FixtureCase {
|
||||
const found = cases.find((c) => c.data.name === name);
|
||||
if (!found) throw new Error(`fixture not found: ${name}`);
|
||||
return found.data;
|
||||
}
|
||||
|
||||
describe('applyHiveOps fixtures', () => {
|
||||
it('has at least 20 cases', () => {
|
||||
expect(cases.length).toBeGreaterThanOrEqual(20);
|
||||
});
|
||||
|
||||
it.each(cases.map((c) => [c.file, c.data] as const))('%s', (_file, data) => {
|
||||
const catalog = loadFixtureCatalog(data.inactivePartTypeIds);
|
||||
const result = applyHiveOps(
|
||||
deepFreeze(structuredClone(data.state)),
|
||||
deepFreeze(structuredClone(data.ops)),
|
||||
catalog,
|
||||
);
|
||||
if (data.expect.ok) {
|
||||
expect(result).toEqual({ ok: true, state: data.expect.state });
|
||||
// The expected end state must itself be valid.
|
||||
expect(validateHiveState(data.expect.state, catalog)).toEqual([]);
|
||||
} else {
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.opIndex).toBe(data.expect.opIndex);
|
||||
expect(result.reason.split(':')[0]).toBe(data.expect.reasonCode);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyHiveOps purity', () => {
|
||||
it('does not mutate a deep-frozen input state or ops', () => {
|
||||
const catalog = loadFixtureCatalog();
|
||||
const sample = findCase('multiple ops apply in order');
|
||||
const state = deepFreeze(structuredClone(sample.state));
|
||||
const ops = deepFreeze(structuredClone(sample.ops));
|
||||
const before = JSON.stringify(state);
|
||||
const result = applyHiveOps(state, ops, catalog);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(JSON.stringify(state)).toBe(before);
|
||||
});
|
||||
|
||||
it('does not share references between input and result', () => {
|
||||
const catalog = loadFixtureCatalog();
|
||||
const state = structuredClone(
|
||||
findCase('setLabel sets and clears a label').state,
|
||||
);
|
||||
const result = applyHiveOps(state, [], catalog);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.state).not.toBe(state);
|
||||
expect(result.state.stack[0]).not.toBe(state.stack[0]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateHiveState', () => {
|
||||
const catalog = loadFixtureCatalog();
|
||||
|
||||
it('rejects non-objects and wrong schemaVersion', () => {
|
||||
expect(validateHiveState(null, catalog).length).toBeGreaterThan(0);
|
||||
expect(
|
||||
validateHiveState({ schemaVersion: 2, stack: [] }, catalog).length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('collects several violations', () => {
|
||||
const state = {
|
||||
schemaVersion: 1,
|
||||
stack: [
|
||||
{ id: 'a', partTypeId: 'hive-part-zl10-roof', state: {} },
|
||||
{ id: 'a', partTypeId: 'hive-part-zl10-floor', state: { x: 1 } },
|
||||
],
|
||||
};
|
||||
const codes = validateHiveState(state, catalog).map((r) => r.split(':')[0]);
|
||||
expect(codes).toEqual(
|
||||
expect.arrayContaining([
|
||||
'DUPLICATE_ID',
|
||||
'RULE_FLOOR_BOTTOM',
|
||||
'RULE_ROOF_TOP',
|
||||
'STATE_UNKNOWN_KEY',
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
import { MAX_OPS } from './hive-types';
|
||||
import type {
|
||||
HiveApplyResult,
|
||||
HiveCatalog,
|
||||
HiveFrame,
|
||||
HiveOp,
|
||||
HivePart,
|
||||
HiveState,
|
||||
} from './hive-types';
|
||||
import { validateHiveState } from './hive-validate';
|
||||
|
||||
interface Location {
|
||||
part: HivePart;
|
||||
list: HivePart[];
|
||||
index: number;
|
||||
isInsert: boolean;
|
||||
}
|
||||
|
||||
/** Result of one op on the working copy: a reason string, or null on success. */
|
||||
type OpOutcome = string | null;
|
||||
|
||||
function locate(state: HiveState, id: unknown): Location | null {
|
||||
for (let index = 0; index < state.stack.length; index++) {
|
||||
const part = state.stack[index];
|
||||
if (part.id === id) {
|
||||
return { part, list: state.stack, index, isInsert: false };
|
||||
}
|
||||
const inserts = part.inserts ?? [];
|
||||
for (let i = 0; i < inserts.length; i++) {
|
||||
if (inserts[i].id === id) {
|
||||
return { part: inserts[i], list: inserts, index: i, isInsert: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function missing(id: unknown): string {
|
||||
return `MISSING_ID: no part with id ${String(id)}`;
|
||||
}
|
||||
|
||||
function clamp(value: unknown, max: number): number {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) return max;
|
||||
return Math.min(Math.max(Math.trunc(value), 0), max);
|
||||
}
|
||||
|
||||
/** Catalog membership and active check for a part to be added (and its inserts). */
|
||||
function checkAddable(part: HivePart, catalog: HiveCatalog): OpOutcome {
|
||||
if (typeof part !== 'object' || part === null) {
|
||||
return 'MISSING_ID: part is not an object';
|
||||
}
|
||||
const type = catalog.partTypes.get(part.partTypeId);
|
||||
if (!type) {
|
||||
return `FOREIGN_PART_TYPE: ${String(part.partTypeId)} is not in this system`;
|
||||
}
|
||||
if (!type.active) {
|
||||
return `INACTIVE_PART_TYPE: ${type.id} is inactive`;
|
||||
}
|
||||
for (const insert of part.inserts ?? []) {
|
||||
const nested = checkAddable(insert, catalog);
|
||||
if (nested) return nested;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Deep copy of an incoming part without empty `inserts` arrays. */
|
||||
function normalisedCopy(part: HivePart): HivePart {
|
||||
const copy = structuredClone(part);
|
||||
if (Array.isArray(copy.inserts)) {
|
||||
if (copy.inserts.length === 0) delete copy.inserts;
|
||||
else copy.inserts = copy.inserts.map(normalisedCopy);
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
function applyAddPart(
|
||||
state: HiveState,
|
||||
op: Extract<HiveOp, { op: 'addPart' }>,
|
||||
catalog: HiveCatalog,
|
||||
): OpOutcome {
|
||||
const problem = checkAddable(op.part, catalog);
|
||||
if (problem) return problem;
|
||||
const at = clamp(op.at, state.stack.length);
|
||||
state.stack.splice(at, 0, normalisedCopy(op.part));
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyAddInsert(
|
||||
state: HiveState,
|
||||
op: Extract<HiveOp, { op: 'addInsert' }>,
|
||||
catalog: HiveCatalog,
|
||||
): OpOutcome {
|
||||
const host = locate(state, op.hostId);
|
||||
if (!host || host.isInsert) return missing(op.hostId);
|
||||
const problem = checkAddable(op.part, catalog);
|
||||
if (problem) return problem;
|
||||
host.part.inserts = [...(host.part.inserts ?? []), normalisedCopy(op.part)];
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyRemovePart(
|
||||
state: HiveState,
|
||||
op: Extract<HiveOp, { op: 'removePart' }>,
|
||||
): OpOutcome {
|
||||
const found = locate(state, op.id);
|
||||
if (!found) return missing(op.id);
|
||||
found.list.splice(found.index, 1);
|
||||
if (found.isInsert) {
|
||||
const host = state.stack.find((p) => p.inserts === found.list);
|
||||
if (host && found.list.length === 0) delete host.inserts;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyMovePart(
|
||||
state: HiveState,
|
||||
op: Extract<HiveOp, { op: 'movePart' }>,
|
||||
): OpOutcome {
|
||||
const found = locate(state, op.id);
|
||||
if (!found) return missing(op.id);
|
||||
if (found.isInsert) {
|
||||
return `RULE_INSERT_HOST: ${op.id} is an insert and cannot be moved`;
|
||||
}
|
||||
const [moved] = state.stack.splice(found.index, 1);
|
||||
state.stack.splice(clamp(op.to, state.stack.length), 0, moved);
|
||||
return null;
|
||||
}
|
||||
|
||||
function applySetState(
|
||||
state: HiveState,
|
||||
op: Extract<HiveOp, { op: 'setState' }>,
|
||||
): OpOutcome {
|
||||
const found = locate(state, op.id);
|
||||
if (!found) return missing(op.id);
|
||||
found.part.state = { ...found.part.state, [op.key]: op.value };
|
||||
return null;
|
||||
}
|
||||
|
||||
function locateBox(state: HiveState, boxId: unknown): Location | string {
|
||||
const found = locate(state, boxId);
|
||||
if (!found) return missing(boxId);
|
||||
if (found.isInsert || !Array.isArray(found.part.frames)) {
|
||||
return `FRAMES_LENGTH: ${String(boxId)} is not a box with frames`;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
function applySetFrame(
|
||||
state: HiveState,
|
||||
op: Extract<HiveOp, { op: 'setFrame' }>,
|
||||
): OpOutcome {
|
||||
const box = locateBox(state, op.boxId);
|
||||
if (typeof box === 'string') return box;
|
||||
const frames = box.part.frames as HiveFrame[];
|
||||
if (!Number.isInteger(op.slot) || op.slot < 1 || op.slot > frames.length) {
|
||||
return `FRAMES_LENGTH: slot ${String(op.slot)} is outside 1..${frames.length}`;
|
||||
}
|
||||
// name: undefined keeps the current name, null removes it.
|
||||
const current = frames[op.slot - 1];
|
||||
const name = op.name === undefined ? current.name : (op.name ?? undefined);
|
||||
frames[op.slot - 1] =
|
||||
name === undefined
|
||||
? { slot: op.slot, type: op.type }
|
||||
: { slot: op.slot, type: op.type, name };
|
||||
return null;
|
||||
}
|
||||
|
||||
function applySetFrames(
|
||||
state: HiveState,
|
||||
op: Extract<HiveOp, { op: 'setFrames' }>,
|
||||
): OpOutcome {
|
||||
const box = locateBox(state, op.boxId);
|
||||
if (typeof box === 'string') return box;
|
||||
box.part.frames = structuredClone(op.frames);
|
||||
return null;
|
||||
}
|
||||
|
||||
function applySetLabel(
|
||||
state: HiveState,
|
||||
op: Extract<HiveOp, { op: 'setLabel' }>,
|
||||
): OpOutcome {
|
||||
const found = locate(state, op.id);
|
||||
if (!found) return missing(op.id);
|
||||
if (op.label === null) {
|
||||
delete found.part.label;
|
||||
} else {
|
||||
found.part.label = op.label;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyOne(
|
||||
state: HiveState,
|
||||
op: HiveOp,
|
||||
catalog: HiveCatalog,
|
||||
): OpOutcome {
|
||||
switch (op?.op) {
|
||||
case 'addPart':
|
||||
return applyAddPart(state, op, catalog);
|
||||
case 'addInsert':
|
||||
return applyAddInsert(state, op, catalog);
|
||||
case 'removePart':
|
||||
return applyRemovePart(state, op);
|
||||
case 'movePart':
|
||||
return applyMovePart(state, op);
|
||||
case 'setState':
|
||||
return applySetState(state, op);
|
||||
case 'setFrame':
|
||||
return applySetFrame(state, op);
|
||||
case 'setFrames':
|
||||
return applySetFrames(state, op);
|
||||
case 'setLabel':
|
||||
return applySetLabel(state, op);
|
||||
default:
|
||||
return 'UNKNOWN_OP: unsupported operation';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies ops in order and validates the whole state after each one.
|
||||
* Never mutates its inputs: it works on a private deep copy.
|
||||
*/
|
||||
export function applyHiveOps(
|
||||
state: HiveState,
|
||||
ops: HiveOp[],
|
||||
catalog: HiveCatalog,
|
||||
): HiveApplyResult {
|
||||
if (ops.length > MAX_OPS) {
|
||||
return {
|
||||
ok: false,
|
||||
opIndex: MAX_OPS,
|
||||
reason: `LIMIT_OPS: at most ${MAX_OPS} operations per request`,
|
||||
};
|
||||
}
|
||||
const working = structuredClone(state);
|
||||
for (let opIndex = 0; opIndex < ops.length; opIndex++) {
|
||||
const failure =
|
||||
applyOne(working, ops[opIndex], catalog) ??
|
||||
validateHiveState(working, catalog)[0] ??
|
||||
null;
|
||||
if (failure) return { ok: false, opIndex, reason: failure };
|
||||
}
|
||||
return { ok: true, state: working };
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { FeedType } from '../../generated/prisma';
|
||||
import type { StateField } from './hive-types';
|
||||
|
||||
const MAX_KEY = 40;
|
||||
const MAX_LABEL = 60;
|
||||
const MAX_UNIT = 10;
|
||||
const MAX_OPTION = 40;
|
||||
const FEED_TYPES: readonly string[] = Object.values(FeedType);
|
||||
|
||||
type Obj = Record<string, unknown>;
|
||||
|
||||
const isObj = (value: unknown): value is Obj =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
|
||||
const isPositive = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isFinite(value) && value > 0;
|
||||
|
||||
const isStringList = (value: unknown): value is string[] =>
|
||||
Array.isArray(value) && value.every((x) => typeof x === 'string');
|
||||
|
||||
function levelProblem(field: Obj, where: string): string | null {
|
||||
if (typeof field.unit !== 'string' || field.unit.length > MAX_UNIT) {
|
||||
return `${where}.unit must be a string of at most ${MAX_UNIT} characters`;
|
||||
}
|
||||
if (!isPositive(field.max)) return `${where}.max must be positive`;
|
||||
if (!isPositive(field.step)) return `${where}.step must be positive`;
|
||||
if (
|
||||
field.feedType !== undefined &&
|
||||
!(typeof field.feedType === 'string' && FEED_TYPES.includes(field.feedType))
|
||||
) {
|
||||
return `${where}.feedType must be one of ${FEED_TYPES.join('/')}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function choiceProblem(field: Obj, where: string): string | null {
|
||||
const { options, optionLabels } = field;
|
||||
if (!isStringList(options) || options.length === 0) {
|
||||
return `${where}.options must be a non-empty list of strings`;
|
||||
}
|
||||
if (new Set(options).size !== options.length) {
|
||||
return `${where}.options must be unique`;
|
||||
}
|
||||
if (!isStringList(optionLabels) || optionLabels.length !== options.length) {
|
||||
return `${where}.optionLabels must match options`;
|
||||
}
|
||||
if ([...options, ...optionLabels].some((text) => text.length > MAX_OPTION)) {
|
||||
return `${where} options and labels must have at most ${MAX_OPTION} characters`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function fieldProblem(field: unknown, index: number): string | null {
|
||||
const where = `stateSchema[${index}]`;
|
||||
if (!isObj(field)) return `${where} must be an object`;
|
||||
if (
|
||||
typeof field.key !== 'string' ||
|
||||
field.key === '' ||
|
||||
field.key.length > MAX_KEY
|
||||
) {
|
||||
return `${where}.key must be 1-${MAX_KEY} characters`;
|
||||
}
|
||||
if (
|
||||
typeof field.label !== 'string' ||
|
||||
field.label === '' ||
|
||||
field.label.length > MAX_LABEL
|
||||
) {
|
||||
return `${where}.label must be 1-${MAX_LABEL} characters`;
|
||||
}
|
||||
if (field.kind === 'level') return levelProblem(field, where);
|
||||
if (field.kind === 'choice') return choiceProblem(field, where);
|
||||
return `${where}.kind must be level or choice`;
|
||||
}
|
||||
|
||||
/** Returns the problems of a raw stateSchema value (empty list means valid). */
|
||||
export function stateSchemaProblems(raw: unknown): string[] {
|
||||
if (!Array.isArray(raw)) return ['stateSchema must be a list'];
|
||||
const problems: string[] = [];
|
||||
const keys = new Set<string>();
|
||||
raw.forEach((field: unknown, index) => {
|
||||
const problem = fieldProblem(field, index);
|
||||
if (problem) {
|
||||
problems.push(problem);
|
||||
return;
|
||||
}
|
||||
const key = (field as Obj).key as string;
|
||||
if (keys.has(key)) problems.push(`duplicate key ${key}`);
|
||||
keys.add(key);
|
||||
});
|
||||
return problems;
|
||||
}
|
||||
|
||||
/** Casts a stored JSON value after the shape check; throws when malformed. */
|
||||
export function parseStateSchema(raw: unknown): StateField[] {
|
||||
const problems = stateSchemaProblems(raw);
|
||||
if (problems.length > 0) {
|
||||
throw new Error(`Malformed stateSchema: ${problems.join('; ')}`);
|
||||
}
|
||||
return raw as StateField[];
|
||||
}
|
||||
|
||||
/** Stable JSON text: object keys sorted, so key order does not matter. */
|
||||
function canonical(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
|
||||
if (isObj(value)) {
|
||||
return `{${Object.keys(value)
|
||||
.sort()
|
||||
.map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`)
|
||||
.join(',')}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
/** True when `next` keeps every field of `current` unchanged and only appends. */
|
||||
export function isAdditiveSchema(
|
||||
current: StateField[],
|
||||
next: StateField[],
|
||||
): boolean {
|
||||
if (next.length < current.length) return false;
|
||||
return current.every((field, i) => canonical(field) === canonical(next[i]));
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { catalogFromRows } from './hive-types';
|
||||
import type { CatalogPartType, HiveCatalog } from './hive-types';
|
||||
|
||||
export const FIXTURE_DIR = join(__dirname, '../../test/fixtures/hive-ops');
|
||||
|
||||
interface CatalogFile {
|
||||
systemId: string;
|
||||
partTypes: CatalogPartType[];
|
||||
}
|
||||
|
||||
/** Loads the shared catalog fixture; `inactiveIds` marks part types inactive. */
|
||||
export function loadFixtureCatalog(inactiveIds: string[] = []): HiveCatalog {
|
||||
const file = JSON.parse(
|
||||
readFileSync(join(FIXTURE_DIR, 'catalog.json'), 'utf8'),
|
||||
) as CatalogFile;
|
||||
const rows = file.partTypes.map((row) => ({
|
||||
...row,
|
||||
active: !inactiveIds.includes(row.id),
|
||||
}));
|
||||
return catalogFromRows(rows, file.systemId);
|
||||
}
|
||||
|
||||
export function deepFreeze<T>(value: T): T {
|
||||
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
|
||||
Object.freeze(value);
|
||||
Object.values(value as Record<string, unknown>).forEach((child) =>
|
||||
deepFreeze(child),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// Pure domain types for the hive setup (spec 5.2, 6.1). No framework or
|
||||
// Prisma imports: the frontend mirrors this module.
|
||||
|
||||
export type HivePartKind =
|
||||
| 'FLOOR'
|
||||
| 'BOX'
|
||||
| 'DIVIDER_BOARD'
|
||||
| 'QUEEN_EXCLUDER'
|
||||
| 'FEEDER'
|
||||
| 'COVER_SHEET'
|
||||
| 'PROPOLIS_SCREEN'
|
||||
| 'INNER_LID'
|
||||
| 'INSULATION'
|
||||
| 'ROOF'
|
||||
| 'FLOOR_INSERT'
|
||||
| 'EVAPORATOR';
|
||||
|
||||
// Mirrors the Prisma enum HiveBoxDepth; used from Task 3 on.
|
||||
export type HiveBoxDepth = 'FULL' | 'SHALLOW';
|
||||
|
||||
export type FrameType =
|
||||
| 'BROOD'
|
||||
| 'DRONE'
|
||||
| 'HONEY'
|
||||
| 'POLLEN'
|
||||
| 'FEED'
|
||||
| 'FOUNDATION'
|
||||
| 'EMPTY_COMB'
|
||||
| 'DRONE_FRAME'
|
||||
| 'DIVIDER'
|
||||
| 'NONE';
|
||||
|
||||
export const FRAME_TYPES: readonly FrameType[] = [
|
||||
'BROOD',
|
||||
'DRONE',
|
||||
'HONEY',
|
||||
'POLLEN',
|
||||
'FEED',
|
||||
'FOUNDATION',
|
||||
'EMPTY_COMB',
|
||||
'DRONE_FRAME',
|
||||
'DIVIDER',
|
||||
'NONE',
|
||||
];
|
||||
|
||||
export const MAX_STACK_PARTS = 30;
|
||||
export const MAX_OPS = 100;
|
||||
export const MAX_TEXT_LENGTH = 40;
|
||||
export const LEVEL_TOLERANCE = 1e-9;
|
||||
|
||||
export interface HiveFrame {
|
||||
slot: number;
|
||||
type: FrameType;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface HivePart {
|
||||
id: string;
|
||||
partTypeId: string;
|
||||
label?: string;
|
||||
state: Record<string, number | string>;
|
||||
inserts?: HivePart[];
|
||||
frames?: HiveFrame[];
|
||||
}
|
||||
|
||||
export interface HiveState {
|
||||
schemaVersion: 1;
|
||||
stack: HivePart[];
|
||||
}
|
||||
|
||||
export type HiveOp =
|
||||
| { op: 'addPart'; part: HivePart; at: number }
|
||||
| { op: 'addInsert'; hostId: string; part: HivePart }
|
||||
| { op: 'removePart'; id: string }
|
||||
| { op: 'movePart'; id: string; to: number }
|
||||
| { op: 'setState'; id: string; key: string; value: number | string }
|
||||
| {
|
||||
op: 'setFrame';
|
||||
boxId: string;
|
||||
slot: number;
|
||||
type: FrameType;
|
||||
name?: string | null;
|
||||
}
|
||||
| { op: 'setFrames'; boxId: string; frames: HiveFrame[] }
|
||||
| { op: 'setLabel'; id: string; label: string | null };
|
||||
|
||||
export interface LevelField {
|
||||
key: string;
|
||||
label: string;
|
||||
kind: 'level';
|
||||
unit: string;
|
||||
max: number;
|
||||
step: number;
|
||||
feedType?: string;
|
||||
}
|
||||
|
||||
export interface ChoiceField {
|
||||
key: string;
|
||||
label: string;
|
||||
kind: 'choice';
|
||||
options: string[];
|
||||
optionLabels: string[];
|
||||
}
|
||||
|
||||
export type StateField = LevelField | ChoiceField;
|
||||
|
||||
export interface CatalogPartType {
|
||||
id: string;
|
||||
kind: HivePartKind;
|
||||
name: string;
|
||||
lengthMm: number;
|
||||
widthMm: number;
|
||||
heightMm: number;
|
||||
frameSlots: number | null;
|
||||
stateSchema: StateField[];
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface HiveCatalog {
|
||||
systemId: string;
|
||||
partTypes: Map<string, CatalogPartType>;
|
||||
}
|
||||
|
||||
export function catalogFromRows(
|
||||
rows: CatalogPartType[],
|
||||
systemId: string,
|
||||
): HiveCatalog {
|
||||
return {
|
||||
systemId,
|
||||
partTypes: new Map(rows.map((row) => [row.id, row])),
|
||||
};
|
||||
}
|
||||
|
||||
export interface HiveLevelFact {
|
||||
partId: string;
|
||||
key: string;
|
||||
label: string;
|
||||
unit: string;
|
||||
value: number;
|
||||
max: number;
|
||||
feedType?: string;
|
||||
}
|
||||
|
||||
export interface HiveFacts {
|
||||
boxCount: number;
|
||||
broodFrames: number;
|
||||
honeyFrames: number;
|
||||
builtFrames: number;
|
||||
levels: HiveLevelFact[];
|
||||
hasFloorInsert: boolean;
|
||||
evaporatorAcidMl: number | null;
|
||||
}
|
||||
|
||||
export type HiveApplyResult =
|
||||
| { ok: true; state: HiveState }
|
||||
| { ok: false; opIndex: number; reason: string };
|
||||
@@ -0,0 +1,269 @@
|
||||
import {
|
||||
FRAME_TYPES,
|
||||
LEVEL_TOLERANCE,
|
||||
MAX_STACK_PARTS,
|
||||
MAX_TEXT_LENGTH,
|
||||
} from './hive-types';
|
||||
import type { CatalogPartType, HiveCatalog, StateField } from './hive-types';
|
||||
|
||||
// Check order decides which code is reported first; the frontend port must
|
||||
// keep it: root shape (schemaVersion must be 1 -> STATE_OUT_OF_RANGE; extra
|
||||
// root keys -> UNKNOWN_FIELD), LIMIT_PARTS, then per stack part: id, unknown
|
||||
// fields (UNKNOWN_FIELD), type, label, state, frames (per frame: slot, unknown
|
||||
// fields, type, name), inserts (each insert in the same order), then FLOOR, then ROOF.
|
||||
// Text lengths are UTF-16 code units (`.length`); the frontend must do the same.
|
||||
const ROOT_KEYS = ['schemaVersion', 'stack'];
|
||||
const PART_KEYS = ['id', 'partTypeId', 'label', 'state', 'inserts', 'frames'];
|
||||
const FRAME_KEYS = ['slot', 'type', 'name'];
|
||||
|
||||
function unknownFields(
|
||||
obj: Record<string, unknown>,
|
||||
allowed: string[],
|
||||
where: string,
|
||||
out: string[],
|
||||
): void {
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (!allowed.includes(key)) out.push(`UNKNOWN_FIELD: ${where}.${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
type Obj = Record<string, unknown>;
|
||||
|
||||
const isObj = (value: unknown): value is Obj =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
|
||||
/** Checks one state value against its schema field; returns a reason or null. */
|
||||
export function checkStateValue(
|
||||
field: StateField,
|
||||
value: unknown,
|
||||
where: string,
|
||||
): string | null {
|
||||
if (field.kind === 'choice') {
|
||||
if (typeof value !== 'string' || !field.options.includes(value)) {
|
||||
return `STATE_OUT_OF_RANGE: ${where} must be one of ${field.options.join('/')}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return `STATE_OUT_OF_RANGE: ${where} must be a number`;
|
||||
}
|
||||
if (value < 0 || value > field.max) {
|
||||
return `STATE_OUT_OF_RANGE: ${where} must be between 0 and ${field.max}`;
|
||||
}
|
||||
const steps = value / field.step;
|
||||
if (Math.abs(steps - Math.round(steps)) * field.step > LEVEL_TOLERANCE) {
|
||||
return `STATE_STEP: ${where} must be a multiple of ${field.step}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateState(
|
||||
part: Obj,
|
||||
type: CatalogPartType,
|
||||
where: string,
|
||||
out: string[],
|
||||
): void {
|
||||
if (!isObj(part.state)) {
|
||||
out.push(`STATE_OUT_OF_RANGE: ${where} has no state object`);
|
||||
return;
|
||||
}
|
||||
for (const [key, value] of Object.entries(part.state)) {
|
||||
const field = type.stateSchema.find((f) => f.key === key);
|
||||
if (!field) {
|
||||
out.push(`STATE_UNKNOWN_KEY: ${where} has unknown key ${key}`);
|
||||
continue;
|
||||
}
|
||||
const problem = checkStateValue(field, value, `${where}.${key}`);
|
||||
if (problem) out.push(problem);
|
||||
}
|
||||
}
|
||||
|
||||
function validateFrames(
|
||||
part: Obj,
|
||||
type: CatalogPartType,
|
||||
where: string,
|
||||
out: string[],
|
||||
): void {
|
||||
const slots = type.kind === 'BOX' ? (type.frameSlots ?? 0) : 0;
|
||||
const frames = part.frames;
|
||||
if (type.kind !== 'BOX') {
|
||||
if (
|
||||
frames !== undefined &&
|
||||
!(Array.isArray(frames) && frames.length === 0)
|
||||
) {
|
||||
out.push(`FRAMES_LENGTH: ${where} is not a box and cannot have frames`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const list = frames === undefined && slots === 0 ? [] : frames;
|
||||
if (!Array.isArray(list) || list.length !== slots) {
|
||||
out.push(`FRAMES_LENGTH: ${where} needs exactly ${slots} frames`);
|
||||
return;
|
||||
}
|
||||
list.forEach((frame: unknown, index) => {
|
||||
if (!isObj(frame) || frame.slot !== index + 1) {
|
||||
out.push(`FRAMES_LENGTH: ${where} frame ${index + 1} has a wrong slot`);
|
||||
return;
|
||||
}
|
||||
unknownFields(frame, FRAME_KEYS, `${where}.frames[${index}]`, out);
|
||||
if (!(FRAME_TYPES as readonly unknown[]).includes(frame.type)) {
|
||||
out.push(
|
||||
`STATE_OUT_OF_RANGE: ${where} frame ${index + 1} has an unknown type`,
|
||||
);
|
||||
}
|
||||
if (frame.name !== undefined) {
|
||||
if (typeof frame.name !== 'string') {
|
||||
out.push(
|
||||
`STATE_OUT_OF_RANGE: ${where} frame ${index + 1} name must be a string`,
|
||||
);
|
||||
} else if (frame.name.length > MAX_TEXT_LENGTH) {
|
||||
out.push(`LIMIT_TEXT: ${where} frame ${index + 1} name is too long`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
interface WalkContext {
|
||||
catalog: HiveCatalog;
|
||||
seenIds: Set<string>;
|
||||
out: string[];
|
||||
}
|
||||
|
||||
/** Checks id, catalog membership, label, state, frames. Returns the type or null. */
|
||||
function validatePartCommon(
|
||||
part: unknown,
|
||||
where: string,
|
||||
ctx: WalkContext,
|
||||
): { part: Obj; type: CatalogPartType } | null {
|
||||
const { out } = ctx;
|
||||
if (!isObj(part)) {
|
||||
out.push(`MISSING_ID: ${where} is not an object`);
|
||||
return null;
|
||||
}
|
||||
if (typeof part.id !== 'string' || part.id === '') {
|
||||
out.push(`MISSING_ID: ${where} has no id`);
|
||||
} else if (ctx.seenIds.has(part.id)) {
|
||||
out.push(`DUPLICATE_ID: ${part.id}`);
|
||||
} else {
|
||||
ctx.seenIds.add(part.id);
|
||||
}
|
||||
unknownFields(part, PART_KEYS, where, out);
|
||||
const type =
|
||||
typeof part.partTypeId === 'string'
|
||||
? ctx.catalog.partTypes.get(part.partTypeId)
|
||||
: undefined;
|
||||
if (!type) {
|
||||
out.push(`FOREIGN_PART_TYPE: ${where} part type is not in this system`);
|
||||
return null;
|
||||
}
|
||||
if (part.label !== undefined) {
|
||||
if (typeof part.label !== 'string') {
|
||||
out.push(`STATE_OUT_OF_RANGE: ${where} label must be a string`);
|
||||
} else if (part.label.length > MAX_TEXT_LENGTH) {
|
||||
out.push(`LIMIT_TEXT: ${where} label is too long`);
|
||||
}
|
||||
}
|
||||
validateState(part, type, where, out);
|
||||
validateFrames(part, type, where, out);
|
||||
return { part, type };
|
||||
}
|
||||
|
||||
function validateInserts(
|
||||
host: Obj,
|
||||
hostType: CatalogPartType,
|
||||
where: string,
|
||||
ctx: WalkContext,
|
||||
): void {
|
||||
const { out } = ctx;
|
||||
const inserts = host.inserts;
|
||||
if (inserts === undefined) return;
|
||||
if (!Array.isArray(inserts)) {
|
||||
out.push(`RULE_INSERT_HOST: ${where} inserts must be a list`);
|
||||
return;
|
||||
}
|
||||
const allowed =
|
||||
hostType.kind === 'FLOOR'
|
||||
? 'FLOOR_INSERT'
|
||||
: hostType.kind === 'BOX'
|
||||
? 'EVAPORATOR'
|
||||
: null;
|
||||
if (inserts.length > 0 && allowed === null) {
|
||||
out.push(`RULE_INSERT_HOST: ${where} cannot hold inserts`);
|
||||
}
|
||||
if (allowed !== null && inserts.length > 1) {
|
||||
out.push(`RULE_INSERT_LIMIT: ${where} holds at most one ${allowed}`);
|
||||
}
|
||||
inserts.forEach((insert: unknown, index) => {
|
||||
const here = `${where}.inserts[${index}]`;
|
||||
const checked = validatePartCommon(insert, here, ctx);
|
||||
if (!checked) return;
|
||||
if (allowed !== null && checked.type.kind !== allowed) {
|
||||
out.push(`RULE_INSERT_HOST: ${here} must be ${allowed}`);
|
||||
}
|
||||
if (checked.part.inserts !== undefined) {
|
||||
const nested = checked.part.inserts;
|
||||
if (!Array.isArray(nested) || nested.length > 0) {
|
||||
out.push(`RULE_INSERT_HOST: ${here} cannot hold inserts`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function validateStackRules(
|
||||
kinds: (CatalogPartType | null)[],
|
||||
out: string[],
|
||||
): void {
|
||||
const floors = kinds.filter((t) => t?.kind === 'FLOOR').length;
|
||||
if (floors !== 1 || kinds[0]?.kind !== 'FLOOR') {
|
||||
out.push(
|
||||
'RULE_FLOOR_BOTTOM: the stack needs exactly one floor at the bottom',
|
||||
);
|
||||
}
|
||||
const roofIndexes = kinds
|
||||
.map((t, i) => (t?.kind === 'ROOF' ? i : -1))
|
||||
.filter((i) => i >= 0);
|
||||
if (
|
||||
roofIndexes.length > 1 ||
|
||||
(roofIndexes.length === 1 && roofIndexes[0] !== kinds.length - 1)
|
||||
) {
|
||||
out.push('RULE_ROOF_TOP: the roof must be the single top part');
|
||||
}
|
||||
}
|
||||
|
||||
/** Validates a whole hive state. Empty result means valid. */
|
||||
export function validateHiveState(
|
||||
state: unknown,
|
||||
catalog: HiveCatalog,
|
||||
): string[] {
|
||||
const out: string[] = [];
|
||||
if (
|
||||
!isObj(state) ||
|
||||
state.schemaVersion !== 1 ||
|
||||
!Array.isArray(state.stack)
|
||||
) {
|
||||
return ['STATE_OUT_OF_RANGE: state needs schemaVersion 1 and a stack list'];
|
||||
}
|
||||
unknownFields(state, ROOT_KEYS, 'state', out);
|
||||
if (state.stack.length > MAX_STACK_PARTS) {
|
||||
out.push(`LIMIT_PARTS: at most ${MAX_STACK_PARTS} parts in the stack`);
|
||||
}
|
||||
const ctx: WalkContext = { catalog, seenIds: new Set(), out };
|
||||
const types: (CatalogPartType | null)[] = [];
|
||||
state.stack.forEach((entry: unknown, index) => {
|
||||
const where = `stack[${index}]`;
|
||||
const checked = validatePartCommon(entry, where, ctx);
|
||||
types.push(checked?.type ?? null);
|
||||
if (!checked) return;
|
||||
if (
|
||||
checked.type.kind === 'FLOOR_INSERT' ||
|
||||
checked.type.kind === 'EVAPORATOR'
|
||||
) {
|
||||
out.push(
|
||||
`RULE_INSERT_HOST: ${where} must be an insert, not a stack part`,
|
||||
);
|
||||
}
|
||||
validateInserts(checked.part, checked.type, where, ctx);
|
||||
});
|
||||
validateStackRules(types, out);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
|
||||
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||
import { Idempotent } from '../common/idempotency/idempotent.decorator';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateHiveChangeDto } from './dto/create-hive-change.dto';
|
||||
import { CreateHiveSetupDto } from './dto/create-hive-setup.dto';
|
||||
import { HiveAtQueryDto, HiveHistoryQueryDto } from './dto/hive-queries.dto';
|
||||
import { HiveService } from './hive.service';
|
||||
import type { HiveOp } from './hive-types';
|
||||
|
||||
@ApiTags('hive')
|
||||
@ApiBearerAuth()
|
||||
@Controller('colonies/:id/hive')
|
||||
export class HiveController {
|
||||
constructor(
|
||||
private readonly hive: HiveService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
@RequirePermission('colonies:read')
|
||||
@Get()
|
||||
get(@Param('id') id: string) {
|
||||
return this.hive.get(id);
|
||||
}
|
||||
|
||||
@RequirePermission('colonies:update')
|
||||
@Idempotent()
|
||||
@Post()
|
||||
create(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: CreateHiveSetupDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.hive.create(id, dto, actor.id);
|
||||
}
|
||||
|
||||
@RequirePermission('colonies:update')
|
||||
@Idempotent()
|
||||
@Post('changes')
|
||||
applyChange(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: CreateHiveChangeDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
// Plain objects only: the DTO instances carry class metadata.
|
||||
const ops = JSON.parse(JSON.stringify(dto.ops)) as HiveOp[];
|
||||
return this.prisma.$transaction((tx) =>
|
||||
this.hive.applyChange(
|
||||
tx,
|
||||
id,
|
||||
{
|
||||
occurredOn: dto.occurredOn,
|
||||
source: dto.source,
|
||||
ops,
|
||||
note: dto.note,
|
||||
},
|
||||
actor.id,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@RequirePermission('colonies:read')
|
||||
@Get('changes')
|
||||
history(@Param('id') id: string, @Query() query: HiveHistoryQueryDto) {
|
||||
return this.hive.history(id, query.cursor, query.limit);
|
||||
}
|
||||
|
||||
@RequirePermission('colonies:read')
|
||||
@Get('at')
|
||||
at(@Param('id') id: string, @Query() query: HiveAtQueryDto) {
|
||||
return this.hive.at(id, query.day);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HiveCatalogController } from './hive-catalog.controller';
|
||||
import { HiveCatalogService } from './hive-catalog.service';
|
||||
import { HiveController } from './hive.controller';
|
||||
import { HiveService } from './hive.service';
|
||||
|
||||
@Module({
|
||||
controllers: [HiveCatalogController, HiveController],
|
||||
providers: [HiveCatalogService, HiveService],
|
||||
exports: [HiveCatalogService, HiveService],
|
||||
})
|
||||
export class HiveModule {}
|
||||
@@ -0,0 +1,609 @@
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
import { addDays, berlinToday } from '../common/dates';
|
||||
import { HiveCatalogService } from './hive-catalog.service';
|
||||
import { HiveService } from './hive.service';
|
||||
import { loadFixtureCatalog } from './hive-test-utils';
|
||||
import type { HiveOp, HiveState } from './hive-types';
|
||||
|
||||
jest.mock('../prisma/row-lock');
|
||||
|
||||
const SYSTEM_ID = 'hive-system-zander-liebig-10';
|
||||
const COLONY = 'colony-1';
|
||||
const USER = 'user-1';
|
||||
const FLOOR = 'hive-part-zl10-floor';
|
||||
const BOX = 'hive-part-zl10-box-10';
|
||||
const ROOF = 'hive-part-zl10-roof';
|
||||
|
||||
const frames = Array.from({ length: 10 }, (_, i) => ({
|
||||
slot: i + 1,
|
||||
type: 'FOUNDATION',
|
||||
}));
|
||||
|
||||
const baseState = {
|
||||
schemaVersion: 1,
|
||||
stack: [
|
||||
{ id: 'p-floor', partTypeId: FLOOR, state: {} },
|
||||
{ id: 'p-box', partTypeId: BOX, state: {}, frames },
|
||||
{ id: 'p-roof', partTypeId: ROOF, state: {} },
|
||||
],
|
||||
} as unknown as HiveState;
|
||||
|
||||
const TODAY = berlinToday();
|
||||
const INIT_DAY = addDays(TODAY, -10);
|
||||
const setLabel = (label: string): HiveOp => ({
|
||||
op: 'setLabel',
|
||||
id: 'p-box',
|
||||
label,
|
||||
});
|
||||
|
||||
function dayDate(day: string): Date {
|
||||
return new Date(`${day}T00:00:00.000Z`);
|
||||
}
|
||||
|
||||
function argOf<T>(mock: jest.Mock, call: number, arg: number): T {
|
||||
return (mock.mock.calls as unknown[][])[call][arg] as T;
|
||||
}
|
||||
|
||||
interface WrittenChange {
|
||||
data: {
|
||||
stateAfter: { stack: { label?: string }[] };
|
||||
occurredOn: Date;
|
||||
inspectionId: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
const lockedTables = (mock: jest.Mock): unknown[] =>
|
||||
(mock.mock.calls as unknown[][]).map((call) => call[1]);
|
||||
|
||||
async function statusAndBody(promise: Promise<unknown>) {
|
||||
try {
|
||||
await promise;
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) {
|
||||
return { status: error.getStatus(), body: error.getResponse() };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
throw new Error('expected the call to reject');
|
||||
}
|
||||
|
||||
describe('HiveService', () => {
|
||||
let service: HiveService;
|
||||
let tx: {
|
||||
colony: { findUnique: jest.Mock };
|
||||
hiveSystem: { findUnique: jest.Mock };
|
||||
hiveSetup: {
|
||||
findUnique: jest.Mock;
|
||||
create: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
hiveChange: {
|
||||
findUnique: jest.Mock;
|
||||
findFirst: jest.Mock;
|
||||
findMany: jest.Mock;
|
||||
create: jest.Mock;
|
||||
};
|
||||
};
|
||||
let prisma: { $transaction: jest.Mock } & typeof tx;
|
||||
let events: { record: jest.Mock };
|
||||
let catalogs: { loadCatalog: jest.Mock };
|
||||
const lock = lockRowForUpdate as jest.Mock;
|
||||
|
||||
const setup = (version = 1) => ({
|
||||
colonyId: COLONY,
|
||||
systemId: SYSTEM_ID,
|
||||
version,
|
||||
state: baseState,
|
||||
updatedAt: new Date('2026-10-01T10:00:00.000Z'),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
tx = {
|
||||
colony: {
|
||||
findUnique: jest.fn().mockResolvedValue({ status: 'ACTIVE' }),
|
||||
},
|
||||
hiveSystem: {
|
||||
findUnique: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ id: SYSTEM_ID, active: true }),
|
||||
},
|
||||
hiveSetup: {
|
||||
findUnique: jest.fn().mockResolvedValue(setup()),
|
||||
create: jest.fn().mockImplementation(({ data }: { data: object }) => ({
|
||||
...data,
|
||||
updatedAt: new Date('2026-10-02T10:00:00.000Z'),
|
||||
})),
|
||||
update: jest.fn().mockImplementation(({ data }: { data: object }) => ({
|
||||
colonyId: COLONY,
|
||||
systemId: SYSTEM_ID,
|
||||
updatedAt: new Date('2026-10-03T10:00:00.000Z'),
|
||||
...data,
|
||||
})),
|
||||
},
|
||||
hiveChange: {
|
||||
findUnique: jest.fn().mockResolvedValue({
|
||||
occurredOn: dayDate(INIT_DAY),
|
||||
}),
|
||||
findFirst: jest.fn(),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
create: jest.fn().mockResolvedValue({}),
|
||||
},
|
||||
};
|
||||
prisma = {
|
||||
...tx,
|
||||
$transaction: jest.fn((cb: (t: typeof tx) => unknown) => cb(tx)),
|
||||
};
|
||||
events = { record: jest.fn().mockResolvedValue(undefined) };
|
||||
catalogs = {
|
||||
loadCatalog: jest.fn().mockResolvedValue(loadFixtureCatalog()),
|
||||
};
|
||||
lock.mockReset().mockResolvedValue(true);
|
||||
service = new HiveService(
|
||||
prisma as unknown as PrismaService,
|
||||
events as unknown as DomainEventsService,
|
||||
catalogs as unknown as HiveCatalogService,
|
||||
);
|
||||
});
|
||||
|
||||
describe('get', () => {
|
||||
it('returns the setup with derived facts', async () => {
|
||||
const view = await service.get(COLONY);
|
||||
expect(view).toMatchObject({
|
||||
colonyId: COLONY,
|
||||
systemId: SYSTEM_ID,
|
||||
version: 1,
|
||||
updatedAt: '2026-10-01T10:00:00.000Z',
|
||||
});
|
||||
expect(view.state).toEqual(baseState);
|
||||
expect(view.facts.boxCount).toBe(1);
|
||||
});
|
||||
|
||||
it('answers 404 HIVE_NOT_FOUND without a setup', async () => {
|
||||
tx.hiveSetup.findUnique.mockResolvedValue(null);
|
||||
const { status, body } = await statusAndBody(service.get(COLONY));
|
||||
expect(status).toBe(404);
|
||||
expect(body).toMatchObject({ code: 'HIVE_NOT_FOUND' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
const input = (overrides = {}) => ({
|
||||
systemId: SYSTEM_ID,
|
||||
state: baseState,
|
||||
occurredOn: TODAY,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
tx.hiveSetup.findUnique.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it('writes version 1, the INIT change and the event in one transaction', async () => {
|
||||
const view = await service.create(COLONY, input(), USER);
|
||||
|
||||
expect(view.version).toBe(1);
|
||||
expect(tx.hiveSetup.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
colonyId: COLONY,
|
||||
systemId: SYSTEM_ID,
|
||||
version: 1,
|
||||
state: baseState,
|
||||
},
|
||||
});
|
||||
expect(tx.hiveChange.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
colonyId: COLONY,
|
||||
version: 1,
|
||||
source: 'INIT',
|
||||
userId: USER,
|
||||
occurredOn: dayDate(TODAY),
|
||||
ops: [],
|
||||
stateAfter: baseState,
|
||||
}) as unknown,
|
||||
});
|
||||
expect(events.record).toHaveBeenCalledWith(tx, {
|
||||
type: 'HiveChanged',
|
||||
aggregateType: 'Colony',
|
||||
aggregateId: COLONY,
|
||||
actorUserId: USER,
|
||||
payload: {
|
||||
colonyId: COLONY,
|
||||
version: 1,
|
||||
source: 'INIT',
|
||||
inspectionId: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('locks the colony before the setup', async () => {
|
||||
await service.create(COLONY, input(), USER);
|
||||
expect(lockedTables(lock)).toEqual(['Colony', 'HiveSetup']);
|
||||
});
|
||||
|
||||
it('answers 409 HIVE_EXISTS for a second setup, decided under the lock', async () => {
|
||||
tx.hiveSetup.findUnique.mockResolvedValue(setup());
|
||||
const { status, body } = await statusAndBody(
|
||||
service.create(COLONY, input(), USER),
|
||||
);
|
||||
expect(status).toBe(409);
|
||||
expect(body).toMatchObject({ code: 'HIVE_EXISTS' });
|
||||
expect(tx.hiveSetup.create).not.toHaveBeenCalled();
|
||||
expect(events.record).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('answers 409 for a colony that no longer lives', async () => {
|
||||
tx.colony.findUnique.mockResolvedValue({ status: 'DISSOLVED' });
|
||||
const { status } = await statusAndBody(
|
||||
service.create(COLONY, input(), USER),
|
||||
);
|
||||
expect(status).toBe(409);
|
||||
});
|
||||
|
||||
it('answers 404 for an unknown colony', async () => {
|
||||
lock.mockResolvedValueOnce(false);
|
||||
const { status } = await statusAndBody(
|
||||
service.create(COLONY, input(), USER),
|
||||
);
|
||||
expect(status).toBe(404);
|
||||
});
|
||||
|
||||
it('answers 400 with messages for a state the catalog refuses', async () => {
|
||||
const state = { schemaVersion: 1, stack: [] };
|
||||
state.stack.push({
|
||||
id: 'x',
|
||||
partTypeId: 'unknown-part',
|
||||
state: {},
|
||||
} as never);
|
||||
const { status, body } = await statusAndBody(
|
||||
service.create(COLONY, input({ state }), USER),
|
||||
);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toMatchObject({ messages: expect.any(Array) as unknown });
|
||||
});
|
||||
|
||||
it('answers 400 for an inactive system and 404 for an unknown one', async () => {
|
||||
tx.hiveSystem.findUnique.mockResolvedValueOnce({
|
||||
id: SYSTEM_ID,
|
||||
active: false,
|
||||
});
|
||||
expect(
|
||||
(await statusAndBody(service.create(COLONY, input(), USER))).status,
|
||||
).toBe(400);
|
||||
tx.hiveSystem.findUnique.mockResolvedValueOnce(null);
|
||||
expect(
|
||||
(await statusAndBody(service.create(COLONY, input(), USER))).status,
|
||||
).toBe(404);
|
||||
});
|
||||
|
||||
it('answers 400 for a day in the future (Berlin)', async () => {
|
||||
const { status } = await statusAndBody(
|
||||
service.create(COLONY, input({ occurredOn: addDays(TODAY, 1) }), USER),
|
||||
);
|
||||
expect(status).toBe(400);
|
||||
});
|
||||
|
||||
it('answers 400 for an impossible day', async () => {
|
||||
const { status } = await statusAndBody(
|
||||
service.create(COLONY, input({ occurredOn: '2026-02-30' }), USER),
|
||||
);
|
||||
expect(status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyChange', () => {
|
||||
const change = (overrides = {}) => ({
|
||||
occurredOn: TODAY,
|
||||
source: 'CARD' as const,
|
||||
ops: [setLabel('Brutraum')],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('increments the version, stores the change and records the event', async () => {
|
||||
const view = await service.applyChange(
|
||||
tx as never,
|
||||
COLONY,
|
||||
change({ note: 'Test' }),
|
||||
USER,
|
||||
);
|
||||
|
||||
expect(view.version).toBe(2);
|
||||
const written = argOf<WrittenChange>(tx.hiveChange.create, 0, 0).data;
|
||||
expect(written).toMatchObject({
|
||||
colonyId: COLONY,
|
||||
version: 2,
|
||||
source: 'CARD',
|
||||
userId: USER,
|
||||
note: 'Test',
|
||||
occurredOn: dayDate(TODAY),
|
||||
inspectionId: null,
|
||||
});
|
||||
expect(written.stateAfter.stack[1].label).toBe('Brutraum');
|
||||
expect(tx.hiveSetup.update).toHaveBeenCalledWith({
|
||||
where: { colonyId: COLONY },
|
||||
data: { version: 2, state: written.stateAfter },
|
||||
});
|
||||
expect(events.record).toHaveBeenCalledWith(tx, {
|
||||
type: 'HiveChanged',
|
||||
aggregateType: 'Colony',
|
||||
aggregateId: COLONY,
|
||||
actorUserId: USER,
|
||||
payload: {
|
||||
colonyId: COLONY,
|
||||
version: 2,
|
||||
source: 'CARD',
|
||||
inspectionId: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('passes the inspection id on to the change and the event', async () => {
|
||||
await service.applyChange(
|
||||
tx as never,
|
||||
COLONY,
|
||||
change({ source: 'INSPECTION', inspectionId: 'insp-1' }),
|
||||
USER,
|
||||
);
|
||||
expect(
|
||||
argOf<WrittenChange>(tx.hiveChange.create, 0, 0).data.inspectionId,
|
||||
).toBe('insp-1');
|
||||
expect(
|
||||
argOf<{ payload: object }>(events.record, 0, 1).payload,
|
||||
).toMatchObject({
|
||||
source: 'INSPECTION',
|
||||
inspectionId: 'insp-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('stores and applies only the fields the op name defines', async () => {
|
||||
const ops = [
|
||||
{ ...setLabel('Sauber'), junk: 1, id: 'p-box' },
|
||||
] as unknown as HiveOp[];
|
||||
await service.applyChange(tx as never, COLONY, change({ ops }), USER);
|
||||
expect(
|
||||
argOf<{ data: { ops: unknown } }>(tx.hiveChange.create, 0, 0).data.ops,
|
||||
).toEqual([{ op: 'setLabel', id: 'p-box', label: 'Sauber' }]);
|
||||
});
|
||||
|
||||
it('answers 400 for INIT and AI_SUGGESTION as source', async () => {
|
||||
for (const source of ['INIT', 'AI_SUGGESTION']) {
|
||||
const { status } = await statusAndBody(
|
||||
service.applyChange(tx as never, COLONY, change({ source }), USER),
|
||||
);
|
||||
expect(status).toBe(400);
|
||||
}
|
||||
expect(lock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requires inspectionId exactly for source INSPECTION', async () => {
|
||||
for (const input of [
|
||||
change({ source: 'INSPECTION' }),
|
||||
change({ source: 'CARD', inspectionId: 'insp-1' }),
|
||||
]) {
|
||||
const { status } = await statusAndBody(
|
||||
service.applyChange(tx as never, COLONY, input, USER),
|
||||
);
|
||||
expect(status).toBe(400);
|
||||
}
|
||||
});
|
||||
|
||||
it('locks the colony, then the setup', async () => {
|
||||
await service.applyChange(tx as never, COLONY, change(), USER);
|
||||
expect(lockedTables(lock)).toEqual(['Colony', 'HiveSetup']);
|
||||
});
|
||||
|
||||
it('answers 409 for a colony that no longer lives', async () => {
|
||||
tx.colony.findUnique.mockResolvedValue({ status: 'SOLD' });
|
||||
const { status } = await statusAndBody(
|
||||
service.applyChange(tx as never, COLONY, change(), USER),
|
||||
);
|
||||
expect(status).toBe(409);
|
||||
expect(tx.hiveChange.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('answers 404 HIVE_NOT_FOUND without a setup', async () => {
|
||||
lock.mockResolvedValueOnce(true).mockResolvedValueOnce(false);
|
||||
const { status, body } = await statusAndBody(
|
||||
service.applyChange(tx as never, COLONY, change(), USER),
|
||||
);
|
||||
expect(status).toBe(404);
|
||||
expect(body).toMatchObject({ code: 'HIVE_NOT_FOUND' });
|
||||
});
|
||||
|
||||
it('answers 409 HIVE_CONFLICT with the current state for a missing id', async () => {
|
||||
tx.hiveSetup.findUnique.mockResolvedValue(setup(4));
|
||||
const ops: HiveOp[] = [setLabel('ok'), { op: 'removePart', id: 'gone' }];
|
||||
const { status, body } = await statusAndBody(
|
||||
service.applyChange(tx as never, COLONY, change({ ops }), USER),
|
||||
);
|
||||
expect(status).toBe(409);
|
||||
expect(body).toMatchObject({
|
||||
code: 'HIVE_CONFLICT',
|
||||
opIndex: 1,
|
||||
reason: expect.stringMatching(/^MISSING_ID/) as unknown,
|
||||
current: { version: 4, state: baseState },
|
||||
});
|
||||
expect(tx.hiveChange.create).not.toHaveBeenCalled();
|
||||
expect(events.record).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('answers 400 for tomorrow (Berlin)', async () => {
|
||||
const { status } = await statusAndBody(
|
||||
service.applyChange(
|
||||
tx as never,
|
||||
COLONY,
|
||||
change({ occurredOn: addDays(TODAY, 1) }),
|
||||
USER,
|
||||
),
|
||||
);
|
||||
expect(status).toBe(400);
|
||||
});
|
||||
|
||||
it('answers 400 for a day before the INIT change', async () => {
|
||||
const { status } = await statusAndBody(
|
||||
service.applyChange(
|
||||
tx as never,
|
||||
COLONY,
|
||||
change({ occurredOn: addDays(INIT_DAY, -1) }),
|
||||
USER,
|
||||
),
|
||||
);
|
||||
expect(status).toBe(400);
|
||||
});
|
||||
|
||||
it('accepts the INIT day itself and a backdated day', async () => {
|
||||
await service.applyChange(
|
||||
tx as never,
|
||||
COLONY,
|
||||
change({ occurredOn: INIT_DAY }),
|
||||
USER,
|
||||
);
|
||||
expect(
|
||||
argOf<WrittenChange>(tx.hiveChange.create, 0, 0).data.occurredOn,
|
||||
).toEqual(dayDate(INIT_DAY));
|
||||
});
|
||||
|
||||
it('leaves no event behind when the change cannot be written', async () => {
|
||||
tx.hiveChange.create.mockRejectedValue(new Error('db down'));
|
||||
await expect(
|
||||
service.applyChange(tx as never, COLONY, change(), USER),
|
||||
).rejects.toThrow('db down');
|
||||
expect(events.record).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('writes the event with the caller transaction client only', async () => {
|
||||
await service.applyChange(tx as never, COLONY, change(), USER);
|
||||
expect(argOf<unknown>(events.record, 0, 0)).toBe(tx);
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('history', () => {
|
||||
const row = (version: number, day: string) => ({
|
||||
id: `c${version}`,
|
||||
version,
|
||||
occurredOn: dayDate(day),
|
||||
source: 'CARD',
|
||||
inspectionId: null,
|
||||
userId: USER,
|
||||
user: { name: 'Nick' },
|
||||
ops: [],
|
||||
note: null,
|
||||
createdAt: new Date('2026-10-05T08:00:00.000Z'),
|
||||
});
|
||||
|
||||
it('lists newest first without stateAfter and maps the user name', async () => {
|
||||
tx.hiveChange.findMany.mockResolvedValue([row(3, '2026-10-05')]);
|
||||
const result = await service.history(COLONY);
|
||||
|
||||
expect(tx.hiveChange.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { colonyId: COLONY },
|
||||
orderBy: [{ occurredOn: 'desc' }, { version: 'desc' }],
|
||||
take: 21,
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
argOf<{ select: Record<string, unknown> }>(tx.hiveChange.findMany, 0, 0)
|
||||
.select.stateAfter,
|
||||
).toBe(undefined);
|
||||
expect(result.items[0]).toEqual({
|
||||
id: 'c3',
|
||||
version: 3,
|
||||
occurredOn: '2026-10-05',
|
||||
source: 'CARD',
|
||||
inspectionId: null,
|
||||
userId: USER,
|
||||
userName: 'Nick',
|
||||
ops: [],
|
||||
note: null,
|
||||
createdAt: '2026-10-05T08:00:00.000Z',
|
||||
});
|
||||
expect(result.nextCursor).toBeNull();
|
||||
});
|
||||
|
||||
it('returns a cursor when more rows exist and continues after it', async () => {
|
||||
tx.hiveChange.findMany.mockResolvedValue([
|
||||
row(4, '2026-10-05'),
|
||||
row(2, '2026-10-04'),
|
||||
row(3, '2026-10-03'),
|
||||
]);
|
||||
const page = await service.history(COLONY, undefined, 2);
|
||||
expect(page.items.map((item) => item.version)).toEqual([4, 2]);
|
||||
expect(page.nextCursor).not.toBeNull();
|
||||
expect(
|
||||
JSON.parse(
|
||||
Buffer.from(page.nextCursor as string, 'base64url').toString(),
|
||||
),
|
||||
).toEqual({ occurredOn: '2026-10-04', version: 2 });
|
||||
|
||||
tx.hiveChange.findMany.mockResolvedValue([]);
|
||||
await service.history(COLONY, page.nextCursor as string, 2);
|
||||
expect(
|
||||
argOf<{ where: unknown }>(tx.hiveChange.findMany, 1, 0).where,
|
||||
).toEqual({
|
||||
colonyId: COLONY,
|
||||
OR: [
|
||||
{ occurredOn: { lt: dayDate('2026-10-04') } },
|
||||
{ occurredOn: dayDate('2026-10-04'), version: { lt: 2 } },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('answers 400 for a malformed cursor', async () => {
|
||||
const { status } = await statusAndBody(
|
||||
service.history(COLONY, 'not-a-cursor'),
|
||||
);
|
||||
expect(status).toBe(400);
|
||||
});
|
||||
|
||||
it('answers 404 HIVE_NOT_FOUND without a setup', async () => {
|
||||
tx.hiveSetup.findUnique.mockResolvedValue(null);
|
||||
const { status } = await statusAndBody(service.history(COLONY));
|
||||
expect(status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('at', () => {
|
||||
it('returns the highest version with occurredOn <= day', async () => {
|
||||
tx.hiveChange.findFirst.mockResolvedValue({
|
||||
version: 3,
|
||||
occurredOn: dayDate('2026-10-04'),
|
||||
stateAfter: baseState,
|
||||
});
|
||||
const result = await service.at(COLONY, '2026-10-05');
|
||||
|
||||
expect(tx.hiveChange.findFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
colonyId: COLONY,
|
||||
occurredOn: { lte: dayDate('2026-10-05') },
|
||||
},
|
||||
orderBy: { version: 'desc' },
|
||||
}),
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
version: 3,
|
||||
occurredOn: '2026-10-04',
|
||||
state: baseState,
|
||||
});
|
||||
expect(result.facts.boxCount).toBe(1);
|
||||
});
|
||||
|
||||
it('answers 404 before the INIT day', async () => {
|
||||
tx.hiveChange.findFirst.mockResolvedValue(null);
|
||||
const { status, body } = await statusAndBody(
|
||||
service.at(COLONY, '2020-01-01'),
|
||||
);
|
||||
expect(status).toBe(404);
|
||||
expect(body).toMatchObject({ code: 'HIVE_NOT_FOUND' });
|
||||
});
|
||||
|
||||
it('answers 400 for a malformed day', async () => {
|
||||
const { status } = await statusAndBody(service.at(COLONY, '05.10.2026'));
|
||||
expect(status).toBe(400);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,457 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { HiveChangeSource, Prisma } from '../../generated/prisma';
|
||||
import { berlinToday, dayToDate } from '../common/dates';
|
||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
import { isLivingColonyStatus } from '../colonies/colony-status';
|
||||
import {
|
||||
HiveConflictException,
|
||||
hiveExists,
|
||||
hiveNotFound,
|
||||
invalidDay,
|
||||
} from './hive-errors';
|
||||
import { deriveHiveFacts } from './hive-facts';
|
||||
import { HiveCatalogService } from './hive-catalog.service';
|
||||
import { canonicalOp } from './hive-canonical';
|
||||
import { applyHiveOps } from './hive-ops';
|
||||
import type { HiveFacts, HiveOp, HiveState } from './hive-types';
|
||||
import { validateHiveState } from './hive-validate';
|
||||
|
||||
export interface HiveSetupView {
|
||||
colonyId: string;
|
||||
systemId: string;
|
||||
version: number;
|
||||
state: HiveState;
|
||||
facts: HiveFacts;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface HiveChangeListItem {
|
||||
id: string;
|
||||
version: number;
|
||||
occurredOn: string;
|
||||
source: HiveChangeSource;
|
||||
inspectionId: string | null;
|
||||
userId: string;
|
||||
userName: string;
|
||||
ops: unknown;
|
||||
note: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ApplyChangeInput {
|
||||
occurredOn: string;
|
||||
source: HiveChangeSource;
|
||||
ops: HiveOp[];
|
||||
note?: string;
|
||||
inspectionId?: string;
|
||||
}
|
||||
|
||||
export interface CreateSetupInput {
|
||||
systemId: string;
|
||||
state: unknown;
|
||||
occurredOn: string;
|
||||
}
|
||||
|
||||
const DEFAULT_HISTORY_LIMIT = 20;
|
||||
const MAX_HISTORY_LIMIT = 100;
|
||||
|
||||
interface HistoryCursor {
|
||||
occurredOn: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
const isoDay = (date: Date): string => date.toISOString().slice(0, 10);
|
||||
|
||||
function parseDay(day: unknown, field: string): Date {
|
||||
if (typeof day !== 'string') throw invalidDay(field);
|
||||
try {
|
||||
return dayToDate(day);
|
||||
} catch {
|
||||
throw invalidDay(field);
|
||||
}
|
||||
}
|
||||
|
||||
/** A day (YYYY-MM-DD) that is valid and not after today in Berlin. */
|
||||
function parseOccurredOn(day: unknown): Date {
|
||||
const date = parseDay(day, 'occurredOn');
|
||||
if (isoDay(date) > berlinToday()) {
|
||||
throw new BadRequestException('occurredOn must not be in the future');
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
function encodeCursor(cursor: HistoryCursor): string {
|
||||
return Buffer.from(JSON.stringify(cursor)).toString('base64url');
|
||||
}
|
||||
|
||||
function decodeCursor(raw: string): HistoryCursor {
|
||||
try {
|
||||
const value: unknown = JSON.parse(
|
||||
Buffer.from(raw, 'base64url').toString('utf8'),
|
||||
);
|
||||
const { occurredOn, version } = value as Partial<HistoryCursor>;
|
||||
if (
|
||||
typeof occurredOn === 'string' &&
|
||||
typeof version === 'number' &&
|
||||
Number.isInteger(version)
|
||||
) {
|
||||
dayToDate(occurredOn);
|
||||
return { occurredOn, version };
|
||||
}
|
||||
} catch {
|
||||
// falls through to the 400 below
|
||||
}
|
||||
throw new BadRequestException('cursor is not valid');
|
||||
}
|
||||
|
||||
/** INIT and AI_SUGGESTION are not accepted here; an inspection id goes with INSPECTION only. */
|
||||
function assertSourceFits(input: ApplyChangeInput): void {
|
||||
if (input.source === 'INIT' || input.source === 'AI_SUGGESTION') {
|
||||
throw new BadRequestException(`source ${input.source} is not allowed`);
|
||||
}
|
||||
if ((input.source === 'INSPECTION') !== (input.inspectionId !== undefined)) {
|
||||
throw new BadRequestException(
|
||||
'inspectionId is required for, and only allowed with, source INSPECTION',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class HiveService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly events: DomainEventsService,
|
||||
private readonly catalogs: HiveCatalogService,
|
||||
) {}
|
||||
|
||||
async get(colonyId: string): Promise<HiveSetupView> {
|
||||
const setup = await this.prisma.hiveSetup.findUnique({
|
||||
where: { colonyId },
|
||||
});
|
||||
if (!setup) throw hiveNotFound();
|
||||
return this.view(setup);
|
||||
}
|
||||
|
||||
async create(
|
||||
colonyId: string,
|
||||
input: CreateSetupInput,
|
||||
userId: string,
|
||||
): Promise<HiveSetupView> {
|
||||
const occurredOn = parseOccurredOn(input.occurredOn);
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await this.lockLivingColony(tx, colonyId);
|
||||
await lockRowForUpdate(tx, 'HiveSetup', colonyId);
|
||||
const existing = await tx.hiveSetup.findUnique({ where: { colonyId } });
|
||||
if (existing) throw hiveExists();
|
||||
|
||||
const catalog = await this.loadActiveSystemCatalog(tx, input.systemId);
|
||||
const messages = validateHiveState(input.state, catalog);
|
||||
if (messages.length > 0) {
|
||||
throw new BadRequestException({ code: 'STATE_INVALID', messages });
|
||||
}
|
||||
const state = input.state as HiveState;
|
||||
|
||||
const setup = await tx.hiveSetup.create({
|
||||
data: {
|
||||
colonyId,
|
||||
systemId: input.systemId,
|
||||
version: 1,
|
||||
state: state as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
await tx.hiveChange.create({
|
||||
data: {
|
||||
colonyId,
|
||||
version: 1,
|
||||
occurredOn,
|
||||
source: 'INIT',
|
||||
userId,
|
||||
ops: [],
|
||||
stateAfter: state as unknown as Prisma.InputJsonValue,
|
||||
note: null,
|
||||
},
|
||||
});
|
||||
await this.recordChanged(tx, colonyId, 1, 'INIT', null, userId);
|
||||
return {
|
||||
colonyId,
|
||||
systemId: setup.systemId,
|
||||
version: setup.version,
|
||||
state,
|
||||
facts: deriveHiveFacts(state, catalog),
|
||||
updatedAt: setup.updatedAt.toISOString(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies ops to the current state under the Colony and HiveSetup locks and
|
||||
* writes the change plus the event in the caller's transaction (also used
|
||||
* by the inspection flow).
|
||||
*/
|
||||
async applyChange(
|
||||
tx: Prisma.TransactionClient,
|
||||
colonyId: string,
|
||||
input: ApplyChangeInput,
|
||||
userId: string,
|
||||
): Promise<HiveSetupView> {
|
||||
assertSourceFits(input);
|
||||
const occurredOn = parseOccurredOn(input.occurredOn);
|
||||
const ops = input.ops.map(canonicalOp);
|
||||
await this.lockLivingColony(tx, colonyId);
|
||||
if (!(await lockRowForUpdate(tx, 'HiveSetup', colonyId))) {
|
||||
throw hiveNotFound();
|
||||
}
|
||||
const setup = await tx.hiveSetup.findUnique({ where: { colonyId } });
|
||||
if (!setup) throw hiveNotFound();
|
||||
await this.assertNotBeforeInit(tx, colonyId, occurredOn);
|
||||
|
||||
const catalog = await this.catalogs.loadCatalog(setup.systemId, tx);
|
||||
const current = setup.state as unknown as HiveState;
|
||||
const result = applyHiveOps(current, ops, catalog);
|
||||
if (!result.ok) {
|
||||
throw new HiveConflictException(result.opIndex, result.reason, {
|
||||
version: setup.version,
|
||||
state: current,
|
||||
});
|
||||
}
|
||||
|
||||
const version = setup.version + 1;
|
||||
const stateJson = result.state as unknown as Prisma.InputJsonValue;
|
||||
await tx.hiveChange.create({
|
||||
data: {
|
||||
colonyId,
|
||||
version,
|
||||
occurredOn,
|
||||
source: input.source,
|
||||
inspectionId: input.inspectionId ?? null,
|
||||
userId,
|
||||
ops: ops as unknown as Prisma.InputJsonValue,
|
||||
stateAfter: stateJson,
|
||||
note: input.note ?? null,
|
||||
},
|
||||
});
|
||||
const updated = await tx.hiveSetup.update({
|
||||
where: { colonyId },
|
||||
data: { version, state: stateJson },
|
||||
});
|
||||
await this.recordChanged(
|
||||
tx,
|
||||
colonyId,
|
||||
version,
|
||||
input.source,
|
||||
input.inspectionId ?? null,
|
||||
userId,
|
||||
);
|
||||
return {
|
||||
colonyId,
|
||||
systemId: updated.systemId,
|
||||
version,
|
||||
state: result.state,
|
||||
facts: deriveHiveFacts(result.state, catalog),
|
||||
updatedAt: updated.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async history(
|
||||
colonyId: string,
|
||||
cursor?: string,
|
||||
limit = DEFAULT_HISTORY_LIMIT,
|
||||
): Promise<{ items: HiveChangeListItem[]; nextCursor: string | null }> {
|
||||
const take = Math.min(Math.max(Math.trunc(limit), 1), MAX_HISTORY_LIMIT);
|
||||
const after = cursor === undefined ? null : decodeCursor(cursor);
|
||||
await this.assertSetupExists(colonyId);
|
||||
|
||||
const rows = await this.prisma.hiveChange.findMany({
|
||||
where: {
|
||||
colonyId,
|
||||
...(after
|
||||
? {
|
||||
OR: [
|
||||
{ occurredOn: { lt: dayToDate(after.occurredOn) } },
|
||||
{
|
||||
occurredOn: dayToDate(after.occurredOn),
|
||||
version: { lt: after.version },
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
orderBy: [{ occurredOn: 'desc' }, { version: 'desc' }],
|
||||
take: take + 1,
|
||||
select: {
|
||||
id: true,
|
||||
version: true,
|
||||
occurredOn: true,
|
||||
source: true,
|
||||
inspectionId: true,
|
||||
userId: true,
|
||||
user: { select: { name: true } },
|
||||
ops: true,
|
||||
note: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
const page = rows.slice(0, take);
|
||||
const last = page[page.length - 1];
|
||||
const nextCursor =
|
||||
rows.length > take && last
|
||||
? encodeCursor({
|
||||
occurredOn: isoDay(last.occurredOn),
|
||||
version: last.version,
|
||||
})
|
||||
: null;
|
||||
return {
|
||||
items: page.map((row) => ({
|
||||
id: row.id,
|
||||
version: row.version,
|
||||
occurredOn: isoDay(row.occurredOn),
|
||||
source: row.source,
|
||||
inspectionId: row.inspectionId,
|
||||
userId: row.userId,
|
||||
userName: row.user.name,
|
||||
ops: row.ops,
|
||||
note: row.note,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
})),
|
||||
nextCursor,
|
||||
};
|
||||
}
|
||||
|
||||
/** The state at the end of a Berlin calendar day (spec 6.2). */
|
||||
async at(
|
||||
colonyId: string,
|
||||
day: string,
|
||||
): Promise<{
|
||||
version: number;
|
||||
state: HiveState;
|
||||
facts: HiveFacts;
|
||||
occurredOn: string;
|
||||
}> {
|
||||
const dayDate = parseDay(day, 'day');
|
||||
const setup = await this.prisma.hiveSetup.findUnique({
|
||||
where: { colonyId },
|
||||
select: { systemId: true },
|
||||
});
|
||||
if (!setup) throw hiveNotFound();
|
||||
const change = await this.prisma.hiveChange.findFirst({
|
||||
where: { colonyId, occurredOn: { lte: dayDate } },
|
||||
orderBy: { version: 'desc' },
|
||||
select: { version: true, occurredOn: true, stateAfter: true },
|
||||
});
|
||||
if (!change) throw hiveNotFound();
|
||||
const catalog = await this.catalogs.loadCatalog(setup.systemId);
|
||||
const state = change.stateAfter as unknown as HiveState;
|
||||
return {
|
||||
version: change.version,
|
||||
state,
|
||||
facts: deriveHiveFacts(state, catalog),
|
||||
occurredOn: isoDay(change.occurredOn),
|
||||
};
|
||||
}
|
||||
|
||||
private async view(setup: {
|
||||
colonyId: string;
|
||||
systemId: string;
|
||||
version: number;
|
||||
state: Prisma.JsonValue;
|
||||
updatedAt: Date;
|
||||
}): Promise<HiveSetupView> {
|
||||
const catalog = await this.catalogs.loadCatalog(setup.systemId);
|
||||
const state = setup.state as unknown as HiveState;
|
||||
return {
|
||||
colonyId: setup.colonyId,
|
||||
systemId: setup.systemId,
|
||||
version: setup.version,
|
||||
state,
|
||||
facts: deriveHiveFacts(state, catalog),
|
||||
updatedAt: setup.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Lock order: Colony first; a colony that no longer lives is read-only. */
|
||||
private async lockLivingColony(
|
||||
tx: Prisma.TransactionClient,
|
||||
colonyId: string,
|
||||
): Promise<void> {
|
||||
if (!(await lockRowForUpdate(tx, 'Colony', colonyId))) {
|
||||
throw new NotFoundException('Colony not found');
|
||||
}
|
||||
const colony = await tx.colony.findUnique({
|
||||
where: { id: colonyId },
|
||||
select: { status: true },
|
||||
});
|
||||
if (!colony) throw new NotFoundException('Colony not found');
|
||||
if (!isLivingColonyStatus(colony.status)) {
|
||||
throw new ConflictException(
|
||||
`Das Volk besteht nicht mehr (Status: ${colony.status})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadActiveSystemCatalog(
|
||||
tx: Prisma.TransactionClient,
|
||||
systemId: string,
|
||||
) {
|
||||
const system = await tx.hiveSystem.findUnique({
|
||||
where: { id: systemId },
|
||||
select: { id: true, active: true },
|
||||
});
|
||||
if (!system) throw new NotFoundException('Hive system not found');
|
||||
if (!system.active) {
|
||||
throw new BadRequestException({
|
||||
code: 'SYSTEM_INACTIVE',
|
||||
message: 'Das Beutensystem ist ausgeblendet.',
|
||||
});
|
||||
}
|
||||
return this.catalogs.loadCatalog(systemId, tx);
|
||||
}
|
||||
|
||||
private async assertNotBeforeInit(
|
||||
tx: Prisma.TransactionClient,
|
||||
colonyId: string,
|
||||
occurredOn: Date,
|
||||
): Promise<void> {
|
||||
const init = await tx.hiveChange.findUnique({
|
||||
where: { colonyId_version: { colonyId, version: 1 } },
|
||||
select: { occurredOn: true },
|
||||
});
|
||||
if (init && occurredOn < init.occurredOn) {
|
||||
throw new BadRequestException(
|
||||
'occurredOn must not be before the day the hive setup was created',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async assertSetupExists(colonyId: string): Promise<void> {
|
||||
const setup = await this.prisma.hiveSetup.findUnique({
|
||||
where: { colonyId },
|
||||
select: { colonyId: true },
|
||||
});
|
||||
if (!setup) throw hiveNotFound();
|
||||
}
|
||||
|
||||
private recordChanged(
|
||||
tx: Prisma.TransactionClient,
|
||||
colonyId: string,
|
||||
version: number,
|
||||
source: HiveChangeSource,
|
||||
inspectionId: string | null,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
return this.events.record(tx, {
|
||||
type: 'HiveChanged',
|
||||
aggregateType: 'Colony',
|
||||
aggregateId: colonyId,
|
||||
actorUserId: userId,
|
||||
payload: { colonyId, version, source, inspectionId },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
@@ -13,15 +14,28 @@ import {
|
||||
IsString,
|
||||
Max,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import {
|
||||
COLONY_TAKEOVER_FIELDS,
|
||||
type ColonyTakeoverField,
|
||||
} from '../../colonies/colony-takeover';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import { HiveOpDto, MAX_OPS_PER_REQUEST } from '../../hive/dto/hive-op.dto';
|
||||
import { ColonyCondition } from '../../../generated/prisma';
|
||||
import { feedConditionFromInput } from '../../common/feed-vocabulary';
|
||||
|
||||
/** Hive operations carried by an inspection (create only). */
|
||||
export class InspectionHiveChangeDto {
|
||||
@ApiProperty({ type: [HiveOpDto], maxItems: MAX_OPS_PER_REQUEST })
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(MAX_OPS_PER_REQUEST)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => HiveOpDto)
|
||||
ops!: HiveOpDto[];
|
||||
}
|
||||
|
||||
export class CreateInspectionDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@@ -172,4 +186,14 @@ export class CreateInspectionDto {
|
||||
@ArrayMaxSize(3)
|
||||
@IsIn(COLONY_TAKEOVER_FIELDS, { each: true })
|
||||
applyToColony?: ColonyTakeoverField[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: InspectionHiveChangeDto,
|
||||
description:
|
||||
'Hive operations saved together with the inspection in one transaction. Needs colonies:update.',
|
||||
})
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => InspectionHiveChangeDto)
|
||||
hiveChange?: InspectionHiveChangeDto;
|
||||
}
|
||||
@@ -8,6 +8,6 @@ import { CreateInspectionDto } from './create-inspection.dto';
|
||||
* not part of a correction: a correction takes nothing over to the colony.
|
||||
*/
|
||||
export class UpdateInspectionDto extends PartialType(
|
||||
OmitType(CreateInspectionDto, ['applyToColony'] as const),
|
||||
OmitType(CreateInspectionDto, ['applyToColony', 'hiveChange'] as const),
|
||||
{ skipNullProperties: false },
|
||||
) {}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TasksModule } from '../tasks/tasks.module';
|
||||
import { ColoniesModule } from '../colonies/colonies.module';
|
||||
import { HiveModule } from '../hive/hive.module';
|
||||
import { InspectionsController } from './inspections.controller';
|
||||
import { ColonyInspectionsController } from './colony-inspections.controller';
|
||||
import { InspectionsService } from './inspections.service';
|
||||
@@ -11,7 +12,7 @@ import { InspectionsService } from './inspections.service';
|
||||
* PermissionResolverService comes from the global resolver module.
|
||||
*/
|
||||
@Module({
|
||||
imports: [TasksModule, ColoniesModule],
|
||||
imports: [TasksModule, ColoniesModule, HiveModule],
|
||||
controllers: [InspectionsController, ColonyInspectionsController],
|
||||
providers: [InspectionsService],
|
||||
exports: [InspectionsService],
|
||||
|
||||
@@ -6,6 +6,7 @@ import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import { TasksService } from '../tasks/tasks.service';
|
||||
import { ColoniesService } from '../colonies/colonies.service';
|
||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
import { HiveService } from '../hive/hive.service';
|
||||
import { Prisma } from '../../generated/prisma';
|
||||
|
||||
describe('InspectionsService', () => {
|
||||
@@ -35,6 +36,7 @@ describe('InspectionsService', () => {
|
||||
let tasks: { createInTx: jest.Mock };
|
||||
let colonies: { updateIn: jest.Mock };
|
||||
let resolver: { getPermissions: jest.Mock };
|
||||
let hive: { applyChange: jest.Mock };
|
||||
|
||||
const inspection = {
|
||||
id: 'inspection-1',
|
||||
@@ -74,6 +76,7 @@ describe('InspectionsService', () => {
|
||||
resolver = {
|
||||
getPermissions: jest.fn().mockResolvedValue(new Set(['colonies:update'])),
|
||||
};
|
||||
hive = { applyChange: jest.fn().mockResolvedValue({ version: 2 }) };
|
||||
service = new InspectionsService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
@@ -81,6 +84,7 @@ describe('InspectionsService', () => {
|
||||
tasks as unknown as TasksService,
|
||||
colonies as unknown as ColoniesService,
|
||||
resolver as unknown as PermissionResolverService,
|
||||
hive as unknown as HiveService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -94,6 +98,7 @@ describe('InspectionsService', () => {
|
||||
TasksService,
|
||||
ColoniesService,
|
||||
PermissionResolverService,
|
||||
HiveService,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -314,6 +319,81 @@ describe('InspectionsService', () => {
|
||||
).rejects.toThrow('outbox down');
|
||||
});
|
||||
|
||||
describe('hiveChange', () => {
|
||||
const ops = [{ op: 'setLabel', id: 'box', label: 'x' }];
|
||||
const dto = {
|
||||
colonyId: 'colony-1',
|
||||
date: '2026-03-10T10:00:00.000Z',
|
||||
hiveChange: { ops },
|
||||
} as never;
|
||||
|
||||
beforeEach(() => {
|
||||
tx.inspection.create.mockResolvedValue(inspection);
|
||||
});
|
||||
|
||||
it('locks the colony, saves the inspection, then applies the ops with the inspection id', async () => {
|
||||
const result = await service.create(dto, 'user-1');
|
||||
|
||||
const lockOrder = tx.$queryRaw.mock.invocationCallOrder[0];
|
||||
expect(lockOrder).toBeLessThan(
|
||||
tx.inspection.create.mock.invocationCallOrder[0],
|
||||
);
|
||||
const [[created]] = tx.inspection.create.mock.calls as [
|
||||
[{ data: object }],
|
||||
];
|
||||
expect(created.data).not.toHaveProperty('hiveChange');
|
||||
expect(hive.applyChange).toHaveBeenCalledWith(
|
||||
tx,
|
||||
'colony-1',
|
||||
{
|
||||
occurredOn: '2026-03-10',
|
||||
source: 'INSPECTION',
|
||||
ops,
|
||||
inspectionId: 'inspection-1',
|
||||
},
|
||||
'user-1',
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
id: 'inspection-1',
|
||||
hive: { version: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
it('answers 403 HIVE_FORBIDDEN without colonies:update and writes nothing', async () => {
|
||||
resolver.getPermissions.mockResolvedValue(new Set());
|
||||
|
||||
await expect(service.create(dto, 'user-1')).rejects.toMatchObject({
|
||||
status: 403,
|
||||
response: { code: 'HIVE_FORBIDDEN' },
|
||||
});
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves permissions once when hiveChange and applyToColony are both used', async () => {
|
||||
await service.create(
|
||||
{ ...(dto as object), applyToColony: ['feedStatus'] } as never,
|
||||
'user-1',
|
||||
);
|
||||
expect(resolver.getPermissions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('propagates an applyChange failure so the transaction aborts', async () => {
|
||||
hive.applyChange.mockRejectedValue(new NotFoundException());
|
||||
await expect(service.create(dto, 'user-1')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('omits hive from the response without hiveChange', async () => {
|
||||
const result = await service.create(
|
||||
{ colonyId: 'colony-1', date: '2026-01-01' },
|
||||
'user-1',
|
||||
);
|
||||
expect(result).not.toHaveProperty('hive');
|
||||
expect(hive.applyChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('update writes the correction, audit entry and InspectionCorrected in one transaction', async () => {
|
||||
tx.inspection.findUnique.mockResolvedValue(inspection);
|
||||
// Re-filed against another colony: the event names the colony it is
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { isLivingColonyStatus } from '../colonies/colony-status';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { DbClient } from '../prisma/db-client';
|
||||
@@ -18,9 +22,14 @@ import { CreateInspectionDto } from './dto/create-inspection.dto';
|
||||
import { CreateTaskFromInspectionDto } from './dto/create-task-from-inspection.dto';
|
||||
import { UpdateInspectionDto } from './dto/update-inspection.dto';
|
||||
import { optionalDate } from '../common/patch-values';
|
||||
import { berlinDayOf } from '../common/dates';
|
||||
import type { HiveOp } from '../hive/hive-types';
|
||||
import { HiveService, HiveSetupView } from '../hive/hive.service';
|
||||
|
||||
export type InspectionWithTakeover = Inspection & {
|
||||
colonyTakeover: ColonyTakeover;
|
||||
/** Present only when the request carried a hiveChange. */
|
||||
hive?: HiveSetupView;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -41,6 +50,7 @@ export class InspectionsService {
|
||||
private readonly tasks: TasksService,
|
||||
private readonly colonies: ColoniesService,
|
||||
private readonly permissionResolver: PermissionResolverService,
|
||||
private readonly hiveService: HiveService,
|
||||
) {}
|
||||
|
||||
async findAll(): Promise<Inspection[]> {
|
||||
@@ -62,16 +72,25 @@ export class InspectionsService {
|
||||
dto: CreateInspectionDto,
|
||||
actingUserId: string,
|
||||
): Promise<InspectionWithTakeover> {
|
||||
const { applyToColony = [], ...data } = dto;
|
||||
// Resolved before the transaction (it may query the database): the
|
||||
// permission is only needed when a takeover was requested.
|
||||
const { applyToColony = [], hiveChange, ...data } = dto;
|
||||
// Resolved before the transaction (it may query the database), once for
|
||||
// both features: a takeover only applies with colonies:update, a hive
|
||||
// change is refused without it.
|
||||
const needsPermission =
|
||||
applyToColony.length > 0 || hiveChange !== undefined;
|
||||
const canUpdateColony =
|
||||
applyToColony.length > 0 &&
|
||||
needsPermission &&
|
||||
(await this.permissionResolver.getPermissions(actingUserId)).has(
|
||||
'colonies:update',
|
||||
);
|
||||
if (hiveChange && !canUpdateColony) {
|
||||
throw new ForbiddenException({
|
||||
code: 'HIVE_FORBIDDEN',
|
||||
message: 'Beute-Änderungen brauchen das Recht colonies:update.',
|
||||
});
|
||||
}
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
if (applyToColony.length > 0) {
|
||||
if (applyToColony.length > 0 || hiveChange) {
|
||||
// Colony first (before the inspection row takes a key-share lock on
|
||||
// it): serializes concurrent inspections of one colony, so "is there
|
||||
// a newer inspection" below is decided on a stable state.
|
||||
@@ -104,7 +123,22 @@ export class InspectionsService {
|
||||
canUpdateColony,
|
||||
actingUserId,
|
||||
);
|
||||
return { ...inspection, colonyTakeover };
|
||||
if (!hiveChange) {
|
||||
return { ...inspection, colonyTakeover };
|
||||
}
|
||||
const hive = await this.hiveService.applyChange(
|
||||
tx,
|
||||
dto.colonyId,
|
||||
{
|
||||
occurredOn: berlinDayOf(new Date(dto.date)),
|
||||
source: 'INSPECTION',
|
||||
// Plain objects only: the DTO instances carry class metadata.
|
||||
ops: JSON.parse(JSON.stringify(hiveChange.ops)) as HiveOp[],
|
||||
inspectionId: inspection.id,
|
||||
},
|
||||
actingUserId,
|
||||
);
|
||||
return { ...inspection, colonyTakeover, hive };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -226,10 +226,12 @@ describe('lock keys per table', () => {
|
||||
expect(tx.$queryRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the map in lock order: Apiary before Colony, the T6 tables last', () => {
|
||||
it('keeps the map in lock order: Apiary before Colony, HiveSetup right after Colony, HivePartType after HiveSetup, the T6 tables last', () => {
|
||||
expect(LOCK_ORDER).toEqual([
|
||||
'Apiary',
|
||||
'Colony',
|
||||
'HiveSetup',
|
||||
'HivePartType',
|
||||
'Suggestion',
|
||||
'Task',
|
||||
'Queen',
|
||||
@@ -262,7 +264,10 @@ describe('lock keys per table', () => {
|
||||
|
||||
it('every table of the map is lockable by id and each appears once', async () => {
|
||||
expect(new Set(LOCK_ORDER).size).toBe(LOCK_ORDER.length);
|
||||
const keyed = new Map([['NotificationPreference', 'userId']]);
|
||||
const keyed = new Map([
|
||||
['NotificationPreference', 'userId'],
|
||||
['HiveSetup', 'colonyId'],
|
||||
]);
|
||||
for (const table of LOCK_ORDER) {
|
||||
const tx = { $queryRaw: jest.fn().mockResolvedValue([]) };
|
||||
|
||||
|
||||
@@ -19,6 +19,11 @@ function lockable(table: string, key = 'id') {
|
||||
const LOCKABLE_TABLES = {
|
||||
Apiary: lockable('Apiary'),
|
||||
Colony: lockable('Colony'),
|
||||
// Beute in 3D: one setup row per colony, keyed by the colony. Locked right
|
||||
// after the colony row it belongs to.
|
||||
HiveSetup: lockable('HiveSetup', 'colonyId'),
|
||||
// Catalog part types: locked while their stateSchema is changed.
|
||||
HivePartType: lockable('HivePartType'),
|
||||
Suggestion: lockable('Suggestion'),
|
||||
Task: lockable('Task'),
|
||||
Queen: lockable('Queen'),
|
||||
|
||||
@@ -87,12 +87,16 @@ describe('WebhookEventHandlers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('none of the four new events is forwarded to webhooks', async () => {
|
||||
it('none of the five new events is forwarded to webhooks', async () => {
|
||||
const herdEvents = [
|
||||
['ColonyCreated', { apiaryId: 'a1' }],
|
||||
['ColonyMoved', { fromApiaryId: 'a1', toApiaryId: 'a2' }],
|
||||
['ApiaryChanged', { change: 'LOCATION_CHANGED' }],
|
||||
['TskReferenceDayReached', { year: 2026, referenceDay: '2026-01-01' }],
|
||||
[
|
||||
'HiveChanged',
|
||||
{ colonyId: 'c1', version: 2, source: 'CARD', inspectionId: null },
|
||||
],
|
||||
] as const;
|
||||
for (const [type, payload] of herdEvents) {
|
||||
expect(Object.keys(DOMAIN_EVENT_WEBHOOKS)).not.toContain(type);
|
||||
|
||||
@@ -601,6 +601,156 @@ describe('Backup round trip (e2e)', () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it(
|
||||
'restores hive setup and history into a fresh install with the seeded catalog',
|
||||
async () => {
|
||||
const systemId = 'hive-system-zander-liebig-10';
|
||||
const day = new Date('2026-07-02T00:00:00.000Z');
|
||||
const state = { schemaVersion: 1, stack: [{ id: 'p1', kind: 'FLOOR' }] };
|
||||
const apiary = await prisma.apiary.create({
|
||||
data: { name: `Hive-Stand ${suffix}` },
|
||||
});
|
||||
const colony = await prisma.colony.create({
|
||||
data: {
|
||||
code: `HV-${suffix}`,
|
||||
apiaryId: apiary.id,
|
||||
colonyType: 'PRODUCTION_COLONY',
|
||||
},
|
||||
});
|
||||
const inspection = await prisma.inspection.create({
|
||||
data: { colonyId: colony.id, userId, date: day },
|
||||
});
|
||||
await prisma.hiveSetup.create({
|
||||
data: { colonyId: colony.id, systemId, version: 2, state },
|
||||
});
|
||||
await prisma.hiveChange.createMany({
|
||||
data: [
|
||||
{
|
||||
colonyId: colony.id,
|
||||
version: 1,
|
||||
occurredOn: day,
|
||||
source: 'INIT',
|
||||
userId,
|
||||
ops: [],
|
||||
stateAfter: state,
|
||||
},
|
||||
{
|
||||
colonyId: colony.id,
|
||||
version: 2,
|
||||
occurredOn: day,
|
||||
source: 'INSPECTION',
|
||||
inspectionId: inspection.id,
|
||||
userId,
|
||||
ops: [{ op: 'SET_NOTE' }],
|
||||
stateAfter: state,
|
||||
note: 'Beute',
|
||||
},
|
||||
],
|
||||
});
|
||||
const catalogCounts = async () => ({
|
||||
systems: await prisma.hiveSystem.count(),
|
||||
partTypes: await prisma.hivePartType.count(),
|
||||
});
|
||||
const catalogBefore = await catalogCounts();
|
||||
expect(catalogBefore.systems).toBeGreaterThan(0);
|
||||
expect(catalogBefore.partTypes).toBeGreaterThan(0);
|
||||
const hiveRows = async () => ({
|
||||
setup: await prisma.hiveSetup.findUnique({
|
||||
where: { colonyId: colony.id },
|
||||
}),
|
||||
changes: await prisma.hiveChange.findMany({
|
||||
where: { colonyId: colony.id },
|
||||
orderBy: { version: 'asc' },
|
||||
}),
|
||||
});
|
||||
const before = await hiveRows();
|
||||
|
||||
try {
|
||||
const bundle = await backup.exportAll();
|
||||
const ownColonyRows = (bundle.data.colony as Row[]).filter(
|
||||
(row) => row.id === colony.id,
|
||||
);
|
||||
const exportedHive = {
|
||||
hiveSystem: bundle.data.hiveSystem as Row[],
|
||||
hivePartType: bundle.data.hivePartType as Row[],
|
||||
colony: ownColonyRows,
|
||||
inspection: (bundle.data.inspection as Row[]).filter(
|
||||
(row) => row.id === inspection.id,
|
||||
),
|
||||
hiveSetup: (bundle.data.hiveSetup as Row[]).filter(
|
||||
(row) => row.colonyId === colony.id,
|
||||
),
|
||||
hiveChange: (bundle.data.hiveChange as Row[]).filter(
|
||||
(row) => row.colonyId === colony.id,
|
||||
),
|
||||
};
|
||||
expect(exportedHive.hiveSetup).toHaveLength(1);
|
||||
expect(exportedHive.hiveChange).toHaveLength(2);
|
||||
expect(exportedHive.hiveSystem.some((row) => row.id === systemId)).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
// The colony is lost; the catalog rows stay, as a fresh install has
|
||||
// them from the migration.
|
||||
await prisma.hiveChange.deleteMany({ where: { colonyId: colony.id } });
|
||||
await prisma.hiveSetup.delete({ where: { colonyId: colony.id } });
|
||||
await prisma.inspection.delete({ where: { id: inspection.id } });
|
||||
await prisma.colony.delete({ where: { id: colony.id } });
|
||||
|
||||
const summary = await backup.importAll(
|
||||
{
|
||||
version: 3,
|
||||
exportedAt: new Date().toISOString(),
|
||||
data: exportedHive,
|
||||
},
|
||||
userId,
|
||||
{ trusted: true },
|
||||
);
|
||||
expect(summary.imported).toMatchObject({
|
||||
hiveSetup: 1,
|
||||
hiveChange: 2,
|
||||
});
|
||||
expect(await hiveRows()).toEqual(before);
|
||||
expect(await catalogCounts()).toEqual(catalogBefore);
|
||||
|
||||
// A second restore over an existing, diverged setup: the backup wins.
|
||||
await prisma.hiveSetup.update({
|
||||
where: { colonyId: colony.id },
|
||||
data: { version: 9 },
|
||||
});
|
||||
await prisma.hiveChange.create({
|
||||
data: {
|
||||
colonyId: colony.id,
|
||||
version: 9,
|
||||
occurredOn: day,
|
||||
source: 'CARD',
|
||||
userId,
|
||||
ops: [],
|
||||
stateAfter: state,
|
||||
},
|
||||
});
|
||||
await backup.importAll(
|
||||
{
|
||||
version: 3,
|
||||
exportedAt: new Date().toISOString(),
|
||||
data: exportedHive,
|
||||
},
|
||||
userId,
|
||||
{ trusted: true },
|
||||
);
|
||||
expect(await hiveRows()).toEqual(before);
|
||||
expect(await catalogCounts()).toEqual(catalogBefore);
|
||||
} finally {
|
||||
await prisma.hiveChange.deleteMany({ where: { colonyId: colony.id } });
|
||||
await prisma.hiveSetup.deleteMany({ where: { colonyId: colony.id } });
|
||||
await prisma.inspection.deleteMany({ where: { colonyId: colony.id } });
|
||||
await prisma.colony.deleteMany({ where: { id: colony.id } });
|
||||
await prisma.apiary.deleteMany({ where: { id: apiary.id } });
|
||||
}
|
||||
},
|
||||
ROUND_TRIP_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it('removes the file a restored document row replaced', async () => {
|
||||
const directory = path.join(
|
||||
path.resolve(process.env.DOCUMENTS_STORAGE_PATH ?? './uploads'),
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
{
|
||||
"name": "addPart inserts at index",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "addPart",
|
||||
"part": {
|
||||
"id": "q1",
|
||||
"partTypeId": "hive-part-zl10-queen-excluder",
|
||||
"state": {}
|
||||
},
|
||||
"at": 2
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": true,
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "q1",
|
||||
"partTypeId": "hive-part-zl10-queen-excluder",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
{
|
||||
"name": "addPart at beyond end is clamped to end",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "addPart",
|
||||
"part": {
|
||||
"id": "q1",
|
||||
"partTypeId": "hive-part-zl10-queen-excluder",
|
||||
"state": {}
|
||||
},
|
||||
"at": 99
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": true,
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "q1",
|
||||
"partTypeId": "hive-part-zl10-queen-excluder",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
{
|
||||
"name": "addPart box brings its own frames",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "addPart",
|
||||
"part": {
|
||||
"id": "b2",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "EMPTY_COMB"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "EMPTY_COMB"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "EMPTY_COMB"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "EMPTY_COMB"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "EMPTY_COMB"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "EMPTY_COMB"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "EMPTY_COMB"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "EMPTY_COMB"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "EMPTY_COMB"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "EMPTY_COMB"
|
||||
}
|
||||
]
|
||||
},
|
||||
"at": 1
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": true,
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "b2",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "EMPTY_COMB"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "EMPTY_COMB"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "EMPTY_COMB"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "EMPTY_COMB"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "EMPTY_COMB"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "EMPTY_COMB"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "EMPTY_COMB"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "EMPTY_COMB"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "EMPTY_COMB"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "EMPTY_COMB"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
{
|
||||
"name": "addInsert floor insert into floor",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "addInsert",
|
||||
"hostId": "f1",
|
||||
"part": {
|
||||
"id": "fi1",
|
||||
"partTypeId": "hive-part-zl10-floor-insert",
|
||||
"state": {}
|
||||
}
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": true,
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {},
|
||||
"inserts": [
|
||||
{
|
||||
"id": "fi1",
|
||||
"partTypeId": "hive-part-zl10-floor-insert",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"name": "addInsert evaporator into empty box",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "e1",
|
||||
"partTypeId": "hive-part-zl10-box-empty",
|
||||
"state": {},
|
||||
"frames": []
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "addInsert",
|
||||
"hostId": "e1",
|
||||
"part": {
|
||||
"id": "ev1",
|
||||
"partTypeId": "hive-part-zl10-evaporator",
|
||||
"state": {
|
||||
"acidMl": 250
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": true,
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "e1",
|
||||
"partTypeId": "hive-part-zl10-box-empty",
|
||||
"state": {},
|
||||
"frames": [],
|
||||
"inserts": [
|
||||
{
|
||||
"id": "ev1",
|
||||
"partTypeId": "hive-part-zl10-evaporator",
|
||||
"state": {
|
||||
"acidMl": 250
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"name": "removePart removes a stack part",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "removePart",
|
||||
"id": "b1"
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": true,
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
{
|
||||
"name": "removePart removes an insert",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {},
|
||||
"inserts": [
|
||||
{
|
||||
"id": "fi1",
|
||||
"partTypeId": "hive-part-zl10-floor-insert",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "removePart",
|
||||
"id": "fi1"
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": true,
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
{
|
||||
"name": "movePart moves within the stack",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b2",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "movePart",
|
||||
"id": "b2",
|
||||
"to": 1
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": true,
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "b2",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
{
|
||||
"name": "movePart target beyond end is clamped",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "q1",
|
||||
"partTypeId": "hive-part-zl10-queen-excluder",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "movePart",
|
||||
"id": "b1",
|
||||
"to": 50
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": true,
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "q1",
|
||||
"partTypeId": "hive-part-zl10-queen-excluder",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"name": "setState sets a level",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "fd1",
|
||||
"partTypeId": "hive-part-zl10-feeder",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "setState",
|
||||
"id": "fd1",
|
||||
"key": "syrupLiters",
|
||||
"value": 2.5
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": true,
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "fd1",
|
||||
"partTypeId": "hive-part-zl10-feeder",
|
||||
"state": {
|
||||
"syrupLiters": 2.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
{
|
||||
"name": "setState sets a choice",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "setState",
|
||||
"id": "f1",
|
||||
"key": "entrance",
|
||||
"value": "NARROW"
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": true,
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {
|
||||
"entrance": "NARROW"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "setState accepts the level maximum",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "fd1",
|
||||
"partTypeId": "hive-part-zl10-feeder",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "setState",
|
||||
"id": "fd1",
|
||||
"key": "syrupLiters",
|
||||
"value": 3
|
||||
},
|
||||
{
|
||||
"op": "setState",
|
||||
"id": "fd1",
|
||||
"key": "fondantKg",
|
||||
"value": 2.5
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": true,
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "fd1",
|
||||
"partTypeId": "hive-part-zl10-feeder",
|
||||
"state": {
|
||||
"syrupLiters": 3,
|
||||
"fondantKg": 2.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"name": "setState on an insert",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "e1",
|
||||
"partTypeId": "hive-part-zl10-box-empty",
|
||||
"state": {},
|
||||
"frames": [],
|
||||
"inserts": [
|
||||
{
|
||||
"id": "ev1",
|
||||
"partTypeId": "hive-part-zl10-evaporator",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "setState",
|
||||
"id": "ev1",
|
||||
"key": "acidMl",
|
||||
"value": 290
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": true,
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "e1",
|
||||
"partTypeId": "hive-part-zl10-box-empty",
|
||||
"state": {},
|
||||
"frames": [],
|
||||
"inserts": [
|
||||
{
|
||||
"id": "ev1",
|
||||
"partTypeId": "hive-part-zl10-evaporator",
|
||||
"state": {
|
||||
"acidMl": 290
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
{
|
||||
"name": "setFrame sets type and name",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "setFrame",
|
||||
"boxId": "b1",
|
||||
"slot": 3,
|
||||
"type": "BROOD",
|
||||
"name": "Königin"
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": true,
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "BROOD",
|
||||
"name": "Königin"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
{
|
||||
"name": "setFrame with null name removes the name",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "BROOD",
|
||||
"name": "x"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "setFrame",
|
||||
"boxId": "b1",
|
||||
"slot": 3,
|
||||
"type": "HONEY",
|
||||
"name": null
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": true,
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "HONEY"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
{
|
||||
"name": "setFrames replaces all frames",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "setFrames",
|
||||
"boxId": "b1",
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "BROOD"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "BROOD"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "BROOD"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "BROOD"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "BROOD"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "BROOD"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "BROOD"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "BROOD"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "BROOD"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "BROOD"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": true,
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "BROOD"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "BROOD"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "BROOD"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "BROOD"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "BROOD"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "BROOD"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "BROOD"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "BROOD"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "BROOD"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "BROOD"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
{
|
||||
"name": "setLabel sets and clears a label",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "setLabel",
|
||||
"id": "b1",
|
||||
"label": "Honigraum 1"
|
||||
},
|
||||
{
|
||||
"op": "setLabel",
|
||||
"id": "f1",
|
||||
"label": "Boden"
|
||||
},
|
||||
{
|
||||
"op": "setLabel",
|
||||
"id": "b1",
|
||||
"label": null
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": true,
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {},
|
||||
"label": "Boden"
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
{
|
||||
"name": "multiple ops apply in order",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "addPart",
|
||||
"part": {
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
"at": 1
|
||||
},
|
||||
{
|
||||
"op": "addPart",
|
||||
"part": {
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
},
|
||||
"at": 2
|
||||
},
|
||||
{
|
||||
"op": "setFrame",
|
||||
"boxId": "b1",
|
||||
"slot": 1,
|
||||
"type": "POLLEN"
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": true,
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "POLLEN"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
{
|
||||
"name": "empty ops return the state unchanged",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [],
|
||||
"expect": {
|
||||
"ok": true,
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+141
@@ -0,0 +1,141 @@
|
||||
{
|
||||
"name": "existing parts with an inactive part type stay valid",
|
||||
"inactivePartTypeIds": [
|
||||
"hive-part-zl10-roof"
|
||||
],
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "setLabel",
|
||||
"id": "r1",
|
||||
"label": "Dach"
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": true,
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {},
|
||||
"label": "Dach"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"name": "removing the floor fails",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "removePart",
|
||||
"id": "f1"
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": false,
|
||||
"opIndex": 0,
|
||||
"reasonCode": "RULE_FLOOR_BOTTOM"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"name": "second floor fails",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "addPart",
|
||||
"part": {
|
||||
"id": "f2",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
"at": 1
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": false,
|
||||
"opIndex": 0,
|
||||
"reasonCode": "RULE_FLOOR_BOTTOM"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"name": "part below the floor fails",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "addPart",
|
||||
"part": {
|
||||
"id": "q1",
|
||||
"partTypeId": "hive-part-zl10-queen-excluder",
|
||||
"state": {}
|
||||
},
|
||||
"at": 0
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": false,
|
||||
"opIndex": 0,
|
||||
"reasonCode": "RULE_FLOOR_BOTTOM"
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"name": "negative at is clamped to 0 and then fails the floor rule",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "addPart",
|
||||
"part": {
|
||||
"id": "q1",
|
||||
"partTypeId": "hive-part-zl10-queen-excluder",
|
||||
"state": {}
|
||||
},
|
||||
"at": -7
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": false,
|
||||
"opIndex": 0,
|
||||
"reasonCode": "RULE_FLOOR_BOTTOM"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"name": "part above the roof fails",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "addPart",
|
||||
"part": {
|
||||
"id": "q1",
|
||||
"partTypeId": "hive-part-zl10-queen-excluder",
|
||||
"state": {}
|
||||
},
|
||||
"at": 3
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": false,
|
||||
"opIndex": 0,
|
||||
"reasonCode": "RULE_ROOF_TOP"
|
||||
}
|
||||
}
|
||||
Vendored
+81
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"name": "at beyond the end is clamped and fails the roof rule",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "addPart",
|
||||
"part": {
|
||||
"id": "q1",
|
||||
"partTypeId": "hive-part-zl10-queen-excluder",
|
||||
"state": {}
|
||||
},
|
||||
"at": 99
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": false,
|
||||
"opIndex": 0,
|
||||
"reasonCode": "RULE_ROOF_TOP"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"name": "second roof fails",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "addPart",
|
||||
"part": {
|
||||
"id": "r2",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
},
|
||||
"at": 3
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": false,
|
||||
"opIndex": 0,
|
||||
"reasonCode": "RULE_ROOF_TOP"
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"name": "floor insert cannot sit in the stack",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "addPart",
|
||||
"part": {
|
||||
"id": "fi1",
|
||||
"partTypeId": "hive-part-zl10-floor-insert",
|
||||
"state": {}
|
||||
},
|
||||
"at": 1
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": false,
|
||||
"opIndex": 0,
|
||||
"reasonCode": "RULE_INSERT_HOST"
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"name": "evaporator cannot sit in the stack",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "addPart",
|
||||
"part": {
|
||||
"id": "ev1",
|
||||
"partTypeId": "hive-part-zl10-evaporator",
|
||||
"state": {}
|
||||
},
|
||||
"at": 1
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": false,
|
||||
"opIndex": 0,
|
||||
"reasonCode": "RULE_INSERT_HOST"
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"name": "evaporator cannot go into a floor",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "addInsert",
|
||||
"hostId": "f1",
|
||||
"part": {
|
||||
"id": "ev1",
|
||||
"partTypeId": "hive-part-zl10-evaporator",
|
||||
"state": {}
|
||||
}
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": false,
|
||||
"opIndex": 0,
|
||||
"reasonCode": "RULE_INSERT_HOST"
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"name": "floor insert cannot go into a box",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "addInsert",
|
||||
"hostId": "b1",
|
||||
"part": {
|
||||
"id": "fi1",
|
||||
"partTypeId": "hive-part-zl10-floor-insert",
|
||||
"state": {}
|
||||
}
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": false,
|
||||
"opIndex": 0,
|
||||
"reasonCode": "RULE_INSERT_HOST"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"name": "feeder cannot be an insert",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "addInsert",
|
||||
"hostId": "b1",
|
||||
"part": {
|
||||
"id": "fd1",
|
||||
"partTypeId": "hive-part-zl10-feeder",
|
||||
"state": {}
|
||||
}
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": false,
|
||||
"opIndex": 0,
|
||||
"reasonCode": "RULE_INSERT_HOST"
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"name": "only one floor insert per floor",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {},
|
||||
"inserts": [
|
||||
{
|
||||
"id": "fi1",
|
||||
"partTypeId": "hive-part-zl10-floor-insert",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "addInsert",
|
||||
"hostId": "f1",
|
||||
"part": {
|
||||
"id": "fi2",
|
||||
"partTypeId": "hive-part-zl10-floor-insert",
|
||||
"state": {}
|
||||
}
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": false,
|
||||
"opIndex": 0,
|
||||
"reasonCode": "RULE_INSERT_LIMIT"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "only one evaporator per box",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "e1",
|
||||
"partTypeId": "hive-part-zl10-box-empty",
|
||||
"state": {},
|
||||
"frames": [],
|
||||
"inserts": [
|
||||
{
|
||||
"id": "ev1",
|
||||
"partTypeId": "hive-part-zl10-evaporator",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "addInsert",
|
||||
"hostId": "e1",
|
||||
"part": {
|
||||
"id": "ev2",
|
||||
"partTypeId": "hive-part-zl10-evaporator",
|
||||
"state": {}
|
||||
}
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": false,
|
||||
"opIndex": 0,
|
||||
"reasonCode": "RULE_INSERT_LIMIT"
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"name": "movePart cannot move an insert",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {},
|
||||
"inserts": [
|
||||
{
|
||||
"id": "fi1",
|
||||
"partTypeId": "hive-part-zl10-floor-insert",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "movePart",
|
||||
"id": "fi1",
|
||||
"to": 1
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": false,
|
||||
"opIndex": 0,
|
||||
"reasonCode": "RULE_INSERT_HOST"
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"name": "movePart past the roof fails the roof rule",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "movePart",
|
||||
"id": "b1",
|
||||
"to": 99
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": false,
|
||||
"opIndex": 0,
|
||||
"reasonCode": "RULE_ROOF_TOP"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"name": "setState with unknown key",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "setState",
|
||||
"id": "f1",
|
||||
"key": "nope",
|
||||
"value": 1
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": false,
|
||||
"opIndex": 0,
|
||||
"reasonCode": "STATE_UNKNOWN_KEY"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "setState level above max",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "fd1",
|
||||
"partTypeId": "hive-part-zl10-feeder",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "setState",
|
||||
"id": "fd1",
|
||||
"key": "syrupLiters",
|
||||
"value": 3.5
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": false,
|
||||
"opIndex": 0,
|
||||
"reasonCode": "STATE_OUT_OF_RANGE"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "setState level below zero",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "fd1",
|
||||
"partTypeId": "hive-part-zl10-feeder",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "setState",
|
||||
"id": "fd1",
|
||||
"key": "syrupLiters",
|
||||
"value": -0.5
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": false,
|
||||
"opIndex": 0,
|
||||
"reasonCode": "STATE_OUT_OF_RANGE"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "setState level off step",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "fd1",
|
||||
"partTypeId": "hive-part-zl10-feeder",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "setState",
|
||||
"id": "fd1",
|
||||
"key": "syrupLiters",
|
||||
"value": 0.3
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": false,
|
||||
"opIndex": 0,
|
||||
"reasonCode": "STATE_STEP"
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "setState evaporator off step of 10",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "e1",
|
||||
"partTypeId": "hive-part-zl10-box-empty",
|
||||
"state": {},
|
||||
"frames": [],
|
||||
"inserts": [
|
||||
{
|
||||
"id": "ev1",
|
||||
"partTypeId": "hive-part-zl10-evaporator",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "setState",
|
||||
"id": "ev1",
|
||||
"key": "acidMl",
|
||||
"value": 255
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": false,
|
||||
"opIndex": 0,
|
||||
"reasonCode": "STATE_STEP"
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"name": "setState choice not in options",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "setState",
|
||||
"id": "f1",
|
||||
"key": "entrance",
|
||||
"value": "HALF"
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": false,
|
||||
"opIndex": 0,
|
||||
"reasonCode": "STATE_OUT_OF_RANGE"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "setState string on a level",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "fd1",
|
||||
"partTypeId": "hive-part-zl10-feeder",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "setState",
|
||||
"id": "fd1",
|
||||
"key": "syrupLiters",
|
||||
"value": "2"
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": false,
|
||||
"opIndex": 0,
|
||||
"reasonCode": "STATE_OUT_OF_RANGE"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"name": "setState number on a choice",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "setState",
|
||||
"id": "f1",
|
||||
"key": "entrance",
|
||||
"value": 1
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": false,
|
||||
"opIndex": 0,
|
||||
"reasonCode": "STATE_OUT_OF_RANGE"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"name": "setState on missing id",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "setState",
|
||||
"id": "zzz",
|
||||
"key": "entrance",
|
||||
"value": "OPEN"
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": false,
|
||||
"opIndex": 0,
|
||||
"reasonCode": "MISSING_ID"
|
||||
}
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"name": "addPart with unknown state key",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "addPart",
|
||||
"part": {
|
||||
"id": "q1",
|
||||
"partTypeId": "hive-part-zl10-queen-excluder",
|
||||
"state": {
|
||||
"foo": 1
|
||||
}
|
||||
},
|
||||
"at": 2
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": false,
|
||||
"opIndex": 0,
|
||||
"reasonCode": "STATE_UNKNOWN_KEY"
|
||||
}
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"name": "setFrames with too few frames",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "setFrames",
|
||||
"boxId": "b1",
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": false,
|
||||
"opIndex": 0,
|
||||
"reasonCode": "FRAMES_LENGTH"
|
||||
}
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
{
|
||||
"name": "setFrames with too many frames",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "setFrames",
|
||||
"boxId": "b1",
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 11,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": false,
|
||||
"opIndex": 0,
|
||||
"reasonCode": "FRAMES_LENGTH"
|
||||
}
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
{
|
||||
"name": "setFrames with wrong slot numbers",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "setFrames",
|
||||
"boxId": "b1",
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": false,
|
||||
"opIndex": 0,
|
||||
"reasonCode": "FRAMES_LENGTH"
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"name": "setFrame slot above frameSlots",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "setFrame",
|
||||
"boxId": "b1",
|
||||
"slot": 11,
|
||||
"type": "BROOD"
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": false,
|
||||
"opIndex": 0,
|
||||
"reasonCode": "FRAMES_LENGTH"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"name": "setFrame slot zero",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "setFrame",
|
||||
"boxId": "b1",
|
||||
"slot": 0,
|
||||
"type": "BROOD"
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": false,
|
||||
"opIndex": 0,
|
||||
"reasonCode": "FRAMES_LENGTH"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"name": "setFrame on a non-box",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "setFrame",
|
||||
"boxId": "f1",
|
||||
"slot": 1,
|
||||
"type": "BROOD"
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": false,
|
||||
"opIndex": 0,
|
||||
"reasonCode": "FRAMES_LENGTH"
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "setFrame on a box with zero slots",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "e1",
|
||||
"partTypeId": "hive-part-zl10-box-empty",
|
||||
"state": {},
|
||||
"frames": []
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "setFrame",
|
||||
"boxId": "e1",
|
||||
"slot": 1,
|
||||
"type": "BROOD"
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": false,
|
||||
"opIndex": 0,
|
||||
"reasonCode": "FRAMES_LENGTH"
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"name": "setFrame with unknown frame type",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "b1",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 9,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 10,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "setFrame",
|
||||
"boxId": "b1",
|
||||
"slot": 1,
|
||||
"type": "GOLD"
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": false,
|
||||
"opIndex": 0,
|
||||
"reasonCode": "STATE_OUT_OF_RANGE"
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"name": "addPart box with wrong frame count",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "addPart",
|
||||
"part": {
|
||||
"id": "b2",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {},
|
||||
"frames": [
|
||||
{
|
||||
"slot": 1,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 2,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 3,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 4,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 5,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 6,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 7,
|
||||
"type": "FOUNDATION"
|
||||
},
|
||||
{
|
||||
"slot": 8,
|
||||
"type": "FOUNDATION"
|
||||
}
|
||||
]
|
||||
},
|
||||
"at": 1
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": false,
|
||||
"opIndex": 0,
|
||||
"reasonCode": "FRAMES_LENGTH"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "addPart box without frames",
|
||||
"state": {
|
||||
"schemaVersion": 1,
|
||||
"stack": [
|
||||
{
|
||||
"id": "f1",
|
||||
"partTypeId": "hive-part-zl10-floor",
|
||||
"state": {}
|
||||
},
|
||||
{
|
||||
"id": "r1",
|
||||
"partTypeId": "hive-part-zl10-roof",
|
||||
"state": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ops": [
|
||||
{
|
||||
"op": "addPart",
|
||||
"part": {
|
||||
"id": "b2",
|
||||
"partTypeId": "hive-part-zl10-box-10",
|
||||
"state": {}
|
||||
},
|
||||
"at": 1
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"ok": false,
|
||||
"opIndex": 0,
|
||||
"reasonCode": "FRAMES_LENGTH"
|
||||
}
|
||||
}
|
||||
Loaded 100 of 141 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user