fix: Beute-Editor und Durchsicht nach Browser-Prüfung

- Umbauen: Fokus nach Rückgängig/Wiederholen am Ende auf den anderen
  Verlaufsknopf, nach Entfernen auf die Nachbarzeile (sonst Überschrift)
- Umbauen: Hinweis „Beute wurde inzwischen geändert“ zuverlässig, der
  Stand wird bei Fokus/Sichtbarkeit und alle 30 s neu geladen, solange
  ein Entwurf besteht; Editor-Wurzel ist <main>
- Verlauf: Futtertaschen-Füllstand als „Flüssigfutter 1,5 l“, Entfernen
  als „Futtertasche entfernt“; Gliederung ohne leere Werte („Boden · /“)
- Durchsicht: Rückgängig der letzten Änderung behält den Schalter
  „Liste“ und setzt den Fokus auf die Überschrift der Änderungen

Refs #21 #22

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 09:12:17 +02:00
1 parent 7a07e1cac4
commit 8166e7c621
12 files changed
+253 -36

No files matched your search

@@ -243,6 +243,51 @@ describe("HiveEditorPage", () => {
expect(fetchHive).toHaveBeenCalledTimes(1);
});
it("moves focus to the other history button when undo or redo runs out", async () => {
renderPage();
await addPart("cover");
await userEvent.click(screen.getByRole("button", { name: "Rückgängig" }));
expect(screen.getByRole("button", { name: "Rückgängig" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Wiederholen" })).toHaveFocus();
await userEvent.click(screen.getByRole("button", { name: "Wiederholen" }));
expect(screen.getByRole("button", { name: "Wiederholen" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Rückgängig" })).toHaveFocus();
});
it("moves focus to the next row after removing a part, else the previous one", async () => {
renderPage();
const stack = await screen.findByRole("list", { name: "Aufbau" });
await userEvent.click(within(stack).getByRole("button", { name: "Entfernen: Futterzarge 2" }));
expect(document.activeElement).toHaveAttribute("data-part-id", "fe");
// The floor insert is the last row: focus falls back to the row above (the floor).
await userEvent.click(within(stack).getByRole("button", { name: "Entfernen: Bausperre" }));
expect(document.activeElement).toHaveAttribute("data-part-id", "f");
});
it("refreshes the setup on window focus and hints when the server moved on", async () => {
renderPage();
await addPart("cover");
const other = sampleState();
other.stack = other.stack.filter((p) => p.id !== "b2");
vi.mocked(fetchHive).mockResolvedValue(setupView(other, "colony-1", 4));
const calls = vi.mocked(fetchHive).mock.calls.length;
act(() => void window.dispatchEvent(new Event("focus")));
expect(await screen.findByText(/Die Beute wurde inzwischen geändert\. Beim Speichern/)).toBeInTheDocument();
expect(vi.mocked(fetchHive).mock.calls.length).toBeGreaterThan(calls);
});
it("is a main landmark", async () => {
renderPage();
expect(await screen.findByRole("main")).toBeInTheDocument();
});
it("announces undo and redo", async () => {
renderPage();
await addPart("cover");
@@ -18,7 +18,9 @@ export default function HiveEditorPage() {
const connectivity = useConnectivity();
const backHref = `/colonies/${encodeURIComponent(id)}`;
const colony = useQuery({ queryKey: ["colonies", id], queryFn: () => fetchColony(id) });
const hive = useQuery({ queryKey: hiveKeys.detail(id), queryFn: () => fetchHive(id),
const hive = useQuery({
queryKey: hiveKeys.detail(id),
queryFn: () => fetchHive(id),
// A colony without a hive answers 404 for good; no point retrying.
retry: (count, error) => hiveErrorCode(error) !== "HIVE_NOT_FOUND" && count < 3,
});
@@ -72,6 +74,7 @@ export default function HiveEditorPage() {
setup={hive.data}
catalog={catalog}
canManageTemplates={hasPermission("hive-systems:manage")}
onRefresh={() => void hive.refetch()}
/>
);
}
@@ -140,17 +140,19 @@ export function InspectionHiveSection({
catalog,
errorText,
}: InspectionHiveSectionProps) {
const sectionRef = useRef<HTMLDivElement>(null);
const headingRef = useRef<HTMLHeadingElement>(null);
const [outlineShown, setOutlineShown] = useState(false);
const [announcement, setAnnouncement] = useState("");
return (
// A portal form inside the card (frame/part sheet) must never submit the enclosing inspection form.
<div
ref={sectionRef}
data-testid="hive-section"
tabIndex={-1}
className="flex flex-col gap-4 outline-none"
onSubmit={(event) => event.stopPropagation()}
>
<h3 ref={headingRef} tabIndex={-1} className="sr-only">
Beute-Änderungen
</h3>
<p role="status" aria-live="polite" className="sr-only">
{announcement}
</p>
@@ -162,6 +164,8 @@ export function InspectionHiveSection({
mode={canEdit ? "inspection" : "view"}
onOpsChange={canEdit ? onOpsChange : undefined}
initialOps={canEdit ? ops : undefined}
initialShowOutline={outlineShown}
onShowOutlineChange={setOutlineShown}
/>
</div>
{canEdit && ops.length > 0 && base && catalog ? (
@@ -171,7 +175,7 @@ export function InspectionHiveSection({
catalog={catalog}
onChange={onOpsReplace}
onUndone={(text) => setAnnouncement(`Rückgängig: ${text}`)}
onEmptied={() => sectionRef.current?.focus()}
onEmptied={() => headingRef.current?.focus()}
/>
) : null}
{errorText ? (
@@ -50,10 +50,16 @@ vi.mock("@/lib/hive/api", async (importOriginal) => ({
fetchHiveSystems: vi.fn(),
}));
const card = vi.hoisted(() => ({ renders: [] as { mode?: string; initialOps?: unknown[] }[] }));
const card = vi.hoisted(() => ({ renders: [] as { mode?: string; initialOps?: unknown[]; initialShowOutline?: boolean }[] }));
vi.mock("@/components/hive/hive-card", () => ({
HiveCard: (props: { mode?: string; initialOps?: unknown[]; onOpsChange?: (ops: unknown[]) => void }) => {
card.renders.push({ mode: props.mode, initialOps: props.initialOps });
HiveCard: (props: {
mode?: string;
initialOps?: unknown[];
initialShowOutline?: boolean;
onOpsChange?: (ops: unknown[]) => void;
onShowOutlineChange?: (shown: boolean) => void;
}) => {
card.renders.push({ mode: props.mode, initialOps: props.initialOps, initialShowOutline: props.initialShowOutline });
const fill = { op: "setState", id: "fe", key: "syrupLiters", value: 2 };
const frame = { op: "setFrame", boxId: "b1", slot: 5, type: "BROOD", name: null };
const add = {
@@ -73,6 +79,9 @@ vi.mock("@/components/hive/hive-card", () => ({
<button type="button" onClick={() => props.onOpsChange?.([add, fillNew])}>
Test neue Zarge
</button>
<button type="button" onClick={() => props.onShowOutlineChange?.(true)}>
Test Liste
</button>
<button type="button" onClick={() => props.onOpsChange?.([fill, fill])}>
Test zwei gleiche
</button>
@@ -217,7 +226,7 @@ describe("NewInspectionPage: section Beute", () => {
await userEvent.click(screen.getByRole("button", { name: /^Rückgängig/ }));
expect(screen.getByTestId("hive-section")).toHaveFocus();
expect(screen.getByRole("heading", { name: "Beute-Änderungen" })).toHaveFocus();
});
it("keeps focus on a row when identical ops renumber their keys", async () => {
@@ -229,6 +238,16 @@ describe("NewInspectionPage: section Beute", () => {
expect(screen.getByRole("button", { name: /^Rückgängig/ })).toHaveFocus();
});
it("keeps the card's Liste toggle across the remount after Rückgängig", async () => {
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Test Futter" }));
await userEvent.click(screen.getByRole("button", { name: "Test Liste" }));
await userEvent.click(screen.getByRole("button", { name: /^Rückgängig/ }));
expect(card.renders.at(-1)?.initialShowOutline).toBe(true);
});
it("hides a refusal once the ops changed", async () => {
vi.mocked(createInspection).mockRejectedValue(
new ApiError(403, "Forbidden", { code: "HIVE_FORBIDDEN" }),