feat(firmware): validate configuration and migrate sensor-driven flight APIs
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.
This commit is contained in:
1 parent
fb1b8af2d1
commit
f9a61a086f
211 files changed
+45657
-12740
No files matched your search
@@ -1,14 +1,27 @@
|
||||
const q = selector => document.querySelector(selector);
|
||||
|
||||
const pendingActions = new Set();
|
||||
let latestState = null;
|
||||
let latestConfig = null;
|
||||
let configRevision = -1;
|
||||
let editorStructure = '';
|
||||
const expandedPackets = new Set();
|
||||
const packetRows = new Map();
|
||||
let logSession = null;
|
||||
let latestLogId = -1;
|
||||
const views = new ViewSession({
|
||||
read: () => api('/api/view'),
|
||||
send: post,
|
||||
render: view => { renderState(view.state); renderConfig(view.config); },
|
||||
error: showError,
|
||||
permissions: renderPermissions,
|
||||
edited: (before, after) => {
|
||||
for (const control of q('#draft-fields').elements) {
|
||||
if (control.dataset.dirty === 'true'
|
||||
&& sameContext(control.intentContext ?? control.viewContext, before)) {
|
||||
control.intentContext = after;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function setText(element, value) {
|
||||
const text = value ?? '';
|
||||
@@ -30,7 +43,9 @@ async function api(path, options = {}) {
|
||||
const value = await responseValue(response);
|
||||
if (!response.ok) {
|
||||
const detail = typeof value === 'object' && value !== null ? value.error : value;
|
||||
throw Error(detail || `Request failed with HTTP ${response.status}`);
|
||||
const error = Error(detail || `Request failed with HTTP ${response.status}`);
|
||||
error.view = value?.view;
|
||||
throw error;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -47,41 +62,30 @@ function showError(message) {
|
||||
setText(q('#connect-error'), message);
|
||||
}
|
||||
|
||||
async function runAction(id, action) {
|
||||
pendingActions.add(id);
|
||||
renderPermissions();
|
||||
try {
|
||||
const value = await action();
|
||||
showError('');
|
||||
return value;
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
return null;
|
||||
} finally {
|
||||
pendingActions.delete(id);
|
||||
renderPermissions();
|
||||
}
|
||||
}
|
||||
|
||||
q('#connect-form').addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
runAction('connect', () => post('/api/connect', {path: q('#path').value}));
|
||||
views.mutate('/api/connect', {path: q('#path').value}, q('#connect-serial').viewContext);
|
||||
});
|
||||
|
||||
q('#disconnect').addEventListener('click', () => {
|
||||
runAction('disconnect', () => post('/api/disconnect'));
|
||||
views.mutate('/api/disconnect', {}, q('#disconnect').viewContext);
|
||||
});
|
||||
|
||||
q('#draft-fields').addEventListener('submit', event => event.preventDefault());
|
||||
|
||||
q('#save').addEventListener('click', () => {
|
||||
runAction('save', () => post('/api/config/save'));
|
||||
views.mutate('/api/config/save', {}, q('#save').viewContext);
|
||||
});
|
||||
|
||||
q('#reset-saved').addEventListener('click', () => {
|
||||
views.mutate('/api/config/reset', {}, q('#reset-saved').viewContext);
|
||||
});
|
||||
|
||||
function fieldStructure(field) {
|
||||
return [
|
||||
field.id, field.key, field.label, field.group, field.unit, field.kind,
|
||||
field.minimum, field.maximum, field.factory, field.help, field.options,
|
||||
field.finite, field.nonzero,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -104,24 +108,37 @@ function fieldControl(field) {
|
||||
if (field.kind === 'bool') {
|
||||
control.type = 'checkbox';
|
||||
} else {
|
||||
control.type = 'number';
|
||||
// Text retains signed zero and IEEE bit strings for nonfinite values.
|
||||
control.type = field.kind === 'decimal' ? 'text' : 'number';
|
||||
control.step = field.kind === 'decimal' ? 'any' : '1';
|
||||
control.inputMode = field.kind === 'decimal' ? 'decimal' : 'numeric';
|
||||
if (field.minimum !== null) control.min = String(field.minimum);
|
||||
if (field.maximum !== null) control.max = String(field.maximum);
|
||||
}
|
||||
}
|
||||
control.addEventListener('input', () => {
|
||||
if (control.dataset.dirty !== 'true') control.intentContext = control.viewContext;
|
||||
control.dataset.dirty = 'true';
|
||||
renderPermissions();
|
||||
});
|
||||
control.addEventListener('change', async () => {
|
||||
let value = control.value;
|
||||
if (field.kind === 'bool') {
|
||||
value = field.options.length === 0 ? control.checked : control.value === 'true';
|
||||
}
|
||||
const action = `edit:${field.id}`;
|
||||
const config = await runAction(action, () => post('/api/config/edit', {
|
||||
control.dataset.dirty = 'true';
|
||||
const displayed = control.type === 'checkbox' ? control.checked : control.value;
|
||||
await views.mutate('/api/config/edit', {
|
||||
field_id: field.id,
|
||||
value,
|
||||
}));
|
||||
if (config !== null) renderConfig(config);
|
||||
}, control.intentContext ?? control.viewContext);
|
||||
if (displayed === (control.type === 'checkbox' ? control.checked : control.value)
|
||||
&& !views.editing(field.id)) {
|
||||
delete control.dataset.dirty;
|
||||
delete control.intentContext;
|
||||
}
|
||||
if (latestConfig) syncEditorValues(latestConfig.fields);
|
||||
renderPermissions();
|
||||
});
|
||||
return control;
|
||||
}
|
||||
@@ -148,11 +165,19 @@ function buildEditor(fields) {
|
||||
key.textContent = `${field.key} [${field.id}]`;
|
||||
const control = fieldControl(field);
|
||||
label.append(title, key, control);
|
||||
const values = document.createElement('small');
|
||||
values.id = `values-${field.id}`;
|
||||
const error = document.createElement('small');
|
||||
error.id = `error-${field.id}`;
|
||||
error.className = 'error';
|
||||
error.setAttribute('role', 'alert');
|
||||
label.append(values, error);
|
||||
|
||||
const bounds = field.minimum !== null || field.maximum !== null
|
||||
? `Inclusive bounds: ${field.minimum ?? 'unbounded'} to ${field.maximum ?? 'unbounded'}.`
|
||||
: '';
|
||||
const helpText = [field.help, `Factory: ${field.factory}.`, bounds].filter(Boolean).join(' ');
|
||||
const rules = [field.finite ? 'Must be finite.' : '', field.nonzero ? 'Must be nonzero.' : ''].filter(Boolean).join(' ');
|
||||
const helpText = [field.help, `Factory: ${field.factory}.`, bounds, rules].filter(Boolean).join(' ');
|
||||
if (helpText) {
|
||||
const help = document.createElement('small');
|
||||
help.id = `help-${field.id}`;
|
||||
@@ -171,9 +196,18 @@ function syncEditorValues(fields) {
|
||||
for (const field of fields) {
|
||||
const control = q(`[data-field-id="${field.id}"]`);
|
||||
if (!control) continue;
|
||||
const display = value => {
|
||||
if (value === null) return 'unknown';
|
||||
return field.options.find(option => String(option.value) === String(value))?.label ?? String(value);
|
||||
};
|
||||
setText(q(`#values-${field.id}`), `Boot-active: ${display(field.active)} · Saved: ${display(field.saved)} · Draft: ${field.draft === null ? 'unchanged' : display(field.draft)}`);
|
||||
setText(q(`#error-${field.id}`), field.error ?? '');
|
||||
control.setAttribute('aria-invalid', String(field.error !== null));
|
||||
if (views.editing(field.id) || control.dataset.dirty === 'true') continue;
|
||||
control.viewContext = latestState.context;
|
||||
if (field.kind === 'bool' && field.options.length === 0) {
|
||||
if (document.activeElement !== control) control.checked = field.value;
|
||||
} else if (document.activeElement !== control && control.value !== String(field.value)) {
|
||||
control.checked = field.value;
|
||||
} else if (control.value !== String(field.value)) {
|
||||
control.value = String(field.value);
|
||||
}
|
||||
}
|
||||
@@ -191,25 +225,32 @@ function renderConfig(config) {
|
||||
q('#restart-guidance').hidden = config.state !== 'restart required';
|
||||
renderValidation(config.validation);
|
||||
|
||||
const structure = JSON.stringify(config.fields.map(fieldStructure));
|
||||
const structure = JSON.stringify([
|
||||
latestState.context.instance, latestState.context.connection, latestState.context.configuration,
|
||||
config.fields.map(fieldStructure),
|
||||
]);
|
||||
if (structure !== editorStructure) {
|
||||
editorStructure = structure;
|
||||
buildEditor(config.fields);
|
||||
}
|
||||
syncEditorValues(config.fields);
|
||||
q('#save').viewContext = latestState.context;
|
||||
q('#reset-saved').viewContext = latestState.context;
|
||||
renderPermissions();
|
||||
}
|
||||
|
||||
function renderPermissions() {
|
||||
if (latestState === null) return;
|
||||
const busy = pendingActions.size !== 0;
|
||||
const busy = views.busy;
|
||||
const dirty = [...q('#draft-fields').elements].some(control => control.dataset.dirty === 'true');
|
||||
q('#path').disabled = latestState.can_disconnect || busy;
|
||||
q('#connect-serial').disabled = latestState.can_disconnect || busy;
|
||||
q('#disconnect').hidden = !latestState.can_disconnect;
|
||||
q('#disconnect').disabled = busy;
|
||||
q('#save').disabled = !latestState.can_save || busy;
|
||||
q('#save').disabled = !latestState.can_save || busy || dirty;
|
||||
q('#reset-saved').disabled = !latestState.can_reset || busy;
|
||||
for (const control of q('#draft-fields').elements) {
|
||||
control.disabled = !latestState.can_edit || busy;
|
||||
control.disabled = !latestState.can_edit || (busy && !views.onlyEdits);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,24 +447,23 @@ function renderState(state) {
|
||||
q('#configuration').hidden = !state.has_config;
|
||||
q('#packet-monitor').hidden = !state.can_disconnect;
|
||||
q('#log-monitor').hidden = !state.can_disconnect;
|
||||
q('#connect-serial').viewContext = state.context;
|
||||
q('#disconnect').viewContext = state.context;
|
||||
renderPermissions();
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const [state, packets, logs] = await Promise.all([
|
||||
api('/api/state'),
|
||||
const context = views.view?.state.context;
|
||||
const [, packets, logs] = await Promise.all([
|
||||
views.refresh(),
|
||||
api('/api/packets'),
|
||||
api('/api/logs'),
|
||||
]);
|
||||
renderState(state);
|
||||
renderPackets(packets);
|
||||
renderLogs(logs);
|
||||
if (state.config_revision !== configRevision) {
|
||||
const config = await api('/api/config');
|
||||
renderConfig(config);
|
||||
configRevision = state.config_revision;
|
||||
q('#configuration').hidden = latestConfig.schema_id === null;
|
||||
if (context?.connection === views.view?.state.context.connection
|
||||
&& context?.instance === views.view?.state.context.instance) {
|
||||
renderPackets(packets);
|
||||
renderLogs(logs);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
|
||||
@@ -35,11 +35,15 @@
|
||||
|
||||
<aside id="restart-guidance" class="important" role="status" hidden>
|
||||
Saved configuration becomes active after the controller restarts.
|
||||
The current serial link keeps its boot-active settings until restart.
|
||||
</aside>
|
||||
<p class="hint">Inputs edit the saved draft. Boot-active values stay in use until restart.
|
||||
USB remains available for configuration and recovery.</p>
|
||||
<ul id="validation"></ul>
|
||||
<form id="draft-fields" class="draft-fields"></form>
|
||||
<div class="actions" aria-label="Configuration actions">
|
||||
<button id="save" type="button">Save</button>
|
||||
<button id="reset-saved" type="button">Reset saved configuration</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -69,6 +73,7 @@
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<script src="/view.js"></script>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,102 @@
|
||||
// Local view ordering and intent binding. Contexts are not credentials.
|
||||
function sameContext(left, right) {
|
||||
return ['instance', 'connection', 'configuration', 'draft'].every(key => left?.[key] === right?.[key]);
|
||||
}
|
||||
|
||||
class ViewSession {
|
||||
constructor({read, send, render, error, permissions, edited = () => {}}) {
|
||||
Object.assign(this, {read, send, render, error, permissions, edited});
|
||||
this.view = null;
|
||||
this.queue = [];
|
||||
this.ticket = 0;
|
||||
this.acceptedTicket = 0;
|
||||
this.retired = new Set();
|
||||
this.message = '';
|
||||
}
|
||||
|
||||
get busy() { return this.queue.length !== 0; }
|
||||
get onlyEdits() { return this.queue.every(entry => entry.path === '/api/config/edit'); }
|
||||
editing(id) {
|
||||
return this.queue.some(entry => entry.body.field_id === id
|
||||
&& entry.context.instance === this.view?.state.context.instance
|
||||
&& entry.context.connection === this.view?.state.context.connection
|
||||
&& entry.context.configuration === this.view?.state.context.configuration);
|
||||
}
|
||||
|
||||
accept(view, ticket) {
|
||||
const current = this.view?.state;
|
||||
const next = view.state;
|
||||
if (current) {
|
||||
if (current.context.instance === next.context.instance) {
|
||||
if (BigInt(next.config_revision) < BigInt(current.config_revision)) return false;
|
||||
} else {
|
||||
if (ticket < this.acceptedTicket || this.retired.has(next.context.instance)) return false;
|
||||
this.retired.add(current.context.instance);
|
||||
}
|
||||
}
|
||||
this.acceptedTicket = Math.max(ticket, this.acceptedTicket);
|
||||
this.view = view;
|
||||
this.render(view);
|
||||
if (this.message) this.error(this.message);
|
||||
return true;
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
const ticket = ++this.ticket;
|
||||
const view = await this.read();
|
||||
return this.accept(view, ticket);
|
||||
}
|
||||
|
||||
mutate(path, body = {}, context = this.view?.state.context) {
|
||||
if (!this.view) return Promise.resolve(null);
|
||||
return new Promise(resolve => {
|
||||
this.queue.push({path, body, context, resolve});
|
||||
this.permissions();
|
||||
if (this.queue.length === 1) this.drain();
|
||||
});
|
||||
}
|
||||
|
||||
async drain() {
|
||||
while (this.queue.length) {
|
||||
const entry = this.queue[0];
|
||||
const ticket = ++this.ticket;
|
||||
let result = null;
|
||||
try {
|
||||
if (!sameContext(entry.context, this.view.state.context)) {
|
||||
const error = Error('View changed; queued action discarded. Review the current configuration.');
|
||||
error.staleIntent = true;
|
||||
throw error;
|
||||
}
|
||||
const view = await this.send(entry.path, {...entry.body, context: entry.context});
|
||||
if (this.accept(view, ticket)) {
|
||||
this.message = '';
|
||||
result = view;
|
||||
// Only our own accepted edits can advance already queued edits.
|
||||
// A poll, another editor, a reset, or a new connection cannot rebase intent.
|
||||
if (entry.path === '/api/config/edit'
|
||||
&& view.state.context.instance === entry.context.instance
|
||||
&& view.state.context.connection === entry.context.connection
|
||||
&& view.state.context.configuration === entry.context.configuration) {
|
||||
for (const queued of this.queue.slice(1)) {
|
||||
if (sameContext(queued.context, entry.context)) queued.context = view.state.context;
|
||||
}
|
||||
this.edited(entry.context, view.state.context);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const relevant = error.view
|
||||
? this.accept(error.view, ticket)
|
||||
: error.staleIntent || sameContext(entry.context, this.view.state.context);
|
||||
if (relevant) this.message = error.message;
|
||||
} finally {
|
||||
this.queue.shift();
|
||||
this.render(this.view);
|
||||
if (this.message) this.error(this.message);
|
||||
this.permissions();
|
||||
entry.resolve(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined') module.exports = {ViewSession, sameContext};
|
||||
@@ -0,0 +1,160 @@
|
||||
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);
|
||||
});
|
||||
Reference in new issue
Block a user