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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 09:12:04 +02:00
1 parent ab45d6e24e
commit 1a57de8573
123 files changed
+7639 -355

No files matched your search

@@ -19,20 +19,32 @@ import { colonyFixture, historyItem, sampleState, setupView, systemDto } from ".
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
vi.mock("sonner", () => ({ toast: vi.fn() }));
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn().mockResolvedValue([]) }));
vi.mock("@/lib/hive/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/hive/api")>()),
fetchHive: vi.fn(),
fetchHiveSystems: vi.fn(),
fetchHiveTemplate: vi.fn(),
fetchHiveHistory: vi.fn(),
fetchHiveAt: vi.fn(),
postHiveChange: vi.fn(),
createHive: vi.fn(),
vi.mock("@/lib/hive/api", async (importOriginal) => {
const fetchHiveSystems = vi.fn();
return {
...(await importOriginal<typeof import("@/lib/hive/api")>()),
fetchHive: vi.fn(),
fetchHiveSystems,
// The offline-aware variant answers like the plain one here (no offline packs in this spec).
fetchHiveSystemsWithFallback: vi.fn(() => fetchHiveSystems()),
fetchHiveTemplate: vi.fn(),
fetchHiveHistory: vi.fn(),
fetchHiveAt: vi.fn(),
postHiveChange: vi.fn(),
createHive: vi.fn(),
};
});
const pendingHive = vi.hoisted(() => ({ ops: [] as unknown[], count: 0 }));
vi.mock("@/components/hive/use-pending-hive", async (importOriginal) => ({
...(await importOriginal<typeof import("@/components/hive/use-pending-hive")>()),
usePendingHiveOps: () => pendingHive.ops,
usePendingHiveCount: () => pendingHive.count,
}));
let permissions: string[] = [];
vi.mock("@/lib/auth/auth-context", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/auth/auth-context")>()),
useAuth: () => ({ hasPermission: (key: string) => permissions.includes(key) }),
useCurrentUserId: () => null,
}));
const connectivity = vi.hoisted(() => ({ value: "online" as "online" | "offline" }));
vi.mock("@/lib/offline/connectivity", async (importOriginal) => ({
@@ -64,6 +76,8 @@ describe("HiveCard", () => {
beforeEach(() => {
permissions = ["colonies:read", "colonies:update"];
connectivity.value = "online";
pendingHive.ops = [];
pendingHive.count = 0;
vi.mocked(fetchHive).mockReset().mockResolvedValue(setupView());
vi.mocked(fetchHiveSystems).mockReset().mockResolvedValue([systemDto()]);
vi.mocked(fetchHiveTemplate).mockReset().mockResolvedValue(templateState());
@@ -152,6 +166,14 @@ describe("HiveCard", () => {
});
});
it("shows changes that wait for network on top of the loaded hive, with a note", async () => {
pendingHive.ops = FRAME_EDIT_OPS;
pendingHive.count = 1;
renderCard();
expect(await screen.findByText(/7 Brutwaben/)).toBeInTheDocument();
expect(screen.getByText("1 Änderung wartet auf Netz")).toBeInTheDocument();
});
it("keeps the outline in the DOM but visually hidden until „Aufbau als Liste“", async () => {
renderCard();
const toggle = await screen.findByRole("button", { name: "Aufbau als Liste" });
@@ -239,6 +261,32 @@ describe("HiveCard", () => {
expect(screen.queryByRole("link", { name: "Umbauen" })).not.toBeInTheDocument();
});
it("seeds the inspection draft from initialOps (after a remount)", async () => {
const onOpsChange = vi.fn();
renderCard({ mode: "inspection", onOpsChange, initialOps: FRAME_EDIT_OPS });
await screen.findByRole("img", { name: /Beute mit/ });
expect(await screen.findByText(/7 Brutwaben/)).toBeInTheDocument();
// The outline shows the seeded draft, and seeding is not reported as a new edit.
await userEvent.click(screen.getByRole("treeitem", { name: /Zarge 1/ }));
expect(screen.getByRole("treeitem", { name: /R5 · Brutwabe/ })).toBeInTheDocument();
expect(onOpsChange).not.toHaveBeenCalled();
});
it("ignores initialOps that do not apply to the loaded hive, and in view mode", async () => {
const broken: HiveOp[] = [{ op: "setFrame", boxId: "gone", slot: 5, type: "BROOD", name: null }];
const onOpsChange = vi.fn();
const first = renderCard({ mode: "inspection", initialOps: broken, onOpsChange });
await screen.findByRole("img", { name: /Beute mit/ });
expect(screen.getByText("2 Zargen · 6 Brutwaben · Futter 5 l · Bausperre drin")).toBeInTheDocument();
// The form learns that its stored ops were dropped, so its list matches the card.
expect(onOpsChange).toHaveBeenCalledWith([]);
first.unmount();
renderCard({ initialOps: FRAME_EDIT_OPS });
await screen.findByRole("img", { name: /Beute mit/ });
expect(screen.getByText("2 Zargen · 6 Brutwaben · Futter 5 l · Bausperre drin")).toBeInTheDocument();
});
it("opens the conflict sheet when the hive changed meanwhile", async () => {
vi.mocked(postHiveChange).mockRejectedValue(
new ApiError(409, "Konflikt", {