Files
mhoennig ba0a1cf419
werkator build succeeded after 02:49
form example
2026-09-16 12:08:41 +02:00

135 lines
7.0 KiB
JavaScript

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: '<b>Increment</b>' },
{ 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, '<b>Increment</b>');
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 = '日本語: <b>hello</b>\nsecond line';
text.listeners.input({});
assert.deepEqual(app.messages, ['value:1:1:0:日本語: <b>hello</b>\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);
});