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); });