Validate configuration through its owning schemas and keep board declarations separate from shared F722 resource ownership. Bound USB transitions, isolate transport failures and retain restart-only activation. Migrate navigation and flight consumers with the sensor and schema contracts. Require explicit installation data and synchronous output authority; board motor outputs remain inhibited. Validation: assembled-prefix host tests, strict Clippy, platform/USB tests and both ARM release builds. Final-source coverage and scoped Miri/Kani evidence are retained separately from hardware qualification.
161 lines
6.9 KiB
JavaScript
161 lines
6.9 KiB
JavaScript
const {test} = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const {ViewSession, sameContext} = require('./view.js');
|
|
|
|
function view(revision, connection = '1', draft = '0', value = 20, instance = 'host') {
|
|
return {
|
|
state: {config_revision: String(revision), context: {instance, connection, configuration: '1', draft}},
|
|
config: {fields: [{id: 401, value}]},
|
|
};
|
|
}
|
|
function deferred() {
|
|
let resolve, reject;
|
|
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
|
|
return {promise, resolve, reject};
|
|
}
|
|
function harness(send = async () => { throw Error('unexpected mutation'); }, edited = () => {}) {
|
|
const reads = [], rendered = [], errors = [];
|
|
const session = new ViewSession({
|
|
read: () => { const next = deferred(); reads.push(next); return next.promise; },
|
|
send, edited, render: snapshot => rendered.push(snapshot), error: message => errors.push(message), permissions() {},
|
|
});
|
|
return {session, reads, rendered, errors};
|
|
}
|
|
|
|
test('late A view cannot replace displayed B or supply a reset context', async () => {
|
|
const sent = [];
|
|
const h = harness(async (path, body) => { sent.push({path, body}); return view(4, '2'); });
|
|
h.session.accept(view(1), 0);
|
|
const old = h.session.refresh();
|
|
const fresh = h.session.refresh();
|
|
h.reads[1].resolve(view(3, '2'));
|
|
await fresh;
|
|
h.reads[0].resolve(view(2));
|
|
assert.equal(await old, false);
|
|
assert.equal(h.rendered.at(-1).state.context.connection, '2');
|
|
await h.session.mutate('/api/config/reset');
|
|
assert.equal(sent[0].body.context.connection, '2');
|
|
});
|
|
|
|
test('an A action already queued is discarded rather than rebound to B', async () => {
|
|
const first = deferred(), sent = [];
|
|
const h = harness((path, body) => { sent.push({path, body}); return first.promise; });
|
|
h.session.accept(view(1), 0);
|
|
const edit = h.session.mutate('/api/config/edit', {field_id: 401, value: 30});
|
|
const reset = h.session.mutate('/api/config/reset');
|
|
const fresh = h.session.refresh();
|
|
h.reads[0].resolve(view(5, '2'));
|
|
await fresh;
|
|
first.resolve(view(2, '1', '1', 30));
|
|
await Promise.all([edit, reset]);
|
|
assert.equal(sent.length, 1);
|
|
assert.equal(h.rendered.at(-1).state.context.connection, '2');
|
|
assert.match(h.errors.at(-1), /queued action discarded/);
|
|
});
|
|
|
|
test('an old input change event retains the context of its displayed value', async () => {
|
|
const h = harness();
|
|
const a = view(1);
|
|
h.session.accept(a, 0);
|
|
h.session.accept(view(3, '2'), 1);
|
|
assert.equal(await h.session.mutate('/api/config/edit', {field_id: 401, value: 30}, a.state.context), null);
|
|
assert.match(h.errors.at(-1), /queued action discarded/);
|
|
assert.equal(h.rendered.at(-1).state.context.connection, '2');
|
|
});
|
|
|
|
test('rapid same-input edits serialize and only our accepted draft advances queued context', async () => {
|
|
const replies = [deferred(), deferred(), deferred()], sent = [];
|
|
let displayedContext = view('9007199254740993').state.context;
|
|
const h = harness((path, body) => { sent.push({path, body}); return replies[sent.length - 1].promise; },
|
|
(before, after) => { if (sameContext(displayedContext, before)) displayedContext = after; });
|
|
h.session.accept(view('9007199254740993'), 0);
|
|
const first = h.session.mutate('/api/config/edit', {field_id: 401, value: 30}, displayedContext);
|
|
const second = h.session.mutate('/api/config/edit', {field_id: 401, value: 40}, displayedContext);
|
|
assert.equal(sent.length, 1);
|
|
assert.equal(h.session.editing(401), true);
|
|
replies[0].resolve(view('9007199254740994', '1', '1', 30));
|
|
await first;
|
|
assert.equal(sent.length, 2);
|
|
assert.equal(sent[1].body.context.draft, '1');
|
|
assert.equal(sent[1].body.value, 40);
|
|
assert.equal(h.session.editing(401), true);
|
|
const third = h.session.mutate('/api/config/edit', {field_id: 401, value: 50}, displayedContext);
|
|
replies[1].resolve(view('9007199254740995', '1', '2', 40));
|
|
await second;
|
|
assert.equal(sent[2].body.context.draft, '2');
|
|
replies[2].resolve(view('9007199254740996', '1', '3', 50));
|
|
await third;
|
|
assert.equal(h.session.editing(401), false);
|
|
assert.equal(h.rendered.at(-1).config.fields[0].value, 50);
|
|
assert.equal(h.session.accept(view('9007199254740994', '1', '1', 30), 100), false);
|
|
});
|
|
|
|
test('another editors draft cannot silently rebase queued local edits', async () => {
|
|
const reply = deferred(), sent = [];
|
|
const h = harness((path, body) => { sent.push(body); return reply.promise; });
|
|
h.session.accept(view(1), 0);
|
|
const first = h.session.mutate('/api/config/edit', {field_id: 401, value: 30});
|
|
const next = h.session.mutate('/api/config/edit', {field_id: 401, value: 40});
|
|
const poll = h.session.refresh();
|
|
h.reads[0].resolve(view(4, '1', '3', 50));
|
|
await poll;
|
|
reply.resolve(view(2, '1', '1', 30));
|
|
await Promise.all([first, next]);
|
|
assert.equal(sent.length, 1);
|
|
assert.equal(h.rendered.at(-1).config.fields[0].value, 50);
|
|
});
|
|
|
|
test('failed refresh can retry and prior server-instance responses stay retired', async () => {
|
|
const h = harness();
|
|
const first = h.session.refresh();
|
|
h.reads[0].reject(Error('HTTP failure'));
|
|
await assert.rejects(first, /HTTP failure/);
|
|
const retry = h.session.refresh();
|
|
h.reads[1].resolve(view(100));
|
|
assert.equal(await retry, true);
|
|
const prior = h.session.refresh();
|
|
const current = h.session.refresh();
|
|
h.reads[3].resolve(view(1, '1', '0', 60, 'new-host'));
|
|
await current;
|
|
h.reads[2].resolve(view(101));
|
|
assert.equal(await prior, false);
|
|
assert.equal(h.rendered.at(-1).config.fields[0].value, 60);
|
|
});
|
|
|
|
test('decimal controls retain signed zero and nonfinite bit strings when submitting edits', async () => {
|
|
const vm = require('node:vm');
|
|
const fs = require('node:fs');
|
|
const sent = [];
|
|
function element() {
|
|
return {
|
|
dataset: {}, value: '', events: {},
|
|
addEventListener(name, callback) { this.events[name] = callback; },
|
|
};
|
|
}
|
|
const pending = () => new Promise(() => {});
|
|
const context = vm.createContext({
|
|
document: {querySelector: element, createElement: element},
|
|
fetch: pending,
|
|
ViewSession: class {
|
|
refresh() { return pending(); }
|
|
editing() { return false; }
|
|
async mutate(path, body) { sent.push({path, body}); }
|
|
},
|
|
});
|
|
vm.runInContext(fs.readFileSync(`${__dirname}/app.js`, 'utf8'), context);
|
|
const field = {id: 401, kind: 'decimal', options: [], minimum: null, maximum: null};
|
|
context.field = field;
|
|
const control = vm.runInContext('fieldControl(field)', context);
|
|
assert.equal(control.type, 'text');
|
|
for (const value of ['-0', 'bits:0xfff0000000000042', 'bits:0x7f800000']) {
|
|
control.value = value;
|
|
await control.events.change();
|
|
const serialized = JSON.parse(JSON.stringify(sent.at(-1)));
|
|
assert.equal(serialized.body.value, value);
|
|
assert.equal(serialized.path, '/api/config/edit');
|
|
}
|
|
const original = vm.runInContext('JSON.stringify(fieldStructure(field))', context);
|
|
field.finite = true;
|
|
assert.notEqual(vm.runInContext('JSON.stringify(fieldStructure(field))', context), original);
|
|
});
|