ESPSomfy-RTS/test/js/model.test.js
cjkas 5721d34a34 feat: say when the firmware is flashed over USB
Builds with one app slot (C6, debug, and the C5 to come) have nowhere
for /updateFirmware to write. It answered "Error updating firmware: "
and rebooted.

- ConfigSettings::otaSlot, from esp_ota_get_next_update_partition().
- The header sends otaSlot, default 1, so only those builds send it.
- /updateFirmware refuses with 400 and a reason, and doesn't stop the
  radio or reboot.
- The web UI's Update Firmware says to flash the onboard image over USB,
  with a backup first. Firmware before the field leaves it undefined,
  which keeps the uploader.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 22:06:26 +01:00

138 lines
5.5 KiB
JavaScript

// The web UI's ProtoModel (data-src/js/model.js): the recorded sync, then
// events.
const test = require('node:test');
const assert = require('node:assert/strict');
const { ProtoModel } = require('../../data-src/js/model.js');
const { recordedSchema, deviceSections, recordedItems } = require('./helpers.js');
function synced() {
const schema = recordedSchema();
const model = new ProtoModel(schema);
model.beginSync();
for (const d of deviceSections(schema)) model.applySync(d);
model.endSync();
return model;
}
test('the sync fills every room, shade and group', () => {
const m = synced();
assert.equal(m.rooms.size, recordedItems('R').length);
assert.equal(m.shades.size, recordedItems('S').length);
assert.equal(m.groups.size, recordedItems('G').length);
assert.equal(m.header.model, 'ESPSomfyRTS');
assert.equal(m.header.maxShades, 32);
assert.ok(m.status.freeHeap > 0);
});
test('a field the sync left out is its default', () => {
const m = synced();
const raw = recordedItems('S').find((s) => typeof s.m === 'undefined');
const shade = m.shades.get(raw.s);
// Left out because they were at their default.
assert.equal(shade.myPos, -1);
assert.equal(shade.stepSize, raw.z === undefined ? 100 : raw.z);
assert.equal(typeof shade.paired, 'boolean');
assert.ok(Array.isArray(shade.linkedRemotes));
for (const r of shade.linkedRemotes) assert.equal(typeof r.lastRollingCode, 'number');
for (const g of m.groupList()) assert.ok(Array.isArray(g.linkedShades));
});
test('otaSlot is false only when the device says so', () => {
// The recording is from firmware before the field.
assert.equal(synced().header.otaSlot, undefined);
const schema = recordedSchema();
schema.add({ F: [['os', 'otaSlot', 'b', 1]] });
const header = deviceSections(schema).find((d) => d.header).header;
const m = new ProtoModel(schema);
m.applySync({ header: header });
assert.equal(m.header.otaSlot, true);
m.applySync({ header: schema.expand(Object.assign({ os: 0 }, schema.pack(header))) });
assert.equal(m.header.otaSlot, false);
});
test('the lists come in their sort order', () => {
const m = synced();
const orders = m.shadeList().map((s) => s.sortOrder);
assert.deepEqual(orders, [...orders].sort((a, b) => a - b));
});
test('a state event changes only the state, and a left-out field is its default', () => {
const m = synced();
const shade = m.shadeList()[0];
const name = shade.name;
// A shade at rest at the top: position 0 is left out.
assert.ok(m.applyEvent('shadeState', { shadeId: shade.shadeId, target: 40, direction: 1 }));
assert.equal(m.shades.get(shade.shadeId).position, 0);
assert.equal(m.shades.get(shade.shadeId).target, 40);
assert.equal(m.shades.get(shade.shadeId).name, name);
// Of a shade the client doesn't have yet: nothing to change.
assert.equal(m.applyEvent('shadeState', { shadeId: 250, position: 5 }), false);
});
test('a whole item replaces what was there', () => {
const m = synced();
const shade = m.shadeList()[0];
m.applyEvent('shade', { shadeId: shade.shadeId, name: 'Renamed' });
assert.equal(m.shades.get(shade.shadeId).name, 'Renamed');
assert.equal(m.shades.get(shade.shadeId).upTime, 0);
m.applyEvent('room', { roomId: 9, name: 'New' });
assert.equal(m.rooms.get(9).sortOrder, 0);
});
test('removals, orders and repeaters', () => {
const m = synced();
const changes = [];
m.onChange((kind, id) => changes.push([kind, id]));
const [first, second] = m.shadeList();
assert.ok(m.applyEvent('shadeRemoved', { shadeId: first.shadeId }));
assert.equal(m.shades.has(first.shadeId), false);
// An id it doesn't have is ignored.
assert.equal(m.applyEvent('roomRemoved', { roomId: 250 }), false);
m.applyEvent('shadeOrder', { ids: [second.shadeId] });
assert.equal(m.shades.get(second.shadeId).sortOrder, 0);
m.applyEvent('repeaters', { repeaters: [7, 8] });
assert.deepEqual(m.repeaters, [7, 8]);
// An order says which list it is.
assert.deepEqual(changes.map((c) => c[0]), ['shadeRemoved', 'shadeOrder', 'repeaters']);
});
test('a sync without repeaters clears them, as the device leaves the list out when it has none', () => {
const m = synced();
m.applyEvent('repeaters', { repeaters: [7] });
m.beginSync();
for (const d of deviceSections(m.schema)) m.applySync(Object.assign({}, d, { repeaters: undefined }));
m.endSync();
assert.deepEqual(m.repeaters, []);
});
test('a sync that is called off removes nothing', () => {
const m = synced();
const shades = m.shades.size;
m.beginSync();
m.cancelSync();
m.endSync();
assert.equal(m.shades.size, shades);
});
test('a sync drops what it no longer lists, and keeps what an event added during it', () => {
const m = synced();
const schema = m.schema;
m.applyEvent('room', { roomId: 99, name: 'Gone' });
m.beginSync();
m.applyEvent('room', { roomId: 98, name: 'Added during the sync' });
for (const d of deviceSections(schema)) m.applySync(d);
m.endSync();
assert.equal(m.rooms.has(99), false);
assert.equal(m.rooms.has(98), true);
});
test('status events', () => {
const m = synced();
m.applyEvent('memory', { freeHeap: 1000, uptime: 5 });
assert.equal(m.status.freeHeap, 1000);
assert.equal(m.status.loopRate, 0);
m.applyEvent('wifi', { strength: -60, channel: 6 });
assert.equal(m.status.ssid, '');
assert.equal(m.status.strength, -60);
assert.equal(m.applyEvent('frame', { remoteAddress: 1 }), false);
});