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
+936
-5
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 { ColonyTakeoverField, CreateInspectionInput } from "@/lib/inspections/types";
|
||||
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 type { HiveOp } from "@/lib/hive/types";
|
||||
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 { Textarea } from "@/components/ui/textarea";
|
||||
import { DictationRow } from "@/components/voice/dictation-button";
|
||||
import { EvaluateDictationButton } from "@/components/voice/evaluate-dictation-button";
|
||||
import { VoiceEntryLink } from "@/components/voice/voice-entry-link";
|
||||
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 { RoundNextPanel } from "@/components/rounds/round-next-panel";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
@@ -76,7 +78,12 @@ import {
|
||||
import { broodHintFor, broodHintLabel, hiveErrorText } from "./hive-change";
|
||||
import { suggestAfterInspection } from "./hive-save-follow-ups";
|
||||
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";
|
||||
|
||||
/** Free-text fields that take dictation. */
|
||||
@@ -248,8 +255,12 @@ function InspectionCaptureForm({
|
||||
const { base: hiveBase, catalog: hiveCatalog } = useInspectionHive(values.colonyId);
|
||||
const broodFromHive = broodHintFor(hiveBase, hiveOps, hiveCatalog);
|
||||
// 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({
|
||||
voiceId,
|
||||
textNote,
|
||||
initialColonyId,
|
||||
values,
|
||||
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. */
|
||||
function appendDictated(key: DictatedField, text: string) {
|
||||
if (text.trim() !== "") setDictated((current) => [...current, text.trim()]);
|
||||
update((current) => ({ ...current, [key]: appendDictation(current[key], text) }));
|
||||
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). */
|
||||
function shownPhotoCount(photoSessionId: string): number {
|
||||
return (
|
||||
@@ -1008,6 +1042,15 @@ function InspectionCaptureForm({
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
{voiceId === null ? (
|
||||
<EvaluateDictationButton
|
||||
colonyId={values.colonyId}
|
||||
inspectionDate={values.date}
|
||||
text={evaluationText}
|
||||
busy={voice.inProgress}
|
||||
onSubmitted={startedTextNote}
|
||||
/>
|
||||
) : null}
|
||||
</FormSection>
|
||||
|
||||
<PhotoCaptureField
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
voiceKeys,
|
||||
} from "@/lib/voice/api";
|
||||
import { isVoiceInProgress } from "@/lib/voice/status-texts";
|
||||
import { VOICE_CAPTURE_BOX_ID } from "@/components/voice/voice-capture-box";
|
||||
import {
|
||||
describeFieldValue,
|
||||
describeSuggestionValue,
|
||||
@@ -26,6 +27,8 @@ import type { InspectionFormValues } from "./inspection-form-values";
|
||||
const POLL_MS = 5000;
|
||||
/** 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;
|
||||
/** 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 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 {
|
||||
/** `?voice=<id>`; null when the form was not opened from a voice note. */
|
||||
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. */
|
||||
initialColonyId: string;
|
||||
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
|
||||
* 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 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 [leftOut, setLeftOut] = useState(false);
|
||||
|
||||
@@ -112,6 +133,14 @@ export function useVoiceReview({ voiceId, initialColonyId, values, apply }: UseV
|
||||
const extractionEnabled = config.data?.extractionEnabled ?? true;
|
||||
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({
|
||||
queryKey: voiceKeys.detail(voiceId ?? ""),
|
||||
queryFn: () => fetchVoiceCapture(voiceId ?? ""),
|
||||
@@ -120,11 +149,20 @@ export function useVoiceReview({ voiceId, initialColonyId, values, apply }: UseV
|
||||
refetchInterval: (state) =>
|
||||
state.state.data &&
|
||||
isVoiceInProgress(state.state.data.status, extractionEnabled) &&
|
||||
!waitGaveUp &&
|
||||
Date.now() - startedAt < POLL_GIVE_UP_MS
|
||||
? POLL_MS
|
||||
: 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 isGone = capture ? isGoneVoiceStatus(capture.status) : notFound;
|
||||
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 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 {
|
||||
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. */
|
||||
voiceCaptureId: sendsId ? voiceId : undefined,
|
||||
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,
|
||||
takeOverAll,
|
||||
discard: () => discard.mutate(),
|
||||
|
||||
@@ -45,6 +45,7 @@ export function VoiceReviewBox({ review }: { review: VoiceReview }) {
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<VoiceCaptureBox
|
||||
capture={capture}
|
||||
openCount={review.openCount}
|
||||
@@ -53,6 +54,13 @@ export function VoiceReviewBox({ review }: { review: VoiceReview }) {
|
||||
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