feat: hive setup data model and API (Beute L1)
Backend Image / build-and-push (push) Successful in 5m5s
Frontend Image / build-and-push (push) Successful in 11m2s

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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-15 21:23:23 +02:00
1 parent 8982d0796a
commit b48fb157c8
141 files changed
+15211 -17

No files matched your search

+45
View File
@@ -215,6 +215,12 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
92. **Beim Bearbeiten einer Behandlung wählt die Oberfläche nie selbst eine Packung vor**: Nur das Anlegen schlägt die Packung mit dem frühesten Verfall vor; im Bearbeiten-Dialog steht nach einem Mittelwechsel „Ohne Packung“, bis der Nutzer wählt. Kosten bei falscher Annahme: Wer eine Abbuchung erwartet, muss die Packung ausdrücklich wählen. Abschnitt 12.11. 92. **Beim Bearbeiten einer Behandlung wählt die Oberfläche nie selbst eine Packung vor**: Nur das Anlegen schlägt die Packung mit dem frühesten Verfall vor; im Bearbeiten-Dialog steht nach einem Mittelwechsel „Ohne Packung“, bis der Nutzer wählt. Kosten bei falscher Annahme: Wer eine Abbuchung erwartet, muss die Packung ausdrücklich wählen. Abschnitt 12.11.
93. **Die Glocke zeigt für `COMPLIANCE` das Kurz-Etikett „Behörden“**: Die Badges der Glocke sind Einwort-Etiketten („Aufgabe“, „Vorschlag“); „Behörden & Prüfung“ würde die Zeile umbrechen und steht darum nur in `lib/notifications/labels.ts` und der Telegram-Kategorienauswahl. Kosten bei falscher Annahme: Zwei Bezeichnungen für dieselbe Kategorie. Abschnitt 12.11. 93. **Die Glocke zeigt für `COMPLIANCE` das Kurz-Etikett „Behörden“**: Die Badges der Glocke sind Einwort-Etiketten („Aufgabe“, „Vorschlag“); „Behörden & Prüfung“ würde die Zeile umbrechen und steht darum nur in `lib/notifications/labels.ts` und der Telegram-Kategorienauswahl. Kosten bei falscher Annahme: Zwei Bezeichnungen für dieselbe Kategorie. Abschnitt 12.11.
94. **Die Dosis ist auf drei Nachkommastellen begrenzt**: Das Eingabefeld lehnt mehr als drei Nachkommastellen mit „Höchstens 3 Nachkommastellen.“ ab (Packungsmengen rechnen auf Tausendstel, `roundToThousandths`). Kosten bei falscher Annahme: Eine feinere Dosis lässt sich nicht erfassen. Abschnitt 12.11. 94. **Die Dosis ist auf drei Nachkommastellen begrenzt**: Das Eingabefeld lehnt mehr als drei Nachkommastellen mit „Höchstens 3 Nachkommastellen.“ ab (Packungsmengen rechnen auf Tausendstel, `roundToThousandths`). Kosten bei falscher Annahme: Eine feinere Dosis lässt sich nicht erfassen. Abschnitt 12.11.
95. **Die Liebig-Zander-Zarge misst außen 525 × 430 mm**: Der Wert stammt aus dem Innenmaß des Edelstahldeckels und steht im Katalog (Migration `20261017100100_hive_catalog_zander_liebig`, `test/fixtures/hive-ops/catalog.json`). Kosten bei falscher Annahme: Die 3D-Maße stimmen nicht; der Inhaber misst nach, der Katalogwert wird mit `hive-systems:manage` geändert. Abschnitt 13.
96. **Die Styropor-Einlage misst 465 × 365 × 20 mm**: Der Inhaber misst nach. Kosten bei falscher Annahme: Die Dämmung sitzt im Modell zu groß oder zu klein; Katalogwert ändern, kein Code. Abschnitt 13.
97. **Der Nassenheider Professional liegt in einer Leerzarge auf den Rähmchen der Zarge darunter**: Im Modell ist er ein Einsatz der Leerzarge (`EVAPORATOR` in einer `BOX` mit 0 Plätzen). Kosten bei falscher Annahme: Die Lage im Aufbau weicht vom Stock ab; der Inhaber bestätigt mit Foto, die Regel steht in `hive-validate.ts`. Abschnitt 13.
98. **Rähmchen-Plätze zählen von links, mit Blick auf das Flugloch**: Platz 1 ist links, Platz `frameSlots` rechts. Kosten bei falscher Annahme: Alle gespeicherten Platznummern sind gespiegelt; das lässt sich nur mit einer Datenmigration über alle Aufbauten beheben. Abschnitt 13.
99. **Den Aufbau ändert nur, wer `colonies:update` hat; den Katalog pflegt nur `hive-systems:manage` (ADMIN)**: STAFF hat `colonies:update` nicht und kann die Beute daher nicht ändern, auch nicht in der Durchsicht (403 `HIVE_FORBIDDEN`). `hive-systems:manage` kommt nur über die Katalog-Synchronisierung zur Rolle ADMIN, ohne Rechte-Migration. Kosten bei falscher Annahme: Mitarbeiter müssen den Aufbau über einen Administrator ändern lassen; der Inhaber vergibt `colonies:update` in der Rollenverwaltung, das ist kein Code. Abschnitt 13.
100. **Die Bausperre ist eine Einlage ohne Zustand; „Winter“ ist kein eigener Modus**: Die Bausperre (`FLOOR_INSERT`) hat kein Zustandsfeld, Einlegen und Entfernen sind die Operationen `addInsert` und `removePart`. Kosten bei falscher Annahme: Wer einen Winter-Modus erwartet, muss Einlagen einzeln setzen; ein Modus wäre eine spätere Erweiterung des Katalogs. Abschnitt 13.
## 7. Kubernetes ## 7. Kubernetes
@@ -965,3 +971,42 @@ Die Oberfläche des Compliance-Blocks entstand in Task 14 bis 19: Prüfbereitsch
- **Ernte:** nur die Warnung „Im Sperrbezirk gelten Auflagen auch für Honig und Waben. Verfügung prüfen.“; sie speichert immer. - **Ernte:** nur die Warnung „Im Sperrbezirk gelten Auflagen auch für Honig und Waben. Verfügung prüfen.“; sie speichert immer.
- **409.** `RESTRICTED_ZONE` und „Der Stand wurde gerade geändert. Bitte neu laden.“ erscheinen mit dem Servertext über dem Knopf, der Fokus springt auf die Meldung, die aktiven Sperren laden neu, und ein Knopf „Neu laden“ (Umsetzen, Vereinigen, Standkorrektur) lädt Volk und Stände neu und löscht die Meldung. Kein automatischer Neuversuch. - **409.** `RESTRICTED_ZONE` und „Der Stand wurde gerade geändert. Bitte neu laden.“ erscheinen mit dem Servertext über dem Knopf, der Fokus springt auf die Meldung, die aktiven Sperren laden neu, und ein Knopf „Neu laden“ (Umsetzen, Vereinigen, Standkorrektur) lädt Volk und Stände neu und löscht die Meldung. Kein automatischer Neuversuch.
- **Service Worker.** Die Sperrbezirk-Pfade sind seit Task 14 nie zwischengespeichert; offline fehlt die frühe Sperre, der Server entscheidet. - **Service Worker.** Die Sperrbezirk-Pfade sind seit Task 14 nie zwischengespeichert; offline fehlt die frühe Sperre, der Server entscheidet.
## 13. Beute (3D-Aufbau)
Der Aufbau einer Beute (Boden, Zargen, Rähmchen, Einlagen, Dach) je Volk. Spec: `docs/superpowers/specs/2026-10-15-beute-3d-design.md`. Lieferung L1 ist das Backend ohne Oberfläche.
**Modul.** `src/hive` (`HiveModule`): `hive.service.ts` (Aufbau je Volk), `hive-catalog.service.ts` (Katalog), `hive-ops.ts` (`applyHiveOps`), `hive-validate.ts` (Validator), `hive-facts.ts` (abgeleitete Fakten), `hive-state-schema.ts` (Zustandsfelder der Teile). Hive-Fakten schreiben nie in `Colony` oder `Inspection`; `Colony.occupiedFrames` bleibt die Volksstärke.
**Speicherform.**
- `HiveSystem` (Maße, `template`) und `HivePartType` (Art, Maße, `frameSlots`, `stateSchema`, `active`) bilden den Katalog. Die Startdaten (Zander nach Liebig, 10 Rähmchen, 14 Teile) kommen per Migration `20261017100100_hive_catalog_zander_liebig` mit festen Ids; `test/fixtures/hive-ops/catalog.json` ist dieselbe Quelle für das Frontend, ein e2e vergleicht beide.
- `HiveSetup` hält den aktuellen Aufbau eines Volks als JSON (Stand und `version`), `HiveChange` das Protokoll: je Änderung `occurredOn` (Berliner Tag), `source`, die Operationen und `stateAfter`. Der Stand an einem Tag kommt aus dem letzten `stateAfter` bis zu diesem Tag. Nachträge sind möglich, aber nie vor dem `INIT`-Tag und nie in der Zukunft.
- Katalogteile werden nie gelöscht, nur `active: false`. Bestehende Aufbauten mit einer inaktiven Art bleiben gültig.
**Operationen.** `addPart`, `addInsert`, `removePart`, `movePart`, `setState`, `setFrame`, `setFrames`, `setLabel`; höchstens 100 pro Anfrage, höchstens 30 Teile im Stapel, Beschriftungen und Rähmchen-Namen höchstens 40 Zeichen. Die Regeln und die Gründe (`reason`-Codes wie `RULE_FLOOR_BOTTOM`, `STATE_OUT_OF_RANGE`, `UNKNOWN_FIELD`, `LIMIT_PARTS`) stehen im Kopf von `hive-validate.ts`; die Prüfreihenfolge entscheidet, welcher Grund zuerst gemeldet wird, und das Frontend muss sie übernehmen. Scheitert eine Operation am aktuellen Stand, wird die ganze Änderung abgelehnt (409 `HIVE_CONFLICT`).
**Endpunkte** (`/colonies/:id/hive`): `GET` (Stand und Fakten), `POST` (Aufbau anlegen), `POST changes` (Änderung mit `Idempotency-Key`: dieselbe Wiederholung liefert dieselbe Antwort ohne zweite Änderung), `GET changes` (Historie mit Cursor), `GET at` (Stand an einem Tag). Katalog: `GET /hive-systems`, `GET /hive-systems/:id/template` (Vorlage mit frischen Ids je Aufruf), `POST` und `PATCH /hive-systems`, `POST /hive-systems/:id/part-types`, `PATCH /hive-part-types/:id`.
**Sperrreihenfolge.** `Colony` FOR UPDATE, dann `HiveSetup` FOR UPDATE, dann `HivePartType` (`LOCKABLE_TABLES` in `src/prisma/row-lock.ts`: `HiveSetup` direkt nach `Colony`). Die Version steigt je Änderung um 1; zwei gleichzeitige Änderungen laufen nacheinander, die zweite sieht den Stand der ersten.
**Rechte.** Lesen `colonies:read`, Aufbau anlegen und ändern `colonies:update`, Katalog ändern `hive-systems:manage` (Gruppe „Beutensysteme“). `hive-systems:manage` kommt nur über die Katalog-Synchronisierung (`PermissionCatalogSyncService`) zur Rolle ADMIN, ohne Rechte-Migration. STAFF hat `colonies:update` nicht und kann die Beute nicht ändern (Annahme 99).
**Ereignis.** `HiveChanged` (Outbox, in derselben Transaktion wie die Änderung; Nutzlast `colonyId`, `version`, `source`, `inspectionId`). Ein nicht lebendes Volk ist schreibgeschützt: 409 wie `assertActive`.
**Durchsicht.** `POST /inspections` nimmt optional `hiveChange` (`ops`). Durchsicht und Beute-Änderung werden in einer Transaktion gespeichert und gemeinsam abgelehnt. Ohne `colonies:update` antwortet der Server 403 `HIVE_FORBIDDEN` und schreibt nichts. Der Tag der Änderung ist der Tag der Durchsicht (nicht in der Zukunft, nicht vor dem `INIT`-Tag); die Änderung hat `source` `INSPECTION` und verweist auf die Durchsicht.
**Backup.** Export und Import enthalten `HiveSystem`, `HivePartType`, `HiveSetup` und `HiveChange`. Katalogzeilen werden nach Id eingespielt (auch in eine frische Installation mit Startdaten). Je Volk gewinnt die Sicherung: Aufbau und Protokoll eines wiederhergestellten Volks ersetzen die vorhandenen. Nicht versiegelte Altdateien ohne diese Tabellen werden gemeldet und übersprungen (Abschnitt 8).
**Fehlercodes.**
| Code | Status | Bedeutung |
|---|---|---|
| `HIVE_CONFLICT` | 409 | Operation passt nicht zum aktuellen Stand; mit `opIndex`, `reason`, `current` (Version und Stand) |
| `HIVE_EXISTS` | 409 | Das Volk hat schon einen Aufbau |
| `HIVE_NOT_FOUND` | 404 | Das Volk hat noch keinen Aufbau |
| `HIVE_FORBIDDEN` | 403 | Durchsicht mit `hiveChange` ohne `colonies:update` |
| `TEMPLATE_NOT_SET` | 409 | Das System hat noch keine Vorlage |
| `SCHEMA_NOT_ADDITIVE` | 400 | Zustandsfelder dürfen nur angehängt werden |
| `TEMPLATE_INVALID` | 400 | Die Vorlage besteht den Validator nicht |
Formfehler (Form der Anfrage, zukünftiger Tag, Tag vor `INIT`, mehr als 100 Operationen) sind 400. Ein neues System beginnt mit leerer Vorlage; sie wird per `PATCH` gesetzt, sobald die Teile existieren.
@@ -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;
@@ -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);
+102 -1
View File
@@ -104,6 +104,7 @@ model User {
refreshTokens RefreshToken[] refreshTokens RefreshToken[]
inspections Inspection[] inspections Inspection[]
hiveChanges HiveChange[]
varroaMeasurements VarroaMeasurement[] varroaMeasurements VarroaMeasurement[]
swarmControlChecks SwarmControlCheck[] swarmControlChecks SwarmControlCheck[]
winterChecks WinterCheck[] winterChecks WinterCheck[]
@@ -331,6 +332,7 @@ model Colony {
harvests HarvestColony[] harvests HarvestColony[]
queenEvaluations QueenEvaluation[] queenEvaluations QueenEvaluation[]
suggestions Suggestion[] suggestions Suggestion[]
hiveSetup HiveSetup?
@@index([apiaryId]) @@index([apiaryId])
@@index([status]) @@index([status])
@@ -501,7 +503,8 @@ model Inspection {
notes String? notes String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
tasks Task[] tasks Task[]
hiveChange HiveChange?
@@index([colonyId, date]) @@index([colonyId, date])
} }
@@ -2182,3 +2185,101 @@ model ComplianceAcknowledgement {
@@index([itemKey, subjectId]) @@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
}
+2
View File
@@ -60,6 +60,7 @@ import { SuggestionsModule } from './suggestions/suggestions.module';
import { AutopilotModule } from './autopilot/autopilot.module'; import { AutopilotModule } from './autopilot/autopilot.module';
import { SystemModule } from './system/system.module'; import { SystemModule } from './system/system.module';
import { WithdrawalModule } from './withdrawal/withdrawal.module'; import { WithdrawalModule } from './withdrawal/withdrawal.module';
import { HiveModule } from './hive/hive.module';
import { VeterinaryMedicinesModule } from './veterinary-medicines/veterinary-medicines.module'; import { VeterinaryMedicinesModule } from './veterinary-medicines/veterinary-medicines.module';
import { ComplianceModule } from './compliance/compliance.module'; import { ComplianceModule } from './compliance/compliance.module';
import { ReadinessModule } from './compliance/readiness/readiness.module'; import { ReadinessModule } from './compliance/readiness/readiness.module';
@@ -136,6 +137,7 @@ import { IdempotencyInterceptor } from './common/idempotency/idempotency.interce
ApiKeysModule, ApiKeysModule,
SystemModule, SystemModule,
VeterinaryMedicinesModule, VeterinaryMedicinesModule,
HiveModule,
ComplianceModule, ComplianceModule,
ReadinessModule, ReadinessModule,
], ],
@@ -412,6 +412,9 @@ export const PERMISSION_CATALOG = [
], ],
3800, 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[]; ] as const satisfies readonly PermissionCatalogEntry[];
export type PermissionKey = (typeof PERMISSION_CATALOG)[number]['key']; 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 * as path from 'node:path';
import { Prisma } from '../../generated/prisma'; import { Prisma } from '../../generated/prisma';
import { import {
BackupBundle,
BackupService, BackupService,
LEGACY_UNSEALED_TABLES, LEGACY_UNSEALED_TABLES,
MODELS_EXCLUDED_FROM_BACKUP, MODELS_EXCLUDED_FROM_BACKUP,
@@ -113,7 +114,15 @@ describe('BackupService', () => {
document: { findMany: jest.Mock; count: jest.Mock }; document: { findMany: jest.Mock; count: jest.Mock };
$transaction: 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 }; permission: { findUnique: jest.Mock };
user: { upsert: jest.Mock; findMany: jest.Mock }; user: { upsert: jest.Mock; findMany: jest.Mock };
queen: { upsert: jest.Mock; update: 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 (tx as unknown as { $queryRaw: jest.Mock }).$queryRaw = jest
.fn() .fn()
.mockResolvedValue([]); .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.update = jest.fn().mockResolvedValue({});
tx.queen.findMany = jest.fn().mockResolvedValue([]); tx.queen.findMany = jest.fn().mockResolvedValue([]);
tx.document.findUnique = jest.fn().mockResolvedValue(null); 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 // 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. // in MODELS_EXCLUDED_FROM_BACKUP with its reason - never silently missing.
describe('model coverage', () => { describe('model coverage', () => {
@@ -329,9 +329,13 @@ export class BackupService {
data.colony = await this.prisma.colony.findMany(); data.colony = await this.prisma.colony.findMany();
data.colonyApiaryHistory = await this.prisma.colonyApiaryHistory.findMany(); data.colonyApiaryHistory = await this.prisma.colonyApiaryHistory.findMany();
data.colonyLineage = await this.prisma.colonyLineage.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.queenEvaluation = await this.prisma.queenEvaluation.findMany();
data.queenColonyHistory = await this.prisma.queenColonyHistory.findMany(); data.queenColonyHistory = await this.prisma.queenColonyHistory.findMany();
data.inspection = await this.prisma.inspection.findMany(); data.inspection = await this.prisma.inspection.findMany();
data.hiveChange = await this.prisma.hiveChange.findMany();
data.taskTemplate = await this.prisma.taskTemplate.findMany(); data.taskTemplate = await this.prisma.taskTemplate.findMany();
data.suggestion = await this.prisma.suggestion.findMany(); data.suggestion = await this.prisma.suggestion.findMany();
data.task = await this.prisma.task.findMany(); data.task = await this.prisma.task.findMany();
@@ -608,6 +612,26 @@ export class BackupService {
} }
imported.queen = rowsOf('queen').length; 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). // Restore deliberately emits no ColonyCreated (no domain events).
for (const row of rowsOf('colony')) { for (const row of rowsOf('colony')) {
await tx.colony.upsert({ await tx.colony.upsert({
@@ -618,6 +642,33 @@ export class BackupService {
} }
imported.colony = rowsOf('colony').length; 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')) { for (const row of rowsOf('queen')) {
if (row.motherQueenId && !preexistingQueenIds.has(row.id as string)) { if (row.motherQueenId && !preexistingQueenIds.has(row.id as string)) {
await tx.queen.update({ await tx.queen.update({
@@ -692,6 +743,21 @@ export class BackupService {
} }
imported.inspection = rowsOf('inspection').length; 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')) { for (const row of rowsOf('taskTemplate')) {
await tx.taskTemplate.upsert({ await tx.taskTemplate.upsert({
where: { id: row.id as string }, where: { id: row.id as string },
@@ -1,6 +1,7 @@
import { import {
ColonyCondition, ColonyCondition,
ColonyStatus, ColonyStatus,
HiveChangeSource,
PhaseSource, PhaseSource,
PhenologyPhase, PhenologyPhase,
Prisma, Prisma,
@@ -144,6 +145,15 @@ export interface DomainEventPayloads {
// reference day (referenceDay, 'YYYY-MM-DD') was reached. Not forwarded to // reference day (referenceDay, 'YYYY-MM-DD') was reached. Not forwarded to
// webhooks. // webhooks.
TskReferenceDayReached: { year: number; referenceDay: string }; 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; export type DomainEventType = keyof DomainEventPayloads;
@@ -188,6 +198,7 @@ const CATALOG: Record<DomainEventType, true> = {
ColonyMoved: true, ColonyMoved: true,
ApiaryChanged: true, ApiaryChanged: true,
TskReferenceDayReached: true, TskReferenceDayReached: true,
HiveChanged: true,
}; };
export const DOMAIN_EVENT_TYPES: readonly DomainEventType[] = Object.freeze( export const DOMAIN_EVENT_TYPES: readonly DomainEventType[] = Object.freeze(
@@ -183,7 +183,7 @@ describe('DomainEventsService', () => {
describe('domain event catalog', () => { describe('domain event catalog', () => {
it('lists every event type exactly once', () => { 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(new Set(DOMAIN_EVENT_TYPES).size).toBe(DOMAIN_EVENT_TYPES.length);
expect(DOMAIN_EVENT_TYPES).toEqual( expect(DOMAIN_EVENT_TYPES).toEqual(
expect.arrayContaining([ expect.arrayContaining([
@@ -206,6 +206,7 @@ describe('domain event catalog', () => {
'ColonyMoved', 'ColonyMoved',
'ApiaryChanged', 'ApiaryChanged',
'TskReferenceDayReached', '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;
}
+144
View File
@@ -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 });
}
}
}
+44
View File
@@ -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`);
}
+118
View File
@@ -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);
});
});
+60
View File
@@ -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,
};
}
+114
View File
@@ -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',
]),
);
});
});
+244
View File
@@ -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;
}
+156
View File
@@ -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 };
+269
View File
@@ -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);
}
}
+12
View File
@@ -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);
});
});
});
+457
View File
@@ -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 { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { import {
ArrayMaxSize, ArrayMaxSize,
ArrayMinSize,
ArrayUnique, ArrayUnique,
IsArray, IsArray,
IsBoolean, IsBoolean,
@@ -13,15 +14,28 @@ import {
IsString, IsString,
Max, Max,
Min, Min,
ValidateNested,
} from 'class-validator'; } from 'class-validator';
import { import {
COLONY_TAKEOVER_FIELDS, COLONY_TAKEOVER_FIELDS,
type ColonyTakeoverField, type ColonyTakeoverField,
} from '../../colonies/colony-takeover'; } 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 { ColonyCondition } from '../../../generated/prisma';
import { feedConditionFromInput } from '../../common/feed-vocabulary'; 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 { export class CreateInspectionDto {
@ApiProperty() @ApiProperty()
@IsString() @IsString()
@@ -172,4 +186,14 @@ export class CreateInspectionDto {
@ArrayMaxSize(3) @ArrayMaxSize(3)
@IsIn(COLONY_TAKEOVER_FIELDS, { each: true }) @IsIn(COLONY_TAKEOVER_FIELDS, { each: true })
applyToColony?: ColonyTakeoverField[]; 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. * not part of a correction: a correction takes nothing over to the colony.
*/ */
export class UpdateInspectionDto extends PartialType( export class UpdateInspectionDto extends PartialType(
OmitType(CreateInspectionDto, ['applyToColony'] as const), OmitType(CreateInspectionDto, ['applyToColony', 'hiveChange'] as const),
{ skipNullProperties: false }, { skipNullProperties: false },
) {} ) {}
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TasksModule } from '../tasks/tasks.module'; import { TasksModule } from '../tasks/tasks.module';
import { ColoniesModule } from '../colonies/colonies.module'; import { ColoniesModule } from '../colonies/colonies.module';
import { HiveModule } from '../hive/hive.module';
import { InspectionsController } from './inspections.controller'; import { InspectionsController } from './inspections.controller';
import { ColonyInspectionsController } from './colony-inspections.controller'; import { ColonyInspectionsController } from './colony-inspections.controller';
import { InspectionsService } from './inspections.service'; import { InspectionsService } from './inspections.service';
@@ -11,7 +12,7 @@ import { InspectionsService } from './inspections.service';
* PermissionResolverService comes from the global resolver module. * PermissionResolverService comes from the global resolver module.
*/ */
@Module({ @Module({
imports: [TasksModule, ColoniesModule], imports: [TasksModule, ColoniesModule, HiveModule],
controllers: [InspectionsController, ColonyInspectionsController], controllers: [InspectionsController, ColonyInspectionsController],
providers: [InspectionsService], providers: [InspectionsService],
exports: [InspectionsService], exports: [InspectionsService],
@@ -6,6 +6,7 @@ import { DomainEventsService } from '../domain-events/domain-events.service';
import { TasksService } from '../tasks/tasks.service'; import { TasksService } from '../tasks/tasks.service';
import { ColoniesService } from '../colonies/colonies.service'; import { ColoniesService } from '../colonies/colonies.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { HiveService } from '../hive/hive.service';
import { Prisma } from '../../generated/prisma'; import { Prisma } from '../../generated/prisma';
describe('InspectionsService', () => { describe('InspectionsService', () => {
@@ -35,6 +36,7 @@ describe('InspectionsService', () => {
let tasks: { createInTx: jest.Mock }; let tasks: { createInTx: jest.Mock };
let colonies: { updateIn: jest.Mock }; let colonies: { updateIn: jest.Mock };
let resolver: { getPermissions: jest.Mock }; let resolver: { getPermissions: jest.Mock };
let hive: { applyChange: jest.Mock };
const inspection = { const inspection = {
id: 'inspection-1', id: 'inspection-1',
@@ -74,6 +76,7 @@ describe('InspectionsService', () => {
resolver = { resolver = {
getPermissions: jest.fn().mockResolvedValue(new Set(['colonies:update'])), getPermissions: jest.fn().mockResolvedValue(new Set(['colonies:update'])),
}; };
hive = { applyChange: jest.fn().mockResolvedValue({ version: 2 }) };
service = new InspectionsService( service = new InspectionsService(
prisma as unknown as PrismaService, prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService, auditLog as unknown as AuditLogService,
@@ -81,6 +84,7 @@ describe('InspectionsService', () => {
tasks as unknown as TasksService, tasks as unknown as TasksService,
colonies as unknown as ColoniesService, colonies as unknown as ColoniesService,
resolver as unknown as PermissionResolverService, resolver as unknown as PermissionResolverService,
hive as unknown as HiveService,
); );
}); });
@@ -94,6 +98,7 @@ describe('InspectionsService', () => {
TasksService, TasksService,
ColoniesService, ColoniesService,
PermissionResolverService, PermissionResolverService,
HiveService,
]); ]);
}); });
@@ -314,6 +319,81 @@ describe('InspectionsService', () => {
).rejects.toThrow('outbox down'); ).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 () => { it('update writes the correction, audit entry and InspectionCorrected in one transaction', async () => {
tx.inspection.findUnique.mockResolvedValue(inspection); tx.inspection.findUnique.mockResolvedValue(inspection);
// Re-filed against another colony: the event names the colony it is // 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 { isLivingColonyStatus } from '../colonies/colony-status';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { DbClient } from '../prisma/db-client'; 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 { CreateTaskFromInspectionDto } from './dto/create-task-from-inspection.dto';
import { UpdateInspectionDto } from './dto/update-inspection.dto'; import { UpdateInspectionDto } from './dto/update-inspection.dto';
import { optionalDate } from '../common/patch-values'; 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 & { export type InspectionWithTakeover = Inspection & {
colonyTakeover: ColonyTakeover; 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 tasks: TasksService,
private readonly colonies: ColoniesService, private readonly colonies: ColoniesService,
private readonly permissionResolver: PermissionResolverService, private readonly permissionResolver: PermissionResolverService,
private readonly hiveService: HiveService,
) {} ) {}
async findAll(): Promise<Inspection[]> { async findAll(): Promise<Inspection[]> {
@@ -62,16 +72,25 @@ export class InspectionsService {
dto: CreateInspectionDto, dto: CreateInspectionDto,
actingUserId: string, actingUserId: string,
): Promise<InspectionWithTakeover> { ): Promise<InspectionWithTakeover> {
const { applyToColony = [], ...data } = dto; const { applyToColony = [], hiveChange, ...data } = dto;
// Resolved before the transaction (it may query the database): the // Resolved before the transaction (it may query the database), once for
// permission is only needed when a takeover was requested. // 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 = const canUpdateColony =
applyToColony.length > 0 && needsPermission &&
(await this.permissionResolver.getPermissions(actingUserId)).has( (await this.permissionResolver.getPermissions(actingUserId)).has(
'colonies:update', '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) => { 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 // Colony first (before the inspection row takes a key-share lock on
// it): serializes concurrent inspections of one colony, so "is there // it): serializes concurrent inspections of one colony, so "is there
// a newer inspection" below is decided on a stable state. // a newer inspection" below is decided on a stable state.
@@ -104,7 +123,22 @@ export class InspectionsService {
canUpdateColony, canUpdateColony,
actingUserId, 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 };
}); });
} }
+7 -2
View File
@@ -226,10 +226,12 @@ describe('lock keys per table', () => {
expect(tx.$queryRaw).not.toHaveBeenCalled(); 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([ expect(LOCK_ORDER).toEqual([
'Apiary', 'Apiary',
'Colony', 'Colony',
'HiveSetup',
'HivePartType',
'Suggestion', 'Suggestion',
'Task', 'Task',
'Queen', '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 () => { 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); 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) { for (const table of LOCK_ORDER) {
const tx = { $queryRaw: jest.fn().mockResolvedValue([]) }; const tx = { $queryRaw: jest.fn().mockResolvedValue([]) };
+5
View File
@@ -19,6 +19,11 @@ function lockable(table: string, key = 'id') {
const LOCKABLE_TABLES = { const LOCKABLE_TABLES = {
Apiary: lockable('Apiary'), Apiary: lockable('Apiary'),
Colony: lockable('Colony'), 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'), Suggestion: lockable('Suggestion'),
Task: lockable('Task'), Task: lockable('Task'),
Queen: lockable('Queen'), 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 = [ const herdEvents = [
['ColonyCreated', { apiaryId: 'a1' }], ['ColonyCreated', { apiaryId: 'a1' }],
['ColonyMoved', { fromApiaryId: 'a1', toApiaryId: 'a2' }], ['ColonyMoved', { fromApiaryId: 'a1', toApiaryId: 'a2' }],
['ApiaryChanged', { change: 'LOCATION_CHANGED' }], ['ApiaryChanged', { change: 'LOCATION_CHANGED' }],
['TskReferenceDayReached', { year: 2026, referenceDay: '2026-01-01' }], ['TskReferenceDayReached', { year: 2026, referenceDay: '2026-01-01' }],
[
'HiveChanged',
{ colonyId: 'c1', version: 2, source: 'CARD', inspectionId: null },
],
] as const; ] as const;
for (const [type, payload] of herdEvents) { for (const [type, payload] of herdEvents) {
expect(Object.keys(DOMAIN_EVENT_WEBHOOKS)).not.toContain(type); expect(Object.keys(DOMAIN_EVENT_WEBHOOKS)).not.toContain(type);
+150
View File
@@ -601,6 +601,156 @@ describe('Backup round trip (e2e)', () => {
).toBeNull(); ).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 () => { it('removes the file a restored document row replaced', async () => {
const directory = path.join( const directory = path.join(
path.resolve(process.env.DOCUMENTS_STORAGE_PATH ?? './uploads'), 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": {}
}
]
}
}
}
@@ -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": {}
}
]
}
}
}
@@ -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": {}
}
]
}
}
}
@@ -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": {}
}
]
}
}
}
@@ -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": {}
}
]
}
}
}
@@ -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": {}
}
]
}
}
}
@@ -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": {}
}
]
}
}
}
@@ -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": {}
}
]
}
}
}
@@ -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": {}
}
]
}
}
}
@@ -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": {}
}
]
}
}
}
@@ -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": {}
}
]
}
}
}
@@ -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": {}
}
]
}
}
}
@@ -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": {}
}
]
}
}
}
@@ -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"
}
}
@@ -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"
}
}
@@ -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"
}
}
@@ -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"
}
}
@@ -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"
}
}
@@ -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"
}
}
@@ -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"
}
}
@@ -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"
}
}
@@ -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"
}
}
@@ -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"
}
}
@@ -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"
}
}
@@ -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"
}
}
@@ -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"
}
}
@@ -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"
}
}
@@ -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"
}
}
@@ -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"
}
}
@@ -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"
}
}
@@ -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"
}
}
@@ -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"
}
}
@@ -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"
}
}
Loaded 100 of 141 files, more files were not shown because too many files have changed in this diff. Show more