diff --git a/src/components/about-card.tsx b/src/components/about-card.tsx index 3e224be..e429736 100644 --- a/src/components/about-card.tsx +++ b/src/components/about-card.tsx @@ -8,6 +8,7 @@ import { useReadOnce } from "@/lib/bus/hooks"; import { modelName } from "@/lib/descriptor"; import { formatVersion, hex16 } from "@/lib/format"; import { features, identityFrom, IDENTITY_REGISTERS } from "@/lib/identity"; +import { useSession } from "@/lib/session"; function Row({ label, children }: { label: string; children: ReactNode }) { return ( @@ -19,7 +20,9 @@ function Row({ label, children }: { label: string; children: ReactNode }) { } export function AboutCard({ id, uid }: { id: number; uid: string }) { - const { snapshot, error } = useReadOnce(id, IDENTITY_REGISTERS); + const { descriptor, descriptorError } = useSession(); + const { snapshot, error: readError } = useReadOnce(id, IDENTITY_REGISTERS, [descriptor]); + const error = (descriptor === undefined ? undefined : readError) ?? descriptorError; const identity = snapshot === undefined ? undefined : identityFrom(snapshot.read); const name = identity === undefined ? undefined : modelName(identity.model); diff --git a/src/lib/session.tsx b/src/lib/session.tsx index 25e4994..3d7f13a 100644 --- a/src/lib/session.tsx +++ b/src/lib/session.tsx @@ -149,7 +149,7 @@ class Controller { private readonly cards = new Map>(); private readonly subscriptions = new Map void>(); private readonly reading = new Set(); - private readonly fetching = new Set(); + private readonly fetching = new Map>(); private readonly listeners = new Set<() => void>(); readonly subscribe = (listener: () => void): (() => void) => { @@ -197,8 +197,7 @@ class Controller { if (ping === undefined) continue; const key = modelKey(ping); if (this.snap.models.has(key) || this.fetching.has(key)) continue; - this.fetching.add(key); - fetchDescriptor(ping.model, ping.fw) + const fetched = fetchDescriptor(ping.model, ping.fw) .then( (descriptor) => { if (this.snap.models.has(key)) { @@ -224,6 +223,7 @@ class Controller { .finally(() => { this.fetching.delete(key); }); + this.fetching.set(key, fetched); } } @@ -425,10 +425,12 @@ class Controller { } /** Test hook: a table into a simulated servo, the way `osc lut write` puts one there. */ - writePosLut(id: number, points: number[]): Promise { + async writePosLut(id: number, points: number[]): Promise { const servo = this.snap.state.servos.find((s) => s.id === id); + // The servo answers before its descriptor arrives; the write needs the descriptor. + if (servo?.ping !== undefined) await this.fetching.get(modelKey(servo.ping)); const descriptor = servo === undefined ? undefined : this.modelFor(servo)?.descriptor; - if (descriptor === undefined) return Promise.reject(new Error(`no descriptor for ID ${id}`)); + if (descriptor === undefined) throw new Error(`no descriptor for ID ${id}`); return this.bus.command((c) => c.writePosLut(id, descriptor, points)); } diff --git a/tests/e2e/control.spec.ts b/tests/e2e/control.spec.ts index 9b9e643..08ab71c 100644 --- a/tests/e2e/control.spec.ts +++ b/tests/e2e/control.spec.ts @@ -14,10 +14,21 @@ async function openLive(page: Page, opts: { debug?: boolean } = {}): Promise { +/** Each mode's goal unit, which the goal readout takes from the servo's read-back of `mode`. */ +const GOAL_UNIT = { + Position: / deg$/, + Velocity: / deg\/s$/, + Current: / mA$/, + "Open loop": / %$/, +} as const; + +async function pickMode(page: Page, mode: keyof typeof GOAL_UNIT): Promise { await page.getByRole("combobox", { name: "Mode" }).click(); await page.getByRole("option", { name: mode }).click(); await expect(page.getByRole("combobox", { name: "Mode" })).toHaveText(mode); + // The combobox shows the pick at once, but the goal control stays on the + // servo's old mode until the read-back lands: a goal typed sooner goes there. + await expect(page.getByLabel("Goal readout")).toHaveText(GOAL_UNIT[mode]); } async function setGoal(page: Page, value: string): Promise { diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts index 2d29dda..da20e28 100644 --- a/tests/e2e/helpers.ts +++ b/tests/e2e/helpers.ts @@ -37,6 +37,19 @@ export async function writeTable(page: Page, id: number, points: number[]): Prom ] as const); } +/** Holds every descriptor request until the returned release is called. */ +export async function holdDescriptors(page: Page): Promise<() => void> { + let release!: () => void; + const released = new Promise((resolve) => { + release = resolve; + }); + await page.route("**/descriptors/**", async (route) => { + await released; + await route.continue(); + }); + return release; +} + /** Lands on the home page with a simulated fleet of `ids` already scanned. */ export async function gotoSim( page: Page, diff --git a/tests/e2e/pos-lut.spec.ts b/tests/e2e/pos-lut.spec.ts index ca0b85c..fb0e00e 100644 --- a/tests/e2e/pos-lut.spec.ts +++ b/tests/e2e/pos-lut.spec.ts @@ -1,6 +1,6 @@ import { expect, test, type Page } from "@playwright/test"; import { errorSeries, hoverWords } from "../../src/lib/pos-lut"; -import { gotoSim, MG90A, writeTable } from "./helpers"; +import { gotoSim, holdDescriptors, MG90A, writeTable } from "./helpers"; /** The simulated servo's angle map: stops 5..4095 read 0..202 deg. */ const SIM = { @@ -84,3 +84,15 @@ test("the mg90-a table reads live, graded A, its error in degrees and percent of page.getByRole("region", { name: "Position calibration" }).getByLabel("Table state"), ).toHaveText("No table: the sensor is read as-is."); }); + +test("a table written while the descriptor is still on its way lands live", async ({ page }) => { + const land = await holdDescriptors(page); + await gotoSim(page, [1, 2]); + const written = writeTable(page, 1, MG90A.points); + land(); + await written; + await openServo(page, 1); + await expect( + page.getByRole("region", { name: "Position calibration" }).getByLabel("Table state"), + ).toHaveText("Live: the servo corrects every sensor sample through this table."); +}); diff --git a/tests/e2e/servo.spec.ts b/tests/e2e/servo.spec.ts index 440cb2e..4ef3ded 100644 --- a/tests/e2e/servo.spec.ts +++ b/tests/e2e/servo.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from "@playwright/test"; -import { CORE_VERSION, gotoSim } from "./helpers"; +import { CORE_VERSION, gotoSim, holdDescriptors } from "./helpers"; const UID_1 = "c94b8419d1092aec87de5fd151ce290f"; @@ -19,6 +19,17 @@ test("the servo page reports the selected servo's identity and health", async ({ await expect(health.getByText("No faults.")).toBeVisible(); }); +test("the about card reads the identity once a late descriptor lands", async ({ page }) => { + const land = await holdDescriptors(page); + await gotoSim(page, [1, 2]); + await page.getByRole("button", { name: "ID 1" }).click(); + const about = page.getByRole("region", { name: "About" }); + await expect(about).toBeVisible(); + land(); + await expect(about.getByText(CORE_VERSION)).toBeVisible(); + await expect(about.getByText(UID_1)).toBeVisible(); +}); + test("the servo page asks for a selection while none is made", async ({ page }) => { await page.goto("/servo?sim=1,2"); await expect(page.getByText("Pick a servo in the left pane.")).toBeVisible();