fix: web UI review findings (step 2b)

- Saving the security settings threw: the form's values were named
  security, hiding the Security object the new token goes to. A save from
  None to None no longer reconnects, as the device closes nobody then.
- Each sync redraws the lists and reloads the settings, so a reconnect
  shows what changed meanwhile; closing the config panel drops its feeds
  even while the socket is down.
- A 401 on backup or an upload opens the login, as a refused socket does
  (Security.tokenRefused); a failed firmware upload says so.
- The general and security panels fill in what the device leaves out at
  its default, so a save never writes back what the form showed before.
  The hostname holds 31 characters, as the device does.
- The views (settings with defaults, a group's shades, frames, scans, the
  network and radio panels, op errors) move to data-src/js/views.js, with
  tests. runOp is the one op path with the busy retry; repeatOf,
  commandArgs, gone(); dead update*List and editedRepeaters removed.
- Tests for ProtoClient.off and a listener removing itself mid-event.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cjkas 2026-10-25 20:43:09 +01:00
parent 0df740df6a
commit 3212230f01
5 changed files with 357 additions and 208 deletions

View file

@ -315,6 +315,21 @@ test('waitFor gives null when no event comes, and stops listening', async () =>
assert.equal((c.listeners.room || []).length, 0);
});
test('off stops a listener, and one that removes itself mid-event leaves the others called', () => {
const { c, ws } = connected();
const seen = [];
const once = (d) => { seen.push('once'); c.off('memory', once); };
const always = () => seen.push('always');
const dropped = () => seen.push('dropped');
c.on('memory', once);
c.on('memory', always);
c.on('memory', dropped);
c.off('memory', dropped);
ws.deliver({ v: 2, t: T.memory, d: { fr: 1 } });
ws.deliver({ v: 2, t: T.memory, d: { fr: 2 } });
assert.deepEqual(seen, ['once', 'always', 'always']);
});
test('subscribe sends the feed number', () => {
const { c, ws } = connected();
c.subscribe('frames');

82
test/js/views.test.js Normal file
View file

@ -0,0 +1,82 @@
// What the web UI's panels bind (data-src/js/views.js), from protocol 2's
// shapes.
const test = require('node:test');
const assert = require('node:assert/strict');
const { ProtoViews } = require('../../data-src/js/views.js');
const { ProtoModel } = require('../../data-src/js/model.js');
const { recordedSchema, deviceSections } = require('./helpers.js');
const schema = recordedSchema();
function synced() {
const model = new ProtoModel(schema);
model.beginSync();
for (const d of deviceSections(schema)) model.applySync(d);
model.endSync();
return model;
}
test('a settings section has every field, defaults where the device left them out', () => {
// ssdpBroadcast off and no NTP server are at their defaults, so not sent.
assert.deepEqual(ProtoViews.settings(schema, 'general', { hostname: 'Somfy' }),
{ hostname: 'Somfy', ssdpBroadcast: false, ntpServer: '', posixZone: '' });
assert.deepEqual(ProtoViews.settings(schema, 'security', {}), { securityType: 0, username: '', hasPassword: false, hasPin: false });
});
test('the settings sections are what the device sends', () => {
for (const section of Object.keys(ProtoViews.SETTINGS))
for (const name of ProtoViews.SETTINGS[section]) assert.ok(schema.keys[name], `${section}.${name}`);
});
test('a group shows its shades whole', () => {
const m = synced();
const group = m.groupList()[0];
const view = ProtoViews.group(m, group);
assert.equal(view.linkedShades.length, group.linkedShades.length);
assert.equal(view.linkedShades[0].name, m.shades.get(group.linkedShades[0]).name);
// The model's item is left as it is.
assert.equal(typeof group.linkedShades[0], 'number');
// A shade the model hasn't got yet still shows.
assert.equal(ProtoViews.group(m, Object.assign({}, group, { linkedShades: [250] })).linkedShades[0].shadeId, 250);
assert.equal(ProtoViews.group(m, undefined), undefined);
});
test('a frame and a scan under the names the UI reads', () => {
const f = ProtoViews.frame(schema, { remoteAddress: 5, lastRollingCode: 9, command: 'Up', rssi: -60, valid: true, pulses: [1, 2] });
assert.equal(f.address, 5);
assert.equal(f.rcode, 9);
assert.equal(f.encKey, 0);
assert.deepEqual(f.pulses, [1, 2]);
assert.equal(f.stepSize, undefined);
const s = ProtoViews.scan(schema, { scanning: true, testFreq: 433.1, testRssi: -80 });
assert.deepEqual(s, { scanning: true, testFreq: 433.1, testRSSI: -80, frequency: 0, RSSI: 0 });
});
test('the network section as the panel binds it', () => {
const v = ProtoViews.network(schema, { connMode: 1, ssid: 'home', hasPassphrase: true, dhcp: true, PWRPin: -1 });
assert.equal(v.connType, 1);
assert.equal(v.hasEthernet, false);
assert.deepEqual(v.wifi, { ssid: 'home', hasPassphrase: true, roaming: false, hidden: false });
assert.equal(v.ethernet.PWRPin, -1);
assert.deepEqual(v.ip, { dhcp: true, ip: '', gateway: '', subnet: '', dns1: '', dns2: '' });
});
test('the radio section as the panel binds it, and back', () => {
const config = ProtoViews.radio(schema, { bitLength: 80, frequency: 433.42, enabled: true, radioInit: true });
assert.equal(config.type, 80);
assert.equal(config.bitLength, undefined);
assert.equal(config.TXPin, 0);
const save = ProtoViews.radioSave(config);
assert.equal(save.section, 'radio');
assert.equal(save.bitLength, 80);
assert.equal(save.frequency, 433.42);
// Only shown, never saved.
assert.equal('radioInit' in save, false);
assert.equal('type' in save, false);
});
test('an op refusal for the error box, with a shade validation code', () => {
assert.deepEqual(ProtoViews.opError('saveShade', { code: 'badRequest', desc: 'Pin in use', data: { validation: -10 } }),
{ htmlError: 'badRequest', service: 'saveShade', desc: 'Pin in use', code: -10 });
assert.deepEqual(ProtoViews.opError('reboot', { code: 'closed', desc: 'The connection closed' }),
{ htmlError: 'closed', service: 'reboot', desc: 'The connection closed' });
});