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

opened this

Check · Review · Land · Live — It conflicts with its base. Next: builder, it conflicts with its base.

Conflicts with main

hunt/tests → main 1 commit

  • Can't merge. Conflicts in 2 files: Software/so101/src/lobby.test.ts, Software/so101/src/precision-history.test.ts. Merge main into the branch and push again.

    Conflicts in 2 filesdeploy 71f41bb

Review

Sign in to review this change.
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 @@
11 import { describe, expect, test } from "bun:test";
2 import { HAVE_MODEL, URDF_PATH } from "./fixtures.ts";
23 import { ArmModel, fromRpyXyz, gripperRadiansToTicks, gripperTicksToRadians, multiply, parseXml, radiansToTicks, rotationAbout, ticksToRadians, transformPoint } from "./kinematics.ts";
34 import fixtures from "./fk-fixtures.json";
45
5 const URDF = new URL("../../../Simulation/SO101/so101_new_calib.urdf", import.meta.url).pathname;
6 const URDF = URDF_PATH;
67 const ORDER = ["shoulder_pan", "shoulder_lift", "elbow_flex", "wrist_flex", "wrist_roll", "gripper"];
78 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);
910
1011 describe("matrices", () => {
1112 test("rpy follows the URDF convention and rotations compose", () => {
@@ -24,7 +25,7 @@ describe("matrices", () => {
2425 });
2526 });
2627
27 describe("the SO-101 model", () => {
28 describe.skipIf(!HAVE_MODEL)("the SO-101 model", () => {
2829 test("loads every link's mesh points and the base geometry", () => {
2930 expect(model.movable.map((j) => j.name)).toEqual(ORDER);
3031 expect(model.root).toBe("base_link");
Software/so101/src/lobby.test.ts +20 −9
@@ -1,4 +1,5 @@
11 import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
2 import { HAVE_MODEL } from "./fixtures.ts";
23 import { mkdtempSync } from "node:fs";
34 import { createServer, type Server } from "node:http";
45 import { tmpdir } from "node:os";
@@ -150,9 +151,12 @@ describe("lobby", () => {
150151 new Promise<string>((resolve) => {
151152 const ws = new WebSocket(`ws://127.0.0.1:${port}/follow`, { headers });
152153 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"));
156160 });
157161
158162 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", () => {
352356
353357 owner.send({ type: "delete", id: said.message.id });
354358 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");
355362 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 }]);
358365
359366 const late = await join(port);
360367 expect((await late.first((m) => m.type === "chat")).messages).toEqual([]);
@@ -779,7 +786,8 @@ describe("lobby", () => {
779786 expect((await post("/settings", ALICE, { maxSpeedDegS: 360 }, "PUT")).status).toBe(403);
780787 expect((await post("/settings/table-here", {})).status).toBe(401);
781788 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)
783791 expect((await post("/settings/table-touch", ALICE, { n: 1 })).status).toBe(403);
784792 expect((await post("/settings/table-touch", { ...OWNER, origin: "https://evil.example" }, { n: 1 })).status).toBe(403); // only this page may touch the table
785793 expect((await post("/settings/joint-zero", ALICE)).status).toBe(403);
@@ -937,10 +945,13 @@ describe("lobby", () => {
937945 const id = troll.body.you.id;
938946 room.send({ type: "ban", id: "u-owner", on: true });
939947 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
940951 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" })]);
944955 expect((await owner.first((m) => m.type === "admin" && m.log.length > 0)).log.at(-1)).toMatchObject({ what: "banned troll" });
945956
946957 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", () => {
105105 expect(live.session?.joints.find((j) => j.id === 1)?.errorTicks.n).toBeGreaterThan(0);
106106 ws.send(JSON.stringify({ "1": 3100 })); // 357 ticks away at 90°/s: still on its way when the stop comes
107107 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
108109 await fetch(`${base}/stop`, { method: "POST", body: JSON.stringify({ by: "test" }) });
109 await new Promise((r) => ws.once("close", r));
110 await closed;
110111 await sleep(100);
111112 await fetch(`${base}/resume`, { method: "POST", body: JSON.stringify({ by: "test" }) });
112113 const after = await card();
@@ -169,9 +170,10 @@ describe("GET /precision on the arm server", () => {
169170 await sleep(600);
170171 ws.send(JSON.stringify({ "1": 1850 })); // on its way at the governor's pace when the turn ends
171172 await sleep(80);
173 const closed = new Promise((r) => ws.once("close", r)); // listen before the STOP, as above
172174 const stop = await fetch(`${base}/stop`, { method: "POST", body: JSON.stringify({ by: "the relay", hold: false }) });
173175 expect(stop.status).toBe(200);
174 await new Promise((r) => ws.once("close", r));
176 await closed;
175177 await sleep(100);
176178 const after = await card();
177179 expect(after.window.snapBacks.byCause["turn-end"]).toBe(before["turn-end"] + 1);
Software/so101/src/readme.test.ts +4 −3
@@ -1,16 +1,17 @@
11 import { expect, test } from "bun:test";
2 import { HAVE_README } from "./fixtures.ts";
23
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() : "";
45 const top = readme.slice(0, readme.indexOf("<div"));
56
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", () => {
78 expect(top).toContain("https://arm--forge-trevato-apps.open-platform.sh");
89 expect(top).toMatch(/\*\*Watch:\*\*/);
910 expect(top).toMatch(/\*\*Drive:\*\*.*[Ss]ign in/);
1011 expect(top).toMatch(/github\.com\/Trevato\/SO-ARM100 is deprecated/);
1112 });
1213
13 test("the upstream README follows, intact", () => {
14 test.skipIf(!HAVE_README)("the upstream README follows, intact", () => {
1415 expect(readme.indexOf("Standard Open SO-100 & SO-101 Arms")).toBeGreaterThan(top.length);
1516 expect(readme).toContain("## Kits");
1617 });
Software/so101/src/release.test.ts +1 −1
@@ -53,7 +53,7 @@ describe("a release lands on the target", () => {
5353 const g = goals(mark);
5454 expect(Math.abs((await present()) - target)).toBeLessThanOrEqual(12);
5555 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)
5757 expect(g.filter((x) => x < target - 12).length).toBeGreaterThan(0); // it was still on its way when it let go
5858 });
5959
Software/so101/src/server.test.ts +4 −3
@@ -7,6 +7,7 @@ import { gzipSync } from "node:zlib";
77 import WebSocket from "ws";
88 import { FeetechBus } from "./bus.ts";
99 import { FakeBus } from "./fake-bus.ts";
10 import { HAVE_MODEL, HAVE_PRINTABLES, MODEL_DIR } from "./fixtures.ts";
1011 import { mapFrame } from "./leader.ts";
1112 import { startServer, type RunningServer } from "./server.ts";
1213 import { alignment, REFERENCES } from "./joint-reference.ts";
@@ -538,7 +539,7 @@ describe("a bus that fails mid-session", () => {
538539 });
539540 });
540541
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", () => {
542543 // A six-joint fake with the follower's real calibrated ranges, the repo's model, and a fast jam watch.
543544 let fake6: FakeBus;
544545 let zoneServer: RunningServer;
@@ -571,7 +572,7 @@ describe("the safe zone, the jam watch, and the model routes", () => {
571572 host: "127.0.0.1",
572573 port: 0,
573574 posesFile: join(mkdtempSync(join(tmpdir(), "so101-zone-")), "poses.json"),
574 modelDir: new URL("../../../Simulation/SO101", import.meta.url).pathname,
575 modelDir: MODEL_DIR,
575576 watchdogMs: 60,
576577 jamSampleMs: 15,
577578 now: () => touchClock,
@@ -596,7 +597,7 @@ describe("the safe zone, the jam watch, and the model routes", () => {
596597 expect((await fetch(`${zurl}/vendor/three/../ws/index.js`)).status).toBe(404);
597598 });
598599
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 () => {
600601 const list = (await (await fetch(`${zurl}/printables`)).json()) as Array<{ file: string; url: string; bytes: number }>;
601602 expect(list.map((p) => p.file)).toEqual(["charuco_board.png", "cube_markers.png", "gripper_marker.png"]);
602603 const png = await fetch(`${zurl}${list[0]!.url}`);
Software/so101/src/stop.test.ts +30 −3
@@ -27,7 +27,7 @@ const person = (id: string) => {
2727 return { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) };
2828 };
2929 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";
3131
3232 /** One line of the recorded stream: what the page did, and what reached the Pi. */
3333 interface Entry {
@@ -152,6 +152,7 @@ class Page {
152152
153153 function print(title: string, timeline: Entry[]) {
154154 if (!LOG || !timeline.length) return;
155 if (process.env.STOP_LOG === "fail" && !title.includes("after a STOP")) return;
155156 const t0 = timeline[0]!.at;
156157 console.log(`\n# ${title}\n${timeline.map((e) => `+${String(e.at - t0).padStart(5)} ms ${e.who.padEnd(4)} ${e.what}`).join("\n")}`);
157158 }
@@ -229,6 +230,30 @@ describe("STOP through the relay, to a Pi that holds nothing itself", () => {
229230 print("(b) replay: 100 commands while held, STOP, resume", timeline);
230231 });
231232
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
232257 const interleavings = async (linkMs: number, SEEDS: number, STEPS: number) => {
233258 for (let seed = 1; seed <= SEEDS; seed++) {
234259 const port = await start(linkMs);
@@ -263,9 +288,10 @@ describe("STOP through the relay, to a Pi that holds nothing itself", () => {
263288 await page.open();
264289 for (const f of stash.splice(0)) page.send(f, "flush (buffered)");
265290 } else if (r < 0.74) {
266 const sent = Date.now();
267291 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());
269295 held = true;
270296 } else if (r < 0.86) {
271297 const answer = await page.post("/resume", port);
@@ -294,6 +320,7 @@ describe("STOP through the relay, to a Pi that holds nothing itself", () => {
294320 const madeAt = made.get(k)!;
295321 expect({ seed, frame: k, heldWhenMade: heldAt(madeAt) }).toEqual({ seed, frame: k, heldWhenMade: false });
296322 const stopAfter = stops.find((s) => s >= madeAt);
323 if (stopAfter !== undefined && at > stopAfter) print(`seed ${seed} frame ${k} after a STOP`, timeline);
297324 expect({ seed, frame: k, afterStop: stopAfter !== undefined && at > stopAfter }).toEqual({ seed, frame: k, afterStop: false });
298325 }
299326 // Not vacuous: each run ends with a resume and a new command, and that command arrives.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs