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,281 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { act, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import NewInspectionPage from "./page";
|
||||||
|
import { fetchColonies } from "@/lib/colonies/api";
|
||||||
|
import { createInspection, fetchColonyInspections } from "@/lib/inspections/api";
|
||||||
|
import { fetchApiaryWeather } from "@/lib/weather/api";
|
||||||
|
import {
|
||||||
|
type VoiceCaptureDetail,
|
||||||
|
discardVoiceCapture,
|
||||||
|
fetchVoiceCapture,
|
||||||
|
fetchVoiceConfig,
|
||||||
|
submitVoiceText,
|
||||||
|
} from "@/lib/voice/api";
|
||||||
|
import { syncPendingEntries } from "@/lib/offline/sync";
|
||||||
|
|
||||||
|
// „Ganze Durchsicht auswerten“ (Task 21): the dictated text is evaluated and the
|
||||||
|
// answer shows in the voice note box above the form.
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
useRouter: () => ({ push: vi.fn() }),
|
||||||
|
useSearchParams: () => new URLSearchParams(search),
|
||||||
|
}));
|
||||||
|
let search = "colonyId=colony-1";
|
||||||
|
vi.mock("@/lib/auth/auth-context", () => ({
|
||||||
|
useCurrentUserId: () => "user-1",
|
||||||
|
useAuth: () => ({ hasPermission: () => true }),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
|
||||||
|
vi.mock("@/lib/inspections/api", () => ({
|
||||||
|
createInspection: vi.fn(),
|
||||||
|
fetchColonyInspections: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/weather/api", () => ({ fetchApiaryWeather: vi.fn() }));
|
||||||
|
vi.mock("@/lib/voice/api", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("@/lib/voice/api")>()),
|
||||||
|
fetchVoiceCapture: vi.fn(),
|
||||||
|
discardVoiceCapture: vi.fn(),
|
||||||
|
fetchVoiceConfig: vi.fn(),
|
||||||
|
submitVoiceText: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }));
|
||||||
|
vi.mock("@/components/hive/hive-card", () => ({ HiveCard: () => null }));
|
||||||
|
vi.mock("@/lib/hive/api", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("@/lib/hive/api")>()),
|
||||||
|
fetchHive: vi.fn().mockRejectedValue(new Error("no hive")),
|
||||||
|
fetchHiveSystems: vi.fn().mockResolvedValue([]),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/offline/sync", () => ({ syncPendingEntries: vi.fn() }));
|
||||||
|
vi.mock("@/lib/photos/store", () => ({
|
||||||
|
PHOTOS_PER_INSPECTION_MAX: 5,
|
||||||
|
listSessionPhotos: vi.fn().mockResolvedValue([]),
|
||||||
|
addDraftPhoto: vi.fn(),
|
||||||
|
removePhoto: vi.fn(),
|
||||||
|
handOverSessionPhotos: vi.fn().mockResolvedValue(0),
|
||||||
|
queueInspectionWithPhotos: vi.fn().mockResolvedValue(0),
|
||||||
|
moveDraftPhotos: vi.fn(),
|
||||||
|
discardSessionPhotos: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
// The browser engine is not the subject: a button per field hands over what was "said".
|
||||||
|
const SAID = "Varroa kaum zu sehen, Futter niedrig, Volk sehr sanft";
|
||||||
|
vi.mock("@/components/voice/dictation-button", () => ({
|
||||||
|
DictationRow: ({
|
||||||
|
children,
|
||||||
|
fieldLabel,
|
||||||
|
onAppend,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
fieldLabel: string;
|
||||||
|
onAppend: (text: string) => void;
|
||||||
|
}) => (
|
||||||
|
<div>
|
||||||
|
{children}
|
||||||
|
<button type="button" onClick={() => onAppend(SAID)}>
|
||||||
|
Diktat {fieldLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const TWO_MINUTES = 120_000;
|
||||||
|
const FIVE_SECONDS = 5_000;
|
||||||
|
|
||||||
|
function textNote(extra: Partial<VoiceCaptureDetail> = {}): VoiceCaptureDetail {
|
||||||
|
return {
|
||||||
|
id: "txt-1",
|
||||||
|
colonyId: "colony-1",
|
||||||
|
colonyName: "V-01",
|
||||||
|
status: "READY",
|
||||||
|
source: "TEXT",
|
||||||
|
createdAt: "2026-10-16T08:00:00.000Z",
|
||||||
|
lastErrorCode: null,
|
||||||
|
contentExpiresAt: "2026-10-23T08:00:00.000Z",
|
||||||
|
inspectionDate: "2026-10-16",
|
||||||
|
transcript: SAID,
|
||||||
|
suggestions: [
|
||||||
|
{ field: "gentleness", value: 5, quote: "Volk sehr sanft", corrected: false, truncated: false },
|
||||||
|
{ field: "colonyStrength", value: 20, quote: "stark", corrected: false, truncated: false },
|
||||||
|
],
|
||||||
|
unclear: [],
|
||||||
|
dropped: 0,
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPage() {
|
||||||
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={client}>
|
||||||
|
<NewInspectionPage />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DICTATE_NOTES = { name: "Diktat Notizen" };
|
||||||
|
const EVALUATE = { name: /Ganze Durchsicht auswerten/ };
|
||||||
|
const row = (fieldLabel: string) =>
|
||||||
|
screen.queryByRole("group", { name: `Vorschlag aus Sprache: ${fieldLabel}` });
|
||||||
|
|
||||||
|
type User = ReturnType<typeof userEvent.setup>;
|
||||||
|
|
||||||
|
/** The free-text fields sit in the folded section „Weitere Angaben“. */
|
||||||
|
async function openMore(user: User) {
|
||||||
|
await user.click(await screen.findByRole("button", { name: /Weitere Angaben/ }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dictateAndSend(user: User) {
|
||||||
|
await openMore(user);
|
||||||
|
await user.click(await screen.findByRole("button", DICTATE_NOTES));
|
||||||
|
await user.click(await screen.findByRole("button", EVALUATE));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("inspection form: Ganze Durchsicht auswerten", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
window.localStorage.clear();
|
||||||
|
window.sessionStorage.clear();
|
||||||
|
search = "colonyId=colony-1";
|
||||||
|
Object.defineProperty(window.navigator, "onLine", { value: true, configurable: true });
|
||||||
|
vi.mocked(fetchColonies).mockResolvedValue([
|
||||||
|
{ id: "colony-1", code: "V-01", status: "ACTIVE", apiaryId: "apiary-1" },
|
||||||
|
] as never);
|
||||||
|
vi.mocked(createInspection).mockReset().mockResolvedValue({ id: "i-1", colonyId: "colony-1" } as never);
|
||||||
|
vi.mocked(fetchColonyInspections).mockResolvedValue([]);
|
||||||
|
vi.mocked(fetchApiaryWeather).mockResolvedValue({ daily: [] });
|
||||||
|
vi.mocked(fetchVoiceCapture).mockReset().mockResolvedValue(textNote());
|
||||||
|
vi.mocked(discardVoiceCapture).mockReset().mockResolvedValue(undefined);
|
||||||
|
vi.mocked(submitVoiceText).mockReset().mockResolvedValue({ id: "txt-1" });
|
||||||
|
vi.mocked(fetchVoiceConfig).mockResolvedValue({
|
||||||
|
recordingEnabled: true,
|
||||||
|
extractionEnabled: true,
|
||||||
|
model: "m",
|
||||||
|
retention: { audioHours: 24, contentDays: 7, rowDays: 90 },
|
||||||
|
});
|
||||||
|
vi.mocked(syncPendingEntries).mockReset().mockResolvedValue({ synced: 0, failed: 0, skippedForeign: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers the button only after something was dictated", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPage();
|
||||||
|
await openMore(user);
|
||||||
|
await screen.findByRole("button", DICTATE_NOTES);
|
||||||
|
expect(screen.queryByRole("button", EVALUATE)).toBeNull();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", DICTATE_NOTES));
|
||||||
|
|
||||||
|
expect(await screen.findByRole("button", EVALUATE)).toBeEnabled();
|
||||||
|
expect(screen.getByText(/Der Text geht zur Auswertung an Anthropic/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is not offered when the form was opened from a voice note", async () => {
|
||||||
|
search = "colonyId=colony-1&voice=cap-1";
|
||||||
|
vi.mocked(fetchVoiceCapture).mockResolvedValue(textNote({ id: "cap-1", source: "AUDIO" }));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPage();
|
||||||
|
await openMore(user);
|
||||||
|
await user.click(await screen.findByRole("button", DICTATE_NOTES));
|
||||||
|
expect(screen.queryByRole("button", EVALUATE)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends only the dictated text still in a field, with colony and day", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPage();
|
||||||
|
await dictateAndSend(user);
|
||||||
|
|
||||||
|
await waitFor(() => expect(submitVoiceText).toHaveBeenCalledTimes(1));
|
||||||
|
const [input, key] = vi.mocked(submitVoiceText).mock.calls[0];
|
||||||
|
expect(input).toMatchObject({ colonyId: "colony-1", text: SAID });
|
||||||
|
expect(input.inspectionDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||||
|
expect(key).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not send text the beekeeper deleted again", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPage();
|
||||||
|
await openMore(user);
|
||||||
|
await user.click(await screen.findByRole("button", DICTATE_NOTES));
|
||||||
|
await user.clear(screen.getByLabelText(/^Notizen/));
|
||||||
|
|
||||||
|
expect(screen.queryByRole("button", EVALUATE)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the answer in the voice box with suggestions only for empty fields and moves the focus", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPage();
|
||||||
|
await user.type(await screen.findByLabelText(/^Volksstärke/), "12");
|
||||||
|
await dictateAndSend(user);
|
||||||
|
|
||||||
|
const heading = await screen.findByRole("heading", { name: /^Sprachnotiz vom 16\.10\./ });
|
||||||
|
expect(heading).toBeInTheDocument();
|
||||||
|
await waitFor(() => expect(document.getElementById("voice-capture-box")).toHaveFocus());
|
||||||
|
expect(row("Sanftmut")).not.toBeNull();
|
||||||
|
expect(row("Volksstärke")).toBeNull();
|
||||||
|
expect(screen.getByRole("heading", { name: /1 Vorschlag$/ })).toBeInTheDocument();
|
||||||
|
// Nothing is filled by itself.
|
||||||
|
expect(screen.getByLabelText(/^Volksstärke/)).toHaveValue("12");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("polls every 5 seconds until the note is ready", async () => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||||
|
vi.mocked(fetchVoiceCapture)
|
||||||
|
.mockResolvedValueOnce(textNote({ status: "TRANSCRIBED", suggestions: null }))
|
||||||
|
.mockResolvedValue(textNote());
|
||||||
|
renderPage();
|
||||||
|
await dictateAndSend(user);
|
||||||
|
expect(await screen.findByText("Wird noch ausgewertet")).toBeInTheDocument();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(FIVE_SECONDS + 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await screen.findByRole("heading", { name: /2 Vorschläge$/ })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops polling after two minutes and points to the notification", async () => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||||
|
vi.mocked(fetchVoiceCapture).mockResolvedValue(textNote({ status: "EXTRACTING", suggestions: null }));
|
||||||
|
renderPage();
|
||||||
|
await dictateAndSend(user);
|
||||||
|
await screen.findByText("Wird noch ausgewertet");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(TWO_MINUTES + 100);
|
||||||
|
});
|
||||||
|
const callsAtGiveUp = vi.mocked(fetchVoiceCapture).mock.calls.length;
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(FIVE_SECONDS * 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(vi.mocked(fetchVoiceCapture).mock.calls.length).toBe(callsAtGiveUp);
|
||||||
|
expect(screen.getByText(/Du bekommst eine Meldung/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves the inspection with the text note's id", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPage();
|
||||||
|
await dictateAndSend(user);
|
||||||
|
await screen.findByRole("heading", { name: /^Sprachnotiz vom/ });
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Durchsicht speichern" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(createInspection).toHaveBeenCalled());
|
||||||
|
expect(vi.mocked(createInspection).mock.calls[0][0]).toMatchObject({ voiceCaptureId: "txt-1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts the box above the form and nothing in the fields on its own", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPage();
|
||||||
|
await dictateAndSend(user);
|
||||||
|
const box = await screen.findByRole("region", { name: /^Sprachnotiz vom/ });
|
||||||
|
expect(within(box).getByRole("button", { name: /Alle übernehmen/ })).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/^Notizen/)).toHaveValue(SAID);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
import { type LastInspection, useLastInspection } from "@/lib/inspections/use-last-inspection";
|
import { type LastInspection, useLastInspection } from "@/lib/inspections/use-last-inspection";
|
||||||
import type { ColonyTakeoverField, CreateInspectionInput } from "@/lib/inspections/types";
|
import type { ColonyTakeoverField, CreateInspectionInput } from "@/lib/inspections/types";
|
||||||
import { hiveKeys } from "@/lib/hive/api";
|
import { hiveKeys } from "@/lib/hive/api";
|
||||||
import { voiceKeys } from "@/lib/voice/api";
|
import { discardVoiceCapture, voiceKeys } from "@/lib/voice/api";
|
||||||
import { INSPECTION_SCALE_DIRECTIONS, INSPECTION_SCALE_LABELS } from "@/lib/inspections/scales";
|
import { INSPECTION_SCALE_DIRECTIONS, INSPECTION_SCALE_LABELS } from "@/lib/inspections/scales";
|
||||||
import type { HiveOp } from "@/lib/hive/types";
|
import type { HiveOp } from "@/lib/hive/types";
|
||||||
import { useAuth, useCurrentUserId } from "@/lib/auth/auth-context";
|
import { useAuth, useCurrentUserId } from "@/lib/auth/auth-context";
|
||||||
@@ -45,8 +45,10 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { DictationRow } from "@/components/voice/dictation-button";
|
import { DictationRow } from "@/components/voice/dictation-button";
|
||||||
|
import { EvaluateDictationButton } from "@/components/voice/evaluate-dictation-button";
|
||||||
import { VoiceEntryLink } from "@/components/voice/voice-entry-link";
|
import { VoiceEntryLink } from "@/components/voice/voice-entry-link";
|
||||||
import { appendDictation } from "@/lib/voice/append-text";
|
import { appendDictation } from "@/lib/voice/append-text";
|
||||||
|
import { emptyVoiceFields, joinDictatedText, stillPresentChunks } from "@/lib/voice/dictated-text";
|
||||||
import { FormBackLink } from "@/components/form/form-back-link";
|
import { FormBackLink } from "@/components/form/form-back-link";
|
||||||
import { RoundNextPanel } from "@/components/rounds/round-next-panel";
|
import { RoundNextPanel } from "@/components/rounds/round-next-panel";
|
||||||
import { PageHeader } from "@/components/page-header";
|
import { PageHeader } from "@/components/page-header";
|
||||||
@@ -76,7 +78,12 @@ import {
|
|||||||
import { broodHintFor, broodHintLabel, hiveErrorText } from "./hive-change";
|
import { broodHintFor, broodHintLabel, hiveErrorText } from "./hive-change";
|
||||||
import { suggestAfterInspection } from "./hive-save-follow-ups";
|
import { suggestAfterInspection } from "./hive-save-follow-ups";
|
||||||
import { InspectionHiveSection, useInspectionHive } from "./hive-section";
|
import { InspectionHiveSection, useInspectionHive } from "./hive-section";
|
||||||
import { isGoneVoiceStatus, isVoiceColonyMismatch, useVoiceReview } from "./use-voice-review";
|
import {
|
||||||
|
isGoneVoiceStatus,
|
||||||
|
isVoiceColonyMismatch,
|
||||||
|
useVoiceReview,
|
||||||
|
type TextNote,
|
||||||
|
} from "./use-voice-review";
|
||||||
import { VoiceFieldRow, VoiceReviewBox } from "./voice-review";
|
import { VoiceFieldRow, VoiceReviewBox } from "./voice-review";
|
||||||
|
|
||||||
/** Free-text fields that take dictation. */
|
/** Free-text fields that take dictation. */
|
||||||
@@ -248,8 +255,12 @@ function InspectionCaptureForm({
|
|||||||
const { base: hiveBase, catalog: hiveCatalog } = useInspectionHive(values.colonyId);
|
const { base: hiveBase, catalog: hiveCatalog } = useInspectionHive(values.colonyId);
|
||||||
const broodFromHive = broodHintFor(hiveBase, hiveOps, hiveCatalog);
|
const broodFromHive = broodHintFor(hiveBase, hiveOps, hiveCatalog);
|
||||||
// A tap on „Übernehmen“ writes like any tap; observations are never ticked for the Volkskarte.
|
// A tap on „Übernehmen“ writes like any tap; observations are never ticked for the Volkskarte.
|
||||||
|
// What was dictated into the free-text fields, and the note „Ganze Durchsicht auswerten“ made from it.
|
||||||
|
const [dictated, setDictated] = useState<string[]>([]);
|
||||||
|
const [textNote, setTextNote] = useState<TextNote | null>(null);
|
||||||
const voice = useVoiceReview({
|
const voice = useVoiceReview({
|
||||||
voiceId,
|
voiceId,
|
||||||
|
textNote,
|
||||||
initialColonyId,
|
initialColonyId,
|
||||||
values,
|
values,
|
||||||
apply: (patch) => {
|
apply: (patch) => {
|
||||||
@@ -340,10 +351,33 @@ function InspectionCaptureForm({
|
|||||||
|
|
||||||
/** Dictation is added to the end of a free-text field and marks the form as changed. */
|
/** Dictation is added to the end of a free-text field and marks the form as changed. */
|
||||||
function appendDictated(key: DictatedField, text: string) {
|
function appendDictated(key: DictatedField, text: string) {
|
||||||
|
if (text.trim() !== "") setDictated((current) => [...current, text.trim()]);
|
||||||
update((current) => ({ ...current, [key]: appendDictation(current[key], text) }));
|
update((current) => ({ ...current, [key]: appendDictation(current[key], text) }));
|
||||||
setDirty(true);
|
setDirty(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The dictated text that is still written in a field: only that leaves for the evaluation. */
|
||||||
|
const evaluationText = joinDictatedText(
|
||||||
|
stillPresentChunks(dictated, [
|
||||||
|
values.varroaIssues,
|
||||||
|
values.healthIssues,
|
||||||
|
values.measures,
|
||||||
|
values.notes,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** The evaluation of the dictated text started: its note shows in the box above the form. */
|
||||||
|
function startedTextNote({ id }: { id: string }) {
|
||||||
|
const earlier = textNote?.id;
|
||||||
|
setTextNote({ id, emptyFields: emptyVoiceFields(values) });
|
||||||
|
if (earlier && earlier !== id) {
|
||||||
|
// The earlier text note is replaced; the server would drop it after 7 days anyway, so a
|
||||||
|
// failure here is not worth a message to the beekeeper.
|
||||||
|
discardVoiceCapture(earlier).catch(() => undefined);
|
||||||
|
}
|
||||||
|
void queryClient.invalidateQueries({ queryKey: voiceKeys.all, refetchType: "none" });
|
||||||
|
}
|
||||||
|
|
||||||
/** How many photos the form's photo section shows (none while it is hidden). */
|
/** How many photos the form's photo section shows (none while it is hidden). */
|
||||||
function shownPhotoCount(photoSessionId: string): number {
|
function shownPhotoCount(photoSessionId: string): number {
|
||||||
return (
|
return (
|
||||||
@@ -1008,6 +1042,15 @@ function InspectionCaptureForm({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
|
{voiceId === null ? (
|
||||||
|
<EvaluateDictationButton
|
||||||
|
colonyId={values.colonyId}
|
||||||
|
inspectionDate={values.date}
|
||||||
|
text={evaluationText}
|
||||||
|
busy={voice.inProgress}
|
||||||
|
onSubmitted={startedTextNote}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
<PhotoCaptureField
|
<PhotoCaptureField
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { ApiError } from "@/lib/auth/api-client";
|
import { ApiError } from "@/lib/auth/api-client";
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
voiceKeys,
|
voiceKeys,
|
||||||
} from "@/lib/voice/api";
|
} from "@/lib/voice/api";
|
||||||
import { isVoiceInProgress } from "@/lib/voice/status-texts";
|
import { isVoiceInProgress } from "@/lib/voice/status-texts";
|
||||||
|
import { VOICE_CAPTURE_BOX_ID } from "@/components/voice/voice-capture-box";
|
||||||
import {
|
import {
|
||||||
describeFieldValue,
|
describeFieldValue,
|
||||||
describeSuggestionValue,
|
describeSuggestionValue,
|
||||||
@@ -26,6 +27,8 @@ import type { InspectionFormValues } from "./inspection-form-values";
|
|||||||
const POLL_MS = 5000;
|
const POLL_MS = 5000;
|
||||||
/** A note still unfinished after this long is not waited for any more; the page can be reopened. */
|
/** A note still unfinished after this long is not waited for any more; the page can be reopened. */
|
||||||
const POLL_GIVE_UP_MS = 10 * 60 * 1000;
|
const POLL_GIVE_UP_MS = 10 * 60 * 1000;
|
||||||
|
/** A note made from dictated text is waited for this long; then the notification takes over. */
|
||||||
|
const TEXT_POLL_GIVE_UP_MS = 2 * 60 * 1000;
|
||||||
const NOT_FOUND = 404;
|
const NOT_FOUND = 404;
|
||||||
const GONE_STATUSES: readonly string[] = ["CONFIRMED", "DISCARDED", "EXPIRED"];
|
const GONE_STATUSES: readonly string[] = ["CONFIRMED", "DISCARDED", "EXPIRED"];
|
||||||
|
|
||||||
@@ -59,9 +62,17 @@ export function isVoiceColonyMismatch(error: unknown): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The note made by „Ganze Durchsicht auswerten“: it offers suggestions for the fields that were empty then. */
|
||||||
|
export interface TextNote {
|
||||||
|
id: string;
|
||||||
|
emptyFields: ReadonlySet<string>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UseVoiceReviewOptions {
|
export interface UseVoiceReviewOptions {
|
||||||
/** `?voice=<id>`; null when the form was not opened from a voice note. */
|
/** `?voice=<id>`; null when the form was not opened from a voice note. */
|
||||||
voiceId: string | null;
|
voiceId: string | null;
|
||||||
|
/** The evaluation of the dictated text; used while `voiceId` is null. */
|
||||||
|
textNote?: TextNote | null;
|
||||||
/** The colony in the address: the best guess for the note's colony until it has loaded. */
|
/** The colony in the address: the best guess for the note's colony until it has loaded. */
|
||||||
initialColonyId: string;
|
initialColonyId: string;
|
||||||
values: InspectionFormValues;
|
values: InspectionFormValues;
|
||||||
@@ -97,8 +108,18 @@ function focusFieldOf(field: string): void {
|
|||||||
* a tap (`apply`). The id goes to the server only while the note is still
|
* a tap (`apply`). The id goes to the server only while the note is still
|
||||||
* open and belongs to the form's colony.
|
* open and belongs to the form's colony.
|
||||||
*/
|
*/
|
||||||
export function useVoiceReview({ voiceId, initialColonyId, values, apply }: UseVoiceReviewOptions) {
|
export function useVoiceReview({
|
||||||
|
voiceId: urlVoiceId,
|
||||||
|
textNote = null,
|
||||||
|
initialColonyId,
|
||||||
|
values,
|
||||||
|
apply,
|
||||||
|
}: UseVoiceReviewOptions) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const voiceId = urlVoiceId ?? textNote?.id ?? null;
|
||||||
|
const emptyFields = urlVoiceId === null && textNote ? textNote.emptyFields : null;
|
||||||
|
const [gaveUpFor, setGaveUpFor] = useState<string | null>(null);
|
||||||
|
const textWaitEnds = emptyFields !== null;
|
||||||
const [settled, setSettled] = useState<ReadonlyMap<string, Settled>>(new Map());
|
const [settled, setSettled] = useState<ReadonlyMap<string, Settled>>(new Map());
|
||||||
const [leftOut, setLeftOut] = useState(false);
|
const [leftOut, setLeftOut] = useState(false);
|
||||||
|
|
||||||
@@ -112,6 +133,14 @@ export function useVoiceReview({ voiceId, initialColonyId, values, apply }: UseV
|
|||||||
const extractionEnabled = config.data?.extractionEnabled ?? true;
|
const extractionEnabled = config.data?.extractionEnabled ?? true;
|
||||||
const [startedAt] = useState(() => Date.now());
|
const [startedAt] = useState(() => Date.now());
|
||||||
|
|
||||||
|
// A text note is evaluated within moments; if it is not, the notification tells when it is.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!textWaitEnds || voiceId === null) return;
|
||||||
|
const timer = window.setTimeout(() => setGaveUpFor(voiceId), TEXT_POLL_GIVE_UP_MS);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [textWaitEnds, voiceId]);
|
||||||
|
const waitGaveUp = textWaitEnds && gaveUpFor === voiceId;
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: voiceKeys.detail(voiceId ?? ""),
|
queryKey: voiceKeys.detail(voiceId ?? ""),
|
||||||
queryFn: () => fetchVoiceCapture(voiceId ?? ""),
|
queryFn: () => fetchVoiceCapture(voiceId ?? ""),
|
||||||
@@ -120,11 +149,20 @@ export function useVoiceReview({ voiceId, initialColonyId, values, apply }: UseV
|
|||||||
refetchInterval: (state) =>
|
refetchInterval: (state) =>
|
||||||
state.state.data &&
|
state.state.data &&
|
||||||
isVoiceInProgress(state.state.data.status, extractionEnabled) &&
|
isVoiceInProgress(state.state.data.status, extractionEnabled) &&
|
||||||
|
!waitGaveUp &&
|
||||||
Date.now() - startedAt < POLL_GIVE_UP_MS
|
Date.now() - startedAt < POLL_GIVE_UP_MS
|
||||||
? POLL_MS
|
? POLL_MS
|
||||||
: false,
|
: false,
|
||||||
});
|
});
|
||||||
const capture: VoiceCaptureDetail | undefined = leftOut ? undefined : query.data;
|
const loaded: VoiceCaptureDetail | undefined = leftOut ? undefined : query.data;
|
||||||
|
// Only the fields that were empty when the text was sent: nothing already answered is replaced.
|
||||||
|
const capture: VoiceCaptureDetail | undefined =
|
||||||
|
loaded && emptyFields && loaded.suggestions
|
||||||
|
? {
|
||||||
|
...loaded,
|
||||||
|
suggestions: loaded.suggestions.filter((suggestion) => emptyFields.has(suggestion.field)),
|
||||||
|
}
|
||||||
|
: loaded;
|
||||||
const notFound = query.error instanceof ApiError && query.error.status === NOT_FOUND;
|
const notFound = query.error instanceof ApiError && query.error.status === NOT_FOUND;
|
||||||
const isGone = capture ? isGoneVoiceStatus(capture.status) : notFound;
|
const isGone = capture ? isGoneVoiceStatus(capture.status) : notFound;
|
||||||
const noteColonyId = capture?.colonyId ?? initialColonyId;
|
const noteColonyId = capture?.colonyId ?? initialColonyId;
|
||||||
@@ -236,6 +274,17 @@ export function useVoiceReview({ voiceId, initialColonyId, values, apply }: UseV
|
|||||||
});
|
});
|
||||||
|
|
||||||
const sendsId = voiceId !== null && !leftOut && !isGone && sameColony;
|
const sendsId = voiceId !== null && !leftOut && !isGone && sameColony;
|
||||||
|
const inProgress = capture ? isVoiceInProgress(capture.status, extractionEnabled) : false;
|
||||||
|
|
||||||
|
// The answer to the dictated text arrives while the beekeeper works elsewhere on the form:
|
||||||
|
// the box takes the focus once, so a screen reader announces it.
|
||||||
|
const focusedFor = useRef<string | null>(null);
|
||||||
|
const answered = textWaitEnds && capture !== undefined && !inProgress;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!answered || voiceId === null || focusedFor.current === voiceId) return;
|
||||||
|
focusedFor.current = voiceId;
|
||||||
|
document.getElementById(VOICE_CAPTURE_BOX_ID)?.focus();
|
||||||
|
}, [answered, voiceId]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
voiceId,
|
voiceId,
|
||||||
@@ -248,6 +297,10 @@ export function useVoiceReview({ voiceId, initialColonyId, values, apply }: UseV
|
|||||||
/** The id to send with the inspection, if the note should be tied to it. */
|
/** The id to send with the inspection, if the note should be tied to it. */
|
||||||
voiceCaptureId: sendsId ? voiceId : undefined,
|
voiceCaptureId: sendsId ? voiceId : undefined,
|
||||||
openCount: open.length,
|
openCount: open.length,
|
||||||
|
/** The note is still being transcribed or evaluated. */
|
||||||
|
inProgress,
|
||||||
|
/** Evaluating, and the wait for it has ended: the notification will tell when it is done. */
|
||||||
|
waitGaveUp: waitGaveUp && inProgress,
|
||||||
rowFor,
|
rowFor,
|
||||||
takeOverAll,
|
takeOverAll,
|
||||||
discard: () => discard.mutate(),
|
discard: () => discard.mutate(),
|
||||||
|
|||||||
@@ -45,14 +45,22 @@ export function VoiceReviewBox({ review }: { review: VoiceReview }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<VoiceCaptureBox
|
<div className="flex flex-col gap-3">
|
||||||
capture={capture}
|
<VoiceCaptureBox
|
||||||
openCount={review.openCount}
|
capture={capture}
|
||||||
onTakeOverAll={review.takeOverAll}
|
openCount={review.openCount}
|
||||||
onDiscard={review.discard}
|
onTakeOverAll={review.takeOverAll}
|
||||||
discarding={review.discarding}
|
onDiscard={review.discard}
|
||||||
extractionEnabled={review.extractionEnabled}
|
discarding={review.discarding}
|
||||||
/>
|
extractionEnabled={review.extractionEnabled}
|
||||||
|
/>
|
||||||
|
{review.waitGaveUp ? (
|
||||||
|
<p role="status" className={NOTE_CLASS}>
|
||||||
|
Die Auswertung dauert länger. Du bekommst eine Meldung, sobald sie fertig ist, und kannst
|
||||||
|
die Durchsicht so lange weiter ausfüllen.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import {
|
||||||
|
DICTATED_TEXT_MAX,
|
||||||
|
DICTATED_TEXT_MIN,
|
||||||
|
dictatedTextProblem,
|
||||||
|
emptyVoiceFields,
|
||||||
|
joinDictatedText,
|
||||||
|
normalizeForLength,
|
||||||
|
stillPresentChunks,
|
||||||
|
} from "./dictated-text";
|
||||||
|
|
||||||
|
describe("joinDictatedText", () => {
|
||||||
|
it("joins the chunks with one space and keeps line feeds and inner spaces", () => {
|
||||||
|
expect(joinDictatedText([" Königin gesehen", "Futter\nniedrig "])).toBe(
|
||||||
|
"Königin gesehen Futter\nniedrig",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is empty without chunks", () => {
|
||||||
|
expect(joinDictatedText([])).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("normalizeForLength (mirrors the backend normalizeInputText)", () => {
|
||||||
|
it("expands NFKC so the check counts what the server counts", () => {
|
||||||
|
const normalized = normalizeForLength("…".repeat(1400));
|
||||||
|
expect(normalized).toBe(".".repeat(4200));
|
||||||
|
expect(dictatedTextProblem(normalized)).toBe("long");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts code points, not UTF-16 units", () => {
|
||||||
|
const emoji = "😀".repeat(DICTATED_TEXT_MIN);
|
||||||
|
expect(emoji.length).toBe(DICTATED_TEXT_MIN * 2);
|
||||||
|
expect(dictatedTextProblem(normalizeForLength(emoji))).toBeNull();
|
||||||
|
expect(dictatedTextProblem(normalizeForLength("😀".repeat(DICTATED_TEXT_MIN - 1)))).toBe("short");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes zero-width and control characters before counting", () => {
|
||||||
|
const padded = "a\u200B".repeat(DICTATED_TEXT_MIN) + "\u0007";
|
||||||
|
expect(padded.length).toBeGreaterThan(DICTATED_TEXT_MIN);
|
||||||
|
expect(normalizeForLength(padded)).toBe("a".repeat(DICTATED_TEXT_MIN));
|
||||||
|
expect(dictatedTextProblem(normalizeForLength("a\u200B".repeat(10)))).toBe("short");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("turns tabs, lone carriage returns and line separators into spaces and keeps line feeds", () => {
|
||||||
|
expect(normalizeForLength("a\tb\rc\u2028d\u2029e\nf\r\ng")).toBe("a b c d e\nf\r\ng".replace("\r\n", "\n"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims", () => {
|
||||||
|
expect(normalizeForLength(" \n text \n ")).toBe("text");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the same steps as the backend source", () => {
|
||||||
|
const path = resolve(process.cwd(), "../ApiCore-Backend/src/voice/core/voice-text.ts");
|
||||||
|
if (!existsSync(path)) return;
|
||||||
|
const source = readFileSync(path, "utf8");
|
||||||
|
expect(source).toContain(".normalize('NFKC')");
|
||||||
|
expect(source).toContain(String.raw`.replace(/\r(?!\n)|[\t\u2028\u2029]/g, ' ')`);
|
||||||
|
expect(source).toContain(String.raw`.replace(/[^\P{Cc}\n]/gu, '')`);
|
||||||
|
expect(source).toContain(String.raw`const FORMAT_CHARACTERS = /\p{Cf}/gu;`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dictatedTextProblem", () => {
|
||||||
|
it("accepts the limits and names the problem outside them", () => {
|
||||||
|
expect(dictatedTextProblem("a".repeat(DICTATED_TEXT_MIN))).toBeNull();
|
||||||
|
expect(dictatedTextProblem("a".repeat(DICTATED_TEXT_MAX))).toBeNull();
|
||||||
|
expect(dictatedTextProblem("a".repeat(DICTATED_TEXT_MIN - 1))).toBe("short");
|
||||||
|
expect(dictatedTextProblem("a".repeat(DICTATED_TEXT_MAX + 1))).toBe("long");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("stillPresentChunks", () => {
|
||||||
|
it("drops chunks the beekeeper deleted from every free-text field", () => {
|
||||||
|
const fields = ["Varroa kaum zu sehen", "", "", ""];
|
||||||
|
expect(stillPresentChunks(["Varroa kaum", "Futter niedrig"], fields)).toEqual(["Varroa kaum"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches ignoring surrounding white space", () => {
|
||||||
|
expect(stillPresentChunks([" Futter niedrig "], ["Notiz: Futter niedrig."])).toEqual([
|
||||||
|
" Futter niedrig ",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("emptyVoiceFields", () => {
|
||||||
|
it("lists the suggestible fields that hold no answer", () => {
|
||||||
|
const empty = emptyVoiceFields({
|
||||||
|
queenSeen: true,
|
||||||
|
eggsPresent: null,
|
||||||
|
gentleness: null,
|
||||||
|
colonyStrength: "",
|
||||||
|
broodFrames: "6",
|
||||||
|
feedStatus: null,
|
||||||
|
notes: " ",
|
||||||
|
measures: "Futter gegeben",
|
||||||
|
} as never);
|
||||||
|
expect(empty.has("eggsPresent")).toBe(true);
|
||||||
|
expect(empty.has("gentleness")).toBe(true);
|
||||||
|
expect(empty.has("colonyStrength")).toBe(true);
|
||||||
|
expect(empty.has("feedStatus")).toBe(true);
|
||||||
|
expect(empty.has("notes")).toBe(true);
|
||||||
|
expect(empty.has("queenSeen")).toBe(false);
|
||||||
|
expect(empty.has("broodFrames")).toBe(false);
|
||||||
|
expect(empty.has("measures")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { VOICE_FIELD_LABELS } from "./suggestions";
|
||||||
|
import type { InspectionFormValues } from "@/app/(app)/inspections/new/inspection-form-values";
|
||||||
|
|
||||||
|
/** The server takes 20 to 4000 characters (code points) after its own normalisation. */
|
||||||
|
export const DICTATED_TEXT_MIN = 20;
|
||||||
|
export const DICTATED_TEXT_MAX = 4000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors ApiCore-Backend normalizeInputText (voice-text.ts): NFKC; tabs, lone
|
||||||
|
* carriage returns and U+2028/U+2029 become spaces; control characters except
|
||||||
|
* the line feed and format characters (zero-width, bidi marks) are removed;
|
||||||
|
* trimmed. No white space is collapsed, line feeds stay. What is checked here
|
||||||
|
* is what is sent, so client and server count the same text.
|
||||||
|
*/
|
||||||
|
export function normalizeForLength(text: string): string {
|
||||||
|
return text
|
||||||
|
.normalize("NFKC")
|
||||||
|
.replace(/\r(?!\n)|[\t\u2028\u2029]/g, " ")
|
||||||
|
.replace(/[^\P{Cc}\n]/gu, "")
|
||||||
|
.replace(/\p{Cf}/gu, "")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The dictated pieces as one text, normalised like the server does. */
|
||||||
|
export function joinDictatedText(chunks: readonly string[]): string {
|
||||||
|
return normalizeForLength(chunks.join(" "));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dictatedTextProblem(text: string): "short" | "long" | null {
|
||||||
|
const length = Array.from(text).length;
|
||||||
|
if (length < DICTATED_TEXT_MIN) return "short";
|
||||||
|
if (length > DICTATED_TEXT_MAX) return "long";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only what is still written in a free-text field leaves the device: a piece
|
||||||
|
* the beekeeper has deleted since is not sent.
|
||||||
|
*/
|
||||||
|
export function stillPresentChunks(
|
||||||
|
chunks: readonly string[],
|
||||||
|
fieldValues: readonly string[],
|
||||||
|
): string[] {
|
||||||
|
return chunks.filter((chunk) => {
|
||||||
|
const piece = chunk.trim();
|
||||||
|
return piece !== "" && fieldValues.some((value) => value.includes(piece));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEmptyAnswer(value: unknown): boolean {
|
||||||
|
return value === null || value === undefined || (typeof value === "string" && value.trim() === "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The fields a suggestion may fill that hold no answer yet. */
|
||||||
|
export function emptyVoiceFields(values: InspectionFormValues): ReadonlySet<string> {
|
||||||
|
const record = values as unknown as Record<string, unknown>;
|
||||||
|
return new Set(Object.keys(VOICE_FIELD_LABELS).filter((field) => isEmptyAnswer(record[field])));
|
||||||
|
}
|
||||||
Reference in new issue
Block a user