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
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user