| 1 | // End-to-end safety tests: the real driver against the simulated machine.
|
| 2 | // The simulator flags anything that would hurt real hardware (driving the
|
| 3 | // carriage past its travel, step rates beyond the firmware limit, motion
|
| 4 | // after a limit stop, malformed commands), so every test asserts that
|
| 5 | // sim.violations is empty in addition to its own checks.
|
| 6 |
|
| 7 | import { describe, expect, test } from 'bun:test';
|
| 8 | import { commands, connected, makeMachine, polylineSetDistance, rng } from './helpers.js';
|
| 9 |
|
| 10 | const TRAVEL = [11.81, 8.58];
|
| 11 | const ONE_STEP = 1 / 2032;
|
| 12 |
|
| 13 | function expectSafe(sim) {
|
| 14 | expect(sim.violations).toEqual([]);
|
| 15 | }
|
| 16 |
|
| 17 | /** Carriage is at (x, y) within ~1 step, and the driver's tracked position is exact. */
|
| 18 | function expectAt(nd, sim, x, y) {
|
| 19 | expect(Math.abs(sim.position[0] - x)).toBeLessThan(0.0015);
|
| 20 | expect(Math.abs(sim.position[1] - y)).toBeLessThan(0.0015);
|
| 21 | expectTracked(nd, sim);
|
| 22 | }
|
| 23 |
|
| 24 | function expectTracked(nd, sim) {
|
| 25 | expect(Math.abs(nd.pen.phys.xpos - sim.position[0])).toBeLessThan(1e-9);
|
| 26 | expect(Math.abs(nd.pen.phys.ypos - sim.position[1])).toBeLessThan(1e-9);
|
| 27 | }
|
| 28 |
|
| 29 | describe('automatic homing', () => {
|
| 30 | const starts = [[0, 0], [5, 4], [11.8, 8.5], [0, 8.5], [11.8, 0], [0.05, 0.05], [3, 0], [0, 3], [7.3, 1.1], [0.2, 6]];
|
| 31 | for (const [x, y] of starts) {
|
| 32 | test(`homes from (${x}, ${y})`, async () => {
|
| 33 | const { nd, sim } = await connected({ startX: x, startY: y });
|
| 34 | expectSafe(sim);
|
| 35 | expect(Math.abs(sim.position[0])).toBeLessThan(2 * ONE_STEP);
|
| 36 | expect(Math.abs(sim.position[1])).toBeLessThan(2 * ONE_STEP);
|
| 37 | expect(nd.currentPos()).toEqual([0, 0]);
|
| 38 | expect(sim.vars[12]).toBe(1); // EBB marked homed
|
| 39 | expect(sim.penUp).toBe(true);
|
| 40 | expect(sim.ink).toEqual([]); // Pen stayed up the whole time
|
| 41 | });
|
| 42 | }
|
| 43 |
|
| 44 | test('fails safely with no limit switch', async () => {
|
| 45 | const { nd, sim } = makeMachine({ startX: 4, startY: 4, limitSwitch: false });
|
| 46 | expect(await nd.connect()).toBe(false);
|
| 47 | expect(nd.plotStatus.stopped).toBe(106);
|
| 48 | expect(sim.vars[12]).toBe(0);
|
| 49 | expectSafe(sim);
|
| 50 | });
|
| 51 |
|
| 52 | test('fails safely without motor power, without moving', async () => {
|
| 53 | const { nd, sim } = makeMachine({ startX: 4, startY: 4, powered: false });
|
| 54 | expect(await nd.connect()).toBe(false);
|
| 55 | expect(nd.plotStatus.stopped).toBe(105);
|
| 56 | expect(sim.position).toEqual([4, 4]);
|
| 57 | expectSafe(sim);
|
| 58 | });
|
| 59 |
|
| 60 | test('errors.homing = true throws', async () => {
|
| 61 | const { nd } = makeMachine({ startX: 4, startY: 4, limitSwitch: false });
|
| 62 | nd.errors.homing = true;
|
| 63 | await expect(nd.connect()).rejects.toThrow('Homing failed');
|
| 64 | });
|
| 65 | });
|
| 66 |
|
| 67 | describe('interactive motion', () => {
|
| 68 | test('square with moveto/lineto draws exactly the square', async () => {
|
| 69 | const { nd, sim } = await connected({ startX: 2, startY: 2 });
|
| 70 | await nd.moveto(1, 1);
|
| 71 | await nd.lineto(3, 1);
|
| 72 | await nd.lineto(3, 2.5);
|
| 73 | await nd.lineto(1, 2.5);
|
| 74 | await nd.lineto(1, 1);
|
| 75 | await nd.penup();
|
| 76 | await nd.moveto(0, 0);
|
| 77 | await nd.disconnect();
|
| 78 | expectSafe(sim);
|
| 79 | expect(sim.ink.length).toBe(1);
|
| 80 | expect(polylineSetDistance(sim.ink, [[[1, 1], [3, 1], [3, 2.5], [1, 2.5], [1, 1]]])).toBeLessThan(0.002);
|
| 81 | expectAt(nd, sim, 0, 0);
|
| 82 | });
|
| 83 |
|
| 84 | test('relative moves and pen state queries', async () => {
|
| 85 | const { nd, sim } = await connected();
|
| 86 | await nd.move(2, 1);
|
| 87 | expect(nd.turtlePos()).toEqual([2, 1]);
|
| 88 | expect(nd.currentPen()).toBe(true);
|
| 89 | await nd.line(1, 0);
|
| 90 | expect(nd.currentPen()).toBe(false);
|
| 91 | await nd.go(0, 1);
|
| 92 | await nd.penup();
|
| 93 | expect(nd.turtlePen()).toBe(true);
|
| 94 | expectSafe(sim);
|
| 95 | expect(polylineSetDistance(sim.ink, [[[2, 1], [3, 1], [3, 2]]])).toBeLessThan(0.002);
|
| 96 | expectAt(nd, sim, 3, 2);
|
| 97 | });
|
| 98 |
|
| 99 | test('pen-up moves never leave ink', async () => {
|
| 100 | const { nd, sim } = await connected();
|
| 101 | const rand = rng(3);
|
| 102 | for (let i = 0; i < 40; i++) await nd.moveto(rand() * 11, rand() * 8);
|
| 103 | expectSafe(sim);
|
| 104 | expect(sim.ink).toEqual([]);
|
| 105 | });
|
| 106 |
|
| 107 | test('units: millimeters and centimeters', async () => {
|
| 108 | const { nd, sim } = await connected();
|
| 109 | nd.options.units = 2;
|
| 110 | await nd.moveto(25.4, 50.8);
|
| 111 | expectAt(nd, sim, 1, 2);
|
| 112 | expect(nd.currentPos()[0]).toBeCloseTo(25.4, 1);
|
| 113 | nd.options.units = 1;
|
| 114 | await nd.moveto(5.08, 2.54);
|
| 115 | expectAt(nd, sim, 2, 1);
|
| 116 | expectSafe(sim);
|
| 117 | });
|
| 118 |
|
| 119 | test('targets outside the travel area are clipped, with the pen lifted outside', async () => {
|
| 120 | const { nd, sim } = await connected();
|
| 121 | await nd.moveto(-3, -3);
|
| 122 | expectAt(nd, sim, 0, 0);
|
| 123 | await nd.lineto(20, 4); // Crosses the right edge
|
| 124 | await nd.lineto(5, 20); // Entirely outside -> no motion
|
| 125 | await nd.lineto(5, 4); // Back inside from outside
|
| 126 | await nd.penup();
|
| 127 | expectSafe(sim);
|
| 128 | // Ink: (0,0)->(11.81, 2.4)ish clipped segment, then re-entry segment into (5,4)
|
| 129 | for (const line of sim.ink) {
|
| 130 | for (const [x, y] of line) {
|
| 131 | expect(x).toBeGreaterThanOrEqual(-ONE_STEP);
|
| 132 | expect(x).toBeLessThanOrEqual(TRAVEL[0] + ONE_STEP);
|
| 133 | expect(y).toBeGreaterThanOrEqual(-ONE_STEP);
|
| 134 | expect(y).toBeLessThanOrEqual(TRAVEL[1] + ONE_STEP);
|
| 135 | }
|
| 136 | }
|
| 137 | expect(nd.turtlePos()).toEqual([5, 4]);
|
| 138 | expectAt(nd, sim, 5, 4);
|
| 139 | });
|
| 140 |
|
| 141 | test('pendown outside the travel area does not lower the pen', async () => {
|
| 142 | const { nd, sim } = await connected();
|
| 143 | await nd.moveto(-1, 2);
|
| 144 | await nd.pendown();
|
| 145 | expect(sim.penUp).toBe(true);
|
| 146 | expectSafe(sim);
|
| 147 | });
|
| 148 |
|
| 149 | test('random stress session stays in bounds and in sync', async () => {
|
| 150 | const { nd, sim } = await connected({ startX: 6, startY: 3 });
|
| 151 | const rand = rng(2024);
|
| 152 | const ops = ['moveto', 'lineto', 'goto', 'move', 'line', 'go', 'penup', 'pendown'];
|
| 153 | for (let i = 0; i < 150; i++) {
|
| 154 | const op = ops[Math.floor(rand() * ops.length)];
|
| 155 | if (op === 'penup' || op === 'pendown') {
|
| 156 | await nd[op]();
|
| 157 | } else if (['move', 'line', 'go'].includes(op)) {
|
| 158 | await nd[op]((rand() - 0.5) * 6, (rand() - 0.5) * 6);
|
| 159 | } else {
|
| 160 | await nd[op](rand() * 15 - 2, rand() * 12 - 2);
|
| 161 | }
|
| 162 | expectTracked(nd, sim);
|
| 163 | }
|
| 164 | expectSafe(sim);
|
| 165 | });
|
| 166 |
|
| 167 | test('delay is sent as hardware SM pauses of at most 750 ms', async () => {
|
| 168 | const { nd, sim } = await connected();
|
| 169 | const n = sim.log.length;
|
| 170 | await nd.delay(2000);
|
| 171 | expect(sim.log.slice(n).map((l) => l.cmd)).toEqual(['SM,750,0,0', 'SM,750,0,0', 'SM,500,0,0']);
|
| 172 | });
|
| 173 |
|
| 174 | test('walkHome returns to the origin', async () => {
|
| 175 | const { nd, sim } = await connected();
|
| 176 | await nd.moveto(7, 5);
|
| 177 | await nd.walkHome();
|
| 178 | expectSafe(sim);
|
| 179 | expectAt(nd, sim, 0, 0);
|
| 180 | });
|
| 181 |
|
| 182 | test('update() applies new pen heights to the servo', async () => {
|
| 183 | const { nd, sim } = await connected();
|
| 184 | nd.options.penPosDown = 20;
|
| 185 | await nd.update();
|
| 186 | expect(sim.servoMax).toBe(5400 + 72 * 20);
|
| 187 | expectSafe(sim);
|
| 188 | });
|
| 189 |
|
| 190 | test('changing handling mode mid-session re-homes and stays in sync', async () => {
|
| 191 | const { nd, sim } = await connected();
|
| 192 | await nd.moveto(4, 3);
|
| 193 | nd.options.handling = 2; // high-res -> low-res motors
|
| 194 | await nd.update();
|
| 195 | expect(sim.vars[12]).toBe(1);
|
| 196 | expectAt(nd, sim, 0, 0);
|
| 197 | await nd.moveto(5, 2);
|
| 198 | await nd.lineto(6, 2);
|
| 199 | await nd.walkHome();
|
| 200 | expectAt(nd, sim, 0, 0);
|
| 201 | expectSafe(sim);
|
| 202 | });
|
| 203 |
|
| 204 | test('raw usbQuery / usbCommand', async () => {
|
| 205 | const { nd } = await connected();
|
| 206 | expect(await nd.usbQuery('QS')).toBe('0,0');
|
| 207 | await nd.usbCommand('SL,7,3');
|
| 208 | expect(await nd.usbQuery('QL,3')).toBe('7');
|
| 209 | });
|
| 210 | });
|
| 211 |
|
| 212 | describe('drawPath', () => {
|
| 213 | test('circle is drawn within tolerance', async () => {
|
| 214 | const { nd, sim } = await connected();
|
| 215 | const circle = [];
|
| 216 | for (let i = 0; i <= 120; i++) {
|
| 217 | const a = (i / 120) * 2 * Math.PI;
|
| 218 | circle.push([4 + 2 * Math.cos(a), 4 + 2 * Math.sin(a)]);
|
| 219 | }
|
| 220 | await nd.drawPath(circle);
|
| 221 | expectSafe(sim);
|
| 222 | expect(sim.penUp).toBe(true);
|
| 223 | expect(polylineSetDistance(sim.ink, [circle])).toBeLessThan(0.003);
|
| 224 | });
|
| 225 |
|
| 226 | test('a path leaving the travel area is split, not driven past the edge', async () => {
|
| 227 | const { nd, sim } = await connected();
|
| 228 | const path = [[10, 2], [14, 2], [14, 4], [10, 4]];
|
| 229 | await nd.drawPath(path);
|
| 230 | expectSafe(sim);
|
| 231 | expect(sim.ink.length).toBe(2);
|
| 232 | expect(polylineSetDistance(sim.ink, [[[10, 2], [TRAVEL[0], 2]], [[TRAVEL[0], 4], [10, 4]]])).toBeLessThan(0.002);
|
| 233 | });
|
| 234 |
|
| 235 | test('millimeter units', async () => {
|
| 236 | const { nd, sim } = await connected();
|
| 237 | nd.options.units = 2;
|
| 238 | await nd.drawPath([[10, 10], [60, 10], [60, 40]]);
|
| 239 | expectSafe(sim);
|
| 240 | const expected = [[[10, 10], [60, 10], [60, 40]].map(([x, y]) => [x / 25.4, y / 25.4])];
|
| 241 | expect(polylineSetDistance(sim.ink, expected)).toBeLessThan(0.002);
|
| 242 | });
|
| 243 | });
|
| 244 |
|
| 245 | describe('speed limits (measured from step rates)', () => {
|
| 246 | for (const handling of [1, 2, 3, 4]) {
|
| 247 | for (const [down, up] of [[25, 75], [100, 100], [5, 10]]) {
|
| 248 | test(`handling ${handling}, speed ${down}/${up}`, async () => {
|
| 249 | const { nd, sim } = await connected({}, (n) => {
|
| 250 | n.options.handling = handling;
|
| 251 | n.options.speedPendown = down;
|
| 252 | n.options.speedPenup = up;
|
| 253 | });
|
| 254 | sim.stats.peakSpeedUp = 0; // Ignore the homing routine's own moves.
|
| 255 | await nd.drawPath([[0.5, 0.5], [11, 0.5], [11, 8], [0.5, 8], [6, 4], [6.01, 4.2], [0.5, 0.5]]);
|
| 256 | await nd.moveto(11, 8);
|
| 257 | await nd.moveto(0.2, 0.3);
|
| 258 | expectSafe(sim);
|
| 259 | // Pen-down: never faster than the configured pen-down speed.
|
| 260 | expect(sim.stats.peakSpeedDown).toBeLessThanOrEqual(nd.speedPendown * 1.01);
|
| 261 | // Pen-up: never faster than the configured pen-up speed.
|
| 262 | expect(sim.stats.peakSpeedUp).toBeLessThanOrEqual(nd.speedPenup * 1.01);
|
| 263 | // Firmware hard limit: < 1 step per 40 µs tick on each motor.
|
| 264 | expect(sim.stats.maxRate).toBeLessThan(2147483648);
|
| 265 | expect(polylineSetDistance(sim.ink,
|
| 266 | [[[0.5, 0.5], [11, 0.5], [11, 8], [0.5, 8], [6, 4], [6.01, 4.2], [0.5, 0.5]]])).toBeLessThan(0.003);
|
| 267 | });
|
| 268 | }
|
| 269 | }
|
| 270 | });
|
| 271 |
|
| 272 | describe('stopping a plot', () => {
|
| 273 | const square = (o) => [[o, o], [o + 3, o], [o + 3, o + 3], [o, o + 3], [o, o]];
|
| 274 | const job = [square(1), square(2), square(3), square(4)];
|
| 275 |
|
| 276 | function interruptAfter(sim, count, action) {
|
| 277 | const orig = sim.handle.bind(sim);
|
| 278 | let n = 0;
|
| 279 | sim.handle = (cmd) => {
|
| 280 | orig(cmd);
|
| 281 | if (/^(T3|TD)/.test(cmd) && ++n === count) action();
|
| 282 | };
|
| 283 | }
|
| 284 |
|
| 285 | test('a full plot draws everything and returns home', async () => {
|
| 286 | const { nd, sim } = await connected();
|
| 287 | const result = await nd.plotPolylines(job);
|
| 288 | expect(result.stopped).toBe(0);
|
| 289 | expectSafe(sim);
|
| 290 | expect(polylineSetDistance(sim.ink, job)).toBeLessThan(0.002);
|
| 291 | expectAt(nd, sim, 0, 0);
|
| 292 | });
|
| 293 |
|
| 294 | test('software stop request raises the pen and stops', async () => {
|
| 295 | const { nd, sim } = await connected();
|
| 296 | interruptAfter(sim, 30, () => nd.requestPause());
|
| 297 | const result = await nd.plotPolylines(job);
|
| 298 | expect(result.stopped).toBe(103);
|
| 299 | expect(sim.penUp).toBe(true);
|
| 300 | expect(commands(sim)).toContain('SP,3');
|
| 301 | expectSafe(sim);
|
| 302 | // Still usable afterwards.
|
| 303 | await nd.moveto(0, 0);
|
| 304 | expectAt(nd, sim, 0, 0);
|
| 305 | expectSafe(sim);
|
| 306 | });
|
| 307 |
|
| 308 | test('PRG button press stops the plot', async () => {
|
| 309 | const { nd, sim } = await connected();
|
| 310 | interruptAfter(sim, 20, () => sim.pressButton());
|
| 311 | const result = await nd.plotPolylines(job);
|
| 312 | expect(result.stopped).toBe(102);
|
| 313 | expect(sim.penUp).toBe(true);
|
| 314 | expectSafe(sim);
|
| 315 | });
|
| 316 |
|
| 317 | test('power loss stops the plot and marks the machine un-homed', async () => {
|
| 318 | const { nd, sim } = await connected();
|
| 319 | interruptAfter(sim, 20, () => sim.setPower(false));
|
| 320 | const result = await nd.plotPolylines(job);
|
| 321 | expect(result.stopped).toBe(105);
|
| 322 | expect(sim.vars[12]).toBe(0);
|
| 323 | expectSafe(sim);
|
| 324 | });
|
| 325 |
|
| 326 | test('a move beyond the firmware step-rate limit is refused, pen raised', async () => {
|
| 327 | const { nd, sim } = await connected();
|
| 328 | const { feed } = await import('../src/control.js');
|
| 329 | await nd.pen.lower(nd);
|
| 330 | const pos = { ...nd.pen.phys };
|
| 331 | await feed(nd, [['T3', [100, 2147483647, 1000, 0, 0, 0, 0], [0.1, pos]]]);
|
| 332 | expect(nd.plotStatus.stopped).toBe(107);
|
| 333 | expect(sim.penUp).toBe(true);
|
| 334 | expect(commands(sim).some((c) => c.startsWith('T3,100'))).toBe(false);
|
| 335 | expectSafe(sim);
|
| 336 | });
|
| 337 |
|
| 338 | test('USB unplug mid-plot stops without hanging', async () => {
|
| 339 | const { nd, sim } = await connected();
|
| 340 | interruptAfter(sim, 20, () => sim.unplug());
|
| 341 | const result = await nd.plotPolylines(job);
|
| 342 | expect(result.stopped).toBe(104);
|
| 343 | expectSafe(sim);
|
| 344 | });
|
| 345 | });
|