|
| 1 | +<!DOCTYPE html> |
| 2 | +<html lang="zh-TW"> |
| 3 | +<head> |
| 4 | + <meta charset="UTF-8"> |
| 5 | + <title>BaseComponent 瀏覽器測試</title> |
| 6 | + <base href="/plainvanillaweb/"> |
| 7 | + <style> |
| 8 | + body { font-family: system-ui, sans-serif; max-width: 720px; margin: 2rem auto; padding: 0 1rem; } |
| 9 | + h1 { font-size: 1.3rem; } |
| 10 | + #summary { font-weight: bold; padding: .6rem 1rem; border-radius: 8px; margin: 1rem 0; } |
| 11 | + #summary.pass { background: #d4f7d4; color: #0a5d0a; } |
| 12 | + #summary.fail { background: #ffd6d6; color: #8a0a0a; } |
| 13 | + ul { list-style: none; padding: 0; } |
| 14 | + li { padding: .4rem .6rem; border-bottom: 1px solid #eee; font-family: ui-monospace, monospace; font-size: .9rem; } |
| 15 | + .ok::before { content: "✅ "; } |
| 16 | + .ng::before { content: "❌ "; } |
| 17 | + .ng { color: #b00020; } |
| 18 | + .err { color: #b00020; white-space: pre-wrap; padding-left: 1.6rem; font-size: .8rem; } |
| 19 | + </style> |
| 20 | +</head> |
| 21 | +<body> |
| 22 | + <h1>🍦 BaseComponent 瀏覽器測試</h1> |
| 23 | + <p>需透過 HTTP 開啟(<code>python3 -m http.server</code> → <code>/tests/browser.html</code>)。需要真實 DOM,故不在 Node 執行。</p> |
| 24 | + <div id="summary">執行中…</div> |
| 25 | + <ul id="results"></ul> |
| 26 | + |
| 27 | + <script type="module"> |
| 28 | + import { BaseComponent } from '../lib/base-component.js'; |
| 29 | + |
| 30 | + // --- 極簡測試骨架 (零依賴) --- |
| 31 | + const results = []; |
| 32 | + const tests = []; |
| 33 | + const test = (name, fn) => tests.push({ name, fn }); |
| 34 | + const assert = (cond, msg) => { if (!cond) throw new Error(msg || '斷言失敗'); }; |
| 35 | + const assertEq = (a, b, msg) => |
| 36 | + assert(a === b, `${msg || ''} 期望 ${JSON.stringify(b)},實得 ${JSON.stringify(a)}`); |
| 37 | + // 等待 BaseComponent 的 rAF 批次更新刷新(兩個 frame 確保 scheduleUpdate 完成) |
| 38 | + const nextFrame = () => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))); |
| 39 | + |
| 40 | + const host = document.createElement('div'); |
| 41 | + host.style.position = 'absolute'; |
| 42 | + host.style.left = '-9999px'; |
| 43 | + document.body.appendChild(host); |
| 44 | + const mount = (el) => { host.appendChild(el); return el; }; |
| 45 | + |
| 46 | + // === 測試案例 === |
| 47 | + |
| 48 | + test('Light DOM 預設將 render() 結果寫入 innerHTML', () => { |
| 49 | + class C extends BaseComponent { render() { return '<p class="x">hi</p>'; } } |
| 50 | + customElements.define('t-light', C); |
| 51 | + const el = mount(document.createElement('t-light')); |
| 52 | + assert(!el.shadowRoot, '預設不應有 shadowRoot'); |
| 53 | + assertEq(el.querySelector('.x')?.textContent, 'hi'); |
| 54 | + }); |
| 55 | + |
| 56 | + test('initReactiveState 變更後經 rAF 觸發重繪', async () => { |
| 57 | + class C extends BaseComponent { |
| 58 | + constructor() { super(); this.initReactiveState({ n: 0 }); } |
| 59 | + render() { return `<span class="n">${this.state.n}</span>`; } |
| 60 | + } |
| 61 | + customElements.define('t-reactive', C); |
| 62 | + const el = mount(document.createElement('t-reactive')); |
| 63 | + assertEq(el.querySelector('.n').textContent, '0'); |
| 64 | + el.state.n = 5; |
| 65 | + await nextFrame(); |
| 66 | + assertEq(el.querySelector('.n').textContent, '5', '狀態變更後'); |
| 67 | + }); |
| 68 | + |
| 69 | + test('html`` 回傳值的內插值會被自動轉義', async () => { |
| 70 | + const { html } = await import('../lib/html.js'); |
| 71 | + class C extends BaseComponent { |
| 72 | + constructor() { super(); this.initReactiveState({ v: '<img src=x onerror=alert(1)>' }); } |
| 73 | + render() { return html`<div class="out">${this.state.v}</div>`; } |
| 74 | + } |
| 75 | + customElements.define('t-safe', C); |
| 76 | + const el = mount(document.createElement('t-safe')); |
| 77 | + // 應為轉義後的文字,而非真的 <img> 元素 |
| 78 | + assertEq(el.querySelector('.out').querySelector('img'), null, '不應產生真實 img'); |
| 79 | + assert(el.querySelector('.out').textContent.includes('<img'), '應保留為轉義文字'); |
| 80 | + }); |
| 81 | + |
| 82 | + test('useShadow=true 時內容渲染進 shadowRoot', () => { |
| 83 | + class C extends BaseComponent { |
| 84 | + static useShadow = true; |
| 85 | + render() { return '<p class="s">shadow</p>'; } |
| 86 | + } |
| 87 | + customElements.define('t-shadow', C); |
| 88 | + const el = mount(document.createElement('t-shadow')); |
| 89 | + assert(el.shadowRoot, '應有 shadowRoot'); |
| 90 | + assertEq(el.shadowRoot.querySelector('.s')?.textContent, 'shadow'); |
| 91 | + assertEq(el.querySelector('.s'), null, 'Light DOM 不應有內容'); |
| 92 | + }); |
| 93 | + |
| 94 | + test('$slot 擷取首次渲染前的原始子內容', () => { |
| 95 | + class C extends BaseComponent { |
| 96 | + render() { return `<div class="wrap">${this.$slot()}</div>`; } |
| 97 | + } |
| 98 | + customElements.define('t-slot', C); |
| 99 | + const el = document.createElement('t-slot'); |
| 100 | + el.innerHTML = '<b>slotted</b>'; |
| 101 | + mount(el); |
| 102 | + assertEq(el.querySelector('.wrap b')?.textContent, 'slotted'); |
| 103 | + }); |
| 104 | + |
| 105 | + test('data-persistent 節點在重繪後保留 (同一個 DOM 實例)', async () => { |
| 106 | + class C extends BaseComponent { |
| 107 | + constructor() { super(); this.initReactiveState({ n: 0 }); } |
| 108 | + render() { |
| 109 | + return `<div><span class="n">${this.state.n}</span> |
| 110 | + <div data-persistent-placeholder="keep"></div></div>`; |
| 111 | + } |
| 112 | + afterFirstRender() { |
| 113 | + const ph = this.querySelector('[data-persistent-placeholder="keep"]'); |
| 114 | + const node = document.createElement('div'); |
| 115 | + node.setAttribute('data-persistent', 'keep'); |
| 116 | + node.innerHTML = '<input class="pers">'; |
| 117 | + ph.replaceWith(node); |
| 118 | + } |
| 119 | + } |
| 120 | + customElements.define('t-persist', C); |
| 121 | + const el = mount(document.createElement('t-persist')); |
| 122 | + const input = el.querySelector('.pers'); |
| 123 | + input.value = 'typed-by-user'; // 模擬使用者輸入的暫態 |
| 124 | + el.state.n = 1; // 觸發重繪 |
| 125 | + await nextFrame(); |
| 126 | + assertEq(el.querySelector('.n').textContent, '1', '計數應更新'); |
| 127 | + assertEq(el.querySelector('.pers')?.value, 'typed-by-user', '持久節點輸入值應保留'); |
| 128 | + }); |
| 129 | + |
| 130 | + test('重繪後恢復輸入框焦點與游標位置', async () => { |
| 131 | + class C extends BaseComponent { |
| 132 | + constructor() { super(); this.initReactiveState({ n: 0 }); } |
| 133 | + render() { return `<input id="focusable" value="abcdef"><span>${this.state.n}</span>`; } |
| 134 | + } |
| 135 | + customElements.define('t-focus', C); |
| 136 | + const el = mount(document.createElement('t-focus')); |
| 137 | + const input = el.querySelector('#focusable'); |
| 138 | + input.focus(); |
| 139 | + input.setSelectionRange(2, 4); |
| 140 | + el.state.n = 1; |
| 141 | + await nextFrame(); |
| 142 | + const after = el.querySelector('#focusable'); |
| 143 | + assertEq(document.activeElement, after, '焦點應恢復至輸入框'); |
| 144 | + assertEq(after.selectionStart, 2, '游標起點'); |
| 145 | + assertEq(after.selectionEnd, 4, '游標終點'); |
| 146 | + }); |
| 147 | + |
| 148 | + test('$t 在 i18n 未初始化時回傳鍵名 (不拋錯)', () => { |
| 149 | + class C extends BaseComponent { render() { return `<p class="t">${this.$t('nonexistent.key')}</p>`; } } |
| 150 | + customElements.define('t-i18n', C); |
| 151 | + const el = mount(document.createElement('t-i18n')); |
| 152 | + assertEq(el.querySelector('.t').textContent, 'nonexistent.key'); |
| 153 | + }); |
| 154 | + |
| 155 | + // === 執行 === |
| 156 | + (async () => { |
| 157 | + for (const { name, fn } of tests) { |
| 158 | + try { |
| 159 | + await fn(); |
| 160 | + results.push({ name, ok: true }); |
| 161 | + } catch (e) { |
| 162 | + results.push({ name, ok: false, error: e.message }); |
| 163 | + } |
| 164 | + } |
| 165 | + |
| 166 | + const ul = document.getElementById('results'); |
| 167 | + ul.innerHTML = results.map(r => |
| 168 | + `<li class="${r.ok ? 'ok' : 'ng'}">${r.name}` + |
| 169 | + (r.ok ? '' : `<div class="err">${r.error}</div>`) + `</li>` |
| 170 | + ).join(''); |
| 171 | + |
| 172 | + const passed = results.filter(r => r.ok).length; |
| 173 | + const summary = document.getElementById('summary'); |
| 174 | + const allPass = passed === results.length; |
| 175 | + summary.className = allPass ? 'pass' : 'fail'; |
| 176 | + summary.textContent = `${passed} / ${results.length} 通過` + (allPass ? ' 🎉' : ''); |
| 177 | + |
| 178 | + // 供自動化擷取 |
| 179 | + window.__TEST_RESULTS__ = { passed, total: results.length, results }; |
| 180 | + })(); |
| 181 | + </script> |
| 182 | +</body> |
| 183 | +</html> |
0 commit comments