| 1 | import { describe, expect, test } from 'bun:test';
|
| 2 | import { commandName, compareVersions, EBB } from '../src/ebb.js';
|
| 3 | import { commands, connected, makeMachine } from './helpers.js';
|
| 4 |
|
| 5 | /** Scripted transport: responds to each command via a function. */
|
| 6 | class FakeTransport {
|
| 7 | constructor(respond) {
|
| 8 | this.respond = respond;
|
| 9 | this.out = [];
|
| 10 | this.sent = [];
|
| 11 | }
|
| 12 |
|
| 13 | async open() {}
|
| 14 |
|
| 15 | async close() {}
|
| 16 |
|
| 17 | async clearInput() {
|
| 18 | this.out = [];
|
| 19 | }
|
| 20 |
|
| 21 | async write(text) {
|
| 22 | for (const cmd of text.split('\r').filter(Boolean)) {
|
| 23 | this.sent.push(cmd);
|
| 24 | const r = this.respond(cmd);
|
| 25 | if (r != null) this.out.push(...[].concat(r));
|
| 26 | }
|
| 27 | }
|
| 28 |
|
| 29 | async readLine() {
|
| 30 | return this.out.length ? this.out.shift() : '';
|
| 31 | }
|
| 32 | }
|
| 33 |
|
| 34 | const EBB_V = 'EBBv13_and_above EB Firmware Version 3.0.3\r\n';
|
| 35 |
|
| 36 | describe('helpers', () => {
|
| 37 | test('commandName', () => {
|
| 38 | expect(commandName('SM,10,1,1')).toBe('SM');
|
| 39 | expect(commandName('R')).toBe('R');
|
| 40 | expect(commandName('A,1')).toBe('A');
|
| 41 | expect(commandName('QG')).toBe('QG');
|
| 42 | });
|
| 43 |
|
| 44 | test('compareVersions', () => {
|
| 45 | expect(compareVersions('3.0.2', '3.0.2')).toBe(0);
|
| 46 | expect(compareVersions('3.0.10', '3.0.2')).toBe(1);
|
| 47 | expect(compareVersions('2.9', '3.0.2')).toBe(-1);
|
| 48 | });
|
| 49 | });
|
| 50 |
|
| 51 | describe('EBB serial layer', () => {
|
| 52 | test('connects, enters future syntax, reads nickname', async () => {
|
| 53 | const t = new FakeTransport((c) => {
|
| 54 | if (c === 'v') return EBB_V;
|
| 55 | if (c === 'CU,10,1') return 'OK\r\n';
|
| 56 | if (c === 'QT') return 'QT,plotbot\n';
|
| 57 | return `${c.slice(0, 2)}\n`;
|
| 58 | });
|
| 59 | const ebb = new EBB();
|
| 60 | expect(await ebb.connect(t)).toBe(true);
|
| 61 | expect(ebb.version).toBe('3.0.3');
|
| 62 | expect(ebb.name).toBe('plotbot');
|
| 63 | expect(t.sent).toEqual(['v', 'CU,10,1', 'QT']);
|
| 64 | });
|
| 65 |
|
| 66 | test('rejects firmware older than 3.0.2', async () => {
|
| 67 | const t = new FakeTransport((c) => (c === 'v' ? 'EBBv13_and_above EB Firmware Version 2.8.1\r\n' : 'OK\r\n'));
|
| 68 | const ebb = new EBB();
|
| 69 | expect(await ebb.connect(t)).toBe(false);
|
| 70 | expect(ebb.err).toContain('not supported');
|
| 71 | expect(t.sent).toEqual(['v']); // Nothing else is sent to an unsupported board.
|
| 72 | });
|
| 73 |
|
| 74 | test('rejects a device that is not an EBB', async () => {
|
| 75 | const t = new FakeTransport(() => 'hello\r\n');
|
| 76 | const ebb = new EBB();
|
| 77 | expect(await ebb.connect(t)).toBe(false);
|
| 78 | expect(t.sent).toEqual(['v', 'v']); // Two identification attempts, as plotink does.
|
| 79 | });
|
| 80 |
|
| 81 | test('query strips the echoed name; command checks the echo', async () => {
|
| 82 | const t = new FakeTransport((c) => {
|
| 83 | if (c === 'v') return EBB_V;
|
| 84 | if (c === 'QS') return 'QS,1421,-429\n';
|
| 85 | if (c === 'QG') return 'QG,3E\n';
|
| 86 | if (c === 'QE') return 'QE,16,0\n';
|
| 87 | return `${c.split(',')[0]}\n`;
|
| 88 | });
|
| 89 | const ebb = new EBB();
|
| 90 | await ebb.connect(t);
|
| 91 | expect(await ebb.querySteps()).toEqual([1421, -429]);
|
| 92 | expect(await ebb.queryStatusByte()).toBe(0x3e);
|
| 93 | expect(await ebb.motorsQueryEnabled()).toEqual([1, 0]);
|
| 94 | expect(await ebb.command('SM,10,0,0')).toBe(true);
|
| 95 | });
|
| 96 |
|
| 97 | test('an EBB error stops all further commands', async () => {
|
| 98 | const t = new FakeTransport((c) => {
|
| 99 | if (c === 'v') return EBB_V;
|
| 100 | if (c.startsWith('SM')) return '!8 Err: Unknown command\n';
|
| 101 | return `${c.split(',')[0]}\n`;
|
| 102 | });
|
| 103 | const ebb = new EBB();
|
| 104 | await ebb.connect(t);
|
| 105 | expect(await ebb.command('SM,1,1,1')).toBe(false);
|
| 106 | expect(ebb.err).toContain('Err');
|
| 107 | const before = t.sent.length;
|
| 108 | expect(await ebb.command('SP,0')).toBe(false);
|
| 109 | expect(await ebb.query('QS')).toBe(null);
|
| 110 | expect(t.sent.length).toBe(before); // Nothing more goes to the machine.
|
| 111 | });
|
| 112 |
|
| 113 | test('a missing reply is a timeout error', async () => {
|
| 114 | const t = new FakeTransport((c) => (c === 'v' ? EBB_V : c === 'QT' ? 'QT\n' : null));
|
| 115 | const ebb = new EBB();
|
| 116 | await ebb.connect(t);
|
| 117 | expect(await ebb.command('SP,1')).toBe(false);
|
| 118 | expect(ebb.err).toContain('timeout');
|
| 119 | });
|
| 120 |
|
| 121 | test('a reply for a different command is an error', async () => {
|
| 122 | const t = new FakeTransport((c) => (c === 'v' ? EBB_V : 'XX\n'));
|
| 123 | const ebb = new EBB();
|
| 124 | await ebb.connect(t);
|
| 125 | expect(await ebb.command('SP,1')).toBe(false);
|
| 126 | expect(ebb.err).toContain('Unexpected response');
|
| 127 | });
|
| 128 |
|
| 129 | test('int32 variables round-trip, including negatives', async () => {
|
| 130 | const { nd } = await connected({}, (n) => { n.options.homing = false; });
|
| 131 | for (const v of [0, 1, -1, 123456789, -2147483648, 2147483647]) {
|
| 132 | await nd.machine.varWriteInt32(v, 24);
|
| 133 | expect(await nd.machine.varReadInt32(24)).toBe(v);
|
| 134 | }
|
| 135 | });
|
| 136 | });
|
| 137 |
|
| 138 | describe('NextDraw connect sequence', () => {
|
| 139 | test('fresh machine: configures, raises pen, enables motors, sets home', async () => {
|
| 140 | const { sim } = await connected({}, (n) => { n.options.homing = false; });
|
| 141 | expect(commands(sim)).toEqual([
|
| 142 | 'v', 'CU,10,1', 'QT',
|
| 143 | 'CU,3,1', 'CU,4,16', 'CU,50,0', // connection setup
|
| 144 | 'QC', // power check
|
| 145 | 'QL,10', 'QL,11', // cached pen configuration (none yet)
|
| 146 | 'SC,11,231', 'SC,12,154', 'SC,4,9720', 'SC,5,8280', // servo rates and positions
|
| 147 | 'SP,1,45,2', // raise pen on pin B2
|
| 148 | 'SC,8,1', 'SL,162,10', 'SL,142,11', // 1 PWM channel; cache pen config
|
| 149 | 'CU,60,135', 'QE', 'QE', 'EM,1,1', 'SL,0,12', 'CS', // enable motors at 16x, not homed
|
| 150 | 'QL,12', // homed?
|
| 151 | 'CS', 'SL,0,24', 'SL,0,25', 'SL,0,26', 'SL,0,27', 'SL,0,28', 'SL,0,29', 'SL,0,30', 'SL,0,31',
|
| 152 | 'SL,1,12', // set home here, zero offsets, mark homed
|
| 153 | 'QS', 'QL,24', 'QL,25', 'QL,26', 'QL,27', 'QL,28', 'QL,29', 'QL,30', 'QL,31',
|
| 154 | 'T3,1,0,0,0,0,0,0,3', // read position and clear accumulators
|
| 155 | ]);
|
| 156 | expect(sim.violations).toEqual([]);
|
| 157 | });
|
| 158 |
|
| 159 | test('reconnect: pen and homing state are reused, nothing moves', async () => {
|
| 160 | const { nd, sim, clock } = await connected({ startX: 2, startY: 3 });
|
| 161 | await nd.moveto(1, 1);
|
| 162 | await nd.disconnect();
|
| 163 | const n = sim.log.length;
|
| 164 | const { NextDraw } = await import('../src/nextdraw.js');
|
| 165 | const nd2 = new NextDraw({ transport: sim, clock, log: () => {} });
|
| 166 | nd2.interactive();
|
| 167 | expect(await nd2.connect()).toBe(true);
|
| 168 | const cmds = sim.log.slice(n).map((l) => l.cmd);
|
| 169 | expect(cmds.some((c) => /^(SP|SM|T3,[^1]|TD|HM|EM)/.test(c))).toBe(false);
|
| 170 | expect(nd2.currentPos()[0]).toBeCloseTo(1, 3);
|
| 171 | expect(nd2.currentPos()[1]).toBeCloseTo(1, 3);
|
| 172 | });
|
| 173 |
|
| 174 | test('connect fails cleanly without a machine', async () => {
|
| 175 | const { nd, sim, messages } = makeMachine();
|
| 176 | sim.unplug();
|
| 177 | expect(await nd.connect()).toBe(false);
|
| 178 | expect(nd.plotStatus.stopped).toBe(101);
|
| 179 | expect(messages.join(' ')).toContain('Failed to connect');
|
| 180 | });
|
| 181 |
|
| 182 | test('errors.connect = true throws instead', async () => {
|
| 183 | const { nd, sim } = makeMachine();
|
| 184 | sim.unplug();
|
| 185 | nd.errors.connect = true;
|
| 186 | await expect(nd.connect()).rejects.toThrow('Failed to connect');
|
| 187 | });
|
| 188 |
|
| 189 | test('motion API requires a connection', async () => {
|
| 190 | const { nd } = makeMachine();
|
| 191 | await expect(nd.moveto(1, 1)).rejects.toThrow('Not connected');
|
| 192 | });
|
| 193 | });
|