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

@@ -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();
});
});
+61
View File
@@ -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",
];