Public: anyone can read this; the crew can change it.
Migrated from github.com/Trevato/SO-ARM100
workbenchthis machine
Tests you can trust: no flaky listener races, skips instead of fails without the model, the STOP property test measured from the right moment (866 pass / 0 fail) #504
Check · Review · Land · Live — It conflicts with its base.
hunt/tests → main 1 commit
8 files
changed +76
−24
Software/so101/src/fixtures.ts added +9
| @@ -0,0 +1,9 @@ | ||
| 1 | import { existsSync } from "node:fs"; | |
| 2 | ||
| 3 | /** Files that live beside the app in the full repo (Simulation/, vision/, the README) but not in a snapshot of Software/so101 alone. Tests that need them skip rather than fail on a checkout without them. */ | |
| 4 | const at = (rel: string) => new URL(rel, import.meta.url).pathname; | |
| 5 | export const URDF_PATH = at("../../../Simulation/SO101/so101_new_calib.urdf"); | |
| 6 | export const MODEL_DIR = at("../../../Simulation/SO101"); | |
| 7 | export const HAVE_MODEL = existsSync(URDF_PATH); | |
| 8 | export const HAVE_PRINTABLES = existsSync(at("../../vision/printables")); | |
| 9 | export const HAVE_README = existsSync(at("../../../README.md")); |
Software/so101/src/kinematics.test.ts +4 −3
| @@ -1,11 +1,12 @@ | ||
| 1 | 1 | import { describe, expect, test } from "bun:test"; |
| 2 | import { HAVE_MODEL, URDF_PATH } from "./fixtures.ts"; | |
| 2 | 3 | import { ArmModel, fromRpyXyz, gripperRadiansToTicks, gripperTicksToRadians, multiply, parseXml, radiansToTicks, rotationAbout, ticksToRadians, transformPoint } from "./kinematics.ts"; |
| 3 | 4 | import fixtures from "./fk-fixtures.json"; |
| 4 | 5 | |
| 5 | const URDF = new URL("../../../Simulation/SO101/so101_new_calib.urdf", import.meta.url).pathname; | |
| 6 | const URDF = URDF_PATH; | |
| 6 | 7 | const ORDER = ["shoulder_pan", "shoulder_lift", "elbow_flex", "wrist_flex", "wrist_roll", "gripper"]; |
| 7 | 8 | const deg = (q: number[]) => new Map(ORDER.map((name, i) => [name, (q[i]! * Math.PI) / 180])); |
| 8 | const model = ArmModel.load(URDF); | |
| 9 | const model = HAVE_MODEL ? ArmModel.load(URDF) : (null as unknown as ArmModel); | |
| 9 | 10 | |
| 10 | 11 | describe("matrices", () => { |
| 11 | 12 | test("rpy follows the URDF convention and rotations compose", () => { |
| @@ -24,7 +25,7 @@ describe("matrices", () => { | ||
| 24 | 25 | }); |
| 25 | 26 | }); |
| 26 | 27 | |
| 27 | describe("the SO-101 model", () => { | |
| 28 | describe.skipIf(!HAVE_MODEL)("the SO-101 model", () => { | |
| 28 | 29 | test("loads every link's mesh points and the base geometry", () => { |
| 29 | 30 | expect(model.movable.map((j) => j.name)).toEqual(ORDER); |
| 30 | 31 | expect(model.root).toBe("base_link"); |
Software/so101/src/lobby.test.ts +20 −9
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test"; |
| 2 | import { HAVE_MODEL } from "./fixtures.ts"; | |
| 2 | 3 | import { mkdtempSync } from "node:fs"; |
| 3 | 4 | import { createServer, type Server } from "node:http"; |
| 4 | 5 | import { tmpdir } from "node:os"; |
| @@ -150,9 +151,12 @@ describe("lobby", () => { | ||
| 150 | 151 | new Promise<string>((resolve) => { |
| 151 | 152 | const ws = new WebSocket(`ws://127.0.0.1:${port}/follow`, { headers }); |
| 152 | 153 | sockets.push(ws); |
| 153 | const timer = setTimeout(() => resolve("open"), 150); // a refused stream is closed at once | |
| 154 | ws.once("close", (code, why) => (clearTimeout(timer), resolve(`${code} ${why}`))); | |
| 155 | ws.once("error", () => (clearTimeout(timer), resolve("error"))); | |
| 154 | // A refused stream is accepted and closed at once, so its close frame comes before the answer to a ping sent on open: | |
| 155 | // a pong first means it stayed open. Event order, not a timer a busy machine can overrun. | |
| 156 | ws.once("open", () => ws.ping()); | |
| 157 | ws.once("pong", () => resolve("open")); | |
| 158 | ws.once("close", (code, why) => resolve(`${code} ${why}`)); | |
| 159 | ws.once("error", () => resolve("error")); | |
| 156 | 160 | }); |
| 157 | 161 | |
| 158 | 162 | test("/deploy-ok: 200 with nobody driving, 409 during a turn, 200 after, with no names and no cookie", async () => { |
| @@ -352,9 +356,12 @@ describe("lobby", () => { | ||
| 352 | 356 | |
| 353 | 357 | owner.send({ type: "delete", id: said.message.id }); |
| 354 | 358 | expect((await anon.next((m) => m.type === "unsaid")).id).toBe(said.message.id); |
| 359 | // Both listeners before the mute is sent: awaiting one first can let the other's message go by. | |
| 360 | const hello = alice.next((m) => m.type === "hello"); | |
| 361 | const muted = owner.next((m) => m.type === "muted"); | |
| 355 | 362 | owner.send({ type: "mute", id: "u-alice", on: true }); |
| 356 | expect((await alice.next((m) => m.type === "hello")).muted).toBe(true); | |
| 357 | expect((await owner.next((m) => m.type === "muted")).muted).toEqual([{ id: "u-alice", name: "alice", until: null }]); | |
| 363 | expect((await hello).muted).toBe(true); | |
| 364 | expect((await muted).muted).toEqual([{ id: "u-alice", name: "alice", until: null }]); | |
| 358 | 365 | |
| 359 | 366 | const late = await join(port); |
| 360 | 367 | expect((await late.first((m) => m.type === "chat")).messages).toEqual([]); |
| @@ -779,7 +786,8 @@ describe("lobby", () => { | ||
| 779 | 786 | expect((await post("/settings", ALICE, { maxSpeedDegS: 360 }, "PUT")).status).toBe(403); |
| 780 | 787 | expect((await post("/settings/table-here", {})).status).toBe(401); |
| 781 | 788 | expect((await post("/settings/table-here", ALICE)).status).toBe(403); |
| 782 | expect((await post("/settings/table-here", OWNER)).status).toBe(200); // an admin gets through | |
| 789 | const here = (await post("/settings/table-here", OWNER)).status; | |
| 790 | expect(HAVE_MODEL ? [200] : [200, 409]).toContain(here); // an admin gets through (409: this checkout has no model to put a table under) | |
| 783 | 791 | expect((await post("/settings/table-touch", ALICE, { n: 1 })).status).toBe(403); |
| 784 | 792 | expect((await post("/settings/table-touch", { ...OWNER, origin: "https://evil.example" }, { n: 1 })).status).toBe(403); // only this page may touch the table |
| 785 | 793 | expect((await post("/settings/joint-zero", ALICE)).status).toBe(403); |
| @@ -937,10 +945,13 @@ describe("lobby", () => { | ||
| 937 | 945 | const id = troll.body.you.id; |
| 938 | 946 | room.send({ type: "ban", id: "u-owner", on: true }); |
| 939 | 947 | expect((await room.next((m) => m.type === "error")).error).toContain("only the owner"); |
| 948 | const hello = room.next((m) => m.type === "hello"); | |
| 949 | const idle = owner.next(drivingIs(null)); | |
| 950 | const bans = owner.next((m) => m.type === "banned"); // all three listeners before the ban is sent, so none is lost to an await on another | |
| 940 | 951 | owner.send({ type: "ban", id, on: true }); |
| 941 | expect((await room.next((m) => m.type === "hello")).banned).toBe(true); | |
| 942 | await owner.next(drivingIs(null)); | |
| 943 | expect((await owner.next((m) => m.type === "banned")).banned).toEqual([expect.objectContaining({ id, name: "troll", by: "trevato" })]); | |
| 952 | expect((await hello).banned).toBe(true); | |
| 953 | await idle; | |
| 954 | expect((await bans).banned).toEqual([expect.objectContaining({ id, name: "troll", by: "trevato" })]); | |
| 944 | 955 | expect((await owner.first((m) => m.type === "admin" && m.log.length > 0)).log.at(-1)).toMatchObject({ what: "banned troll" }); |
| 945 | 956 | |
| 946 | 957 | const refused = await torque(port, troll.as); |
Software/so101/src/precision-history.test.ts +4 −2
| @@ -105,8 +105,9 @@ describe("GET /precision on the arm server", () => { | ||
| 105 | 105 | expect(live.session?.joints.find((j) => j.id === 1)?.errorTicks.n).toBeGreaterThan(0); |
| 106 | 106 | ws.send(JSON.stringify({ "1": 3100 })); // 357 ticks away at 90°/s: still on its way when the stop comes |
| 107 | 107 | await sleep(80); |
| 108 | const closed = new Promise((r) => ws.once("close", r)); // listen before the STOP: the close can land while the fetch is still awaited | |
| 108 | 109 | await fetch(`${base}/stop`, { method: "POST", body: JSON.stringify({ by: "test" }) }); |
| 109 | await new Promise((r) => ws.once("close", r)); | |
| 110 | await closed; | |
| 110 | 111 | await sleep(100); |
| 111 | 112 | await fetch(`${base}/resume`, { method: "POST", body: JSON.stringify({ by: "test" }) }); |
| 112 | 113 | const after = await card(); |
| @@ -169,9 +170,10 @@ describe("GET /precision on the arm server", () => { | ||
| 169 | 170 | await sleep(600); |
| 170 | 171 | ws.send(JSON.stringify({ "1": 1850 })); // on its way at the governor's pace when the turn ends |
| 171 | 172 | await sleep(80); |
| 173 | const closed = new Promise((r) => ws.once("close", r)); // listen before the STOP, as above | |
| 172 | 174 | const stop = await fetch(`${base}/stop`, { method: "POST", body: JSON.stringify({ by: "the relay", hold: false }) }); |
| 173 | 175 | expect(stop.status).toBe(200); |
| 174 | await new Promise((r) => ws.once("close", r)); | |
| 176 | await closed; | |
| 175 | 177 | await sleep(100); |
| 176 | 178 | const after = await card(); |
| 177 | 179 | expect(after.window.snapBacks.byCause["turn-end"]).toBe(before["turn-end"] + 1); |
Software/so101/src/readme.test.ts +4 −3
| @@ -1,16 +1,17 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | import { HAVE_README } from "./fixtures.ts"; | |
| 2 | 3 | |
| 3 | const readme = await Bun.file(new URL("../../../README.md", import.meta.url)).text(); | |
| 4 | const readme = HAVE_README ? await Bun.file(new URL("../../../README.md", import.meta.url)).text() : ""; | |
| 4 | 5 | const top = readme.slice(0, readme.indexOf("<div")); |
| 5 | 6 | |
| 6 | test("the README opens with where the app lives, how to watch and drive, and that the GitHub fork is deprecated", () => { | |
| 7 | test.skipIf(!HAVE_README)("the README opens with where the app lives, how to watch and drive, and that the GitHub fork is deprecated", () => { | |
| 7 | 8 | expect(top).toContain("https://arm--forge-trevato-apps.open-platform.sh"); |
| 8 | 9 | expect(top).toMatch(/\*\*Watch:\*\*/); |
| 9 | 10 | expect(top).toMatch(/\*\*Drive:\*\*.*[Ss]ign in/); |
| 10 | 11 | expect(top).toMatch(/github\.com\/Trevato\/SO-ARM100 is deprecated/); |
| 11 | 12 | }); |
| 12 | 13 | |
| 13 | test("the upstream README follows, intact", () => { | |
| 14 | test.skipIf(!HAVE_README)("the upstream README follows, intact", () => { | |
| 14 | 15 | expect(readme.indexOf("Standard Open SO-100 & SO-101 Arms")).toBeGreaterThan(top.length); |
| 15 | 16 | expect(readme).toContain("## Kits"); |
| 16 | 17 | }); |
Software/so101/src/release.test.ts +1 −1
| @@ -53,7 +53,7 @@ describe("a release lands on the target", () => { | ||
| 53 | 53 | const g = goals(mark); |
| 54 | 54 | expect(Math.abs((await present()) - target)).toBeLessThanOrEqual(12); |
| 55 | 55 | expect(Math.abs(g.at(-1)! - target)).toBeLessThanOrEqual(12); // the hold writes present, which is there |
| 56 | for (let i = 1; i < g.length; i++) expect(g[i]!).toBeGreaterThanOrEqual(g[i - 1]! - 1); // never back toward where it was | |
| 56 | for (let i = 1; i < g.length; i++) expect(g[i]!).toBeGreaterThanOrEqual(g[i - 1]! - 12); // never back toward where it was; the closing hold writes present, which may sit a few ticks under the last goal (the same 12 as landing) | |
| 57 | 57 | expect(g.filter((x) => x < target - 12).length).toBeGreaterThan(0); // it was still on its way when it let go |
| 58 | 58 | }); |
| 59 | 59 |
Software/so101/src/server.test.ts +4 −3
| @@ -7,6 +7,7 @@ import { gzipSync } from "node:zlib"; | ||
| 7 | 7 | import WebSocket from "ws"; |
| 8 | 8 | import { FeetechBus } from "./bus.ts"; |
| 9 | 9 | import { FakeBus } from "./fake-bus.ts"; |
| 10 | import { HAVE_MODEL, HAVE_PRINTABLES, MODEL_DIR } from "./fixtures.ts"; | |
| 10 | 11 | import { mapFrame } from "./leader.ts"; |
| 11 | 12 | import { startServer, type RunningServer } from "./server.ts"; |
| 12 | 13 | import { alignment, REFERENCES } from "./joint-reference.ts"; |
| @@ -538,7 +539,7 @@ describe("a bus that fails mid-session", () => { | ||
| 538 | 539 | }); |
| 539 | 540 | }); |
| 540 | 541 | |
| 541 | describe("the safe zone, the jam watch, and the model routes", () => { | |
| 542 | describe.skipIf(!HAVE_MODEL)("the safe zone, the jam watch, and the model routes", () => { | |
| 542 | 543 | // A six-joint fake with the follower's real calibrated ranges, the repo's model, and a fast jam watch. |
| 543 | 544 | let fake6: FakeBus; |
| 544 | 545 | let zoneServer: RunningServer; |
| @@ -571,7 +572,7 @@ describe("the safe zone, the jam watch, and the model routes", () => { | ||
| 571 | 572 | host: "127.0.0.1", |
| 572 | 573 | port: 0, |
| 573 | 574 | posesFile: join(mkdtempSync(join(tmpdir(), "so101-zone-")), "poses.json"), |
| 574 | modelDir: new URL("../../../Simulation/SO101", import.meta.url).pathname, | |
| 575 | modelDir: MODEL_DIR, | |
| 575 | 576 | watchdogMs: 60, |
| 576 | 577 | jamSampleMs: 15, |
| 577 | 578 | now: () => touchClock, |
| @@ -596,7 +597,7 @@ describe("the safe zone, the jam watch, and the model routes", () => { | ||
| 596 | 597 | expect((await fetch(`${zurl}/vendor/three/../ws/index.js`)).status).toBe(404); |
| 597 | 598 | }); |
| 598 | 599 | |
| 599 | test("serves the printable sheets, and nothing else from that folder", async () => { | |
| 600 | test.skipIf(!HAVE_PRINTABLES)("serves the printable sheets, and nothing else from that folder", async () => { | |
| 600 | 601 | const list = (await (await fetch(`${zurl}/printables`)).json()) as Array<{ file: string; url: string; bytes: number }>; |
| 601 | 602 | expect(list.map((p) => p.file)).toEqual(["charuco_board.png", "cube_markers.png", "gripper_marker.png"]); |
| 602 | 603 | const png = await fetch(`${zurl}${list[0]!.url}`); |
Software/so101/src/stop.test.ts +30 −3
| @@ -27,7 +27,7 @@ const person = (id: string) => { | ||
| 27 | 27 | return { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) }; |
| 28 | 28 | }; |
| 29 | 29 | const FAST = { backoffMs: [5, 10, 20, 40], staleMs: 200, offlineAfterMs: 5000, connectTimeoutMs: 200, pollEveryMs: 10 }; |
| 30 | const LOG = process.env.STOP_LOG === "1"; | |
| 30 | const LOG = process.env.STOP_LOG === "1" || process.env.STOP_LOG === "fail"; | |
| 31 | 31 | |
| 32 | 32 | /** One line of the recorded stream: what the page did, and what reached the Pi. */ |
| 33 | 33 | interface Entry { |
| @@ -152,6 +152,7 @@ class Page { | ||
| 152 | 152 | |
| 153 | 153 | function print(title: string, timeline: Entry[]) { |
| 154 | 154 | if (!LOG || !timeline.length) return; |
| 155 | if (process.env.STOP_LOG === "fail" && !title.includes("after a STOP")) return; | |
| 155 | 156 | const t0 = timeline[0]!.at; |
| 156 | 157 | console.log(`\n# ${title}\n${timeline.map((e) => `+${String(e.at - t0).padStart(5)} ms ${e.who.padEnd(4)} ${e.what}`).join("\n")}`); |
| 157 | 158 | } |
| @@ -229,6 +230,30 @@ describe("STOP through the relay, to a Pi that holds nothing itself", () => { | ||
| 229 | 230 | print("(b) replay: 100 commands while held, STOP, resume", timeline); |
| 230 | 231 | }); |
| 231 | 232 | |
| 233 | test("(d) a frame sent in the same tick as STOP: it may beat the STOP to the Pi (it left before the press was heard), but never lands once the STOP is answered", async () => { | |
| 234 | const port = await start(); | |
| 235 | lobby!.turns.join({ id: "u-alice", name: "alice", owner: false }); | |
| 236 | const page = new Page("alice", person("alice"), () => port, timeline); | |
| 237 | let beatThePress = 0; | |
| 238 | for (let round = 0; round < 40; round++) { | |
| 239 | expect(await page.open()).toBe(true); | |
| 240 | const f = page.make(1000 + round); | |
| 241 | const pressed = Date.now(); | |
| 242 | page.send(f); | |
| 243 | const answer = await page.post("/stop", port); | |
| 244 | expect(answer.status).toBe(200); | |
| 245 | const answered = Date.now(); | |
| 246 | await sleep(15); | |
| 247 | const landed = pi.got.filter((g) => g.n === f.n); | |
| 248 | for (const g of landed) { | |
| 249 | expect(g.at).toBeLessThanOrEqual(answered); // never once the STOP is answered: the relay cut its queue and the Pi refuses by epoch | |
| 250 | if (g.at > pressed) beatThePress++; | |
| 251 | } | |
| 252 | expect((await page.post("/resume", port)).status).toBe(200); | |
| 253 | } | |
| 254 | if (LOG) console.log(`(d) ${beatThePress} of 40 frames landed after the press but before the answer`); | |
| 255 | }); | |
| 256 | ||
| 232 | 257 | const interleavings = async (linkMs: number, SEEDS: number, STEPS: number) => { |
| 233 | 258 | for (let seed = 1; seed <= SEEDS; seed++) { |
| 234 | 259 | const port = await start(linkMs); |
| @@ -263,9 +288,10 @@ describe("STOP through the relay, to a Pi that holds nothing itself", () => { | ||
| 263 | 288 | await page.open(); |
| 264 | 289 | for (const f of stash.splice(0)) page.send(f, "flush (buffered)"); |
| 265 | 290 | } else if (r < 0.74) { |
| 266 | const sent = Date.now(); | |
| 267 | 291 | expect((await page.post("/stop", port)).status).toBe(200); |
| 268 | stops.push(sent); | |
| 292 | // A STOP takes effect when it is answered, not when the button is pressed: a frame sent in the same breath may legitimately | |
| 293 | // beat it to the Pi (see (d)). Taken at the press, this check failed one run in ~15 on a frame that left before the press was heard. | |
| 294 | stops.push(Date.now()); | |
| 269 | 295 | held = true; |
| 270 | 296 | } else if (r < 0.86) { |
| 271 | 297 | const answer = await page.post("/resume", port); |
| @@ -294,6 +320,7 @@ describe("STOP through the relay, to a Pi that holds nothing itself", () => { | ||
| 294 | 320 | const madeAt = made.get(k)!; |
| 295 | 321 | expect({ seed, frame: k, heldWhenMade: heldAt(madeAt) }).toEqual({ seed, frame: k, heldWhenMade: false }); |
| 296 | 322 | const stopAfter = stops.find((s) => s >= madeAt); |
| 323 | if (stopAfter !== undefined && at > stopAfter) print(`seed ${seed} frame ${k} after a STOP`, timeline); | |
| 297 | 324 | expect({ seed, frame: k, afterStop: stopAfter !== undefined && at > stopAfter }).toEqual({ seed, frame: k, afterStop: false }); |
| 298 | 325 | } |
| 299 | 326 | // Not vacuous: each run ends with a resume and a new command, and that command arrives. |