feat: Beute L3–L5 (3D-Szene, Umbauen, Durchsicht, offline) und Diktat (T7 L1)
Beute L3 Runden 2+3 und Review-Fixes: - Glas-Holo ohne warmes Leuchten, Wanderboden-Fuss, Fokusflug mit Geist-Ansicht, Inhalte im Fokus, Rähmchen mit Art-Farbe beim Anheben - Hinweis-Geometrie je Zarge nur einmal je Layout gebaut; Drehen mit Pointer-Capture, endet auch bei Loslassen ausserhalb (rig-pointer.ts) - Tiefenunschärfe ab 55 fps, Leistungsprobe bei jeder Bewegungsstufe, Context-Loss-Listener aufgeräumt, Trefferboxen als skalierte Einheitsbox Beute L4 Umbauen (/colonies/[id]/hive): Entwurf mit Basis-Schnappschuss beim ersten Schritt, Rückgängig/Wiederholen, Hinweis bei Serverstand- Änderung, Konfliktblatt. Beute L5: - Durchsicht-Abschnitt „Beute“: Aufbau-Änderungen in der Durchsicht, Blatt-Formulare stoppen das Submit-Bubbling durch Portale (sonst speicherte „Speichern“ im Blatt die ganze Durchsicht), Fokus nach Rückgängig, Folgevorschläge beim Speichern - Offline: Beute im Offline-Paket, Warteschlange pending-hive-changes (DB v5), Abarbeiten in Reihenfolge, keine Online-Änderung überholt wartende, Hinweis bei abgelehnter Änderung mit „Warteschlange öffnen“ T7 L1 Diktat: Spracherkennungs-Kapsel (Web Speech API), Einwilligung standardmässig aus, Diktat-Knopf an Freitextfeldern, Seite „Sprache & Datenschutz“; axe-core als exakte devDependency. Backend: NotificationsService liest Empfänger und schreibt in einer Transaktion mit FOR KEY SHARE (paralleles Löschen eines Benutzers führte zu P2003 und verzögerten Vorschlägen; Ursache der wackeligen Endtests). Zwei e2e-Prüfungen gegen geteilten DB-Zustand entschärft. Gates: Frontend lint, typecheck, 2740 Tests, build; Backend lint, typecheck, 4266 Unit-Tests, build; parallele e2e 3x grün (978). Browser-Prüfung 1280/390 px, Tastatur, offline, reduced motion. Refs #19 #20 #21 #22 #27 #32 Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
ab45d6e24e
commit
1a57de8573
123 files changed
+7639
-355
No files matched your search
@@ -1,5 +1,6 @@
|
||||
import type { ApiaryStatus } from "./types";
|
||||
import type { NextStep, SeasonSegment } from "@/lib/autopilot/types";
|
||||
import type { HiveSetupView, HiveSystemDto } from "@/lib/hive/types";
|
||||
import type { Colony, ColonyCondition, ColonyStatus } from "@/lib/colonies/types";
|
||||
import type { Inspection } from "@/lib/inspections/types";
|
||||
import type { Queen } from "@/lib/queens/types";
|
||||
@@ -97,4 +98,11 @@ export interface OfflinePackV1 {
|
||||
* returns as `steps`). Absent in packs cached before T4.
|
||||
*/
|
||||
nextSteps?: Record<string, NextStep[]>;
|
||||
/**
|
||||
* The hive setup per colony id: only colonies of this apiary that have one.
|
||||
* Absent in packs cached before the hive card.
|
||||
*/
|
||||
hives?: Record<string, HiveSetupView>;
|
||||
/** The hive systems with their part types. Absent in older packs. */
|
||||
hiveCatalog?: HiveSystemDto[];
|
||||
}
|
||||
@@ -120,6 +120,8 @@ interface PackOptions {
|
||||
inspections?: OfflinePackV1["inspections"];
|
||||
tasks?: OfflinePackV1["tasks"];
|
||||
inspectionsVisible?: boolean;
|
||||
hives?: OfflinePackV1["hives"];
|
||||
hiveCatalog?: OfflinePackV1["hiveCatalog"];
|
||||
}
|
||||
|
||||
/** A pack as the server sends it: colonies, their overview rows and the sections. */
|
||||
@@ -131,6 +133,8 @@ export function offlinePack({
|
||||
inspections = [],
|
||||
tasks = [],
|
||||
inspectionsVisible = true,
|
||||
hives,
|
||||
hiveCatalog,
|
||||
}: PackOptions = {}): OfflinePackV1 {
|
||||
return {
|
||||
version: 1,
|
||||
@@ -145,6 +149,9 @@ export function offlinePack({
|
||||
queens,
|
||||
inspections,
|
||||
tasks,
|
||||
// Like the server: absent in packs from before the hive card.
|
||||
...(hives ? { hives } : {}),
|
||||
...(hiveCatalog ? { hiveCatalog } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApiError, apiFetch } from "@/lib/auth/api-client";
|
||||
import { listPacks } from "@/lib/apiaries/offline-pack";
|
||||
import { offlinePack, packColony, storedPack } from "@/lib/apiaries/offline-pack.fixtures";
|
||||
import { setupView, systemDto } from "@/components/hive/test-fixtures";
|
||||
import {
|
||||
createHive,
|
||||
fetchHive,
|
||||
fetchHiveAt,
|
||||
fetchHiveHistory,
|
||||
fetchHiveSystems,
|
||||
fetchHiveSystemsWithFallback,
|
||||
fetchHiveTemplate,
|
||||
hiveConflictOf,
|
||||
hiveErrorCode,
|
||||
@@ -18,6 +22,17 @@ vi.mock("@/lib/auth/api-client", async (importOriginal) => {
|
||||
return { ...original, apiFetch: vi.fn() };
|
||||
});
|
||||
|
||||
vi.mock("@/lib/apiaries/offline-pack", () => ({ listPacks: vi.fn() }));
|
||||
|
||||
const NETWORK_DOWN = new TypeError("Failed to fetch");
|
||||
const packWithHive = storedPack(
|
||||
offlinePack({
|
||||
colonies: [packColony("colony-1", "V-1"), packColony("colony-2", "V-2")],
|
||||
hives: { "colony-1": setupView() },
|
||||
hiveCatalog: [systemDto()],
|
||||
}),
|
||||
);
|
||||
|
||||
describe("hive api", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(apiFetch).mockReset();
|
||||
@@ -66,4 +81,49 @@ describe("hive api", () => {
|
||||
expect(hiveErrorCode(new ApiError(404, "nf", { code: "HIVE_NOT_FOUND" }))).toBe("HIVE_NOT_FOUND");
|
||||
expect(hiveErrorCode(new ApiError(500, "x"))).toBeNull();
|
||||
});
|
||||
|
||||
describe("offline reading", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(listPacks).mockReset().mockResolvedValue([packWithHive]);
|
||||
vi.mocked(apiFetch).mockRejectedValue(NETWORK_DOWN);
|
||||
});
|
||||
|
||||
it("answers the hive from the pack when the network fails", async () => {
|
||||
await expect(fetchHive("colony-1", "user-1")).resolves.toEqual(setupView());
|
||||
expect(listPacks).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
|
||||
it("answers HIVE_NOT_FOUND for a colony the pack knows has no setup", async () => {
|
||||
const error = await fetchHive("colony-2", "user-1").catch((caught: unknown) => caught);
|
||||
expect(error).toBeInstanceOf(ApiError);
|
||||
expect(hiveErrorCode(error)).toBe("HIVE_NOT_FOUND");
|
||||
});
|
||||
|
||||
it("passes the network error on when the pack cannot tell (older pack, unknown colony)", async () => {
|
||||
vi.mocked(listPacks).mockResolvedValue([storedPack(offlinePack())]);
|
||||
await expect(fetchHive("colony-1", "user-1")).rejects.toBe(NETWORK_DOWN);
|
||||
vi.mocked(listPacks).mockResolvedValue([packWithHive]);
|
||||
await expect(fetchHive("unknown", "user-1")).rejects.toBe(NETWORK_DOWN);
|
||||
});
|
||||
|
||||
it("never covers up a server answer with the pack", async () => {
|
||||
vi.mocked(apiFetch).mockRejectedValue(new ApiError(404, "nf", { code: "HIVE_NOT_FOUND" }));
|
||||
await expect(fetchHive("colony-1", "user-1")).rejects.toBeInstanceOf(ApiError);
|
||||
expect(listPacks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reads the server only without a user", async () => {
|
||||
await expect(fetchHive("colony-1")).rejects.toBe(NETWORK_DOWN);
|
||||
expect(listPacks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to the catalog of the pack for the systems", async () => {
|
||||
await expect(fetchHiveSystemsWithFallback("user-1")).resolves.toEqual([systemDto()]);
|
||||
});
|
||||
|
||||
it("passes the network error on when no pack carries a catalog", async () => {
|
||||
vi.mocked(listPacks).mockResolvedValue([storedPack(offlinePack())]);
|
||||
await expect(fetchHiveSystemsWithFallback("user-1")).rejects.toBe(NETWORK_DOWN);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ApiError, apiFetch } from "@/lib/auth/api-client";
|
||||
import { idempotencyHeaders } from "@/lib/offline/idempotency";
|
||||
import { withPackFallback } from "@/lib/offline/pack-fallback";
|
||||
import { hiveCatalogFromPacks, hiveFromPacks } from "./pack";
|
||||
import type {
|
||||
CreateHiveInput,
|
||||
HiveAtView,
|
||||
@@ -19,13 +21,41 @@ export const hiveKeys = {
|
||||
|
||||
const colonyPath = (colonyId: string) => `/colonies/${encodeURIComponent(colonyId)}/hive`;
|
||||
|
||||
/** Every system with its part types (usable as a plain queryFn). */
|
||||
export function fetchHiveSystems(): Promise<HiveSystemDto[]> {
|
||||
return apiFetch<HiveSystemDto[]>("/hive-systems");
|
||||
}
|
||||
|
||||
/** 404 HIVE_NOT_FOUND when the colony has no setup yet. */
|
||||
export function fetchHive(colonyId: string): Promise<HiveSetupView> {
|
||||
return apiFetch<HiveSetupView>(colonyPath(colonyId));
|
||||
/**
|
||||
* Like fetchHiveSystems; when the network fails, the catalog of the user's
|
||||
* offline packs answers (spec 10).
|
||||
*/
|
||||
export function fetchHiveSystemsWithFallback(
|
||||
ownerUserId: string | null,
|
||||
fromServer: () => Promise<HiveSystemDto[]> = fetchHiveSystems,
|
||||
): Promise<HiveSystemDto[]> {
|
||||
if (!ownerUserId) return fromServer();
|
||||
return withPackFallback(fromServer, hiveCatalogFromPacks, ownerUserId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 404 HIVE_NOT_FOUND when the colony has no setup yet. Only when the network
|
||||
* fails, the colony's offline pack answers - also with that 404 when the pack
|
||||
* knows the colony has no setup.
|
||||
*/
|
||||
export function fetchHive(colonyId: string, ownerUserId: string | null = null): Promise<HiveSetupView> {
|
||||
const fromServer = () => apiFetch<HiveSetupView>(colonyPath(colonyId));
|
||||
if (!ownerUserId) return fromServer();
|
||||
return withPackFallback(
|
||||
fromServer,
|
||||
(packs) => {
|
||||
const packed = hiveFromPacks(packs, colonyId);
|
||||
if (!packed) return null;
|
||||
if (!packed.setup) throw new ApiError(404, "Hive not found", { code: "HIVE_NOT_FOUND" });
|
||||
return packed.setup;
|
||||
},
|
||||
ownerUserId,
|
||||
);
|
||||
}
|
||||
|
||||
/** Template of a system with fresh ids; 409 TEMPLATE_NOT_SET while it is empty. */
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { apiFetch } from "@/lib/auth/api-client";
|
||||
import type { HiveBoxDepth, HivePartKind, HivePartTypeDto, HiveSystemDto, StateField } from "./types";
|
||||
import type { HiveBoxDepth, HiveTemplate, HivePartKind, HivePartTypeDto, HiveSystemDto, StateField } from "./types";
|
||||
|
||||
// Admin writes of the hive catalog (hive-systems:manage). Reads stay in api.ts
|
||||
// (`fetchHiveSystems`); callers refresh them via `hiveKeys.systems`.
|
||||
@@ -12,8 +12,8 @@ export interface HiveSystemInput {
|
||||
footprintWMm: number;
|
||||
}
|
||||
|
||||
/** Template editing is not part of this screen (it is saved from the editor). */
|
||||
export type HiveSystemPatch = Partial<HiveSystemInput> & { active?: boolean };
|
||||
/** `template` is saved from the hive editor („Als Vorlage speichern“), not from the catalog screen. */
|
||||
export type HiveSystemPatch = Partial<HiveSystemInput> & { active?: boolean; template?: HiveTemplate };
|
||||
|
||||
export interface HivePartTypeInput {
|
||||
kind: HivePartKind;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { offlinePack, packColony, storedPack } from "@/lib/apiaries/offline-pack.fixtures";
|
||||
import { setupView, systemDto } from "@/components/hive/test-fixtures";
|
||||
import { hiveCatalogFromPacks, hiveFromPacks } from "./pack";
|
||||
|
||||
const withHive = storedPack(
|
||||
offlinePack({
|
||||
apiaryId: "apiary-1",
|
||||
colonies: [packColony("c1", "V-1"), packColony("c2", "V-2")],
|
||||
hives: { c1: setupView(undefined, "c1") },
|
||||
hiveCatalog: [systemDto()],
|
||||
}),
|
||||
{ savedAt: "2026-10-13T10:00:00.000Z" },
|
||||
);
|
||||
const oldPack = storedPack(offlinePack({ apiaryId: "apiary-2", colonies: [packColony("c9", "V-9")] }), {
|
||||
savedAt: "2026-10-01T10:00:00.000Z",
|
||||
});
|
||||
|
||||
describe("hive pack readers", () => {
|
||||
it("returns the setup of a colony in the pack", () => {
|
||||
expect(hiveFromPacks([withHive, oldPack], "c1")?.setup?.colonyId).toBe("c1");
|
||||
});
|
||||
|
||||
it("tells a colony without a hive from a pack that cannot tell", () => {
|
||||
expect(hiveFromPacks([withHive], "c2")).toEqual({ setup: null });
|
||||
expect(hiveFromPacks([oldPack], "c9")).toBeNull();
|
||||
expect(hiveFromPacks([withHive], "unknown")).toBeNull();
|
||||
});
|
||||
|
||||
it("prefers the newest pack that lists the colony", () => {
|
||||
const newer = storedPack(
|
||||
offlinePack({
|
||||
apiaryId: "apiary-3",
|
||||
colonies: [packColony("c1", "V-1")],
|
||||
hives: { c1: setupView(undefined, "c1", 9) },
|
||||
}),
|
||||
{ savedAt: "2026-10-14T10:00:00.000Z" },
|
||||
);
|
||||
expect(hiveFromPacks([withHive, newer], "c1")?.setup?.version).toBe(9);
|
||||
});
|
||||
|
||||
it("takes the catalog from the newest pack that has one", () => {
|
||||
expect(hiveCatalogFromPacks([oldPack, withHive])?.[0].id).toBe("sys");
|
||||
expect(hiveCatalogFromPacks([oldPack])).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { StoredPack } from "@/lib/offline/db";
|
||||
import type { HiveSetupView, HiveSystemDto } from "./types";
|
||||
|
||||
// What the offline packs say about hives (spec 10). Both fields are optional:
|
||||
// a pack from before the hive card says nothing, and "nothing" is not "no hive".
|
||||
|
||||
function newestFirst(packs: readonly StoredPack[]): StoredPack[] {
|
||||
return [...packs].sort((a, b) => b.savedAt.localeCompare(a.savedAt));
|
||||
}
|
||||
|
||||
/**
|
||||
* The colony's hive per its newest pack. `null`: no pack lists the colony or
|
||||
* the pack predates hives, so it cannot tell. `{ setup: null }`: the pack
|
||||
* knows the colony and the colony has no hive yet.
|
||||
*/
|
||||
export function hiveFromPacks(
|
||||
packs: readonly StoredPack[],
|
||||
colonyId: string,
|
||||
): { setup: HiveSetupView | null } | null {
|
||||
const holding = newestFirst(packs).find((stored) =>
|
||||
stored.pack.colonies.some((colony) => colony.id === colonyId),
|
||||
);
|
||||
if (!holding?.pack.hives) return null;
|
||||
return { setup: holding.pack.hives[colonyId] ?? null };
|
||||
}
|
||||
|
||||
/** The hive systems of the newest pack that carries them; null when none does. */
|
||||
export function hiveCatalogFromPacks(packs: readonly StoredPack[]): HiveSystemDto[] | null {
|
||||
return newestFirst(packs).find((stored) => stored.pack.hiveCatalog)?.pack.hiveCatalog ?? null;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import "fake-indexeddb/auto";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { frames, inlineCatalog, part } from "@/lib/hive/test-utils";
|
||||
import type { HiveOp, HiveSetupView, HiveState } from "@/lib/hive/types";
|
||||
import { deriveHiveFacts } from "./facts";
|
||||
|
||||
type DbModule = typeof import("@/lib/offline/db");
|
||||
type PendingModule = typeof import("./pending");
|
||||
|
||||
const catalog = inlineCatalog();
|
||||
const COLONY = "colony-1";
|
||||
const day = "2026-10-15";
|
||||
|
||||
const state: HiveState = {
|
||||
schemaVersion: 1,
|
||||
stack: [part("f", "floor"), part("b", "box", { frames: frames(10) }), part("r", "roof")],
|
||||
};
|
||||
const setup: HiveSetupView = {
|
||||
colonyId: COLONY,
|
||||
systemId: "sys",
|
||||
version: 1,
|
||||
state,
|
||||
facts: deriveHiveFacts(state, catalog),
|
||||
updatedAt: "2026-10-15T10:00:00.000Z",
|
||||
};
|
||||
const label = (value: string): HiveOp => ({ op: "setLabel", id: "b", label: value });
|
||||
|
||||
let db: DbModule;
|
||||
let pending: PendingModule;
|
||||
|
||||
async function hiveChange(ops: HiveOp[], owner = "user-1", colonyId = COLONY) {
|
||||
await db.queueHiveChange({ colonyId, occurredOn: day, source: "CARD", ops }, `k-${Math.random()}`, owner);
|
||||
}
|
||||
|
||||
const inspection = (colonyId = COLONY, hive?: HiveOp[]) => ({
|
||||
colonyId,
|
||||
date: "2026-10-15T00:00:00.000Z",
|
||||
...(hive ? { hiveChange: { ops: hive } } : {}),
|
||||
});
|
||||
|
||||
/** Entries queued in the same millisecond keep their order only by the clock moving. */
|
||||
async function tick(): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 3));
|
||||
}
|
||||
|
||||
describe("pending hive changes", () => {
|
||||
beforeEach(async () => {
|
||||
globalThis.indexedDB = new IDBFactory();
|
||||
vi.resetModules();
|
||||
db = await import("@/lib/offline/db");
|
||||
pending = await import("./pending");
|
||||
});
|
||||
|
||||
it("merges both queues in queuedAt order", async () => {
|
||||
await hiveChange([label("eins")]);
|
||||
await tick();
|
||||
await db.queueInspection(inspection(COLONY, [label("zwei")]), "k-i", "user-1");
|
||||
await tick();
|
||||
await hiveChange([label("drei")]);
|
||||
|
||||
const ops = await pending.pendingHiveOps(COLONY, "user-1");
|
||||
|
||||
expect(ops).toEqual([label("eins"), label("zwei"), label("drei")]);
|
||||
});
|
||||
|
||||
it("ignores other colonies, other owners, inspections without hive change and rejected entries", async () => {
|
||||
await hiveChange([label("andere Wabe")], "user-1", "colony-2");
|
||||
await hiveChange([label("fremd")], "user-2");
|
||||
await db.queueInspection(inspection(COLONY), "k-plain", "user-1");
|
||||
await hiveChange([label("abgelehnt")]);
|
||||
const rejected = (await db.getPendingEntries("pending-hive-changes")).find(
|
||||
(entry) => JSON.stringify(entry.input.ops).includes("abgelehnt"),
|
||||
);
|
||||
await db.markFailed("pending-hive-changes", rejected?.id ?? -1, { status: 409, message: "x", at: day });
|
||||
|
||||
expect(await pending.pendingHiveOps(COLONY, "user-1")).toEqual([]);
|
||||
expect(await pending.pendingHiveChanges(COLONY, "user-1")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("classifies an entry as either waiting or rejected, never both", async () => {
|
||||
await hiveChange([label("a")]);
|
||||
expect(await pending.hasRejectedHiveChange(COLONY, "user-1")).toBe(false);
|
||||
const [entry] = await db.getPendingEntries("pending-hive-changes");
|
||||
await db.markFailed("pending-hive-changes", entry.id, { status: 409, message: "x", at: day });
|
||||
expect(await pending.hasRejectedHiveChange(COLONY, "user-1")).toBe(true);
|
||||
expect(await pending.pendingHiveChanges(COLONY, "user-1")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("counts the waiting changes, not their ops", async () => {
|
||||
await hiveChange([label("a"), label("b")]);
|
||||
expect(await pending.pendingHiveChanges(COLONY, "user-1")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("applies waiting ops to the loaded setup", () => {
|
||||
const shown = pending.withPendingOps(setup, [label("Brut")], catalog);
|
||||
|
||||
expect(shown.state.stack[1].label).toBe("Brut");
|
||||
expect(shown.version).toBe(1);
|
||||
});
|
||||
|
||||
it("shows the setup unchanged when the ops do not fit it", () => {
|
||||
const bad: HiveOp = { op: "removePart", id: "gone" };
|
||||
expect(pending.withPendingOps(setup, [bad], catalog)).toBe(setup);
|
||||
expect(pending.withPendingOps(setup, [], catalog)).toBe(setup);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { getPendingEntries, hasHiveChange, isForeignEntry } from "@/lib/offline/db";
|
||||
import type { PendingEntry } from "@/lib/offline/db";
|
||||
import { deriveHiveFacts } from "./facts";
|
||||
import { applyHiveOps } from "./ops";
|
||||
import type { HiveCatalog, HiveOp, HiveSetupView } from "./types";
|
||||
|
||||
/** One waiting change of a colony's hive: a card change, or the hive part of a queued inspection. */
|
||||
export interface PendingHiveChange {
|
||||
ops: HiveOp[];
|
||||
queuedAt: string;
|
||||
}
|
||||
|
||||
function opsOfInspection(input: unknown): HiveOp[] {
|
||||
return (input as { hiveChange?: { ops?: HiveOp[] } }).hiveChange?.ops ?? [];
|
||||
}
|
||||
|
||||
function isWaiting(entry: PendingEntry<unknown>, ownerUserId: string): boolean {
|
||||
// A rejected entry is not waiting for network: the queue sheet shows it.
|
||||
return !isForeignEntry(entry, ownerUserId) && !entry.lastError;
|
||||
}
|
||||
|
||||
/** True when a queued hive change of the colony was refused by the server and still waits for the user. */
|
||||
export async function hasRejectedHiveChange(colonyId: string, ownerUserId: string): Promise<boolean> {
|
||||
const [hive, inspections] = await Promise.all([
|
||||
getPendingEntries("pending-hive-changes"),
|
||||
getPendingEntries("pending-inspections"),
|
||||
]);
|
||||
const rejected = (entry: PendingEntry<{ colonyId: string }>) =>
|
||||
!isForeignEntry(entry, ownerUserId) && entry.input.colonyId === colonyId && !!entry.lastError;
|
||||
return hive.some(rejected) || inspections.some((entry) => rejected(entry) && hasHiveChange(entry.input));
|
||||
}
|
||||
|
||||
/**
|
||||
* True while any hive change of the colony is queued for the user, rejected
|
||||
* ones included: a new change must queue behind it, not overtake it.
|
||||
*/
|
||||
export async function hasQueuedHiveChange(colonyId: string, ownerUserId: string): Promise<boolean> {
|
||||
const [hive, inspections] = await Promise.all([
|
||||
getPendingEntries("pending-hive-changes"),
|
||||
getPendingEntries("pending-inspections"),
|
||||
]);
|
||||
const mine = (entry: PendingEntry<{ colonyId: string }>) =>
|
||||
!isForeignEntry(entry, ownerUserId) && entry.input.colonyId === colonyId;
|
||||
return hive.some(mine) || inspections.some((entry) => mine(entry) && hasHiveChange(entry.input));
|
||||
}
|
||||
|
||||
/**
|
||||
* The colony's waiting hive changes of the user, oldest first, from both
|
||||
* sources: the queue `pending-hive-changes` and the `hiveChange` of queued
|
||||
* inspections.
|
||||
*/
|
||||
export async function pendingHiveChanges(
|
||||
colonyId: string,
|
||||
ownerUserId: string,
|
||||
): Promise<PendingHiveChange[]> {
|
||||
const [hive, inspections] = await Promise.all([
|
||||
getPendingEntries("pending-hive-changes"),
|
||||
getPendingEntries("pending-inspections"),
|
||||
]);
|
||||
const fromHive = hive
|
||||
.filter((entry) => isWaiting(entry, ownerUserId) && entry.input.colonyId === colonyId)
|
||||
.map((entry) => ({ ops: entry.input.ops, queuedAt: entry.queuedAt }));
|
||||
const fromInspections = inspections
|
||||
.filter(
|
||||
(entry) =>
|
||||
isWaiting(entry, ownerUserId) &&
|
||||
entry.input.colonyId === colonyId &&
|
||||
hasHiveChange(entry.input),
|
||||
)
|
||||
.map((entry) => ({ ops: opsOfInspection(entry.input), queuedAt: entry.queuedAt }));
|
||||
return [...fromHive, ...fromInspections].sort((a, b) => a.queuedAt.localeCompare(b.queuedAt));
|
||||
}
|
||||
|
||||
/** All ops of the colony's waiting hive changes, in the order they will be sent. */
|
||||
export async function pendingHiveOps(colonyId: string, ownerUserId: string): Promise<HiveOp[]> {
|
||||
return (await pendingHiveChanges(colonyId, ownerUserId)).flatMap((change) => change.ops);
|
||||
}
|
||||
|
||||
/**
|
||||
* The setup with the waiting ops applied, for the card. When they do not fit
|
||||
* the loaded state (it moved on), the setup is shown as it is - the queue
|
||||
* reports the conflict when it is sent.
|
||||
*/
|
||||
export function withPendingOps(
|
||||
setup: HiveSetupView,
|
||||
ops: readonly HiveOp[],
|
||||
catalog: HiveCatalog,
|
||||
): HiveSetupView {
|
||||
if (ops.length === 0) return setup;
|
||||
const applied = applyHiveOps(setup.state, [...ops], catalog);
|
||||
if (!applied.ok) return setup;
|
||||
return { ...setup, state: applied.state, facts: deriveHiveFacts(applied.state, catalog) };
|
||||
}
|
||||
@@ -66,6 +66,14 @@ export interface HivePart {
|
||||
slot?: number;
|
||||
}
|
||||
|
||||
/** A stored template part: no ids (the server stamps fresh ones when a hive starts from it). */
|
||||
export type HiveTemplatePart = Omit<HivePart, "id" | "inserts"> & { inserts?: HiveTemplatePart[] };
|
||||
|
||||
export interface HiveTemplate {
|
||||
schemaVersion: 1;
|
||||
stack: HiveTemplatePart[];
|
||||
}
|
||||
|
||||
export interface HiveState {
|
||||
schemaVersion: 1;
|
||||
stack: HivePart[];
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ColonyCondition } from "@/lib/colonies/types";
|
||||
import type { HiveOp } from "@/lib/hive/types";
|
||||
|
||||
export interface Inspection {
|
||||
id: string;
|
||||
@@ -79,6 +80,12 @@ export interface CreateInspectionInput {
|
||||
* server, which answers 201 either way.
|
||||
*/
|
||||
applyToColony?: ColonyTakeoverField[];
|
||||
/**
|
||||
* Hive changes made in the „Beute“ section (1-100 ops), saved with the
|
||||
* inspection in one transaction. Creating only; needs colonies:update
|
||||
* (403 HIVE_FORBIDDEN otherwise). Sent only when there are ops.
|
||||
*/
|
||||
hiveChange?: { ops: HiveOp[] };
|
||||
}
|
||||
|
||||
/** `null` clears an optional field, an omitted one stays unchanged. */
|
||||
|
||||
@@ -95,7 +95,7 @@ describe("offline queue database", () => {
|
||||
it.each([
|
||||
["a fresh install (0)", 0],
|
||||
["version 1", 1],
|
||||
])("opening from %s creates the plant report store at version 4", async (_name, from) => {
|
||||
])("opening from %s creates the plant report store at version 5", async (_name, from) => {
|
||||
if (from === 1) {
|
||||
const old = await openDB(DB_NAME, 1, {
|
||||
upgrade(database) {
|
||||
@@ -120,7 +120,7 @@ describe("offline queue database", () => {
|
||||
);
|
||||
|
||||
const upgraded = await openDB(DB_NAME);
|
||||
expect(upgraded.version).toBe(4);
|
||||
expect(upgraded.version).toBe(5);
|
||||
expect([...upgraded.objectStoreNames]).toContain("pending-phenology-reports");
|
||||
expect(await upgraded.count("pending-phenology-reports")).toBe(1);
|
||||
expect(await upgraded.count("pending-inspections")).toBe(from === 1 ? 1 : 0);
|
||||
@@ -166,11 +166,11 @@ describe("offline queue database", () => {
|
||||
expect((await db.listQueue("user-1")).map((item) => item.label)).toContain("Pflanzenmeldung");
|
||||
|
||||
const upgraded = await openDB(DB_NAME);
|
||||
expect(upgraded.version).toBe(4);
|
||||
expect(upgraded.version).toBe(5);
|
||||
upgraded.close();
|
||||
});
|
||||
|
||||
it("upgrading v2 → v4 keeps already queued inspections", async () => {
|
||||
it("upgrading v2 → v5 keeps already queued inspections", async () => {
|
||||
await createVersion2Queue({
|
||||
input: INSPECTION,
|
||||
idempotencyKey: "key-from-v2",
|
||||
@@ -192,7 +192,7 @@ describe("offline queue database", () => {
|
||||
expect(await db.summarizeQueue("user-1")).toEqual({ own: 1, failed: 0, foreign: 0 });
|
||||
|
||||
const upgraded = await openDB(DB_NAME);
|
||||
expect(upgraded.version).toBe(4);
|
||||
expect(upgraded.version).toBe(5);
|
||||
expect([...upgraded.objectStoreNames]).toEqual(
|
||||
expect.arrayContaining([
|
||||
...VERSION_2_STORES,
|
||||
@@ -201,6 +201,7 @@ describe("offline queue database", () => {
|
||||
"offline-packs",
|
||||
"meta",
|
||||
"pending-phenology-reports",
|
||||
"pending-hive-changes",
|
||||
]),
|
||||
);
|
||||
expect([...upgraded.transaction("pending-photos").store.indexNames].sort()).toEqual([
|
||||
@@ -211,6 +212,92 @@ describe("offline queue database", () => {
|
||||
upgraded.close();
|
||||
});
|
||||
|
||||
it("db upgrade 4 -> 5 keeps every store and entry and adds the hive change queue", async () => {
|
||||
const old = await openDB(DB_NAME, 4, {
|
||||
upgrade(database) {
|
||||
for (const store of [...VERSION_2_STORES, "pending-bulk", "pending-phenology-reports"]) {
|
||||
database.createObjectStore(store, { keyPath: "id", autoIncrement: true });
|
||||
}
|
||||
database.createObjectStore("pending-photos", { keyPath: "id" });
|
||||
database.createObjectStore("offline-packs", { keyPath: "apiaryId" });
|
||||
database.createObjectStore("meta");
|
||||
},
|
||||
});
|
||||
await old.add("pending-inspections", { input: INSPECTION, queuedAt: "2026-10-01T08:00:00.000Z" });
|
||||
await old.add("pending-phenology-reports", { input: {}, queuedAt: "2026-10-01T09:00:00.000Z" });
|
||||
old.close();
|
||||
|
||||
const db = await loadDb();
|
||||
await db.queueHiveChange(
|
||||
{ colonyId: "colony-1", occurredOn: "2026-10-15", source: "CARD", ops: [] },
|
||||
"key-h",
|
||||
"user-1",
|
||||
);
|
||||
const [hive] = await db.getPendingEntries("pending-hive-changes");
|
||||
expect(hive).toMatchObject({ idempotencyKey: "key-h", ownerUserId: "user-1" });
|
||||
|
||||
const upgraded = await openDB(DB_NAME);
|
||||
expect(upgraded.version).toBe(5);
|
||||
expect(await upgraded.count("pending-inspections")).toBe(1);
|
||||
expect(await upgraded.count("pending-phenology-reports")).toBe(1);
|
||||
expect([...upgraded.objectStoreNames]).toEqual(
|
||||
expect.arrayContaining(["pending-bulk", "pending-photos", "offline-packs", "meta", "pending-hive-changes"]),
|
||||
);
|
||||
upgraded.close();
|
||||
});
|
||||
|
||||
it("lists a hive change under its own name and marks inspections that carry one", async () => {
|
||||
const db = await loadDb();
|
||||
await db.queueHiveChange(
|
||||
{ colonyId: "colony-1", occurredOn: "2026-10-15", source: "CARD", ops: [] },
|
||||
"key-h",
|
||||
"user-1",
|
||||
);
|
||||
await db.queueInspection({ ...INSPECTION, hiveChange: { ops: [] } }, "key-i", "user-1");
|
||||
await db.queueInspection(INSPECTION, "key-j", "user-1");
|
||||
|
||||
const items = await db.listQueue("user-1");
|
||||
|
||||
expect(items.map((item) => [item.label, item.hasHiveChange ?? false]).sort()).toEqual([
|
||||
["Beute-Änderung", true],
|
||||
["Durchsicht", false],
|
||||
["Durchsicht", true],
|
||||
]);
|
||||
});
|
||||
|
||||
it("retryInspectionWithoutHiveChange strips the hive change, clears the rejection and keeps the key", async () => {
|
||||
const db = await loadDb();
|
||||
await db.queueInspection({ ...INSPECTION, hiveChange: { ops: [] } }, "key-i", "user-1");
|
||||
const [entry] = await db.getPendingEntries("pending-inspections");
|
||||
await db.markFailed("pending-inspections", entry.id, { ...REJECTION, status: 403 });
|
||||
expect((await db.listQueue("user-1"))[0].canResendWithoutHiveChange).toBe(true);
|
||||
|
||||
await db.retryInspectionWithoutHiveChange(entry.id);
|
||||
|
||||
const [after] = await db.getPendingEntries("pending-inspections");
|
||||
expect(after.input).toEqual(INSPECTION);
|
||||
expect(after.lastError).toBeUndefined();
|
||||
expect(after.idempotencyKey).toBe("key-i");
|
||||
});
|
||||
|
||||
it("offers „ohne Beute-Änderung“ only for a 403 or a hive conflict, not for any other rejection", async () => {
|
||||
const db = await loadDb();
|
||||
await db.queueInspection({ ...INSPECTION, hiveChange: { ops: [] } }, "k1", "user-1");
|
||||
await db.queueInspection({ ...INSPECTION, hiveChange: { ops: [] } }, "k2", "user-1");
|
||||
const [plain, conflict] = await db.getPendingEntries("pending-inspections");
|
||||
await db.markFailed("pending-inspections", plain.id, REJECTION);
|
||||
await db.markFailed("pending-inspections", conflict.id, {
|
||||
status: 409,
|
||||
message: "Konflikt",
|
||||
details: { code: "HIVE_CONFLICT" },
|
||||
at: REJECTION.at,
|
||||
});
|
||||
|
||||
const items = await db.listQueue("user-1");
|
||||
|
||||
expect(items.map((item) => item.canResendWithoutHiveChange ?? false)).toEqual([false, true]);
|
||||
});
|
||||
|
||||
it("summarizeQueue separates own, failed and foreign entries", async () => {
|
||||
const db = await loadDb();
|
||||
await db.queueInspection(INSPECTION, "key-1", "user-1");
|
||||
|
||||
@@ -8,12 +8,21 @@ import { CreateVarroaMeasurementInput } from "@/lib/varroa/types";
|
||||
import { CreateSwarmControlCheckInput } from "@/lib/swarm-control/types";
|
||||
import { CreateHarvestInput } from "@/lib/harvests/types";
|
||||
import type { CreatePhenologyReportInput } from "@/lib/phenology/types";
|
||||
import type { HiveOp } from "@/lib/hive/types";
|
||||
import {
|
||||
colonyIdsOfBulkInput,
|
||||
invalidColonyIdsOfBody,
|
||||
withoutColonies,
|
||||
} from "@/lib/bulk/bulk-input";
|
||||
|
||||
/** A change of a colony's hive made on the card, waiting for network. */
|
||||
export interface HiveChangeQueueInput {
|
||||
colonyId: string;
|
||||
occurredOn: string;
|
||||
source: "CARD";
|
||||
ops: HiveOp[];
|
||||
}
|
||||
|
||||
/** What each single-record queue (one per quick-capture form) holds. */
|
||||
export interface RecordQueueInputs {
|
||||
"pending-inspections": CreateInspectionInput;
|
||||
@@ -23,6 +32,7 @@ export interface RecordQueueInputs {
|
||||
"pending-swarm-control-checks": CreateSwarmControlCheckInput;
|
||||
"pending-harvests": CreateHarvestInput;
|
||||
"pending-phenology-reports": CreatePhenologyReportInput;
|
||||
"pending-hive-changes": HiveChangeQueueInput;
|
||||
}
|
||||
export type RecordStoreName = keyof RecordQueueInputs;
|
||||
/** Every store whose entries wait to be sent and are shown in the queue sheet. */
|
||||
@@ -110,11 +120,11 @@ export interface OfflineDB extends DBSchema, RecordStores {
|
||||
const DB_NAME = "apicore-offline";
|
||||
// v1 only had "pending-inspections", v2 added the other five quick-capture
|
||||
// types, v3 adds bulk entries, photos, offline packs and meta, v4 adds plant
|
||||
// reports (Pflanzenentwicklung). Entries already
|
||||
// reports (Pflanzenentwicklung), v5 adds hive changes (Beute). Entries already
|
||||
// queued on a phone must survive every upgrade, so each store is only created
|
||||
// if it doesn't exist yet (guarded by oldVersion, per idb's upgrade()
|
||||
// convention), never recreated.
|
||||
const DB_VERSION = 4;
|
||||
const DB_VERSION = 5;
|
||||
|
||||
const RECORD_STORES: readonly RecordStoreName[] = [
|
||||
"pending-inspections",
|
||||
@@ -124,6 +134,7 @@ const RECORD_STORES: readonly RecordStoreName[] = [
|
||||
"pending-swarm-control-checks",
|
||||
"pending-harvests",
|
||||
"pending-phenology-reports",
|
||||
"pending-hive-changes",
|
||||
];
|
||||
const QUEUE_STORES: readonly QueueStoreName[] = [...RECORD_STORES, "pending-bulk"];
|
||||
|
||||
@@ -135,6 +146,7 @@ const RECORD_LABELS: Record<RecordStoreName, string> = {
|
||||
"pending-swarm-control-checks": "Schwarmkontrolle",
|
||||
"pending-harvests": "Ernte",
|
||||
"pending-phenology-reports": "Pflanzenmeldung",
|
||||
"pending-hive-changes": "Beute-Änderung",
|
||||
};
|
||||
const BULK_LABELS: Record<BulkKind, string> = {
|
||||
treatments: "Behandlung für mehrere Völker",
|
||||
@@ -183,6 +195,9 @@ export function getDb(): Promise<IDBPDatabase<OfflineDB>> {
|
||||
autoIncrement: true,
|
||||
});
|
||||
}
|
||||
if (oldVersion < 5) {
|
||||
db.createObjectStore("pending-hive-changes", { keyPath: "id", autoIncrement: true });
|
||||
}
|
||||
},
|
||||
// A newer app version in another tab wants to upgrade: step aside
|
||||
// instead of blocking it. The next call here opens the new version.
|
||||
@@ -282,6 +297,14 @@ export function queuePhenologyReport(
|
||||
return queue("pending-phenology-reports", input, idempotencyKey, ownerUserId);
|
||||
}
|
||||
|
||||
export function queueHiveChange(
|
||||
input: HiveChangeQueueInput,
|
||||
idempotencyKey: string,
|
||||
ownerUserId: string,
|
||||
): Promise<void> {
|
||||
return queue("pending-hive-changes", input, idempotencyKey, ownerUserId);
|
||||
}
|
||||
|
||||
/**
|
||||
* One entry for several colonies. The key belongs to this exact selection of
|
||||
* colonies: it is stored with the entry and sent again unchanged.
|
||||
@@ -445,6 +468,37 @@ export async function summarizeQueue(currentUserId: string): Promise<QueueSummar
|
||||
);
|
||||
}
|
||||
|
||||
/** The server refused the hive part: 403, or a conflict with the hive's current state. */
|
||||
function isHiveRefusal(error: QueueError | undefined): boolean {
|
||||
if (!error) return false;
|
||||
const code = (error.details as { code?: unknown } | null | undefined)?.code;
|
||||
return error.status === 403 || code === "HIVE_CONFLICT";
|
||||
}
|
||||
|
||||
/** A queued inspection that also carries a change of the hive. */
|
||||
export function hasHiveChange(input: unknown): boolean {
|
||||
return (input as { hiveChange?: unknown } | null)?.hiveChange !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ohne Beute-Änderung senden: takes the hive change out of a queued
|
||||
* inspection and clears its rejection, so the next sync run sends the rest.
|
||||
* The key stays: the server stored nothing for the rejected try.
|
||||
*/
|
||||
export async function retryInspectionWithoutHiveChange(entryId: number): Promise<void> {
|
||||
const db = await getDb();
|
||||
const tx = db.transaction("pending-inspections", "readwrite");
|
||||
const entry = await tx.store.get(entryId);
|
||||
if (entry && hasHiveChange(entry.input)) {
|
||||
const input = { ...entry.input };
|
||||
delete input.hiveChange;
|
||||
const cleared: PendingEntry<CreateInspectionInput> = { ...entry, input };
|
||||
delete cleared.lastError;
|
||||
await tx.store.put(cleared);
|
||||
}
|
||||
await tx.done;
|
||||
}
|
||||
|
||||
export interface QueueItem {
|
||||
store: string;
|
||||
id: number | string;
|
||||
@@ -452,6 +506,10 @@ export interface QueueItem {
|
||||
queuedAt: string;
|
||||
lastError?: QueueError;
|
||||
photoCount?: number;
|
||||
/** The entry changes a colony's hive (a hive entry, or an inspection with a hive change). */
|
||||
hasHiveChange?: boolean;
|
||||
/** A rejected inspection whose hive change can be left out and the rest sent. */
|
||||
canResendWithoutHiveChange?: boolean;
|
||||
/**
|
||||
* A rejected bulk entry that can go out without the colonies the server
|
||||
* refused (`codes`: the ones named in the loaded packs, maybe fewer).
|
||||
@@ -501,19 +559,31 @@ export async function listQueue(currentUserId: string): Promise<QueueItem[]> {
|
||||
queuedAt: entry.queuedAt,
|
||||
...(entry.lastError ? { lastError: entry.lastError } : {}),
|
||||
};
|
||||
if (store === "pending-hive-changes") {
|
||||
return { ...item, hasHiveChange: true };
|
||||
}
|
||||
if (store === "pending-bulk") {
|
||||
const resendWithout = await resendWithoutOf(db, entry as PendingBulkEntry);
|
||||
return resendWithout ? { ...item, resendWithout } : item;
|
||||
}
|
||||
if (store !== "pending-inspections" || !entry.idempotencyKey) {
|
||||
if (store !== "pending-inspections") {
|
||||
return item;
|
||||
}
|
||||
const hive = hasHiveChange((entry as PendingEntry<unknown>).input)
|
||||
? {
|
||||
hasHiveChange: true,
|
||||
...(isHiveRefusal(entry.lastError) ? { canResendWithoutHiveChange: true } : {}),
|
||||
}
|
||||
: {};
|
||||
if (!entry.idempotencyKey) {
|
||||
return { ...item, ...hive };
|
||||
}
|
||||
const photoCount = await db.countFromIndex(
|
||||
"pending-photos",
|
||||
"inspectionIdempotencyKey",
|
||||
entry.idempotencyKey,
|
||||
);
|
||||
return photoCount > 0 ? { ...item, photoCount } : item;
|
||||
return photoCount > 0 ? { ...item, ...hive, photoCount } : { ...item, ...hive };
|
||||
}),
|
||||
);
|
||||
return items.sort((a, b) => a.queuedAt.localeCompare(b.queuedAt));
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
foreignEntriesWaiting,
|
||||
photosAttached,
|
||||
photosNotTakenOver,
|
||||
queueRejectionText,
|
||||
rejectionText,
|
||||
sessionExpiredWithEntries,
|
||||
} from "./queue-texts";
|
||||
@@ -56,4 +57,32 @@ describe("rejectionText", () => {
|
||||
it("passes other server reasons through", () => {
|
||||
expect(rejectionText("Datei zu groß")).toBe("Datei zu groß");
|
||||
});
|
||||
|
||||
describe("queueRejectionText", () => {
|
||||
const conflict = {
|
||||
status: 409,
|
||||
message: "Conflict",
|
||||
details: { code: "HIVE_CONFLICT", opIndex: 0, reason: "MISSING_ID: no part" },
|
||||
};
|
||||
|
||||
it("words a 403 on a hive change", () => {
|
||||
expect(queueRejectionText({ status: 403, message: "Forbidden" }, true)).toBe(
|
||||
"Keine Berechtigung, die Beute zu ändern.",
|
||||
);
|
||||
});
|
||||
|
||||
it("words a HIVE_CONFLICT with the reason", () => {
|
||||
expect(queueRejectionText(conflict, true)).toBe(
|
||||
"Die Beute wurde inzwischen geändert. Ein Teil, auf das sich die Änderung bezieht, gibt es nicht mehr.",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves every other rejection as it was", () => {
|
||||
expect(queueRejectionText({ status: 403, message: "Forbidden" }, false)).toBe("Forbidden");
|
||||
expect(queueRejectionText(conflict, false)).toBe("Conflict");
|
||||
expect(
|
||||
queueRejectionText({ status: 409, message: "Idempotency-Key reused", details: { code: "X" } }, true),
|
||||
).toBe("Idempotency-Key reused");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { hiveReasonText } from "@/lib/hive/reason-text";
|
||||
|
||||
// German counts for the offline queue: "1 Eintrag wartet", "2 Einträge warten".
|
||||
|
||||
function count(n: number, one: string, many: string): string {
|
||||
@@ -42,6 +44,38 @@ export function rejectionText(message: string): string {
|
||||
return message;
|
||||
}
|
||||
|
||||
/** What the queue sheet needs of a rejection to word it. */
|
||||
interface RejectionInfo {
|
||||
status: number;
|
||||
message: string;
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
export const HIVE_FORBIDDEN_TEXT = "Keine Berechtigung, die Beute zu ändern.";
|
||||
|
||||
/** The 409 HIVE_CONFLICT body of a rejection, if it is one. */
|
||||
function hiveConflictReason(details: unknown): string | null {
|
||||
const body = details as { code?: unknown; reason?: unknown } | null;
|
||||
if (body?.code !== "HIVE_CONFLICT") return null;
|
||||
return typeof body.reason === "string" ? body.reason : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* The reason for a rejected queue entry. Entries that change a hive get their
|
||||
* own words for 403 and for a conflict with the hive's current state; every
|
||||
* other rejection is the server's message.
|
||||
*/
|
||||
export function queueRejectionText(error: RejectionInfo, changesHive: boolean): string {
|
||||
if (changesHive && error.status === 403) {
|
||||
return HIVE_FORBIDDEN_TEXT;
|
||||
}
|
||||
const reason = changesHive && error.status === 409 ? hiveConflictReason(error.details) : null;
|
||||
if (reason !== null) {
|
||||
return `Die Beute wurde inzwischen geändert. ${reason ? hiveReasonText(reason) : ""}`.trim();
|
||||
}
|
||||
return rejectionText(error.message);
|
||||
}
|
||||
|
||||
/** Sign-out dialog: "3 Einträge sind noch nicht gesendet." */
|
||||
export function entriesNotSent(n: number): string {
|
||||
return n === 1 ? "1 Eintrag ist noch nicht gesendet." : `${n} Einträge sind noch nicht gesendet.`;
|
||||
|
||||
@@ -189,6 +189,19 @@ describe("service worker routes", () => {
|
||||
expect(cachesApi("/api/v1/tasks")).toBe(true);
|
||||
});
|
||||
|
||||
it("never caches a colony's hive setup, so the pack answers offline - but not its other hive paths", () => {
|
||||
for (const path of [
|
||||
"/api/v1/colonies/c-1/hive",
|
||||
"/api/v1/colonies/c-1/hive/",
|
||||
"/api/v1/Colonies/c-1/Hive",
|
||||
]) {
|
||||
expect(cachesApi(path)).toBe(false);
|
||||
}
|
||||
expect(cachesApi("/api/v1/colonies/c-1/hive/changes")).toBe(true);
|
||||
expect(cachesApi("/api/v1/colonies/c-1/hive/at")).toBe(true);
|
||||
expect(cachesApi("/api/v1/hive-systems")).toBe(true);
|
||||
});
|
||||
|
||||
it("never caches apiaries themselves - they carry coordinates and the address", () => {
|
||||
for (const path of ["/api/v1/apiaries", "/api/v1/apiaries/", "/api/v1/apiaries/a-1", "/api/v1/apiaries/a-1/"]) {
|
||||
expect(cachesApi(path)).toBe(false);
|
||||
|
||||
@@ -8,6 +8,7 @@ vi.mock("@/lib/inspections/api", () => ({ createInspection: vi.fn() }));
|
||||
vi.mock("@/lib/treatments/api", () => ({ createTreatment: vi.fn() }));
|
||||
vi.mock("@/lib/documents/api", () => ({ uploadDocument: vi.fn() }));
|
||||
vi.mock("@/lib/phenology/api", () => ({ createPhenologyReport: vi.fn() }));
|
||||
vi.mock("@/lib/hive/api", () => ({ postHiveChange: vi.fn() }));
|
||||
vi.mock("@/lib/apiaries/offline-pack", () => ({ refreshPacksAfterSync: vi.fn() }));
|
||||
|
||||
// What the inspection drain hands over to the photos, seen at that moment.
|
||||
@@ -668,3 +669,222 @@ describe("syncPendingEntries", () => {
|
||||
expect(photo).toMatchObject({ state: "ready", inspectionId: "srv-1" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("drainHiveTimeline", () => {
|
||||
let postHiveChange: MockedFunction<typeof import("@/lib/hive/api").postHiveChange>;
|
||||
const op = (value: string) => ({ op: "setLabel" as const, id: "b", label: value });
|
||||
const hiveEntry = (colonyId: string, value: string) => ({
|
||||
colonyId,
|
||||
occurredOn: "2026-10-15",
|
||||
source: "CARD" as const,
|
||||
ops: [op(value)],
|
||||
});
|
||||
const withHive = (colony: number, value: string) => ({
|
||||
...inspection(colony),
|
||||
hiveChange: { ops: [op(value)] },
|
||||
});
|
||||
/** Entries queued in the same millisecond would tie on queuedAt. */
|
||||
const tick = () => new Promise((resolve) => setTimeout(resolve, 3));
|
||||
const labelsSent = () =>
|
||||
postHiveChange.mock.calls.map(([, input]) => (input.ops[0] as { label: string }).label);
|
||||
|
||||
beforeEach(async () => {
|
||||
globalThis.indexedDB = new IDBFactory();
|
||||
window.localStorage.clear();
|
||||
vi.resetModules();
|
||||
db = await import("./db");
|
||||
sync = await import("./sync");
|
||||
ApiError = (await import("@/lib/auth/api-client")).ApiError;
|
||||
createInspection = vi.mocked((await import("@/lib/inspections/api")).createInspection);
|
||||
postHiveChange = vi.mocked((await import("@/lib/hive/api")).postHiveChange);
|
||||
// The describe above leaves a mock that resolves uploads: start from scratch.
|
||||
uploadDocument = vi.mocked((await import("@/lib/documents/api")).uploadDocument);
|
||||
uploadDocument.mockReset();
|
||||
// The photo store mock wraps the first module instance, which keeps its
|
||||
// own connection: photos of earlier tests are still there and would be uploaded.
|
||||
const photoStore = await import("@/lib/photos/store");
|
||||
for (const photo of await photoStore.listPendingPhotos("user-1")) {
|
||||
await photoStore.discardReadyPhoto(photo.id);
|
||||
}
|
||||
refreshPacksAfterSync = vi.mocked(
|
||||
(await import("@/lib/apiaries/offline-pack")).refreshPacksAfterSync,
|
||||
);
|
||||
createInspection.mockReset().mockResolvedValue({ id: "i" } as never);
|
||||
postHiveChange.mockReset().mockResolvedValue({} as never);
|
||||
refreshPacksAfterSync.mockReset().mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("sends hive entries and inspections with a hive change in queuedAt order across both queues", async () => {
|
||||
const order: string[] = [];
|
||||
postHiveChange.mockImplementation(async (_c, input) => {
|
||||
order.push(`hive:${(input.ops[0] as { label: string }).label}`);
|
||||
return {} as never;
|
||||
});
|
||||
createInspection.mockImplementation(async (input) => {
|
||||
order.push(`inspection:${(input.hiveChange?.ops[0] as { label: string }).label}`);
|
||||
return { id: "i" } as never;
|
||||
});
|
||||
await db.queueHiveChange(hiveEntry("colony-1", "a"), "k1", "user-1");
|
||||
await tick();
|
||||
await db.queueInspection(withHive(1, "b") as never, "k2", "user-1");
|
||||
await tick();
|
||||
await db.queueHiveChange(hiveEntry("colony-1", "c"), "k3", "user-1");
|
||||
|
||||
const result = await sync.syncPendingEntries("user-1");
|
||||
|
||||
expect(order).toEqual(["hive:a", "inspection:b", "hive:c"]);
|
||||
expect(result).toEqual({ synced: 3, failed: 0, skippedForeign: 0 });
|
||||
expect(await db.getPendingEntries("pending-hive-changes")).toEqual([]);
|
||||
expect(await db.getPendingEntries("pending-inspections")).toEqual([]);
|
||||
});
|
||||
|
||||
it("sends each entry with its own idempotency key", async () => {
|
||||
await db.queueHiveChange(hiveEntry("colony-1", "a"), "key-hive", "user-1");
|
||||
|
||||
await sync.syncPendingEntries("user-1");
|
||||
|
||||
expect(postHiveChange).toHaveBeenCalledWith(
|
||||
"colony-1",
|
||||
{ occurredOn: "2026-10-15", source: "CARD", ops: [op("a")] },
|
||||
"key-hive",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves inspections with a hive change to the timeline and sends plain ones in the normal drain", async () => {
|
||||
await db.queueInspection(inspection(1), "plain", "user-1");
|
||||
await db.queueInspection(withHive(2, "x") as never, "with-hive", "user-1");
|
||||
|
||||
await sync.syncPendingEntries("user-1");
|
||||
|
||||
expect(createInspection).toHaveBeenCalledTimes(2);
|
||||
expect(createInspection.mock.calls.map(([, options]) => options?.idempotencyKey).sort()).toEqual([
|
||||
"plain",
|
||||
"with-hive",
|
||||
]);
|
||||
});
|
||||
|
||||
it("holds later hive entries of the colony after a conflict, but not other colonies", async () => {
|
||||
postHiveChange.mockImplementation(async (colonyId, input) => {
|
||||
if (colonyId === "colony-1" && (input.ops[0] as { label: string }).label === "a") {
|
||||
throw new ApiError(409, "Konflikt", { code: "HIVE_CONFLICT", opIndex: 0, reason: "MISSING_ID: x" });
|
||||
}
|
||||
return {} as never;
|
||||
});
|
||||
await db.queueHiveChange(hiveEntry("colony-1", "a"), "k1", "user-1");
|
||||
await tick();
|
||||
await db.queueInspection(withHive(1, "b") as never, "k2", "user-1");
|
||||
await tick();
|
||||
await db.queueHiveChange(hiveEntry("colony-2", "c"), "k3", "user-1");
|
||||
|
||||
const result = await sync.syncPendingEntries("user-1");
|
||||
|
||||
expect(labelsSent()).toEqual(["a", "c"]);
|
||||
expect(createInspection).not.toHaveBeenCalled();
|
||||
expect(result.failed).toBe(1);
|
||||
const [held] = await db.getPendingEntries("pending-inspections");
|
||||
expect(held.lastError).toBeUndefined();
|
||||
const [rejected] = await db.getPendingEntries("pending-hive-changes");
|
||||
expect(rejected.lastError).toMatchObject({ status: 409 });
|
||||
});
|
||||
|
||||
it("keeps only the small part of a conflict body in the stored rejection", async () => {
|
||||
postHiveChange.mockRejectedValue(
|
||||
new ApiError(409, "Konflikt", {
|
||||
code: "HIVE_CONFLICT",
|
||||
opIndex: 2,
|
||||
reason: "MISSING_ID: x",
|
||||
current: { version: 7, state: { schemaVersion: 1, stack: [{ id: "big" }] } },
|
||||
}),
|
||||
);
|
||||
await db.queueHiveChange(hiveEntry("colony-1", "a"), "k1", "user-1");
|
||||
|
||||
await sync.syncPendingEntries("user-1");
|
||||
|
||||
const [entry] = await db.getPendingEntries("pending-hive-changes");
|
||||
expect(entry.lastError?.details).toEqual({
|
||||
code: "HIVE_CONFLICT",
|
||||
opIndex: 2,
|
||||
reason: "MISSING_ID: x",
|
||||
current: { version: 7 },
|
||||
});
|
||||
});
|
||||
|
||||
it("holds the colony after a 403 and marks the inspection with the hive change", async () => {
|
||||
createInspection.mockRejectedValue(new ApiError(403, "Forbidden"));
|
||||
await db.queueInspection(withHive(1, "a") as never, "k1", "user-1");
|
||||
await tick();
|
||||
await db.queueHiveChange(hiveEntry("colony-1", "b"), "k2", "user-1");
|
||||
|
||||
await sync.syncPendingEntries("user-1");
|
||||
|
||||
expect(postHiveChange).not.toHaveBeenCalled();
|
||||
const [entry] = await db.getPendingEntries("pending-inspections");
|
||||
expect(entry.lastError).toMatchObject({ status: 403 });
|
||||
});
|
||||
|
||||
it("stops at a network error and sends the rest in the next run, in order", async () => {
|
||||
postHiveChange.mockRejectedValueOnce(new TypeError("Failed to fetch"));
|
||||
await db.queueHiveChange(hiveEntry("colony-1", "a"), "k1", "user-1");
|
||||
await tick();
|
||||
await db.queueHiveChange(hiveEntry("colony-2", "b"), "k2", "user-1");
|
||||
|
||||
await sync.syncPendingEntries("user-1");
|
||||
expect(labelsSent()).toEqual(["a"]);
|
||||
expect((await db.getPendingEntries("pending-hive-changes")).map((e) => e.lastError)).toEqual([
|
||||
undefined,
|
||||
undefined,
|
||||
]);
|
||||
|
||||
await sync.syncPendingEntries("user-1");
|
||||
expect(labelsSent()).toEqual(["a", "a", "b"]);
|
||||
});
|
||||
|
||||
it("stops at a 5xx instead of marking the entry", async () => {
|
||||
postHiveChange.mockRejectedValueOnce(new ApiError(500, "Boom"));
|
||||
await db.queueHiveChange(hiveEntry("colony-1", "a"), "k1", "user-1");
|
||||
await db.queueHiveChange(hiveEntry("colony-2", "b"), "k2", "user-1");
|
||||
|
||||
await sync.syncPendingEntries("user-1");
|
||||
|
||||
expect(labelsSent()).toEqual(["a"]);
|
||||
const entries = await db.getPendingEntries("pending-hive-changes");
|
||||
expect(entries.every((entry) => entry.lastError === undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats a key still in progress (409) as retry later and holds the colony", async () => {
|
||||
postHiveChange.mockRejectedValueOnce(
|
||||
new ApiError(409, "Diese Anfrage wird bereits verarbeitet - bitte kurz warten"),
|
||||
);
|
||||
await db.queueHiveChange(hiveEntry("colony-1", "a"), "k1", "user-1");
|
||||
await db.queueHiveChange(hiveEntry("colony-1", "b"), "k2", "user-1");
|
||||
|
||||
await sync.syncPendingEntries("user-1");
|
||||
|
||||
expect(labelsSent()).toEqual(["a"]);
|
||||
expect((await db.getPendingEntries("pending-hive-changes"))[0].lastError).toBeUndefined();
|
||||
});
|
||||
|
||||
it("never sends another account's hive entries", async () => {
|
||||
await db.queueHiveChange(hiveEntry("colony-1", "a"), "k1", "user-2");
|
||||
|
||||
const result = await sync.syncPendingEntries("user-1");
|
||||
|
||||
expect(postHiveChange).not.toHaveBeenCalled();
|
||||
expect(result.skippedForeign).toBe(1);
|
||||
});
|
||||
|
||||
it("sends the inspection without its hive change after strip-and-resend, with the same key", async () => {
|
||||
createInspection.mockRejectedValueOnce(new ApiError(403, "Forbidden"));
|
||||
await db.queueInspection(withHive(1, "a") as never, "same-key", "user-1");
|
||||
await sync.syncPendingEntries("user-1");
|
||||
const [rejected] = await db.getPendingEntries("pending-inspections");
|
||||
|
||||
await db.retryInspectionWithoutHiveChange(rejected.id);
|
||||
await sync.syncPendingEntries("user-1");
|
||||
|
||||
const [input, options] = createInspection.mock.calls[1];
|
||||
expect(input).not.toHaveProperty("hiveChange");
|
||||
expect(options?.idempotencyKey).toBe("same-key");
|
||||
expect(await db.getPendingEntries("pending-inspections")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { createVarroaMeasurement } from "@/lib/varroa/api";
|
||||
import { createSwarmControlCheck } from "@/lib/swarm-control/api";
|
||||
import { createHarvest } from "@/lib/harvests/api";
|
||||
import { createPhenologyReport } from "@/lib/phenology/api";
|
||||
import { postHiveChange } from "@/lib/hive/api";
|
||||
import { uploadDocument } from "@/lib/documents/api";
|
||||
import {
|
||||
createFeedingsBulk,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
BulkKind,
|
||||
PendingBulkEntry,
|
||||
PendingEntry,
|
||||
QueueError,
|
||||
QueueStoreName,
|
||||
RecordQueueInputs,
|
||||
RecordStoreName,
|
||||
@@ -29,6 +31,7 @@ import {
|
||||
getBulkEntry,
|
||||
getPendingEntries,
|
||||
getPendingEntry,
|
||||
hasHiveChange,
|
||||
isForeignEntry,
|
||||
markFailed,
|
||||
removeQueueEntry,
|
||||
@@ -57,6 +60,9 @@ const UNAUTHORIZED = 401;
|
||||
/** Answers that say „not now“, not „this entry is wrong“. */
|
||||
const TRANSIENT_STATUSES: readonly number[] = [408, 429, 502, 503, 504];
|
||||
|
||||
/** From this status on the server failed, not the entry. */
|
||||
const SERVER_ERROR_FROM = 500;
|
||||
|
||||
const NOTHING_SYNCED: SyncResult = { synced: 0, failed: 0, skippedForeign: 0 };
|
||||
|
||||
/** One queue's drain: its counts and the colonies the server rejected entries for. */
|
||||
@@ -182,10 +188,12 @@ async function drain<Store extends RecordStoreName, Result>(
|
||||
create: (input: RecordQueueInputs[Store], options: CreateOptions) => Promise<Result>,
|
||||
run: SyncRun,
|
||||
onCreated?: (entry: PendingEntry<RecordQueueInputs[Store]>, result: Result) => Promise<void>,
|
||||
/** Entries this drain leaves to another one (the hive timeline). */
|
||||
skip: (entry: PendingEntry<RecordQueueInputs[Store]>) => boolean = () => false,
|
||||
): Promise<DrainResult> {
|
||||
return drainQueue<PendingEntry<RecordQueueInputs[Store]>, Result>({
|
||||
store,
|
||||
list: () => getPendingEntries(store),
|
||||
list: async () => (await getPendingEntries(store)).filter((entry) => !skip(entry)),
|
||||
read: (id) => getPendingEntry(store, id),
|
||||
send: (entry) => create(entry.input, { idempotencyKey: entry.idempotencyKey }),
|
||||
rejectedColonyIds: (entry) => {
|
||||
@@ -321,6 +329,141 @@ function colonyIdOf(input: unknown): string | null {
|
||||
return typeof colonyId === "string" ? colonyId : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A HIVE_CONFLICT body carries the whole current state; the queue keeps only
|
||||
* what it shows (code, op, reason, version) so IndexedDB stays small.
|
||||
*/
|
||||
function slimQueueError(error: QueueError): QueueError {
|
||||
const body = error.details as
|
||||
| { code?: unknown; opIndex?: unknown; reason?: unknown; current?: { version?: unknown } }
|
||||
| null
|
||||
| undefined;
|
||||
if (body?.code !== "HIVE_CONFLICT") return error;
|
||||
return {
|
||||
...error,
|
||||
details: {
|
||||
code: body.code,
|
||||
opIndex: body.opIndex,
|
||||
reason: body.reason,
|
||||
current: { version: body.current?.version },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** One entry of the hive timeline: from `pending-hive-changes` or an inspection with a hive change. */
|
||||
type TimelineEntry =
|
||||
| { store: "pending-hive-changes"; entry: PendingEntry<RecordQueueInputs["pending-hive-changes"]> }
|
||||
| { store: "pending-inspections"; entry: PendingEntry<CreateInspectionInput> };
|
||||
|
||||
async function readTimeline(): Promise<TimelineEntry[]> {
|
||||
const [hive, inspections] = await Promise.all([
|
||||
getPendingEntries("pending-hive-changes"),
|
||||
getPendingEntries("pending-inspections"),
|
||||
]);
|
||||
return [
|
||||
...hive.map((entry): TimelineEntry => ({ store: "pending-hive-changes", entry })),
|
||||
...inspections
|
||||
.filter((entry) => hasHiveChange(entry.input))
|
||||
.map((entry): TimelineEntry => ({ store: "pending-inspections", entry })),
|
||||
].sort((a, b) => a.entry.queuedAt.localeCompare(b.entry.queuedAt) || a.entry.id - b.entry.id);
|
||||
}
|
||||
|
||||
function readTimelineEntry(item: TimelineEntry): Promise<TimelineEntry["entry"] | undefined> {
|
||||
return item.store === "pending-hive-changes"
|
||||
? getPendingEntry(item.store, item.entry.id)
|
||||
: getPendingEntry(item.store, item.entry.id);
|
||||
}
|
||||
|
||||
function sendTimelineEntry(item: TimelineEntry): Promise<unknown> {
|
||||
const { entry } = item;
|
||||
const options = { idempotencyKey: entry.idempotencyKey };
|
||||
if (item.store === "pending-hive-changes") {
|
||||
const { colonyId, occurredOn, source, ops } = item.entry.input;
|
||||
return postHiveChange(colonyId, { occurredOn, source, ops }, entry.idempotencyKey ?? "");
|
||||
}
|
||||
return createInspection(item.entry.input, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the hive changes and the inspections that carry one in the order they
|
||||
* were made (`queuedAt`), one after the other: a hive change may rely on the
|
||||
* hive an earlier inspection left. A network error or a 5xx stops the drain
|
||||
* (the rest waits for the next run). Any other refusal (HIVE_CONFLICT, 403,
|
||||
* 400 ...) is stored on the entry, which stays queued, and holds back the
|
||||
* later entries of the same colony until the user resolves it; other colonies
|
||||
* go on. A key still in progress (409) is „later“: nothing is stored, the
|
||||
* colony is held for this run.
|
||||
*/
|
||||
async function drainTimeline(run: SyncRun): Promise<DrainResult> {
|
||||
const queued = await readTimeline();
|
||||
const own = queued.filter(({ entry }) => !isForeignEntry(entry, run.userId));
|
||||
const held = new Set<string>();
|
||||
const rejectedColonyIds: string[] = [];
|
||||
let synced = 0;
|
||||
let failed = 0;
|
||||
let skippedForeign = queued.length - own.length;
|
||||
|
||||
for (const item of own) {
|
||||
const entry = await readTimelineEntry(item);
|
||||
if (run.isCancelled()) {
|
||||
break;
|
||||
}
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
if (isForeignEntry(entry, run.userId)) {
|
||||
skippedForeign += 1;
|
||||
continue;
|
||||
}
|
||||
// Stripped meanwhile („Ohne Beute-Änderung senden“): the normal drain sends it.
|
||||
if (item.store === "pending-inspections" && !hasHiveChange(entry.input)) {
|
||||
continue;
|
||||
}
|
||||
const colonyId = colonyIdOf(entry.input);
|
||||
if (colonyId && held.has(colonyId)) {
|
||||
continue;
|
||||
}
|
||||
let result: unknown;
|
||||
try {
|
||||
result = await sendTimelineEntry({ ...item, entry } as TimelineEntry);
|
||||
} catch (error) {
|
||||
if (isRetryLaterError(error)) {
|
||||
if (colonyId) held.add(colonyId);
|
||||
continue;
|
||||
}
|
||||
if (error instanceof ApiError && error.status === UNAUTHORIZED) {
|
||||
break;
|
||||
}
|
||||
failed += 1;
|
||||
if (
|
||||
!(error instanceof ApiError) ||
|
||||
error.status >= SERVER_ERROR_FROM ||
|
||||
TRANSIENT_STATUSES.includes(error.status)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
await markFailed(item.store, entry.id, slimQueueError(toQueueError(error)));
|
||||
if (colonyId) {
|
||||
held.add(colonyId);
|
||||
rejectedColonyIds.push(colonyId);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (item.store === "pending-inspections") {
|
||||
try {
|
||||
await handOverPhotos(entry as PendingEntry<CreateInspectionInput>, result as Inspection);
|
||||
} catch {
|
||||
failed += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
await removeQueueEntry(item.store, entry.id);
|
||||
synced += 1;
|
||||
}
|
||||
|
||||
return { synced, failed, skippedForeign, rejectedColonyIds };
|
||||
}
|
||||
|
||||
function addResults(total: SyncResult, result: SyncResult): SyncResult {
|
||||
return {
|
||||
synced: total.synced + result.synced,
|
||||
@@ -331,7 +474,10 @@ function addResults(total: SyncResult, result: SyncResult): SyncResult {
|
||||
|
||||
async function runSync(run: SyncRun): Promise<SyncResult> {
|
||||
const records = await Promise.all([
|
||||
drain("pending-inspections", createInspection, run, handOverPhotos),
|
||||
drain("pending-inspections", createInspection, run, handOverPhotos, (entry) =>
|
||||
hasHiveChange(entry.input),
|
||||
),
|
||||
drainTimeline(run),
|
||||
drain("pending-treatments", createTreatment, run),
|
||||
drain("pending-feedings", createFeeding, run),
|
||||
drain("pending-varroa-measurements", createVarroaMeasurement, run),
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { appendDictation } from "./append-text";
|
||||
|
||||
describe("appendDictation", () => {
|
||||
it("joins with a single space and keeps the existing text", () => {
|
||||
expect(appendDictation("Alt", "neu")).toBe("Alt neu");
|
||||
expect(appendDictation("Alt ", " neu ")).toBe("Alt neu");
|
||||
});
|
||||
|
||||
it("starts from empty without a leading space", () => {
|
||||
expect(appendDictation("", "neu")).toBe("neu");
|
||||
});
|
||||
|
||||
it("ignores empty dictation", () => {
|
||||
expect(appendDictation("Alt", " ")).toBe("Alt");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
/** Dictation is added to the end of a field; it never replaces what is there. */
|
||||
export function appendDictation(existing: string, dictated: string): string {
|
||||
const addition = dictated.trim();
|
||||
if (addition === "") {
|
||||
return existing;
|
||||
}
|
||||
const base = existing.trimEnd();
|
||||
return base === "" ? addition : `${base} ${addition}`;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
getCloudRecognitionConsent,
|
||||
hasSeenRecordingNotice,
|
||||
markRecordingNoticeSeen,
|
||||
setCloudRecognitionConsent,
|
||||
} from "./consent";
|
||||
|
||||
describe("voice consent", () => {
|
||||
beforeEach(() => window.localStorage.clear());
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("defaults to no consent and no seen notice", () => {
|
||||
expect(getCloudRecognitionConsent()).toBe(false);
|
||||
expect(hasSeenRecordingNotice()).toBe(false);
|
||||
});
|
||||
|
||||
it("stores and withdraws the cloud consent", () => {
|
||||
setCloudRecognitionConsent(true);
|
||||
expect(getCloudRecognitionConsent()).toBe(true);
|
||||
setCloudRecognitionConsent(false);
|
||||
expect(getCloudRecognitionConsent()).toBe(false);
|
||||
});
|
||||
|
||||
it("remembers that the recording notice was seen", () => {
|
||||
markRecordingNoticeSeen();
|
||||
expect(hasSeenRecordingNotice()).toBe(true);
|
||||
});
|
||||
|
||||
it("consent survives a throwing localStorage", () => {
|
||||
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
|
||||
throw new Error("blocked");
|
||||
});
|
||||
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
|
||||
throw new Error("blocked");
|
||||
});
|
||||
|
||||
expect(getCloudRecognitionConsent()).toBe(false);
|
||||
expect(hasSeenRecordingNotice()).toBe(false);
|
||||
expect(() => setCloudRecognitionConsent(true)).not.toThrow();
|
||||
expect(() => markRecordingNoticeSeen()).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Voice settings that live in the browser only. Every access sits in
|
||||
* try/catch: private windows and blocked site data make localStorage throw,
|
||||
* and the default (no consent, notice not seen) is the safe answer then.
|
||||
*/
|
||||
const CLOUD_CONSENT_KEY = "apicore.voice.cloud-consent";
|
||||
const NOTICE_SEEN_KEY = "apicore.voice.notice-seen";
|
||||
|
||||
function readFlag(key: string): boolean {
|
||||
try {
|
||||
return window.localStorage.getItem(key) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function writeFlag(key: string, value: boolean): void {
|
||||
try {
|
||||
if (value) {
|
||||
window.localStorage.setItem(key, "1");
|
||||
} else {
|
||||
window.localStorage.removeItem(key);
|
||||
}
|
||||
} catch {
|
||||
// Storage is a convenience here; without it the flag simply stays at its default.
|
||||
}
|
||||
}
|
||||
|
||||
export function getCloudRecognitionConsent(): boolean {
|
||||
return readFlag(CLOUD_CONSENT_KEY);
|
||||
}
|
||||
|
||||
const consentListeners = new Set<() => void>();
|
||||
|
||||
/** For useSyncExternalStore: re-reads after this tab or another tab changed the consent. */
|
||||
export function subscribeCloudRecognitionConsent(listener: () => void): () => void {
|
||||
consentListeners.add(listener);
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key === CLOUD_CONSENT_KEY || event.key === null) {
|
||||
listener();
|
||||
}
|
||||
};
|
||||
window.addEventListener("storage", onStorage);
|
||||
return () => {
|
||||
consentListeners.delete(listener);
|
||||
window.removeEventListener("storage", onStorage);
|
||||
};
|
||||
}
|
||||
|
||||
export function setCloudRecognitionConsent(value: boolean): void {
|
||||
writeFlag(CLOUD_CONSENT_KEY, value);
|
||||
consentListeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
export function hasSeenRecordingNotice(): boolean {
|
||||
return readFlag(NOTICE_SEEN_KEY);
|
||||
}
|
||||
|
||||
export function markRecordingNoticeSeen(): void {
|
||||
writeFlag(NOTICE_SEEN_KEY, true);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createRecognition, detectSpeechSupport } from "./speech-support";
|
||||
import { setCloudRecognitionConsent } from "./consent";
|
||||
|
||||
class FakeRecognition {
|
||||
static instances: FakeRecognition[] = [];
|
||||
lang = "";
|
||||
continuous = false;
|
||||
interimResults = false;
|
||||
maxAlternatives = 1;
|
||||
declare processLocally?: boolean;
|
||||
declare phrases?: unknown;
|
||||
handlers: Record<string, (event: unknown) => void> = {};
|
||||
start = vi.fn();
|
||||
stop = vi.fn();
|
||||
abort = vi.fn();
|
||||
constructor() {
|
||||
FakeRecognition.instances.push(this);
|
||||
}
|
||||
set onresult(fn: (event: unknown) => void) {
|
||||
this.handlers.result = fn;
|
||||
}
|
||||
set onend(fn: (event: unknown) => void) {
|
||||
this.handlers.end = fn;
|
||||
}
|
||||
set onerror(fn: (event: unknown) => void) {
|
||||
this.handlers.error = fn;
|
||||
}
|
||||
set onaudiostart(fn: (event: unknown) => void) {
|
||||
this.handlers.audiostart = fn;
|
||||
}
|
||||
}
|
||||
|
||||
/** A plain object stands in for `window`; only the members the module reads exist. */
|
||||
function fakeWindow(extra: Record<string, unknown> = {}): Window {
|
||||
return {
|
||||
navigator: { standalone: false, userAgent: "x" },
|
||||
...extra,
|
||||
} as unknown as Window;
|
||||
}
|
||||
|
||||
describe("detectSpeechSupport", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
FakeRecognition.instances = [];
|
||||
});
|
||||
|
||||
it("returns api false without SpeechRecognition and webkitSpeechRecognition", async () => {
|
||||
const support = await detectSpeechSupport(fakeWindow());
|
||||
|
||||
expect(support).toEqual({
|
||||
api: false,
|
||||
local: "unknown",
|
||||
cloudAllowed: false,
|
||||
isInstalledIosPwa: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports local available via SpeechRecognition.available({ langs: ['de-DE'], processLocally: true })", async () => {
|
||||
const available = vi.fn().mockResolvedValue("available");
|
||||
const Ctor = Object.assign(class extends FakeRecognition {}, { available });
|
||||
|
||||
const support = await detectSpeechSupport(fakeWindow({ SpeechRecognition: Ctor }));
|
||||
|
||||
expect(available).toHaveBeenCalledWith({ langs: ["de-DE"], processLocally: true });
|
||||
expect(support.api).toBe(true);
|
||||
expect(support.local).toBe("available");
|
||||
});
|
||||
|
||||
it("maps downloadable and unavailable answers", async () => {
|
||||
for (const answer of ["downloadable", "unavailable"] as const) {
|
||||
const Ctor = Object.assign(class extends FakeRecognition {}, {
|
||||
available: vi.fn().mockResolvedValue(answer),
|
||||
});
|
||||
const support = await detectSpeechSupport(fakeWindow({ webkitSpeechRecognition: Ctor }));
|
||||
expect(support.local).toBe(answer);
|
||||
}
|
||||
});
|
||||
|
||||
it("reports unknown when available() is missing or throws", async () => {
|
||||
const missing = await detectSpeechSupport(fakeWindow({ SpeechRecognition: FakeRecognition }));
|
||||
expect(missing.api).toBe(true);
|
||||
expect(missing.local).toBe("unknown");
|
||||
|
||||
const Throwing = Object.assign(class extends FakeRecognition {}, {
|
||||
available: vi.fn().mockRejectedValue(new Error("nope")),
|
||||
});
|
||||
const thrown = await detectSpeechSupport(fakeWindow({ SpeechRecognition: Throwing }));
|
||||
expect(thrown.local).toBe("unknown");
|
||||
});
|
||||
|
||||
it("cloudAllowed follows the stored consent and defaults to false", async () => {
|
||||
const win = fakeWindow({ SpeechRecognition: FakeRecognition });
|
||||
expect((await detectSpeechSupport(win)).cloudAllowed).toBe(false);
|
||||
|
||||
setCloudRecognitionConsent(true);
|
||||
expect((await detectSpeechSupport(win)).cloudAllowed).toBe(true);
|
||||
});
|
||||
|
||||
it("detects the installed iOS PWA", async () => {
|
||||
const win = fakeWindow({
|
||||
SpeechRecognition: FakeRecognition,
|
||||
navigator: {
|
||||
standalone: true,
|
||||
userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)",
|
||||
},
|
||||
});
|
||||
|
||||
expect((await detectSpeechSupport(win)).isInstalledIosPwa).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createRecognition", () => {
|
||||
beforeEach(() => {
|
||||
FakeRecognition.instances = [];
|
||||
});
|
||||
|
||||
const options = {
|
||||
lang: "de-DE",
|
||||
continuous: false,
|
||||
interimResults: true,
|
||||
maxAlternatives: 1,
|
||||
processLocally: false,
|
||||
} as const;
|
||||
|
||||
it("returns null without the API", () => {
|
||||
expect(createRecognition(options, fakeWindow())).toBeNull();
|
||||
});
|
||||
|
||||
it("sets processLocally only when requested and phrases only when supported", () => {
|
||||
const win = fakeWindow({ SpeechRecognition: FakeRecognition });
|
||||
createRecognition(options, win);
|
||||
expect("processLocally" in FakeRecognition.instances[0]).toBe(false);
|
||||
|
||||
createRecognition({ ...options, processLocally: true, phrases: ["Varroa"] }, win);
|
||||
const local = FakeRecognition.instances[1];
|
||||
expect(local.processLocally).toBe(true);
|
||||
// The fake engine does not expose phrases at runtime: it must not be invented.
|
||||
expect("phrases" in local).toBe(false);
|
||||
});
|
||||
|
||||
class WithPhrases extends FakeRecognition {
|
||||
constructor() {
|
||||
super();
|
||||
this.phrases = [];
|
||||
}
|
||||
}
|
||||
class FakePhrase {
|
||||
constructor(
|
||||
public phrase: string,
|
||||
public boost: number,
|
||||
) {}
|
||||
}
|
||||
|
||||
it("builds SpeechRecognitionPhrase objects with boost 1 when the engine supports them", () => {
|
||||
createRecognition(
|
||||
{ ...options, phrases: ["Varroa", "Königin"] },
|
||||
fakeWindow({ SpeechRecognition: WithPhrases, SpeechRecognitionPhrase: FakePhrase }),
|
||||
);
|
||||
|
||||
expect(FakeRecognition.instances[0].phrases).toEqual([
|
||||
new FakePhrase("Varroa", 1.0),
|
||||
new FakePhrase("Königin", 1.0),
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips phrases without a SpeechRecognitionPhrase constructor", () => {
|
||||
createRecognition(
|
||||
{ ...options, phrases: ["Varroa"] },
|
||||
fakeWindow({ SpeechRecognition: WithPhrases }),
|
||||
);
|
||||
|
||||
expect(FakeRecognition.instances[0].phrases).toEqual([]);
|
||||
});
|
||||
|
||||
it("never throws when assigning phrases fails", () => {
|
||||
class ThrowingSetter extends FakeRecognition {
|
||||
constructor() {
|
||||
super();
|
||||
Object.defineProperty(this, "phrases", {
|
||||
get: () => [],
|
||||
set: () => {
|
||||
throw new Error("rejected");
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
expect(() =>
|
||||
createRecognition(
|
||||
{ ...options, phrases: ["Varroa"] },
|
||||
fakeWindow({ SpeechRecognition: ThrowingSetter, SpeechRecognitionPhrase: FakePhrase }),
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("maps downloading to unavailable on the device", async () => {
|
||||
const Ctor = Object.assign(class extends FakeRecognition {}, {
|
||||
available: vi.fn().mockResolvedValue("downloading"),
|
||||
});
|
||||
const support = await detectSpeechSupport(fakeWindow({ SpeechRecognition: Ctor }));
|
||||
expect(support.local).toBe("unavailable");
|
||||
});
|
||||
|
||||
it("maps results into final and interim text", () => {
|
||||
const handle = createRecognition(options, fakeWindow({ SpeechRecognition: FakeRecognition }));
|
||||
const onResult = vi.fn();
|
||||
handle?.on("result", onResult);
|
||||
|
||||
const final = Object.assign(
|
||||
[{ transcript: "Königin gesehen" }, { transcript: "Königin gesehn" }],
|
||||
{ isFinal: true },
|
||||
);
|
||||
const interim = Object.assign([{ transcript: " und Stifte" }], { isFinal: false });
|
||||
FakeRecognition.instances[0].handlers.result({ resultIndex: 0, results: [final, interim] });
|
||||
|
||||
expect(onResult).toHaveBeenCalledWith({
|
||||
finalText: "Königin gesehen",
|
||||
interimText: "und Stifte",
|
||||
alternatives: ["Königin gesehen", "Königin gesehn"],
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards end, audiostart and error codes and the control calls", () => {
|
||||
const handle = createRecognition(options, fakeWindow({ SpeechRecognition: FakeRecognition }));
|
||||
const onEnd = vi.fn();
|
||||
const onAudio = vi.fn();
|
||||
const onError = vi.fn();
|
||||
handle?.on("end", onEnd);
|
||||
handle?.on("audiostart", onAudio);
|
||||
handle?.on("error", onError);
|
||||
const engine = FakeRecognition.instances[0];
|
||||
|
||||
engine.handlers.end({});
|
||||
engine.handlers.audiostart({});
|
||||
engine.handlers.error({ error: "not-allowed" });
|
||||
handle?.start();
|
||||
handle?.stop();
|
||||
handle?.abort();
|
||||
|
||||
expect(onEnd).toHaveBeenCalled();
|
||||
expect(onAudio).toHaveBeenCalled();
|
||||
expect(onError).toHaveBeenCalledWith("not-allowed");
|
||||
expect(engine.start).toHaveBeenCalled();
|
||||
expect(engine.stop).toHaveBeenCalled();
|
||||
expect(engine.abort).toHaveBeenCalled();
|
||||
expect(engine.lang).toBe("de-DE");
|
||||
expect(engine.interimResults).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import { getCloudRecognitionConsent } from "./consent";
|
||||
|
||||
export type LocalAvailability = "available" | "downloadable" | "unavailable" | "unknown";
|
||||
|
||||
export interface SpeechSupport {
|
||||
api: boolean;
|
||||
local: LocalAvailability;
|
||||
cloudAllowed: boolean;
|
||||
isInstalledIosPwa: boolean;
|
||||
}
|
||||
|
||||
export interface RecognitionOptions {
|
||||
lang: "de-DE";
|
||||
continuous: boolean;
|
||||
interimResults: boolean;
|
||||
maxAlternatives: number;
|
||||
processLocally: boolean;
|
||||
phrases?: readonly string[];
|
||||
}
|
||||
|
||||
export interface RecognitionResultEvent {
|
||||
finalText: string;
|
||||
interimText: string;
|
||||
alternatives: string[];
|
||||
}
|
||||
|
||||
export interface RecognitionHandle {
|
||||
start(): void;
|
||||
stop(): void;
|
||||
abort(): void;
|
||||
on(event: "result", fn: (e: RecognitionResultEvent) => void): void;
|
||||
on(event: "end" | "audiostart", fn: () => void): void;
|
||||
on(event: "error", fn: (code: string) => void): void;
|
||||
}
|
||||
|
||||
interface RecognitionAlternative {
|
||||
transcript: string;
|
||||
}
|
||||
interface RecognitionResult {
|
||||
isFinal: boolean;
|
||||
length: number;
|
||||
[index: number]: RecognitionAlternative;
|
||||
}
|
||||
interface RawResultEvent {
|
||||
resultIndex: number;
|
||||
results: ArrayLike<RecognitionResult>;
|
||||
}
|
||||
interface RawErrorEvent {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface RecognitionEngine {
|
||||
lang: string;
|
||||
continuous: boolean;
|
||||
interimResults: boolean;
|
||||
maxAlternatives: number;
|
||||
processLocally?: boolean;
|
||||
phrases?: unknown;
|
||||
onresult: ((event: RawResultEvent) => void) | null;
|
||||
onend: (() => void) | null;
|
||||
onerror: ((event: RawErrorEvent) => void) | null;
|
||||
onaudiostart: (() => void) | null;
|
||||
start(): void;
|
||||
stop(): void;
|
||||
abort(): void;
|
||||
}
|
||||
|
||||
type PhraseConstructor = new (phrase: string, boost: number) => unknown;
|
||||
|
||||
interface RecognitionConstructor {
|
||||
new (): RecognitionEngine;
|
||||
available?: (options: { langs: string[]; processLocally: boolean }) => Promise<unknown>;
|
||||
}
|
||||
|
||||
const LANGUAGE = "de-DE";
|
||||
|
||||
function recognitionConstructor(win: Window): RecognitionConstructor | null {
|
||||
const scope = win as unknown as {
|
||||
SpeechRecognition?: RecognitionConstructor;
|
||||
webkitSpeechRecognition?: RecognitionConstructor;
|
||||
};
|
||||
return scope.SpeechRecognition ?? scope.webkitSpeechRecognition ?? null;
|
||||
}
|
||||
|
||||
function isInstalledIosPwa(win: Window): boolean {
|
||||
const nav = win.navigator as Navigator & { standalone?: boolean };
|
||||
return nav.standalone === true && /iPhone|iPad|iPod/.test(nav.userAgent);
|
||||
}
|
||||
|
||||
function toAvailability(answer: unknown): LocalAvailability {
|
||||
// Chrome answers "downloading" while the language pack arrives. For now that
|
||||
// is treated like "unavailable" on the device: no on-device dictation yet.
|
||||
if (answer === "downloading") {
|
||||
return "unavailable";
|
||||
}
|
||||
return answer === "available" || answer === "downloadable" || answer === "unavailable"
|
||||
? answer
|
||||
: "unknown";
|
||||
}
|
||||
|
||||
async function queryLocalAvailability(ctor: RecognitionConstructor): Promise<LocalAvailability> {
|
||||
if (typeof ctor.available !== "function") {
|
||||
return "unknown";
|
||||
}
|
||||
try {
|
||||
return toAvailability(await ctor.available({ langs: [LANGUAGE], processLocally: true }));
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
export async function detectSpeechSupport(
|
||||
win: Window = window,
|
||||
): Promise<SpeechSupport> {
|
||||
const ctor = recognitionConstructor(win);
|
||||
return {
|
||||
api: ctor !== null,
|
||||
local: ctor ? await queryLocalAvailability(ctor) : "unknown",
|
||||
cloudAllowed: getCloudRecognitionConsent(),
|
||||
isInstalledIosPwa: isInstalledIosPwa(win),
|
||||
};
|
||||
}
|
||||
|
||||
function mapResult(event: RawResultEvent): RecognitionResultEvent {
|
||||
let finalText = "";
|
||||
let interimText = "";
|
||||
const alternatives: string[] = [];
|
||||
for (let index = event.resultIndex; index < event.results.length; index += 1) {
|
||||
const result = event.results[index];
|
||||
if (result.length === 0) {
|
||||
continue;
|
||||
}
|
||||
if (result.isFinal) {
|
||||
finalText += result[0].transcript;
|
||||
for (let alt = 0; alt < result.length; alt += 1) {
|
||||
alternatives.push(result[alt].transcript);
|
||||
}
|
||||
} else {
|
||||
interimText += result[0].transcript;
|
||||
}
|
||||
}
|
||||
return { finalText: finalText.trim(), interimText: interimText.trim(), alternatives };
|
||||
}
|
||||
|
||||
/**
|
||||
* Chrome takes contextual biasing as `SpeechRecognitionPhrase(phrase, boost)`
|
||||
* objects. Without that constructor or the property the list is skipped; a
|
||||
* failing assignment never breaks dictation.
|
||||
*/
|
||||
function applyPhrases(engine: RecognitionEngine, phrases: readonly string[], win: Window): void {
|
||||
const PhraseCtor = (win as unknown as { SpeechRecognitionPhrase?: PhraseConstructor })
|
||||
.SpeechRecognitionPhrase;
|
||||
if (typeof PhraseCtor !== "function" || !("phrases" in engine)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
engine.phrases = phrases.map((text) => new PhraseCtor(text, 1.0));
|
||||
} catch {
|
||||
// Biasing is optional; recognition works without it.
|
||||
}
|
||||
}
|
||||
|
||||
/** Wraps the browser engine; `null` when the browser has none. */
|
||||
export function createRecognition(
|
||||
options: RecognitionOptions,
|
||||
win: Window = window,
|
||||
): RecognitionHandle | null {
|
||||
const Ctor = recognitionConstructor(win);
|
||||
if (!Ctor) {
|
||||
return null;
|
||||
}
|
||||
const engine = new Ctor();
|
||||
engine.lang = options.lang;
|
||||
engine.continuous = options.continuous;
|
||||
engine.interimResults = options.interimResults;
|
||||
engine.maxAlternatives = options.maxAlternatives;
|
||||
if (options.processLocally) {
|
||||
engine.processLocally = true;
|
||||
}
|
||||
if (options.phrases) {
|
||||
applyPhrases(engine, options.phrases, win);
|
||||
}
|
||||
|
||||
return {
|
||||
start: () => engine.start(),
|
||||
stop: () => engine.stop(),
|
||||
abort: () => engine.abort(),
|
||||
on(event: string, fn: (arg?: never) => void) {
|
||||
const listener = fn as (arg?: unknown) => void;
|
||||
if (event === "result") {
|
||||
engine.onresult = (raw) => listener(mapResult(raw));
|
||||
} else if (event === "end") {
|
||||
engine.onend = () => listener();
|
||||
} else if (event === "audiostart") {
|
||||
engine.onaudiostart = () => listener();
|
||||
} else if (event === "error") {
|
||||
engine.onerror = (raw) => listener(raw.error ?? "unknown");
|
||||
}
|
||||
},
|
||||
} as RecognitionHandle;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { VOICE_VOCABULARY } from "./vocabulary";
|
||||
|
||||
// The frontend image build only sees ApiCore-Frontend, so the comparison runs
|
||||
// in the monorepo checkout and is skipped inside that build.
|
||||
const fixture = path.resolve(
|
||||
process.cwd(),
|
||||
"../ApiCore-Backend/test/fixtures/voice/vocabulary.json",
|
||||
);
|
||||
|
||||
describe("voice vocabulary", () => {
|
||||
it.skipIf(!existsSync(fixture))("VOICE_VOCABULARY equals the shared fixture", () => {
|
||||
const shared: unknown = JSON.parse(readFileSync(fixture, "utf8"));
|
||||
|
||||
expect([...VOICE_VOCABULARY]).toEqual(shared);
|
||||
});
|
||||
|
||||
it("contains the core beekeeping terms", () => {
|
||||
for (const term of ["Königin", "Stifte", "Weiselzellen", "Varroa", "Drohnenbrut"]) {
|
||||
expect(VOICE_VOCABULARY).toContain(term);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Beekeeping terms the recognizer should expect. Mirrors
|
||||
* ApiCore-Backend/test/fixtures/voice/vocabulary.json (vocabulary.spec.ts
|
||||
* compares both); the backend builds its Whisper prompt from the same list.
|
||||
*/
|
||||
export const VOICE_VOCABULARY: readonly string[] = [
|
||||
"Königin",
|
||||
"Stifte",
|
||||
"Weiselzellen",
|
||||
"Brutwaben",
|
||||
"Futterkranz",
|
||||
"Honigraum",
|
||||
"Varroa",
|
||||
"Schwarmstimmung",
|
||||
"sanftmütig",
|
||||
"Wabensitz",
|
||||
"Drohnenbrut",
|
||||
"Brutnest",
|
||||
"Brutfläche",
|
||||
"Spielnäpfchen",
|
||||
"Schwarmzellen",
|
||||
"Absperrgitter",
|
||||
"Futterteig",
|
||||
"Ableger",
|
||||
"Jungvolk",
|
||||
"Rähmchen",
|
||||
"Mittelwand",
|
||||
"Flugloch",
|
||||
"Beute",
|
||||
"Zarge",
|
||||
"Ameisensäure",
|
||||
"Oxalsäure",
|
||||
"Windel",
|
||||
"Faulbrut",
|
||||
"Kalkbrut",
|
||||
"Nosema",
|
||||
"Wachsmotten",
|
||||
"Pollen",
|
||||
];
|
||||
Reference in new issue
Block a user