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