From e1fdf4c4a595ade6d1ffd5d6f40e3ac64abf13c4 Mon Sep 17 00:00:00 2001 From: Aaron Qian Date: Tue, 22 Sep 2026 17:20:47 -0700 Subject: [PATCH 1/3] app: control table page with tabs, groups and span reads --- src/lib/table-read.test.ts | 108 ++++++++++++++++ src/lib/table-read.ts | 72 +++++++++++ src/routes/table.tsx | 259 ++++++++++++++++++++++++++++++++++++- tests/e2e/table.spec.ts | 33 +++++ 4 files changed, 467 insertions(+), 5 deletions(-) create mode 100644 src/lib/table-read.test.ts create mode 100644 src/lib/table-read.ts create mode 100644 tests/e2e/table.spec.ts diff --git a/src/lib/table-read.test.ts b/src/lib/table-read.test.ts new file mode 100644 index 0000000..6fc8fa1 --- /dev/null +++ b/src/lib/table-read.test.ts @@ -0,0 +1,108 @@ +import type { Field, Value } from "@openservocore/client"; +import { expect, test } from "vitest"; +import descriptor from "../../../open-servo-core/descriptors/osc-servo/0.1.json"; +import { buildTable, type Row } from "./table-model"; +import { decodeSpan, MAX_READ, readRows, readSpans } from "./table-read"; + +const fields = descriptor.fields as Field[]; +const model = buildTable({ fields: () => fields }); + +function tabRows(name: string): Row[] { + const tab = model.tabs.find((t) => t.name === name); + if (tab === undefined) throw new Error(`no tab ${name}`); + return tab.groups.flatMap((g) => g.rows); +} + +function row(name: string, addr: number, width: number, kind: Field["kind"] = "uint"): Row { + const field: Field = { name, addr, width, access: "rw", kind, variants: [] }; + return { field, label: name, kind: { kind: "raw", width }, editable: true, blob: false }; +} + +test("the real descriptor's tabs read in a handful of spans each", () => { + expect(readSpans(tabRows("Settings"))).toEqual([ + { addr: 0, count: 9 }, + { addr: 16, count: 4 }, + { addr: 32, count: 59 }, + { addr: 92, count: 25 }, + ]); + expect(readSpans(tabRows("Calibration"))).toEqual([ + { addr: 128, count: 114 }, + { addr: 282, count: 6 }, + ]); + expect(readSpans(tabRows("Board"))).toEqual([ + { addr: 242, count: 40 }, + { addr: 288, count: 12 }, + ]); + expect(readSpans(tabRows("Live values"))).toHaveLength(9); +}); + +test("contiguous fields merge whatever their order, a gap splits", () => { + const rows = [row("c", 6, 2), row("a", 0, 4), row("b", 4, 2), row("d", 10, 1)]; + expect(readSpans(rows)).toEqual([ + { addr: 0, count: 8 }, + { addr: 10, count: 1 }, + ]); +}); + +test("a span stops at the protocol payload cap", () => { + const rows = [row("a", 0, 200), row("b", 200, 52), row("c", 252, 1)]; + expect(readSpans(rows)).toEqual([ + { addr: 0, count: MAX_READ }, + { addr: 252, count: 1 }, + ]); + expect(readSpans([row("a", 0, 200), row("b", 200, 53)])).toEqual([ + { addr: 0, count: 200 }, + { addr: 200, count: 53 }, + ]); +}); + +/** Records the slice each field got; the value is the slice's first byte. */ +function recorder(): { decode: (name: string, bytes: Uint8Array) => Value; slices: string[] } { + const slices: string[] = []; + return { + slices, + decode: (name, bytes) => { + slices.push(`${name}:${Array.from(bytes).join(",")}`); + return { kind: "uint", value: bytes[0] ?? -1 }; + }, + }; +} + +test("decodeSpan slices each field at its offset and skips rows outside the span", () => { + const rows = [row("a", 10, 1), row("b", 11, 2), row("far", 20, 1)]; + const d = recorder(); + const values = decodeSpan(d, rows, { addr: 10, count: 3 }, new Uint8Array([7, 8, 9])); + expect(d.slices).toEqual(["a:7", "b:8,9"]); + expect([...values]).toEqual([ + ["a", 7], + ["b", 8], + ]); +}); + +test("decodeSpan rejects a short read", () => { + const d = recorder(); + expect(() => decodeSpan(d, [row("a", 4, 2)], { addr: 4, count: 2 }, new Uint8Array(1))).toThrow( + "read at 0x004 returned 1 of 2 bytes", + ); +}); + +test("readRows reads the spans in order, one at a time", async () => { + const rows = [row("a", 0, 1), row("b", 1, 1), row("c", 5, 1)]; + const d = recorder(); + const calls: string[] = []; + let pending = 0; + const values = await readRows(rows, d, async (addr, count) => { + expect(pending).toBe(0); + pending++; + calls.push(`${addr}+${count}`); + await Promise.resolve(); + pending--; + return Uint8Array.from({ length: count }, (_, i) => addr + i); + }); + expect(calls).toEqual(["0+2", "5+1"]); + expect([...values]).toEqual([ + ["a", 0], + ["b", 1], + ["c", 5], + ]); +}); diff --git a/src/lib/table-read.ts b/src/lib/table-read.ts new file mode 100644 index 0000000..7980f69 --- /dev/null +++ b/src/lib/table-read.ts @@ -0,0 +1,72 @@ +import type { Descriptor } from "@openservocore/client"; +import type { EditValue } from "./edit"; +import { hexAddr } from "./format"; +import type { Row } from "./table-model"; + +/** A READ payload is at most 252 bytes (protocol sec 5.1). */ +export const MAX_READ = 252; + +export interface Span { + addr: number; + count: number; +} + +export type Values = ReadonlyMap; + +/** + * The reads covering `rows`: fields in address order, each joined to the + * span before it when it starts inside or right after that span and keeps + * it within MAX_READ. A gap between fields starts a new span. + */ +export function readSpans(rows: readonly Row[]): Span[] { + const fields = rows.map((r) => r.field).sort((a, b) => a.addr - b.addr); + const spans: Span[] = []; + for (const f of fields) { + const last = spans.at(-1); + const end = f.addr + f.width; + if (last !== undefined && f.addr <= last.addr + last.count && end - last.addr <= MAX_READ) { + last.count = Math.max(last.count, end - last.addr); + } else { + spans.push({ addr: f.addr, count: f.width }); + } + } + return spans; +} + +/** The rows one span's bytes hold, decoded through the descriptor's codec. */ +export function decodeSpan( + descriptor: Pick, + rows: readonly Row[], + span: Span, + bytes: Uint8Array, +): Map { + if (bytes.length !== span.count) { + throw new Error( + `read at ${hexAddr(span.addr)} returned ${bytes.length} of ${span.count} bytes`, + ); + } + const values = new Map(); + for (const { field } of rows) { + const at = field.addr - span.addr; + if (at < 0 || at + field.width > span.count) continue; + values.set( + field.name, + descriptor.decode(field.name, bytes.subarray(at, at + field.width)).value, + ); + } + return values; +} + +/** Every row's value, one span read after another. */ +export async function readRows( + rows: readonly Row[], + descriptor: Pick, + read: (addr: number, count: number) => Promise, +): Promise { + const values = new Map(); + for (const span of readSpans(rows)) { + const bytes = await read(span.addr, span.count); + for (const [name, value] of decodeSpan(descriptor, rows, span, bytes)) values.set(name, value); + } + return values; +} diff --git a/src/routes/table.tsx b/src/routes/table.tsx index 935008e..7b7db4e 100644 --- a/src/routes/table.tsx +++ b/src/routes/table.tsx @@ -1,14 +1,263 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { useSession } from "../lib/session"; +import type { Descriptor } from "@openservocore/client"; +import { createFileRoute, Link } from "@tanstack/react-router"; +import { ChevronRight, CircleHelp, Cog, Download } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import type { EditValue } from "@/lib/edit"; +import { hexAddr } from "@/lib/format"; +import { useSession } from "@/lib/session"; +import { + buildTable, + formatRow, + type Group, + type Link as LinkTarget, + type Row, + type Tab, +} from "@/lib/table-model"; +import { readRows, type Values } from "@/lib/table-read"; export const Route = createFileRoute("/table")({ component: TablePage }); +const HELP: ReadonlyMap = new Map([ + [ + "Control loops", + "Gains of the current, velocity and position loops and the observer behind them.", + ], + ["Safety", "Trip thresholds and what the servo does when one is crossed."], + ["Thermal", "The winding temperature model and its cutoff."], + ["Motor model", "Electrical and mechanical constants of the motor the estimators rely on."], + ["Profile/capture", "Read profiles and the telemetry burst window."], +]); + +const linkClass = "text-sm text-accent underline-offset-4 hover:underline"; + function TablePage() { - const { status } = useSession(); + const { status, selected, descriptor, descriptorError } = useSession(); + const picked = status === "ready" && selected !== undefined; return ( <> -

Control Table

- {status !== "ready" &&

Connect first.

} +
+

Control table

+ {picked && ( + + + ID {selected} + + )} +
+ {!picked ? ( +

Pick a servo in the sidebar.

+ ) : descriptorError !== undefined ? ( +

{descriptorError}

+ ) : descriptor === undefined ? ( + + ) : ( + + )} ); } + +function Table({ id, descriptor }: { id: number; descriptor: Descriptor }) { + const model = useMemo(() => buildTable(descriptor), [descriptor]); + return ( + + + {model.tabs.map((tab) => ( + + {tab.name} + + ))} + + {model.tabs.map((tab) => ( + + + + ))} + + ); +} + +function TabPanel({ id, descriptor, tab }: { id: number; descriptor: Descriptor; tab: Tab }) { + const { run } = useSession(); + const [values, setValues] = useState(); + const [error, setError] = useState(); + + useEffect(() => { + let live = true; + const rows = tab.groups.flatMap((g) => g.rows); + run((c) => readRows(rows, descriptor, (addr, count) => c.read(id, addr, count))).then( + (v) => { + if (live) setValues(v); + }, + (e: unknown) => { + if (live) setError(e instanceof Error ? e.message : String(e)); + }, + ); + return () => { + live = false; + }; + }, [run, id, descriptor, tab]); + + return ( +
+ {error !== undefined &&

{error}

} + {tab.groups.map((group) => ( + + ))} +
+ ); +} + +function GroupSection({ + group, + values, + loading, +}: { + group: Group; + values: Values | undefined; + loading: boolean; +}) { + return ( + +
+ + + {group.label} + {group.rows.length} + + {group.help && } +
+ +
+ + {group.rows.map((row) => ( + + ))} + +
+ + + ); +} + +function HelpMark({ label }: { label: string }) { + return ( + + + + + + {HELP.get(label)} + + + ); +} + +function RowLine({ + row, + value, + loading, +}: { + row: Row; + value: EditValue | undefined; + loading: boolean; +}) { + return ( + + +
{row.label}
+
+ {row.field.name} {hexAddr(row.field.addr)} +
+ + + {value !== undefined ? ( + + ) : loading ? ( + + ) : ( + "-" + )} + + + ); +} + +function ValueCell({ row, value }: { row: Row; value: EditValue }) { + return ( +
+ + {formatRow(row, value)} + {row.hint !== undefined && ( + <> + {" "} + {row.hint} + + )} + + {row.link !== undefined && ( + <> + {" "} + + + )} + {row.blob && value instanceof Uint8Array && ( + <> + {" "} + + + )} +
+ ); +} + +function RowLink({ target }: { target: LinkTarget }) { + return target === "connection" ? ( + + Change in Connection + + ) : ( + + Change on Servo page + + ); +} + +function download(name: string, bytes: Uint8Array): void { + const url = URL.createObjectURL(new Blob([bytes.slice()])); + const a = document.createElement("a"); + a.href = url; + a.download = name; + a.click(); + URL.revokeObjectURL(url); +} diff --git a/tests/e2e/table.spec.ts b/tests/e2e/table.spec.ts new file mode 100644 index 0000000..90b2fd1 --- /dev/null +++ b/tests/e2e/table.spec.ts @@ -0,0 +1,33 @@ +import { expect, test, type Locator, type Page } from "@playwright/test"; +import { gotoSim } from "./helpers"; + +async function openTable(page: Page): Promise { + await gotoSim(page, [1, 2]); + await page.getByRole("button", { name: "ID 1" }).click(); + await page.getByRole("link", { name: "Control table" }).click(); + // A cold dev server compiles the route chunk on this first request. + await expect(page.getByRole("tab", { name: "Settings" })).toBeVisible({ timeout: 15_000 }); +} + +/** The row whose register name is `name`. */ +function row(page: Page, name: string): Locator { + return page.getByRole("row").filter({ has: page.getByText(name, { exact: true }) }); +} + +test("the Settings tab reads the id row", async ({ page }) => { + await openTable(page); + await page.getByRole("button", { name: /^Identity and bus/ }).click(); + await expect(row(page, "id").getByRole("cell")).toHaveText(/^1\b/); +}); + +test("the Board tab reads the sim's shunt resistance", async ({ page }) => { + await openTable(page); + await page.getByRole("tab", { name: "Board" }).click(); + await expect(row(page, "shunt_r_mohm").getByRole("cell")).toHaveText("33 mOhm"); +}); + +test("the Live values tab reads an integer position", async ({ page }) => { + await openTable(page); + await page.getByRole("tab", { name: "Live values" }).click(); + await expect(row(page, "pos").getByRole("cell")).toHaveText(/^\d+$/); +}); From c342685701a1477529180f145ff27952e888add9 Mon Sep 17 00:00:00 2001 From: Aaron Qian Date: Tue, 22 Sep 2026 17:35:17 -0700 Subject: [PATCH 2/3] app: control table editing through the popover and the live values poll --- src/lib/edit.test.ts | 11 +++ src/lib/edit.ts | 20 ++++- src/lib/table-live.test.ts | 113 +++++++++++++++++++++++ src/lib/table-live.ts | 52 +++++++++++ src/routes/table.tsx | 168 ++++++++++++++++++++++++++++------- tests/e2e/helpers.ts | 16 +++- tests/e2e/table-edit.spec.ts | 57 ++++++++++++ tests/e2e/table.spec.ts | 19 +--- 8 files changed, 408 insertions(+), 48 deletions(-) create mode 100644 src/lib/table-live.test.ts create mode 100644 src/lib/table-live.ts create mode 100644 tests/e2e/table-edit.spec.ts diff --git a/src/lib/edit.test.ts b/src/lib/edit.test.ts index 4925f6b..1fdd097 100644 --- a/src/lib/edit.test.ts +++ b/src/lib/edit.test.ts @@ -8,6 +8,7 @@ import { parseInput, rangeHint, toRaw, + toValue, type NumberKind, } from "./edit"; @@ -213,3 +214,13 @@ describe("round trips", () => { }); }); }); + +test("toValue tags a parsed edit by the field's kind and rejects a mismatch", () => { + expect(toValue(id, 7)).toEqual({ kind: "uint", value: 7 }); + expect(toValue(goal, -3)).toEqual({ kind: "int", value: -3 }); + expect(toValue(mode, 2)).toEqual({ kind: "enum", value: 2 }); + expect(toValue(torque, true)).toEqual({ kind: "bool", value: true }); + const bytes = new Uint8Array([1, 2, 3, 4]); + expect(toValue(words, bytes)).toEqual({ kind: "bytes", value: bytes }); + expect(() => toValue(torque, 1)).toThrow("torque_enable is bool, not number"); +}); diff --git a/src/lib/edit.ts b/src/lib/edit.ts index 3280433..2e1e317 100644 --- a/src/lib/edit.ts +++ b/src/lib/edit.ts @@ -1,4 +1,4 @@ -import type { Field } from "@openservocore/client"; +import type { Field, Value } from "@openservocore/client"; /** `min`/`max` are the descriptor's bounds in counts; `scale` is display units per count. */ export interface NumberKind { @@ -40,6 +40,24 @@ export type ValueOf = K extends BoolKind export type ParseResult = { ok: true; value: T } | { ok: false; reason: string }; +/** The codec's tagged value for a parsed edit; the tag follows the field, so `raw` must match it. */ +export function toValue(field: Pick, raw: EditValue): Value { + switch (field.kind) { + case "uint": + case "int": + case "enum": + if (typeof raw === "number") return { kind: field.kind, value: raw }; + break; + case "bool": + if (typeof raw === "boolean") return { kind: "bool", value: raw }; + break; + case "bytes": + if (raw instanceof Uint8Array) return { kind: "bytes", value: raw }; + break; + } + throw new Error(`${field.name} is ${field.kind}, not ${typeof raw}`); +} + export function fieldKind(field: Field): FieldKind { switch (field.kind) { case "uint": diff --git a/src/lib/table-live.test.ts b/src/lib/table-live.test.ts new file mode 100644 index 0000000..b84a1e1 --- /dev/null +++ b/src/lib/table-live.test.ts @@ -0,0 +1,113 @@ +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import { spanHolding, startPoll } from "./table-live"; + +const spans = [ + { addr: 0, count: 9 }, + { addr: 16, count: 4 }, +]; + +test("spanHolding picks the span the field lies inside", () => { + expect(spanHolding(spans, { addr: 2, width: 2 })).toBe(spans[0]); + expect(spanHolding(spans, { addr: 16, width: 4 })).toBe(spans[1]); +}); + +test("spanHolding falls back to the field's own extent when no span holds all of it", () => { + expect(spanHolding(spans, { addr: 8, width: 2 })).toEqual({ addr: 8, count: 2 }); + expect(spanHolding([], { addr: 30, width: 1 })).toEqual({ addr: 30, count: 1 }); +}); + +/** A tick whose completion the test controls. */ +function gate(): { tick: () => Promise; release: () => void; calls: number } { + const resolvers: (() => void)[] = []; + const g = { + calls: 0, + tick: () => { + g.calls++; + return new Promise((resolve) => resolvers.push(resolve)); + }, + release: () => { + for (const r of resolvers.splice(0)) r(); + }, + }; + return g; +} + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +test("ticks at once, then once per period", async () => { + const g = gate(); + const stop = startPoll({ periodMs: 100, tick: g.tick, onError: () => undefined }); + expect(g.calls).toBe(1); + g.release(); + await vi.advanceTimersByTimeAsync(100); + expect(g.calls).toBe(2); + g.release(); + await vi.advanceTimersByTimeAsync(100); + expect(g.calls).toBe(3); + stop(); +}); + +test("a tick still pending is not overlapped; the next period after it resolves ticks", async () => { + const g = gate(); + const stop = startPoll({ periodMs: 100, tick: g.tick, onError: () => undefined }); + await vi.advanceTimersByTimeAsync(350); + expect(g.calls).toBe(1); + g.release(); + await vi.advanceTimersByTimeAsync(100); + expect(g.calls).toBe(2); + stop(); +}); + +test("a hold skips ticks until it lifts", async () => { + const g = gate(); + let held = true; + const stop = startPoll({ + periodMs: 100, + tick: g.tick, + hold: () => held, + onError: () => undefined, + }); + await vi.advanceTimersByTimeAsync(300); + expect(g.calls).toBe(0); + held = false; + await vi.advanceTimersByTimeAsync(100); + expect(g.calls).toBe(1); + stop(); +}); + +test("stop ends the ticks and silences a late error", async () => { + const errors: unknown[] = []; + let reject: (e: Error) => void = () => undefined; + const tick = vi.fn( + () => + new Promise((_, rej) => { + reject = rej; + }), + ); + const stop = startPoll({ periodMs: 100, tick, onError: (e) => errors.push(e) }); + stop(); + reject(new Error("late")); + await vi.advanceTimersByTimeAsync(500); + expect(tick).toHaveBeenCalledTimes(1); + expect(errors).toEqual([]); +}); + +test("an error is reported and polling goes on", async () => { + const errors: unknown[] = []; + let fail = true; + const tick = vi.fn(() => (fail ? Promise.reject(new Error("bus")) : Promise.resolve())); + const stop = startPoll({ periodMs: 100, tick, onError: (e) => errors.push(e) }); + await vi.advanceTimersByTimeAsync(0); + expect(errors).toHaveLength(1); + fail = false; + await vi.advanceTimersByTimeAsync(200); + expect(tick).toHaveBeenCalledTimes(3); + expect(errors).toHaveLength(1); + stop(); +}); diff --git a/src/lib/table-live.ts b/src/lib/table-live.ts new file mode 100644 index 0000000..0115451 --- /dev/null +++ b/src/lib/table-live.ts @@ -0,0 +1,52 @@ +import type { Field } from "@openservocore/client"; +import type { Span } from "./table-read"; + +export const LIVE_POLL_MS = 1000; +export const FLASH_MS = 800; + +/** The span a re-read after a write covers: the one holding `field`, else the field alone. */ +export function spanHolding(spans: readonly Span[], field: Pick): Span { + const end = field.addr + field.width; + return ( + spans.find((s) => s.addr <= field.addr && end <= s.addr + s.count) ?? { + addr: field.addr, + count: field.width, + } + ); +} + +export interface PollOptions { + periodMs: number; + /** One read pass. */ + tick: () => Promise; + /** While true a tick is skipped: an edit is in flight and the next tick picks up its result. */ + hold?: () => boolean; + onError: (error: unknown) => void; +} + +/** + * Ticks at once and then every `periodMs`, skipping a tick while the previous + * one is still pending or `hold` is set. An error is reported and polling goes + * on; only the returned stop ends it. + */ +export function startPoll(o: PollOptions): () => void { + let pending = false; + let stopped = false; + const tick = (): void => { + if (stopped || pending || o.hold?.() === true) return; + pending = true; + o.tick() + .catch((e: unknown) => { + if (!stopped) o.onError(e); + }) + .finally(() => { + pending = false; + }); + }; + const timer = setInterval(tick, o.periodMs); + tick(); + return () => { + stopped = true; + clearInterval(timer); + }; +} diff --git a/src/routes/table.tsx b/src/routes/table.tsx index 7b7db4e..a653ea3 100644 --- a/src/routes/table.tsx +++ b/src/routes/table.tsx @@ -1,13 +1,14 @@ import type { Descriptor } from "@openservocore/client"; import { createFileRoute, Link } from "@tanstack/react-router"; -import { ChevronRight, CircleHelp, Cog, Download } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { ChevronRight, CircleHelp, Cog, Download, RefreshCw } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Skeleton } from "@/components/ui/skeleton"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import type { EditValue } from "@/lib/edit"; +import { ValueEditor } from "@/components/value-editor"; +import { toValue, type EditValue } from "@/lib/edit"; import { hexAddr } from "@/lib/format"; import { useSession } from "@/lib/session"; import { @@ -17,8 +18,10 @@ import { type Link as LinkTarget, type Row, type Tab, + type TabName, } from "@/lib/table-model"; -import { readRows, type Values } from "@/lib/table-read"; +import { FLASH_MS, LIVE_POLL_MS, spanHolding, startPoll } from "@/lib/table-live"; +import { decodeSpan, readRows, readSpans, type Values } from "@/lib/table-read"; export const Route = createFileRoute("/table")({ component: TablePage }); @@ -68,44 +71,123 @@ function TablePage() { function Table({ id, descriptor }: { id: number; descriptor: Descriptor }) { const model = useMemo(() => buildTable(descriptor), [descriptor]); + const [tab, setTab] = useState("Settings"); + const [refresh, setRefresh] = useState(0); return ( - - - {model.tabs.map((tab) => ( - - {tab.name} - - ))} - - {model.tabs.map((tab) => ( - - + { + setTab(v as TabName); + }} + > +
+ + {model.tabs.map((t) => ( + + {t.name} + + ))} + + {tab !== "Live values" && ( + + )} +
+ {model.tabs.map((t) => ( + + ))}
); } -function TabPanel({ id, descriptor, tab }: { id: number; descriptor: Descriptor; tab: Tab }) { +interface Flash { + name: string; + seq: number; +} + +function TabPanel({ + id, + descriptor, + tab, + refresh, +}: { + id: number; + descriptor: Descriptor; + tab: Tab; + refresh: number; +}) { const { run } = useSession(); const [values, setValues] = useState(); const [error, setError] = useState(); + const [flash, setFlash] = useState(); + const holds = useRef(0); + const rows = useMemo(() => tab.groups.flatMap((g) => g.rows), [tab]); + const spans = useMemo(() => readSpans(rows), [rows]); useEffect(() => { let live = true; - const rows = tab.groups.flatMap((g) => g.rows); - run((c) => readRows(rows, descriptor, (addr, count) => c.read(id, addr, count))).then( - (v) => { - if (live) setValues(v); - }, - (e: unknown) => { - if (live) setError(e instanceof Error ? e.message : String(e)); - }, - ); + const load = async () => { + const v = await run((c) => + readRows(rows, descriptor, (addr, count) => c.read(id, addr, count)), + ); + if (!live) return; + setValues(v); + setError(undefined); + }; + const fail = (e: unknown) => { + if (live) setError(e instanceof Error ? e.message : String(e)); + }; + const stop = + tab.name === "Live values" + ? startPoll({ + periodMs: LIVE_POLL_MS, + tick: load, + hold: () => holds.current > 0, + onError: fail, + }) + : (load().catch(fail), undefined); return () => { live = false; + stop?.(); }; - }, [run, id, descriptor, tab]); + }, [run, id, descriptor, tab, rows, refresh]); + + useEffect(() => { + if (flash === undefined) return; + const timer = setTimeout(() => { + setFlash(undefined); + }, FLASH_MS); + return () => { + clearTimeout(timer); + }; + }, [flash]); + + async function apply(row: Row, raw: EditValue): Promise { + const { field } = row; + const bytes = descriptor.encode(field.name, toValue(field, raw)); + const span = spanHolding(spans, field); + holds.current++; + try { + const fresh = await run(async (c) => { + await c.write(id, field.addr, bytes); + return decodeSpan(descriptor, rows, span, await c.read(id, span.addr, span.count)); + }); + setValues((v) => new Map([...(v ?? []), ...fresh])); + setFlash((f) => ({ name: field.name, seq: (f?.seq ?? 0) + 1 })); + } finally { + holds.current--; + } + } return (
@@ -116,6 +198,8 @@ function TabPanel({ id, descriptor, tab }: { id: number; descriptor: Descriptor; group={group} values={values} loading={values === undefined && error === undefined} + flashed={flash?.name} + onApply={apply} /> ))}
@@ -126,10 +210,14 @@ function GroupSection({ group, values, loading, + flashed, + onApply, }: { group: Group; values: Values | undefined; loading: boolean; + flashed: string | undefined; + onApply: (row: Row, raw: EditValue) => Promise; }) { return ( @@ -150,6 +238,8 @@ function GroupSection({ row={row} value={values?.get(row.field.name)} loading={loading} + flashed={row.field.name === flashed} + onApply={onApply} /> ))} @@ -178,13 +268,19 @@ function RowLine({ row, value, loading, + flashed, + onApply, }: { row: Row; value: EditValue | undefined; loading: boolean; + flashed: boolean; + onApply: (row: Row, raw: EditValue) => Promise; }) { return ( - +
{row.label}
@@ -193,7 +289,7 @@ function RowLine({ {value !== undefined ? ( - + ) : loading ? ( ) : ( @@ -204,11 +300,23 @@ function RowLine({ ); } -function ValueCell({ row, value }: { row: Row; value: EditValue }) { +function ValueCell({ + row, + value, + onApply, +}: { + row: Row; + value: EditValue; + onApply: (row: Row, raw: EditValue) => Promise; +}) { return (
- {formatRow(row, value)} + {row.editable && !row.blob ? ( + onApply(row, raw)} /> + ) : ( + formatRow(row, value) + )} {row.hint !== undefined && ( <> {" "} diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts index d9fb7f0..4bef242 100644 --- a/tests/e2e/helpers.ts +++ b/tests/e2e/helpers.ts @@ -1,7 +1,21 @@ -import { expect, type Page } from "@playwright/test"; +import { expect, type Locator, type Page } from "@playwright/test"; /** Lands on the home page with a simulated fleet of `ids` already scanned. */ export async function gotoSim(page: Page, ids: number[]): Promise { await page.goto(`/?sim=${ids.join(",")}`); await expect(page.getByRole("button", { name: /^Connected/ })).toBeVisible(); } + +/** Selects ID 1 of a simulated fleet of 1 and 2 and opens the control table. */ +export async function openTable(page: Page): Promise { + await gotoSim(page, [1, 2]); + await page.getByRole("button", { name: "ID 1" }).click(); + await page.getByRole("link", { name: "Control table" }).click(); + // A cold dev server compiles the route chunk on this first request. + await expect(page.getByRole("tab", { name: "Settings" })).toBeVisible({ timeout: 15_000 }); +} + +/** The control table row whose register name is `name`. */ +export function tableRow(page: Page, name: string): Locator { + return page.getByRole("row").filter({ has: page.getByText(name, { exact: true }) }); +} diff --git a/tests/e2e/table-edit.spec.ts b/tests/e2e/table-edit.spec.ts new file mode 100644 index 0000000..07e86b2 --- /dev/null +++ b/tests/e2e/table-edit.spec.ts @@ -0,0 +1,57 @@ +import { expect, test, type Locator, type Page } from "@playwright/test"; +import { openTable, tableRow } from "./helpers"; + +/** Opens the row's editor and returns the popover. */ +async function openEditor(page: Page, name: string): Promise { + await tableRow(page, name).getByRole("button", { expanded: false }).click(); + return page.getByRole("dialog"); +} + +test("a number edit shows after apply and survives a refresh", async ({ page }) => { + await openTable(page); + await page.getByRole("button", { name: /^Identity and bus/ }).click(); + const cell = tableRow(page, "response_deadline_us").getByRole("cell"); + await expect(cell).toHaveText(/^\d+ us$/); + const next = Number(/^\d+/.exec((await cell.textContent()) ?? "")?.[0]) + 100; + const editor = await openEditor(page, "response_deadline_us"); + await editor.getByRole("textbox").fill(String(next)); + await editor.getByRole("button", { name: "Apply" }).click(); + await expect(editor).toHaveCount(0); + await expect(cell).toHaveText(`${next} us`); + await page.getByRole("button", { name: "Refresh" }).click(); + await expect(cell).toHaveText(`${next} us`); +}); + +test("a bool row flips through the switch", async ({ page }) => { + await openTable(page); + const cell = tableRow(page, "openloop_zero_brake").getByRole("cell"); + await expect(cell).toHaveText(/^(On|Off)$/); + const was = await cell.textContent(); + const editor = await openEditor(page, "openloop_zero_brake"); + await editor.getByRole("switch").click(); + await editor.getByRole("button", { name: "Apply" }).click(); + await expect(cell).toHaveText(was === "On" ? "Off" : "On"); +}); + +test("an enum row picks a variant through the select", async ({ page }) => { + await openTable(page); + const cell = tableRow(page, "stall_response").getByRole("cell"); + await expect(cell).toHaveText("Fault"); + const editor = await openEditor(page, "stall_response"); + await editor.getByRole("combobox").click(); + await page.getByRole("option", { name: "Yield" }).click(); + await editor.getByRole("button", { name: "Apply" }).click(); + await expect(cell).toHaveText("Yield"); +}); + +test("Live values moves on its own", async ({ page }) => { + await openTable(page); + await page.getByRole("tab", { name: "Live values" }).click(); + // The replayed pot sits at its rail for seconds at a time; the motor + // current moves every tick. + const cell = tableRow(page, "current").getByRole("cell"); + await expect(cell).toHaveText(/^\d+$/); + const first = (await cell.textContent()) ?? ""; + await expect(cell).not.toHaveText(first, { timeout: 5000 }); + await expect(cell).toHaveText(/^\d+$/); +}); diff --git a/tests/e2e/table.spec.ts b/tests/e2e/table.spec.ts index 90b2fd1..f07c1df 100644 --- a/tests/e2e/table.spec.ts +++ b/tests/e2e/table.spec.ts @@ -1,18 +1,5 @@ -import { expect, test, type Locator, type Page } from "@playwright/test"; -import { gotoSim } from "./helpers"; - -async function openTable(page: Page): Promise { - await gotoSim(page, [1, 2]); - await page.getByRole("button", { name: "ID 1" }).click(); - await page.getByRole("link", { name: "Control table" }).click(); - // A cold dev server compiles the route chunk on this first request. - await expect(page.getByRole("tab", { name: "Settings" })).toBeVisible({ timeout: 15_000 }); -} - -/** The row whose register name is `name`. */ -function row(page: Page, name: string): Locator { - return page.getByRole("row").filter({ has: page.getByText(name, { exact: true }) }); -} +import { expect, test } from "@playwright/test"; +import { openTable, tableRow as row } from "./helpers"; test("the Settings tab reads the id row", async ({ page }) => { await openTable(page); @@ -23,7 +10,7 @@ test("the Settings tab reads the id row", async ({ page }) => { test("the Board tab reads the sim's shunt resistance", async ({ page }) => { await openTable(page); await page.getByRole("tab", { name: "Board" }).click(); - await expect(row(page, "shunt_r_mohm").getByRole("cell")).toHaveText("33 mOhm"); + await expect(row(page, "shunt_r_mohm").getByRole("cell")).toHaveText("60 mOhm"); }); test("the Live values tab reads an integer position", async ({ page }) => { From 61b4ad3b818b11958fff25930f214c1dfc7932a3 Mon Sep 17 00:00:00 2001 From: Aaron Qian Date: Tue, 22 Sep 2026 17:49:57 -0700 Subject: [PATCH 3/3] app: control table quick search that jumps to the row --- src/components/table-search.tsx | 95 +++++++++++++++++++++++ src/lib/table-search.test.ts | 53 +++++++++++++ src/lib/table-search.ts | 27 +++++++ src/routes/table.tsx | 131 +++++++++++++++++++++++++++++--- tests/e2e/table-search.spec.ts | 41 ++++++++++ 5 files changed, 337 insertions(+), 10 deletions(-) create mode 100644 src/components/table-search.tsx create mode 100644 src/lib/table-search.test.ts create mode 100644 src/lib/table-search.ts create mode 100644 tests/e2e/table-search.spec.ts diff --git a/src/components/table-search.tsx b/src/components/table-search.tsx new file mode 100644 index 0000000..8143ae3 --- /dev/null +++ b/src/components/table-search.tsx @@ -0,0 +1,95 @@ +import { Search } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandInput, + CommandItem, + CommandList, + CommandShortcut, +} from "@/components/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { hexAddr } from "@/lib/format"; +import { matchRows, type SearchEntry } from "@/lib/table-model"; + +const PLACEHOLDER = "Search the table"; + +export function TableSearch({ + index, + onPick, +}: { + index: readonly SearchEntry[]; + onPick: (entry: SearchEntry) => void; +}) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const trigger = useRef(null); + const hits = useMemo(() => matchRows(index, query), [index, query]); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key !== "k" || !(e.metaKey || e.ctrlKey)) return; + e.preventDefault(); + // The pop-under hangs off the trigger, which a scrolled table has left behind. + if (!open) trigger.current?.scrollIntoView({ block: "nearest" }); + openChange(!open); + }; + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("keydown", onKey); + }; + }, [open]); + + function openChange(next: boolean) { + setOpen(next); + if (!next) setQuery(""); + } + + function pick(entry: SearchEntry) { + openChange(false); + onPick(entry); + } + + return ( + + + + + + {/* matchRows owns the matching, so cmdk must not score the items again. */} + + + + {query.trim() !== "" && Nothing matches.} + {hits.map((entry) => ( + { + pick(entry); + }} + > + {entry.label} + + {entry.name} {hexAddr(entry.addr)} + + + {entry.tab} / {entry.group} + + + ))} + + + + + ); +} diff --git a/src/lib/table-search.test.ts b/src/lib/table-search.test.ts new file mode 100644 index 0000000..931da4e --- /dev/null +++ b/src/lib/table-search.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "vitest"; +import { expandFor, initialOpen, searchTarget, withOpen } from "./table-search"; + +const groups = [ + { label: "Identity and bus", expanded: false }, + { label: "Motion limits", expanded: true }, + { label: "Thermal", expanded: false }, +]; + +test("searchTarget names the tab, the group and the row", () => { + expect( + searchTarget({ + label: "Response deadline", + name: "response_deadline_us", + tab: "Settings", + group: "Identity and bus", + addr: 0x012, + }), + ).toEqual({ tab: "Settings", group: "Identity and bus", row: "response_deadline_us" }); +}); + +test("initialOpen takes the model's defaults", () => { + expect([...initialOpen(groups)]).toEqual([ + ["Identity and bus", false], + ["Motion limits", true], + ["Thermal", false], + ]); +}); + +test("withOpen sets one group and leaves the rest", () => { + const open = withOpen(initialOpen(groups), "Motion limits", false); + expect(open.get("Motion limits")).toBe(false); + expect(open.get("Identity and bus")).toBe(false); + expect(open.get("Thermal")).toBe(false); +}); + +describe("expandFor", () => { + test("expands a collapsed group and leaves the rest", () => { + const open = expandFor(initialOpen(groups), "Thermal"); + expect(open.get("Thermal")).toBe(true); + expect(open.get("Identity and bus")).toBe(false); + expect(open.get("Motion limits")).toBe(true); + }); + + test("keeps the map itself when the group is already expanded", () => { + const open = initialOpen(groups); + expect(expandFor(open, "Motion limits")).toBe(open); + }); + + test("expands a group the map does not carry", () => { + expect(expandFor(new Map(), "Calibration").get("Calibration")).toBe(true); + }); +}); diff --git a/src/lib/table-search.ts b/src/lib/table-search.ts new file mode 100644 index 0000000..ed4aa93 --- /dev/null +++ b/src/lib/table-search.ts @@ -0,0 +1,27 @@ +import type { Group, SearchEntry, TabName } from "./table-model"; + +/** Where a search hit sits in the table: its tab, its group and its row's register name. */ +export interface SearchTarget { + tab: TabName; + group: string; + row: string; +} + +export function searchTarget(entry: SearchEntry): SearchTarget { + return { tab: entry.tab, group: entry.group, row: entry.name }; +} + +export type OpenGroups = ReadonlyMap; + +export function initialOpen(groups: readonly Pick[]): OpenGroups { + return new Map(groups.map((g) => [g.label, g.expanded])); +} + +export function withOpen(open: OpenGroups, group: string, value: boolean): OpenGroups { + return new Map(open).set(group, value); +} + +/** `open` with the jumped-to group expanded, or `open` itself when it already is. */ +export function expandFor(open: OpenGroups, group: string): OpenGroups { + return open.get(group) === true ? open : withOpen(open, group, true); +} diff --git a/src/routes/table.tsx b/src/routes/table.tsx index a653ea3..8e08fd4 100644 --- a/src/routes/table.tsx +++ b/src/routes/table.tsx @@ -7,6 +7,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 { TableSearch } from "@/components/table-search"; import { ValueEditor } from "@/components/value-editor"; import { toValue, type EditValue } from "@/lib/edit"; import { hexAddr } from "@/lib/format"; @@ -14,12 +15,23 @@ import { useSession } from "@/lib/session"; import { buildTable, formatRow, + searchIndex, type Group, type Link as LinkTarget, type Row, + type SearchEntry, type Tab, type TabName, + type TableModel, } from "@/lib/table-model"; +import { + expandFor, + initialOpen, + searchTarget, + withOpen, + type OpenGroups, + type SearchTarget, +} from "@/lib/table-search"; import { FLASH_MS, LIVE_POLL_MS, spanHolding, startPoll } from "@/lib/table-live"; import { decodeSpan, readRows, readSpans, type Values } from "@/lib/table-read"; @@ -38,9 +50,45 @@ const HELP: ReadonlyMap = new Map([ const linkClass = "text-sm text-accent underline-offset-4 hover:underline"; +/** A search hit to jump to; `seq` separates repeat jumps to the same row. */ +interface Jump extends SearchTarget { + seq: number; +} + function TablePage() { const { status, selected, descriptor, descriptorError } = useSession(); const picked = status === "ready" && selected !== undefined; + const model = useMemo( + () => (descriptor === undefined ? undefined : buildTable(descriptor)), + [descriptor], + ); + const index = useMemo(() => (model === undefined ? [] : searchIndex(model)), [model]); + const defaults = useMemo( + () => initialOpen(model === undefined ? [] : model.tabs.flatMap((t) => t.groups)), + [model], + ); + const [tab, setTab] = useState("Settings"); + const [toggled, setToggled] = useState(); + const [jump, setJump] = useState(); + const open = toggled ?? defaults; + + useEffect(() => { + if (jump === undefined) return; + const timer = setTimeout(() => { + setJump(undefined); + }, FLASH_MS); + return () => { + clearTimeout(timer); + }; + }, [jump]); + + function pick(entry: SearchEntry) { + const target = searchTarget(entry); + setTab(target.tab); + setToggled(expandFor(open, target.group)); + setJump((j) => ({ ...target, seq: (j?.seq ?? 0) + 1 })); + } + return ( <>
@@ -55,29 +103,61 @@ function TablePage() { ID {selected} )} + {picked && model !== undefined && ( +
+ +
+ )}
{!picked ? (

Pick a servo in the sidebar.

) : descriptorError !== undefined ? (

{descriptorError}

- ) : descriptor === undefined ? ( + ) : descriptor === undefined || model === undefined ? ( ) : ( - +
{ + setToggled(withOpen(open, group, next)); + }} + jump={jump} + /> )} ); } -function Table({ id, descriptor }: { id: number; descriptor: Descriptor }) { - const model = useMemo(() => buildTable(descriptor), [descriptor]); - const [tab, setTab] = useState("Settings"); +function Table({ + id, + descriptor, + model, + tab, + onTab, + open, + onToggle, + jump, +}: { + id: number; + descriptor: Descriptor; + model: TableModel; + tab: TabName; + onTab: (tab: TabName) => void; + open: OpenGroups; + onToggle: (group: string, open: boolean) => void; + jump: Jump | undefined; +}) { const [refresh, setRefresh] = useState(0); return ( { - setTab(v as TabName); + onTab(v as TabName); }} >
@@ -103,7 +183,16 @@ function Table({ id, descriptor }: { id: number; descriptor: Descriptor }) {
{model.tabs.map((t) => ( - + ))}
@@ -120,16 +209,23 @@ function TabPanel({ descriptor, tab, refresh, + open, + onToggle, + jump, }: { id: number; descriptor: Descriptor; tab: Tab; refresh: number; + open: OpenGroups; + onToggle: (group: string, open: boolean) => void; + jump: Jump | undefined; }) { const { run } = useSession(); const [values, setValues] = useState(); const [error, setError] = useState(); const [flash, setFlash] = useState(); + const body = useRef(null); const holds = useRef(0); const rows = useMemo(() => tab.groups.flatMap((g) => g.rows), [tab]); const spans = useMemo(() => readSpans(rows), [rows]); @@ -162,6 +258,12 @@ function TabPanel({ }; }, [run, id, descriptor, tab, rows, refresh]); + // A collapsed group's rows reach the DOM only with the render that expands it. + useEffect(() => { + if (jump === undefined) return; + body.current?.querySelector(`[data-row="${jump.row}"]`)?.scrollIntoView({ block: "center" }); + }, [jump, open]); + useEffect(() => { if (flash === undefined) return; const timer = setTimeout(() => { @@ -190,15 +292,19 @@ function TabPanel({ } return ( -
+
{error !== undefined &&

{error}

} {tab.groups.map((group) => ( { + onToggle(group.label, next); + }} values={values} loading={values === undefined && error === undefined} - flashed={flash?.name} + flashed={jump?.row ?? flash?.name} onApply={apply} /> ))} @@ -208,19 +314,23 @@ function TabPanel({ function GroupSection({ group, + open, + onOpenChange, values, loading, flashed, onApply, }: { group: Group; + open: boolean; + onOpenChange: (open: boolean) => void; values: Values | undefined; loading: boolean; flashed: string | undefined; onApply: (row: Row, raw: EditValue) => Promise; }) { return ( - +
@@ -279,6 +389,7 @@ function RowLine({ }) { return (
diff --git a/tests/e2e/table-search.spec.ts b/tests/e2e/table-search.spec.ts new file mode 100644 index 0000000..367c5ed --- /dev/null +++ b/tests/e2e/table-search.spec.ts @@ -0,0 +1,41 @@ +import { expect, test, type Page } from "@playwright/test"; +import { openTable, tableRow as row } from "./helpers"; + +async function search(page: Page, query: string): Promise { + await page.getByRole("button", { name: "Search the table" }).click(); + await page.getByPlaceholder("Search the table").fill(query); +} + +test("a label search lists the hit and Enter lands on its row", async ({ page }) => { + await openTable(page); + await search(page, "deadline"); + const hit = page.getByRole("option"); + await expect(hit).toHaveCount(1); + await expect(hit).toContainText("response_deadline_us 0x012"); + await expect(hit).toContainText("Settings / Identity and bus"); + await page.keyboard.press("Enter"); + await expect(page.getByRole("tab", { name: "Settings" })).toHaveAttribute( + "aria-selected", + "true", + ); + // Identity and bus starts collapsed, so a visible row means the jump expanded it. + const cell = row(page, "response_deadline_us").getByRole("cell"); + await expect(cell).toBeVisible(); + await expect(cell).toHaveText(/^\d+ us$/); +}); + +test("an address search lists the field at that address", async ({ page }) => { + await openTable(page); + await search(page, "0x020"); + const hit = page.getByRole("option"); + await expect(hit).toHaveCount(1); + await expect(hit).toContainText("pos_min_phys_counts 0x020"); + await expect(hit).toContainText("Settings / Motion limits"); +}); + +test("a query nothing matches says so", async ({ page }) => { + await openTable(page); + await search(page, "zzz"); + await expect(page.getByText("Nothing matches.")).toBeVisible(); + await expect(page.getByRole("option")).toHaveCount(0); +});