CI Log in

zach/dotter

test/safety.test.js 345 lines · 12.4 KB · javascript
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
});