feat: Ganze Durchsicht aus Diktat auswerten (T7 L4 Task 21)

Knopf „Ganze Durchsicht auswerten“ unter den Freitextfeldern, sobald
diktiert wurde: sendet nur unveränderte Diktat-Teile, die noch in den
Feldern stehen, mit Hinweis auf die Auswertung bei Anthropic. Länge
und Text werden genau wie im Backend normalisiert (NFKC, Steuer- und
Formatzeichen, Codepoints) und so gesendet; Idempotency-Key je Inhalt,
nach endgültigem 4xx neu. Ergebnis in der Sprachnotiz-Box der
Durchsicht, Vorschläge nur für leere Felder, nichts ohne Tipp; offline
und bei ausgeschalteter Auswertung mit Hinweis statt Knopf.

Gates: Frontend lint, typecheck, 3203 Tests, build.

Refs #27

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick.RosenloecherandClaude Opus 5.5 committed 2026-10-16 16:06:00 +02:00
1 parent 8b63c50f7a
commit f6e1f34baa
8 files changed
+944 -13

No files matched your search

@@ -0,0 +1,221 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { EvaluateDictationButton } from "./evaluate-dictation-button";
import { ApiError } from "@/lib/auth/api-client";
import { fetchVoiceConfig, submitVoiceText } from "@/lib/voice/api";
import { reportRequestOutcome } from "@/lib/offline/connectivity";
let allowed = true;
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ hasPermission: () => allowed }),
}));
vi.mock("@/lib/voice/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/voice/api")>()),
fetchVoiceConfig: vi.fn(),
submitVoiceText: vi.fn(),
}));
const TEXT = "Königin gesehen, Futter niedrig, Volk sehr sanft.";
const BUTTON = { name: /Ganze Durchsicht auswerten/ };
const CONFIG = {
recordingEnabled: true,
extractionEnabled: true,
model: "m",
retention: { audioHours: 24, contentDays: 7, rowDays: 90 },
};
type Props = React.ComponentProps<typeof EvaluateDictationButton>;
function ui(props: Partial<Props> = {}) {
return (
<EvaluateDictationButton
colonyId="colony-1"
inspectionDate="2026-10-16"
text={TEXT}
onSubmitted={vi.fn()}
{...props}
/>
);
}
function renderButton(props: Partial<Props> = {}) {
const onSubmitted = vi.fn();
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const view = render(
<QueryClientProvider client={client}>{ui({ onSubmitted, ...props })}</QueryClientProvider>,
);
return {
onSubmitted,
rerenderWith: (next: Partial<Props>) =>
view.rerender(
<QueryClientProvider client={client}>{ui({ onSubmitted, ...next })}</QueryClientProvider>,
),
...view,
};
}
describe("EvaluateDictationButton", () => {
beforeEach(() => {
allowed = true;
reportRequestOutcome("reached-server");
Object.defineProperty(window.navigator, "onLine", { value: true, configurable: true });
vi.mocked(fetchVoiceConfig).mockReset().mockResolvedValue(CONFIG);
vi.mocked(submitVoiceText).mockReset().mockResolvedValue({ id: "cap-9" });
});
it("is hidden without dictation", () => {
const { container } = renderButton({ text: "" });
expect(container).toBeEmptyDOMElement();
});
it("is hidden without the right to use voice notes", () => {
allowed = false;
const { container } = renderButton();
expect(container).toBeEmptyDOMElement();
expect(fetchVoiceConfig).not.toHaveBeenCalled();
});
it("says in one line that the text goes to Anthropic", async () => {
renderButton();
const button = await screen.findByRole("button", BUTTON);
expect(button).toHaveAccessibleDescription(/Der Text geht zur Auswertung an Anthropic/);
expect(button.className).toMatch(/min-h-14/);
});
it("is disabled offline and says why", async () => {
Object.defineProperty(window.navigator, "onLine", { value: false, configurable: true });
renderButton();
expect(await screen.findByRole("button", BUTTON)).toBeDisabled();
expect(screen.getByText(/braucht eine Verbindung/)).toBeInTheDocument();
});
it("explains instead of offering when the evaluation is switched off", async () => {
vi.mocked(fetchVoiceConfig).mockResolvedValue({ ...CONFIG, extractionEnabled: false });
renderButton();
expect(await screen.findByText(/Auswertung ist gerade ausgeschaltet/)).toBeInTheDocument();
expect(screen.queryByRole("button", BUTTON)).toBeNull();
});
it("explains when voice notes are switched off", async () => {
vi.mocked(fetchVoiceConfig).mockResolvedValue({ ...CONFIG, recordingEnabled: false });
renderButton();
expect(await screen.findByText(/Sprachnotizen sind gerade ausgeschaltet/)).toBeInTheDocument();
expect(screen.queryByRole("button", BUTTON)).toBeNull();
});
it("keeps the button when the switch cannot be asked", async () => {
vi.mocked(fetchVoiceConfig).mockRejectedValue(new TypeError("Failed to fetch"));
renderButton();
expect(await screen.findByRole("button", BUTTON)).toBeEnabled();
});
it("blocks a text that is too short or too long with a hint", async () => {
const { rerenderWith } = renderButton({ text: "zu kurz" });
expect(await screen.findByRole("button", BUTTON)).toBeDisabled();
expect(screen.getByText(/mindestens 20 Zeichen/)).toBeInTheDocument();
rerenderWith({ text: "wort ".repeat(900).trim() });
expect(await screen.findByText(/höchstens 4000 Zeichen/)).toBeInTheDocument();
expect(screen.getByRole("button", BUTTON)).toBeDisabled();
});
it("sends the dictated text once with an idempotency key and hands over the id", async () => {
const user = userEvent.setup();
const { onSubmitted } = renderButton();
await user.click(await screen.findByRole("button", BUTTON));
await waitFor(() => expect(onSubmitted).toHaveBeenCalledWith({ id: "cap-9" }));
expect(submitVoiceText).toHaveBeenCalledTimes(1);
const [input, key] = vi.mocked(submitVoiceText).mock.calls[0];
expect(input).toEqual({ colonyId: "colony-1", inspectionDate: "2026-10-16", text: TEXT });
expect(key).toMatch(/\S{8,}/);
});
it("repeats a lost send with the same key and a new send with a new one", async () => {
const user = userEvent.setup();
vi.mocked(submitVoiceText).mockRejectedValueOnce(new TypeError("Failed to fetch"));
renderButton();
await user.click(await screen.findByRole("button", BUTTON));
expect(await screen.findByRole("alert")).toHaveTextContent(/noch einmal/);
await user.click(screen.getByRole("button", BUTTON));
await waitFor(() => expect(submitVoiceText).toHaveBeenCalledTimes(2));
const keys = vi.mocked(submitVoiceText).mock.calls.map((call) => call[1]);
expect(keys[0]).toBe(keys[1]);
await waitFor(() => expect(screen.getByRole("button", BUTTON)).toBeEnabled());
await user.click(screen.getByRole("button", BUTTON));
await waitFor(() => expect(submitVoiceText).toHaveBeenCalledTimes(3));
expect(vi.mocked(submitVoiceText).mock.calls[2][1]).not.toBe(keys[1]);
});
it("shows progress in a live region while sending", async () => {
const user = userEvent.setup();
let release: (value: { id: string }) => void = () => undefined;
vi.mocked(submitVoiceText).mockReturnValue(new Promise((resolve) => (release = resolve)));
renderButton();
await user.click(await screen.findByRole("button", BUTTON));
expect(await screen.findByRole("status")).toHaveTextContent(/Wird gesendet/);
expect(screen.getByRole("button", BUTTON)).toBeDisabled();
release({ id: "cap-9" });
await waitFor(() => expect(screen.getByRole("button", BUTTON)).toBeEnabled());
});
it("asks for colony and day first when one is missing", async () => {
renderButton({ colonyId: "" });
expect(await screen.findByRole("button", BUTTON)).toBeDisabled();
expect(screen.getByText(/Volk und Datum/)).toBeInTheDocument();
});
it("checks and sends the text the way the server counts it", async () => {
const user = userEvent.setup();
const padded = "​zu kurz​".repeat(2);
const { rerenderWith } = renderButton({ text: padded });
expect(await screen.findByRole("button", BUTTON)).toBeDisabled();
const real = "Königin​ gesehen, Futter niedrig";
rerenderWith({ text: real });
await user.click(screen.getByRole("button", BUTTON));
await waitFor(() => expect(submitVoiceText).toHaveBeenCalled());
expect(vi.mocked(submitVoiceText).mock.calls[0][0].text).toBe("Königin gesehen, Futter niedrig");
});
it("says that only unchanged dictation is sent", async () => {
renderButton();
expect(await screen.findByText(/Nur unveränderte Diktat-Teile/)).toBeInTheDocument();
});
it("starts a new send after a definitive refusal, keeps the key after a network error or 5xx", async () => {
const user = userEvent.setup();
vi.mocked(submitVoiceText)
.mockRejectedValueOnce(new ApiError(429, "x", { code: "TOO_MANY_OPEN" }))
.mockRejectedValueOnce(new ApiError(503, "x", {}))
.mockRejectedValueOnce(new TypeError("Failed to fetch"))
.mockResolvedValue({ id: "cap-9" });
renderButton();
for (let i = 0; i < 4; i++) {
await user.click(await screen.findByRole("button", BUTTON));
await waitFor(() => expect(submitVoiceText).toHaveBeenCalledTimes(i + 1));
await waitFor(() => expect(screen.getByRole("button", BUTTON)).toBeEnabled());
}
const keys = vi.mocked(submitVoiceText).mock.calls.map((call) => call[1]);
expect(keys[1]).not.toBe(keys[0]);
expect(keys[2]).toBe(keys[1]);
expect(keys[3]).toBe(keys[1]);
});
it("is disabled while the earlier note is still being evaluated", async () => {
renderButton({ busy: true });
expect(await screen.findByRole("button", BUTTON)).toBeDisabled();
expect(screen.getByText(/läuft noch/)).toBeInTheDocument();
});
it.each([
[403, "VOICE_RECORDING_DISABLED", /Sprachnotizen sind gerade ausgeschaltet/],
[429, "TOO_MANY_OPEN", /Zu viele offene Sprachnotizen/],
[400, "BAD_REQUEST", /nicht gesendet werden/],
])("names refusal %s %s", async (status, code, message) => {
const user = userEvent.setup();
vi.mocked(submitVoiceText).mockRejectedValue(new ApiError(status, "x", { code }));
renderButton();
await user.click(await screen.findByRole("button", BUTTON));
expect(await screen.findByRole("alert")).toHaveTextContent(message);
});
});
@@ -0,0 +1,158 @@
"use client";
import { useId, useRef, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ApiError } from "@/lib/auth/api-client";
import { useAuth } from "@/lib/auth/auth-context";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
import { useConnectivity } from "@/lib/offline/connectivity";
import { submitVoiceText } from "@/lib/voice/api";
import {
DICTATED_TEXT_MAX,
DICTATED_TEXT_MIN,
dictatedTextProblem,
normalizeForLength,
} from "@/lib/voice/dictated-text";
import { voiceErrorText } from "@/lib/voice/status-texts";
import { offersRecording, useVoiceConfig, useVoiceRecordingSwitch } from "@/lib/voice/use-voice-recording-switch";
interface EvaluateDictationButtonProps {
colonyId: string;
/** The inspection day, "YYYY-MM-DD". */
inspectionDate: string;
/** The dictated text, pieces joined and white space collapsed; "" while nothing was dictated. */
text: string;
/** A note made from an earlier send is still being evaluated. */
busy?: boolean;
onSubmitted(result: { id: string }): void;
}
const HINT_CLASS = "text-sm text-muted-foreground";
const SEND_FAILED = "Der Text konnte nicht gesendet werden. Bitte noch einmal versuchen.";
const RECORDING_OFF = "Sprachnotizen sind gerade ausgeschaltet.";
function refusalText(error: unknown): string {
if (error instanceof ApiError) {
const code = (error.body as { code?: unknown } | null)?.code;
if (code === "VOICE_RECORDING_DISABLED") return RECORDING_OFF;
if (code === "TOO_MANY_OPEN") return voiceErrorText("TOO_MANY_OPEN");
if (error.status === 429) return "Zu viele Anfragen. Bitte gleich noch einmal versuchen.";
if (error.status === 400) return "Der Text konnte nicht gesendet werden. Bitte kürzen oder ändern.";
}
return SEND_FAILED;
}
function problemText(problem: "short" | "long"): string {
return problem === "short"
? `Zu kurz zum Auswerten: mindestens ${DICTATED_TEXT_MIN} Zeichen.`
: `Zu lang zum Auswerten: höchstens ${DICTATED_TEXT_MAX} Zeichen.`;
}
/**
* „Ganze Durchsicht auswerten“ under the free-text fields: sends what was
* dictated to the server for evaluation. The text leaves for Anthropic, so
* the button says so right next to itself. It needs a connection (nothing is
* queued) and never fills a field: the answer comes as a voice note above the
* form, whose suggestions are taken over by tap.
*/
export function EvaluateDictationButton({
colonyId,
inspectionDate,
text: rawText,
busy = false,
onSubmitted,
}: EvaluateDictationButtonProps) {
// Checked and sent are the same text: the server's own normalisation.
const text = normalizeForLength(rawText);
const { hasPermission } = useAuth();
const allowed = hasPermission("voice-captures:use");
const wanted = allowed && text !== "";
const recordingSwitch = useVoiceRecordingSwitch(wanted);
const config = useVoiceConfig(wanted);
const online = useConnectivity() === "online";
const noteId = useId();
// One key per text: a repeat after a lost answer is the same send, a changed text a new one.
const pending = useRef<{ payload: string; key: string } | null>(null);
const [error, setError] = useState<string | null>(null);
const send = useMutation({
mutationFn: () => {
const payload = JSON.stringify([colonyId, inspectionDate, text]);
if (pending.current?.payload !== payload) {
pending.current = { payload, key: newIdempotencyKey() };
}
return submitVoiceText({ colonyId, inspectionDate, text }, pending.current.key);
},
onSuccess: (result) => {
pending.current = null;
setError(null);
onSubmitted(result);
},
onError: (failure) => {
// A definitive refusal ends this send; only a lost answer (network, 5xx) is repeated with its key.
if (failure instanceof ApiError && failure.status >= 400 && failure.status < 500) {
pending.current = null;
}
setError(refusalText(failure));
},
});
if (!wanted || recordingSwitch === "loading") return null;
if (!offersRecording(recordingSwitch)) {
return <p className={HINT_CLASS}>{RECORDING_OFF}</p>;
}
if (config.data?.extractionEnabled === false) {
return (
<p className={HINT_CLASS}>
Die Auswertung ist gerade ausgeschaltet. Bitte von Hand ausfüllen.
</p>
);
}
const problem = dictatedTextProblem(text);
const unplaced = colonyId === "" || inspectionDate === "";
const hint = !online
? "Die Auswertung braucht eine Verbindung."
: unplaced
? "Bitte zuerst Volk und Datum wählen."
: busy
? "Die Auswertung läuft noch."
: problem
? problemText(problem)
: null;
const disabled = !online || unplaced || busy || problem !== null || send.isPending;
return (
<div className="flex flex-col items-start gap-2">
<Button
type="button"
variant="outline"
size="touch"
className="min-h-14"
disabled={disabled}
aria-describedby={noteId}
onClick={() => {
setError(null);
send.mutate();
}}
>
<Sparkles aria-hidden="true" />
Ganze Durchsicht auswerten
</Button>
<p id={noteId} className={HINT_CLASS}>
Der Text geht zur Auswertung an Anthropic. Nur unveränderte Diktat-Teile werden gesendet.
{hint ? ` ${hint}` : ""}
</p>
<div role="status" className="text-sm">
{send.isPending ? "Wird gesendet …" : null}
</div>
{error ? (
<p role="alert" className="text-sm font-medium text-destructive">
{error}
</p>
) : null}
</div>
);
}