Skip to content

Commit a5d5cc6

Browse files
committed
test: add browser-based BaseComponent tests; fix markdown code-block escaping
- tests/browser.html: zero-dependency in-browser test harness for BaseComponent (reactive re-render, html escaping, shadow DOM, slots, data-persistent preservation, focus/cursor restoration, $t). Verified 8/8 in headless Chrome. - doc-service: protect fenced code blocks with placeholders so inner backticks are no longer mis-parsed as inline code; flip the pinned test to assert the fix.
1 parent bd71c20 commit a5d5cc6

3 files changed

Lines changed: 195 additions & 8 deletions

File tree

lib/doc-service.js

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@ export function parseMarkdown(md) {
1010
// 工具函數:轉義 HTML
1111
const escapeHTML = (str) => str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1212

13-
// 1. 優先處理代碼塊 (避免被行內代碼干擾)
13+
// 1. 優先抽出代碼塊並以佔位符取代,避免其內容被後續行內語法 (反引號、清單等) 破壞。
14+
const codeBlocks = [];
1415
let html = md.replace(/```(.*?)\n([\s\S]*?)```/g, (match, lang, code) => {
1516
const escapedCode = escapeHTML(code.trim());
16-
return `<pre class="md-code-block"><code class="language-${lang.trim()}">${escapedCode}</code></pre>`;
17+
codeBlocks.push(`<pre class="md-code-block"><code class="language-${lang.trim()}">${escapedCode}</code></pre>`);
18+
return `@@CODEBLOCK${codeBlocks.length - 1}@@`;
1719
});
1820

1921
html = html
@@ -36,6 +38,9 @@ export function parseMarkdown(md) {
3638
// 包裹 li 在 ul 中 (簡易處理,僅針對連續的 li)
3739
html = html.replace(/(<li>[\s\S]*?<\/li>)/g, '<ul>$1</ul>');
3840

41+
// 還原代碼塊佔位符
42+
html = html.replace(/@@CODEBLOCK(\d+)@@/g, (m, i) => codeBlocks[Number(i)]);
43+
3944
return html;
4045
}
4146

@@ -51,7 +56,7 @@ export class DocService extends BaseService {
5156

5257
/**
5358
* 獲取指定路徑的 Markdown 內容並轉為簡單 HTML
54-
* @param {string} docName
59+
* @param {string} docName
5560
*/
5661
async getDoc(docName) {
5762
try {

tests/browser.html

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
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>

tests/doc.test.js

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,11 @@ test('代碼塊保留語言類別並轉義內容', () => {
2424
assert.match(out, /1 &lt; 2/); // < 被轉義
2525
});
2626

27-
test('[KNOWN BUG] 代碼塊內的反引號被誤判為行內代碼', () => {
28-
// 已知缺陷:行內代碼的 regex 在代碼塊轉換「之後」才執行,
29-
// 因此代碼塊內殘留的反引號 (`x`) 仍會被包成 md-inline-code,破壞渲染。
30-
// 本測試先固定現況;待 order 4 重寫解析器時,應改為 doesNotMatch 並修正。
27+
test('代碼塊內的反引號不被當成行內代碼 (佔位符保護)', () => {
28+
// 代碼塊先以佔位符抽出,內部的反引號 (`x`) 不應被包成 md-inline-code。
3129
const out = parseMarkdown('```js\nconst t = `x`;\n```');
32-
assert.match(out, /md-inline-code/); // 反映 BUG 現況,非預期正確行為
30+
assert.doesNotMatch(out, /md-inline-code/);
31+
assert.match(out, /<code class="language-js">const t = `x`;<\/code>/);
3332
});
3433

3534
test('行內代碼轉義 HTML', () => {

0 commit comments

Comments
 (0)