From fcf7c298f113c6c9f6dd61f62bb962c03228f6c2 Mon Sep 17 00:00:00 2001 From: Aaron Qian Date: Wed, 14 Oct 2026 17:39:36 -0700 Subject: [PATCH] servo: offer save and stamp after a calibration card edit --- src/components/calibration-card.tsx | 5 ++++ src/components/stamp-notice.tsx | 14 +++++++++++ src/routes/table.tsx | 19 ++++----------- tests/e2e/stamp.spec.ts | 37 ++++++++++++++++++++++++++++- 4 files changed, 59 insertions(+), 16 deletions(-) diff --git a/src/components/calibration-card.tsx b/src/components/calibration-card.tsx index 6072907..ca0302b 100644 --- a/src/components/calibration-card.tsx +++ b/src/components/calibration-card.tsx @@ -1,6 +1,7 @@ import type { Field } from "@openservocore/client"; import { Check, CircleQuestionMark, Ruler } from "lucide-react"; import { useId } from "react"; +import { StampNotice, useStampNotice } from "@/components/stamp-notice"; import { ValueEditor } from "@/components/value-editor"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -10,6 +11,7 @@ import { Skeleton } from "@/components/ui/skeleton"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useBus, useClientOnce } from "@/lib/bus/hooks"; import { CALIBRATION_REGISTERS, editReason, type CalibrationRegister } from "@/lib/calibration"; +import { STAMP_MISMATCH } from "@/lib/data-state"; import { hex16, hexAddr } from "@/lib/format"; import { useSession } from "@/lib/session"; import { stampWords } from "@/lib/stamp"; @@ -34,12 +36,14 @@ export function CalibrationCard({ id, uid }: { id: number; uid: string }) { (c) => (descriptor === undefined ? Promise.resolve(undefined) : c.plantStamp(id, descriptor)), [id, descriptor, cal, card?.data.flags], ); + const notice = useStampNotice(((card?.data.flags ?? 0) & STAMP_MISMATCH) !== 0); async function apply(field: Field, raw: number) { await bus.write(id, field.name, { kind: field.kind === "int" ? "int" : "uint", value: raw }); // The write dirties the CALIB span; the session re-reads the constants // every card on the page shows. refreshConstants(uid); + notice.onEdit(); } return ( @@ -67,6 +71,7 @@ export function CalibrationCard({ id, uid }: { id: number; uid: string }) { )} + {notice.shown && } {descriptorError !== undefined &&

{descriptorError}

} {cal === undefined || fields === undefined ? (
diff --git a/src/components/stamp-notice.tsx b/src/components/stamp-notice.tsx index 80723c2..c71e609 100644 --- a/src/components/stamp-notice.tsx +++ b/src/components/stamp-notice.tsx @@ -6,6 +6,20 @@ import { useBus } from "@/lib/bus/hooks"; import { useSession } from "@/lib/session"; import { saveAndStamp } from "@/lib/stamp"; +/** Whether the notice shows: an edit landed while the stamp matched, so a mismatch now is that edit's doing. */ +export function useStampNotice(mismatch: boolean) { + const [edited, setEdited] = useState(false); + return { + shown: edited && mismatch, + onEdit: () => { + if (!mismatch) setEdited(true); + }, + onStamped: () => { + setEdited(false); + }, + }; +} + /** Why closed loop went off after a covered edit, and the one way back the editor offers. */ export function StampNotice({ id, onStamped }: { id: number; onStamped: () => void }) { const bus = useBus(); diff --git a/src/routes/table.tsx b/src/routes/table.tsx index a1ee5fb..71db4f8 100644 --- a/src/routes/table.tsx +++ b/src/routes/table.tsx @@ -6,7 +6,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Skeleton } from "@/components/ui/skeleton"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { StampNotice } from "@/components/stamp-notice"; +import { StampNotice, useStampNotice } from "@/components/stamp-notice"; import { TableSearch } from "@/components/table-search"; import { ValueEditor } from "@/components/value-editor"; import { useBus, useReadOnce, useRegisters } from "@/lib/bus/hooks"; @@ -159,21 +159,12 @@ function Table({ }) { const { servos, values } = useSession(); const [refresh, setRefresh] = useState(0); - /** An edit landed while the stamp matched, so a mismatch now is that edit's doing. */ - const [edited, setEdited] = useState(false); const servo = servos.find((s) => s.id === id); const flags = (servo === undefined ? undefined : values.get(servo.uid)?.data.flags) ?? 0; - const mismatch = (flags & STAMP_MISMATCH) !== 0; + const notice = useStampNotice((flags & STAMP_MISMATCH) !== 0); return ( <> - {edited && mismatch && ( - { - setEdited(false); - }} - /> - )} + {notice.shown && } { @@ -211,9 +202,7 @@ function Table({ open={open} onToggle={onToggle} jump={jump?.tab === t.name ? jump : undefined} - onEdit={() => { - if (!mismatch) setEdited(true); - }} + onEdit={notice.onEdit} /> ))} diff --git a/tests/e2e/stamp.spec.ts b/tests/e2e/stamp.spec.ts index 4c4fe05..ec247aa 100644 --- a/tests/e2e/stamp.spec.ts +++ b/tests/e2e/stamp.spec.ts @@ -1,5 +1,5 @@ import { expect, test, type Locator, type Page } from "@playwright/test"; -import { openTable, tableRow } from "./helpers"; +import { gotoSim, openTable, tableRow } from "./helpers"; /** Opens the editor of velocity_limit_cps, a covered row low on the page, and lowers its value. */ async function editLowRow(page: Page): Promise { @@ -73,3 +73,38 @@ test("a stamp notice that mounts while the editor closes stays in view", async ( await expect(editor).toHaveCount(0); await expectInView(page, notice); }); + +test("a calibration card edit raises the same notice, and save and stamp makes the stamp match again", async ({ + page, +}) => { + await gotoSim(page, [1]); + await page.getByRole("button", { name: "ID 1" }).click(); + const card = page.getByRole("region", { name: "Calibration" }); + const stamp = card.getByRole("group", { name: "Plant stamp" }); + await expect(stamp).toContainText("Stamped: the calibration"); + + await card + .getByRole("group", { name: "Sensor highest" }) + .getByRole("button", { name: "4095 counts" }) + .click(); + await page.getByRole("textbox").fill("4000"); + await page.getByRole("button", { name: "Apply" }).click(); + await expect(stamp).toContainText("Changed since it was stamped"); + + const notice = page.getByRole("status", { name: "Stamp" }); + await expect(notice).toContainText("Closed loop is off: the stamp no longer matches."); + await notice.getByRole("button", { name: "Save and stamp" }).click(); + await expect(notice).toHaveCount(0); + + await expect(stamp).toContainText("Stamped: the calibration"); + const [, stored, computed] = + /= (0x[0-9a-f]{4}), computed (0x[0-9a-f]{4})/i.exec((await stamp.textContent()) ?? "") ?? []; + expect(stored).toBeDefined(); + expect(stored).toBe(computed); + await expect( + card + .getByRole("group", { name: "Sensor highest" }) + .getByRole("button", { name: "4000 counts" }), + ).toBeVisible(); + await expect(page.getByRole("status", { name: "Data state" })).toHaveCount(0); +});