Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 20 additions & 26 deletions tests/e2e/calibration.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import { expect, test, type Locator, type Page } from "@playwright/test";
import { gotoSim } from "./helpers";

// The sim boots with the whole calibration block at zero, so three edits
// (sensor top, angle top, gear ratio) are what it takes to reach "Calibrated".
const VALUE = /^-?\d+(\.\d+)?( counts| deg)?$/;
// Every simulated servo boots with a real servo's calibration table, so the
// card opens Calibrated and each row reads its seeded value in the row's unit.
const SEEDED: readonly (readonly [string, string])[] = [
["Sensor lowest", "5 counts"],
["Sensor highest", "4095 counts"],
["Angle lowest", "0.00 deg"],
["Angle highest", "202.00 deg"],
["Gear ratio", "254.64"],
];

async function openCalibration(page: Page, id: number): Promise<Locator> {
await gotoSim(page, [1, 2]);
Expand All @@ -21,62 +27,50 @@ async function edit(card: Locator, row: string, from: string, text: string): Pro

test("the card shows the status chip and one row per calibration register", async ({ page }) => {
const card = await openCalibration(page, 1);
await card.getByText("Not calibrated").hover();
await expect(page.getByRole("tooltip")).toContainText("sensor lowest must be below");
for (const row of [
"Sensor lowest",
"Sensor highest",
"Angle lowest",
"Angle highest",
"Gear ratio",
]) {
await expect(card.getByText("Calibrated", { exact: true })).toBeVisible();
for (const [row, value] of SEEDED) {
const group = card.getByRole("group", { name: row });
await expect(group.getByRole("button", { name: VALUE })).toBeVisible();
await expect(group.getByRole("button", { name: value })).toBeVisible();
}
await expect(card.getByText("raw_max 0x082")).toBeVisible();
});

test("edits write through, the row shows the new value and the status follows", async ({
test("edits write through, the row shows the new value and the status stays Calibrated", async ({
page,
}) => {
const card = await openCalibration(page, 1);
await edit(card, "Sensor highest", "0 counts", "4000");
await edit(card, "Sensor highest", "4095 counts", "4000");
await expect(
card
.getByRole("group", { name: "Sensor highest" })
.getByRole("button", { name: "4000 counts" }),
).toBeVisible();
await expect(card.getByText("Not calibrated")).toBeVisible();
await expect(card.getByText("Calibrated", { exact: true })).toBeVisible();

await edit(card, "Angle highest", "0.00 deg", "180");
await edit(card, "Angle highest", "202.00 deg", "180");
await expect(
card.getByRole("group", { name: "Angle highest" }).getByRole("button", { name: "180.00 deg" }),
).toBeVisible();
await edit(card, "Gear ratio", "0.00", "1");
await expect(
card.getByRole("group", { name: "Gear ratio" }).getByRole("button", { name: "1.00" }),
).toBeVisible();
await expect(card.getByText("Calibrated", { exact: true })).toBeVisible();
});

test("an edit that inverts the sensor range is refused until cancelled", async ({ page }) => {
const card = await openCalibration(page, 1);
await edit(card, "Sensor highest", "0 counts", "4000");
const lowest = card.getByRole("group", { name: "Sensor lowest" });
await lowest.getByRole("button", { name: "0 counts" }).click();
await lowest.getByRole("button", { name: "5 counts" }).click();
const input = page.getByRole("textbox");
await input.fill("4100");
await expect(page.getByText("sensor lowest must be below sensor highest")).toBeVisible();
await expect(page.getByRole("button", { name: "Apply" })).toBeDisabled();
await page.getByRole("button", { name: "Cancel" }).click();
await expect(input).toBeHidden();
await expect(lowest.getByRole("button", { name: "0 counts" })).toBeVisible();
await expect(lowest.getByRole("button", { name: "5 counts" })).toBeVisible();

const highest = card.getByRole("group", { name: "Sensor highest" });
await highest.getByRole("button", { name: "4000 counts" }).click();
await highest.getByRole("button", { name: "4095 counts" }).click();
await input.fill("5000");
await expect(page.getByText("sensor range must sit inside 0 to 4095 counts")).toBeVisible();
await expect(page.getByRole("button", { name: "Apply" })).toBeDisabled();
await page.getByRole("button", { name: "Cancel" }).click();
await expect(highest.getByRole("button", { name: "4000 counts" })).toBeVisible();
await expect(highest.getByRole("button", { name: "4095 counts" })).toBeVisible();
});
34 changes: 21 additions & 13 deletions tests/e2e/control.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@ import { gotoSim } from "./helpers";
async function openLive(page: Page): Promise<void> {
await gotoSim(page, [1, 2]);
await page.getByRole("button", { name: "ID 1" }).click();
// The servo page proves the selection landed: leaving for Live while that
// navigation is still compiling on a cold dev server races the two.
await expect(page.getByRole("heading", { name: "ID 1" })).toBeVisible();
await page.getByRole("link", { name: "Live" }).click();
// A cold dev server compiles the Live route chunk on this first request.
await expect(page.getByRole("region", { name: /^Motion/ })).toBeVisible({ timeout: 15_000 });
await expect(page.getByText("waiting for data")).toHaveCount(0);
await expect(page.getByRole("combobox", { name: "Mode" })).toBeEnabled();
}

Expand Down Expand Up @@ -41,9 +45,9 @@ test("each mode shows its own goal control and the warning is open loop only", a
await expect(page.getByRole("alert")).toHaveCount(0);
await expect(page.getByLabel("Commanded duty value")).toHaveCount(0);
await pickMode(page, "Velocity");
await expect(page.getByLabel("Goal readout")).toHaveText(/ counts\/s$/);
await expect(page.getByLabel("Goal readout")).toHaveText(/ deg\/s$/);
await pickMode(page, "Position");
await expect(page.getByLabel("Goal readout")).toHaveText(/ counts$/);
await expect(page.getByLabel("Goal readout")).toHaveText(/ deg$/);
await pickMode(page, "Open loop");
await expect(page.getByLabel("Goal readout")).toHaveText(/ %$/);
await expect(page.getByRole("alert")).toBeVisible();
Expand All @@ -54,11 +58,12 @@ test("a velocity goal reads back into the Motion goal readout in the mode's unit
}) => {
await openLive(page);
await pickMode(page, "Velocity");
await setGoal(page, "600");
await expect(page.getByLabel("Goal readout")).toHaveText("600 counts/s");
// Inside velocity_limit_cps, which caps the slider at 74 deg/s.
await setGoal(page, "50");
await expect(page.getByLabel("Goal readout")).toHaveText("50 deg/s");
const motion = page.getByRole("region", { name: /^Motion/ });
await expect(motion).toBeVisible();
await expect(page.getByLabel("Goal value")).toHaveText("600 counts/s");
await expect(page.getByLabel("Goal value")).toHaveText("50 deg/s");
});

test("a current goal reads back in milliamps and the goal series moves to Electrical", async ({
Expand All @@ -71,17 +76,20 @@ test("a current goal reads back in milliamps and the goal series moves to Electr
await expect(page.getByLabel("Goal value")).toHaveText("50 mA");
});

test("position mode shows the goal in counts and the servo's answer to a goal it rejects", async ({
test("position mode shows the goal in degrees and a goal past the rail stops at it", async ({
page,
}) => {
await openLive(page);
await pickMode(page, "Position");
await expect(page.getByLabel("Goal value")).toHaveText("0 counts");
// The simulated servo's physical position limits are both 0, so any other
// goal fails the firmware's validator; the answer lands in the cluster.
await setGoal(page, "2000");
await expect(page.getByText("servo answered Validation")).toBeVisible();
await expect(page.getByLabel("Goal value")).toHaveText("0 counts");
// goal_position boots at 0, one count below the calibrated sensor floor.
await expect(page.getByLabel("Goal value")).toHaveText("-0.2 deg");
await setGoal(page, "100");
await expect(page.getByLabel("Goal value")).toHaveText("100.0 deg");
// The slider's range is the calibrated sensor span, which is also what the
// firmware validates against, so a goal beyond it clamps instead of nacking.
await setGoal(page, "500");
await expect(page.getByLabel("Goal value")).toHaveText("202.0 deg");
await expect(page.getByText(/servo answered/)).toHaveCount(0);
});

test("torque on reads back on and the servo keeps answering without a fault", async ({ page }) => {
Expand All @@ -91,7 +99,7 @@ test("torque on reads back on and the servo keeps answering without a fault", as
await torque.click();
await expect(torque).toHaveAttribute("aria-checked", "true");
await page.waitForTimeout(1000);
await expect(page.getByRole("button", { name: "ID 1 raw", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "ID 1", exact: true })).toBeVisible();
await expect(page.getByText(/servo answered/)).toHaveCount(0);
});

Expand Down
3 changes: 2 additions & 1 deletion tests/e2e/dashboard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { gotoSim } from "./helpers";

const UID_1 = "c94b8419d1092aec87de5fd151ce290f";
const UID_2 = "30634f42dde2095340c8d84f67f28244";
const READOUT = /^-?\d+(\.\d+)? (deg|counts)$/;
// The simulated fleet boots calibrated, so the position readout is degrees.
const READOUT = /^-?\d+(\.\d+)? deg$/;

test("each simulated servo gets a card with its identity and a position readout", async ({
page,
Expand Down
21 changes: 14 additions & 7 deletions tests/e2e/live.spec.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,33 @@
import { expect, test, type Page } from "@playwright/test";
import { gotoSim } from "./helpers";

const READOUT = /^-?\d+(\.\d+)? \S+$/;
// The simulated fleet boots calibrated, so the position family reads degrees.
const READOUT = /^-?\d+(\.\d+)? deg$/;

async function openLive(page: Page): Promise<void> {
await gotoSim(page, [1, 2]);
await page.getByRole("button", { name: "ID 1" }).click();
// The servo page proves the selection landed: leaving for Live while that
// navigation is still compiling on a cold dev server races the two.
await expect(page.getByRole("heading", { name: "ID 1" })).toBeVisible();
await page.getByRole("link", { name: "Live" }).click();
// A cold dev server compiles the Live route chunk on this first request.
await expect(page.getByRole("region", { name: /^Motion/ })).toBeVisible({ timeout: 15_000 });
// Readouts are placeholders until the poll delivers its first sample.
await expect(page.getByText("waiting for data")).toHaveCount(0);
}

test("the position readout is a number with a unit and moves with the replayed track", async ({
test("the position readout is a number with a unit and the replayed track keeps moving", async ({
page,
}) => {
await openLive(page);
const position = page.getByLabel("Position value");
await openLive(page);
await expect(position).toHaveText(READOUT);
// The recorded pot sits at its rail most of the loop, so the second reading
// is whichever comes first that differs, not one a fixed delay later.
const first = (await position.textContent()) ?? "";
await expect(position).not.toHaveText(first, { timeout: 5000 });
// The recorded pot rests at a rail for seconds at a time, so movement is
// read off the shunt instead, which is noisy on every tick.
const current = page.getByLabel("Current value");
const first = (await current.textContent()) ?? "";
await expect(current).not.toHaveText(first, { timeout: 5000 });
await expect(position).toHaveText(READOUT);
});

Expand Down
17 changes: 10 additions & 7 deletions tests/e2e/manage.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import { expect, test, type Locator, type Page } from "@playwright/test";
import { gotoSim } from "./helpers";

// The simulated servos run the production stacks over a bus with no config
// store behind them, so SAVE and FACTORY reach the servo and come back
// `hardware` (firmware dispatch.rs: both need a store to write). REBOOT and
// ASSIGN need no flash and land for real.
const NO_FLASH = "servo answered Hardware";
// The simulated servos run the production stacks over a bus with a config
// store behind each of them, so all four actions land for real: SAVE writes
// the store, FACTORY wipes it and reboots the servo onto board defaults.

async function openManage(page: Page, id: number): Promise<Locator> {
await gotoSim(page, [1, 2]);
Expand Down Expand Up @@ -56,15 +54,20 @@ test("Factory reset runs behind the confirm strip", async ({ page }) => {
await card.getByRole("button", { name: "Factory reset" }).click();
await erase.click();
await expect(erase).toBeHidden();
await expect(card.getByText(NO_FLASH)).toBeVisible();
await expect(card.getByText("Erased. It comes back as ID 1 at 1 M.")).toBeVisible();
await expect(page.getByRole("button", { name: "ID 2" })).toBeVisible();
// The seeded calibration is written into the servo's live table, never into
// its store, so the wipe reboots it onto board defaults and it reads blank.
await expect(
page.getByRole("region", { name: "Calibration" }).getByText("Not calibrated"),
).toBeVisible();
});

test("Save settings reaches the servo while torque is off", async ({ page }) => {
const card = await openManage(page, 2);
const save = card.getByRole("button", { name: "Save settings" });
await expect(save).toBeEnabled();
await save.click();
await expect(card.getByText(NO_FLASH)).toBeVisible();
await expect(card.getByText("Settings saved.")).toBeVisible();
await expect(page.getByRole("button", { name: "ID 2" })).toBeVisible();
});
Loading