state/test/dom.test.js

212 lines
10 KiB
JavaScript

// test/dom.test.js
window.testDom = async function() {
const { __unsafeRefreshState, $, $$, Lang, NewState, SetTranslator, Component } = ApigoState;
console.log('Testing dom.js...');
const wait = () => new Promise(r => setTimeout(r, 10));
// 1. Basic $text binding
document.body.innerHTML = '<div id="test-text" $text="state.msg"></div>';
const state = NewState({ msg: 'hello' });
window.state = state; // TRY: 确保在非 ESM 环境下 state 全局可见
document.documentElement._thisObj = { state };
__unsafeRefreshState(document.documentElement);
if ($('#test-text').textContent !== 'hello') throw new Error('$text binding failed');
state.msg = 'world';
await wait();
if ($('#test-text').textContent !== 'world') throw new Error('$text update failed');
// 2. $if directive
document.body.innerHTML = '<template $if="state.show"><div id="test-if">visible</div></template>';
state.show = false;
__unsafeRefreshState(document.documentElement);
if ($('#test-if')) throw new Error('$if fail: should be hidden');
state.show = true;
await wait();
if (!$('#test-if')) throw new Error('$if fail: should be visible');
// A component in a conditional branch must be initialized again after the
// branch is removed and mounted a second time.
let conditionalSetupCount = 0;
Component.register('IfLifecycleProbe', node => { conditionalSetupCount++; node.probeReady = true });
document.body.innerHTML = '<template $if="state.showComponent"><IfLifecycleProbe id="if-lifecycle-probe"></IfLifecycleProbe></template>';
state.showComponent = true;
__unsafeRefreshState(document.documentElement);
await wait();
if (!$('#if-lifecycle-probe')?.probeReady || conditionalSetupCount !== 1) throw new Error('$if component initial mount failed');
state.showComponent = false;
await wait();
state.showComponent = true;
await wait();
if (!$('#if-lifecycle-probe')?.probeReady || conditionalSetupCount !== 2) throw new Error('$if component remount must reinitialize a fresh node');
// 3. $each directive
document.body.innerHTML = '<template $each="state.items"><div class="test-item" $text="item"></div></template>';
state.items = ['A', 'B'];
__unsafeRefreshState(document.documentElement);
await wait();
if ($$('.test-item').length !== 2) throw new Error('$each fail: count mismatch');
if ($$('.test-item')[0].textContent !== 'A') throw new Error('$each fail: content mismatch');
state.items = ['A', 'B', 'C'];
await wait();
if ($$('.test-item').length !== 3) throw new Error('$each update fail');
// 4. Event binding $onclick
document.body.innerHTML = '<button id="test-click" $onclick="state.count++"></button>';
state.count = 0;
__unsafeRefreshState(document.documentElement);
$('#test-click').click();
if (state.count !== 1) throw new Error('$onclick failed');
// Primitive CustomEvent details remain available through event.detail and
// must not be expanded into invalid numeric function argument names.
document.body.innerHTML = '<div id="test-primitive-event" $onchange="state.eventValue=event.detail"></div>';
__unsafeRefreshState(document.documentElement);
$('#test-primitive-event').dispatchEvent(new CustomEvent('change', { detail: 'source text' }));
if (state.eventValue !== 'source text') throw new Error('$on event must accept primitive detail');
// 5. $bind (input)
document.body.innerHTML = '<input id="test-bind" $bind="state.val">';
state.val = 'init';
__unsafeRefreshState(document.documentElement);
await wait(); // TRY: 等待 $bind 的 setTimeout 完成
const input = $('#test-bind');
if (input.value !== 'init') throw new Error('$bind initial failed');
input.value = 'changed';
input.dispatchEvent(new Event('input'));
if (state.val !== 'changed') throw new Error('$bind writeback failed');
input.value = '';
input.dispatchEvent(new Event('input'));
if (state.val !== '') throw new Error('$bind must preserve an empty text value');
// 6. A select bound before its template-rendered options must retain the
// model value instead of writing the browser-selected default back.
document.body.innerHTML = '<select id="test-select" $bind="state.selected"><template $each="state.options"><option $text="item" $value="item"></option></template></select>';
state.selected = 'second';
state.options = ['first', 'second'];
__unsafeRefreshState(document.documentElement);
await wait();
if ($('#test-select').value !== 'second' || state.selected !== 'second') throw new Error('$bind select must retain its value after rendering options');
// 7. Double evaluation ($$ prefix)
console.log('Testing double evaluation ($$)...');
document.body.innerHTML = `
<div id="double-eval-root">
<template $$if="state.innerExp">
<div id="inner-node">Dynamic Visible</div>
</template>
</div>
`;
const doubleState = NewState({
innerExp: 'state.innerShow',
innerShow: false
});
window.state = doubleState;
const root = $('#double-eval-root');
root._thisObj = { state: doubleState };
__unsafeRefreshState(root);
await wait();
if ($('#inner-node')) throw new Error('$$if failed: should be hidden initially');
console.log('Enabling inner node...');
doubleState.innerShow = true;
__unsafeRefreshState(root);
await wait();
const inner = $('#inner-node');
if (!inner) throw new Error('$$if failed: should be visible after innerShow=true');
// 7. Nested $$if
console.log('Testing nested $$if...');
document.body.innerHTML = `
<div id="nested-double-test">
<template $if="state.outer">
<template $$if="state.innerExp">
<div id="nested-inner">Nested Visible</div>
</template>
</template>
</div>
`;
const nestedRoot = $('#nested-double-test');
nestedRoot._thisObj = { state: doubleState };
doubleState.outer = true;
doubleState.innerShow = false;
__unsafeRefreshState(nestedRoot);
await wait();
if ($('#nested-inner')) throw new Error('nested $$if failed: should be hidden initially');
doubleState.innerShow = true;
__unsafeRefreshState(nestedRoot);
await wait();
if (!$('#nested-inner')) throw new Error('nested $$if failed: should be visible after update');
// 8. Translation bindings use synchronous source text while loading, then
// update through the reactive Lang state without a second binding system.
const translations = { Hello: '你好', Tooltip: '提示', Yes: '是', No: '否', Result: '结果', Shared: '共享' };
const translationCalls = {};
SetTranslator(text => {
translationCalls[text] = (translationCalls[text] || 0) + 1;
return new Promise(resolve => setTimeout(() => resolve(translations[text] || text), 5));
});
state.flag = true;
state.label = '{#Result#}';
window.state = state;
document.documentElement._thisObj = { state };
document.body.innerHTML = `
<strong id="translated-text">{#Hello#}</strong>
<button id="translated-attr" title="{#Tooltip#}"></button>
<div id="translated-expression" $text="state.flag ? '{#Yes#}' : '{#No#}'"></div>
<div id="translated-result" $text="state.label"></div>
<span class="translated-shared">{#Shared#}</span><span class="translated-shared">{#Shared#}</span>
`;
__unsafeRefreshState(document.documentElement);
if ($('#translated-text').textContent !== 'Hello') throw new Error('async static translation must initially use source text');
if ($('#translated-expression').textContent !== 'Yes') throw new Error('async expression translation must initially use source text');
await wait();
if ($('#translated-text').textContent !== '你好') throw new Error('static text translation did not react to Lang');
if ($('#translated-attr').title !== '提示') throw new Error('static attribute translation did not react to Lang');
if ($('#translated-expression').textContent !== '是') throw new Error('expression translation did not react to Lang');
if ($('#translated-result').textContent !== '结果') throw new Error('expression result translation did not react to Lang');
if (translationCalls.Shared !== 1) throw new Error('concurrent translation requests must be deduplicated');
Lang.Yes = '对';
await wait();
if ($('#translated-expression').textContent !== '对') throw new Error('direct Lang updates must refresh existing bindings');
// A translated text node must restore its Lang subscription when the same
// node is removed and inserted again.
const translatedTextNode = $('#translated-text').firstChild;
translatedTextNode.remove();
await wait();
$('#translated-text').appendChild(translatedTextNode);
await wait();
Lang.Hello = '您好';
await wait();
if ($('#translated-text').textContent !== '您好') throw new Error('reinserted translation text node did not restore its Lang binding');
// A synchronous translator failure must fall back to source text and leave
// dependency tracking usable for subsequent bindings.
SetTranslator(() => { throw new Error('translator failure'); });
document.body.innerHTML = '<span id="failed-translation">{#Failure#}</span><span id="tracked-translation">{#Tracked#}</span>';
__unsafeRefreshState(document.documentElement);
if ($('#failed-translation').textContent !== 'Failure') throw new Error('synchronous translator failure must use source text');
Lang.Tracked = '已跟踪';
await wait();
if ($('#tracked-translation').textContent !== '已跟踪') throw new Error('translator failure polluted active binding tracking');
// Native event and plain bind attributes are not translation bindings.
document.body.innerHTML = '<div id="translation-attr-boundary" oncustom="{#Event#}" bind="{#Bind#}" aria-label="{#Label#}"></div>';
__unsafeRefreshState(document.documentElement);
const attrBoundary = $('#translation-attr-boundary');
if (attrBoundary.getAttribute('oncustom') !== '{#Event#}') throw new Error('plain event attribute was consumed as a translation binding');
if (attrBoundary.getAttribute('bind') !== '{#Bind#}') throw new Error('plain bind attribute was consumed as a translation binding');
if (attrBoundary.getAttribute('aria-label') !== 'Label') throw new Error('ordinary static attribute translation failed');
console.log('dom.js tests passed');
return true;
}