135 lines
7.0 KiB
JavaScript
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);
|
|
});
|