const assert = require('node:assert/strict'); const fs = require('node:fs'); const vm = require('node:vm'); const path = require('node:path'); const { test } = require('node:test'); function setup() { const messages = []; function element(tag) { return { tag, dataset: {}, attributes: {}, children: [], listeners: {}, textContent: '', value: '', checked: false, disabled: false, readOnly: false, style: { setProperty(name, value) { this[name] = value; } }, appendChild(child) { this.children.push(child); }, setAttribute(name, value) { this.attributes[name] = value; if (name === "type" || name === "name") this[name] = value; }, addEventListener(name, callback) { this.listeners[name] = callback; } }; } const root = element('main'); const listeners = {}; const window = { addEventListener: (name, handler) => { listeners[name] = handler; }, webkit: { messageHandlers: { keluna: { postMessage: message => messages.push(message) } } } }; const document = { getElementById: () => root, createElement: element }; vm.runInNewContext(fs.readFileSync(path.join(__dirname, 'runtime.js'), 'utf8'), { window, document }); return { root, messages, listeners, apply: window.keluna.apply }; } test('DOM patches preserve node identity and forward click messages', () => { const { root, messages, apply } = setup(); apply([{ op: 'create', node: 1, parent: 0, tag: 'button' }, { op: 'text', node: 1, value: 'Increment' }, { op: 'attribute', node: 1, name: 'class', value: 'primary' }, { op: 'style', node: 1, name: 'width', value: '50%' }, { op: 'listen', node: 1, event: 'click' }]); const button = root.children[0]; assert.equal(button.textContent, 'Increment'); assert.equal(button.attributes.class, 'primary'); assert.equal(button.style.width, '50%'); button.listeners.click(); assert.deepEqual(messages, ['click:1']); apply([{ op: 'text', node: 1, value: 'Updated' }]); assert.equal(root.children.length, 1); assert.equal(root.children[0], button); assert.equal(button.dataset.kelunaId, '1'); assert.equal(button.textContent, 'Updated'); }); test('unknown nodes and operations fail visibly', () => { const { apply } = setup(); assert.throws(() => apply([{ op: 'text', node: 99, value: 'bad' }]), /Unknown Keluna node/); assert.throws(() => apply([{ op: 'unknown', node: 0 }]), /Unknown Keluna operation/); }); test('keyboard is opt-in, normalizes letters, suppresses repeats and forwards focus loss', () => { const { apply, listeners, messages } = setup(); let prevented = 0; const key = (value, extra = {}) => listeners.keydown({ key: value, preventDefault() { prevented++; }, ...extra }); key('w'); listeners.blur(); assert.deepEqual(messages, []); apply([{ op: 'keyboard', keys: ['w', ' '] }, { op: 'blur' }]); key('W'); key(' ', { repeat: true }); key('w', { ctrlKey: true }); key('x'); listeners.blur(); assert.deepEqual(messages, ['key:w', 'blur']); assert.equal(prevented, 2); apply([{ op: 'keyboard', keys: ['ArrowUp'] }]); key('w'); key('ArrowUp'); assert.deepEqual(messages, ['key:w', 'blur', 'key:ArrowUp']); }); function field(setupResult, id = 1, property = 'value', event = 'input') { setupResult.apply([{ op: 'create', node: id, parent: 0, tag: 'input' }, { op: 'listenValue', node: id, property, event }]); return setupResult.root.children.at(-1); } test('input/change transport preserves arbitrary text and typed checkbox state', () => { const app = setup(); const text = field(app); text.value = '日本語: hello\nsecond line'; text.listeners.input({}); assert.deepEqual(app.messages, ['value:1:1:0:日本語: hello\nsecond line']); const checkbox = field(app, 2, 'checked', 'change'); checkbox.checked = true; checkbox.listeners.change({}); assert.equal(app.messages.at(-1), 'value:2:2:0:true'); app.apply([{ op: 'property', node: 2, name: 'checked', value: false, revision: 2 }]); assert.equal(checkbox.checked, false); assert.equal(app.messages.length, 2); }); test('bad input is distinct from empty input and disabled/read-only edits are not sent', () => { const app = setup(); const input = field(app); input.value = ''; input.validity = { badInput: true }; input.listeners.input({}); assert.equal(app.messages.at(-1), 'value:1:1:1:'); input.validity.badInput = false; input.listeners.input({}); assert.equal(app.messages.at(-1), 'value:1:2:0:'); input.disabled = true; input.value = 'x'; input.listeners.input({}); input.disabled = false; input.readOnly = true; input.listeners.input({}); assert.equal(app.messages.length, 2); }); test('IME composition is not sent or overwritten midway and commits exactly once', () => { const app = setup(); const input = field(app); input.listeners.compositionstart({}); input.value = 'に'; input.listeners.input({ isComposing: true }); app.apply([{ op: 'property', node: 1, name: 'value', value: 'stale', revision: 0 }]); assert.equal(input.value, 'に'); assert.deepEqual(app.messages, []); input.value = '日本'; input.listeners.compositionend({}); input.listeners.input({}); assert.deepEqual(app.messages, ['value:1:1:0:日本']); }); test('stale corrections cannot overwrite newer typing and identical values preserve caret', () => { const app = setup(); const input = field(app); input.value = 'A'; input.listeners.input({}); input.value = 'Ada'; input.listeners.input({}); let current = input.value, writes = 0; input.selectionStart = 1; Object.defineProperty(input, 'value', { get() { return current; }, set(value) { writes++; current = value; input.selectionStart = value.length; } }); app.apply([{ op: 'property', node: 1, name: 'value', value: 'old', revision: 1 }]); app.apply([{ op: 'property', node: 1, name: 'value', value: 'Ada', revision: 2 }]); assert.equal(input.value, 'Ada'); assert.equal(writes, 0); assert.equal(input.selectionStart, 1); app.apply([{ op: 'property', node: 1, name: 'value', value: '', revision: 2 }]); assert.equal(input.value, ''); assert.equal(writes, 1); input.value = 'Ada'; input.listeners.input({}); assert.equal(app.messages.at(-1), 'value:1:3:0:Ada'); }); test('radio revisions cover the whole group to avoid reverting newer selection', () => { const app = setup(); const a = field(app, 1, 'checked', 'change'); const b = field(app, 2, 'checked', 'change'); a.type = b.type = 'radio'; a.name = b.name = 'mode'; b.checked = true; b.listeners.change({}); app.apply([{ op: 'property', node: 1, name: 'checked', value: true, revision: 0 }]); assert.equal(a.checked, false); assert.equal(b.checked, true); }); test('reset clears unfinished invalid number text even when DOM value is already empty', () => { const app = setup(); const input = field(app); input.validity = { badInput: true }; let writes = 0; Object.defineProperty(input, 'value', { get() { return ''; }, set(value) { assert.equal(value, ''); writes++; input.validity.badInput = false; } }); input.listeners.input({}); app.apply([{ op: 'property', node: 1, name: 'value', value: '', revision: 1 }]); assert.equal(writes, 1); assert.equal(input.validity.badInput, false); });