update
This commit is contained in:
@@ -13,7 +13,7 @@
|
||||
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { MarkdownViewer } from '../../src/markdown-viewer.js';
|
||||
|
||||
// jsdom 不实现 scrollIntoView;polyfill 成 noop 让 scrollToAnchor 路径
|
||||
@@ -785,4 +785,518 @@ describe('MarkdownViewer dispose 释放 IntersectionObserver (audit Round 12 P3)
|
||||
window.IntersectionObserver = RealIO;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// audit R7b P1-16:loadContentLive rAF chunked insert
|
||||
// ============================================================================
|
||||
//
|
||||
// 核心契约:loadContentLive 不再同步 `innerHTML = html` 整页替换。
|
||||
// - 小文档(≤ 256KB html)同步解析 + 一次性插入,手感与原实现一致
|
||||
// - 大文档解析推迟到下一帧,children > 200 时每帧最多 append 200 个
|
||||
// - 单飞行:新一次 loadContentLive abort 上一次未完成的 insert
|
||||
// - heading observer / resolveRelativeImages 推迟到全部 chunk 落地后才跑
|
||||
// - loadContent / clear / showUnsupportedFile / dispose 都会取消 in-flight insert
|
||||
//
|
||||
// mock 策略:window.api.renderMarkdown = pass-through(markdown 输入即 HTML 输出),
|
||||
// 这样测试可以自己构造精确的 DOM 结构(段落数 / heading id)。
|
||||
describe('MarkdownViewer loadContentLive chunked insert (audit R7b P1-16)', () => {
|
||||
let body;
|
||||
let empty;
|
||||
let viewer;
|
||||
let savedApi;
|
||||
let RealIO;
|
||||
|
||||
function makeParas(n, prefix = 'p') {
|
||||
const parts = [];
|
||||
for (let i = 0; i < n; i++) parts.push(`<p>${prefix}-${i}</p>`);
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '<article class="body"></article><div class="empty"></div>';
|
||||
body = document.querySelector('.body');
|
||||
empty = document.querySelector('.empty');
|
||||
savedApi = window.api;
|
||||
window.api = { renderMarkdown: (md) => md };
|
||||
// jsdom 没有 IntersectionObserver —— 与 dispose 测试相同的 mock 策略。
|
||||
// 不 mock 的话,fixture 里带 heading 时 _rebuildHeadingObserver 会抛
|
||||
// ReferenceError,_finishLiveInsert 后续的 resolveRelativeImages 被跳过。
|
||||
RealIO = window.IntersectionObserver;
|
||||
window.IntersectionObserver = vi.fn(function () {
|
||||
return { observe: vi.fn(), disconnect: vi.fn(), unobserve: vi.fn() };
|
||||
});
|
||||
viewer = new MarkdownViewer({
|
||||
bodyElement: body,
|
||||
emptyElement: empty,
|
||||
statusPath: document.createElement('div'),
|
||||
statusMeta: document.createElement('div'),
|
||||
});
|
||||
// resolveRelativeImages 需要 currentFile.path;无 getDataDir → 跳过越界检查
|
||||
viewer.currentFile = { path: '/notes/a.md', name: 'a.md' };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (viewer) viewer.dispose();
|
||||
if (savedApi === undefined) delete window.api;
|
||||
else window.api = savedApi;
|
||||
window.IntersectionObserver = RealIO;
|
||||
});
|
||||
|
||||
it('changed 语义:内容变了 → true;同内容 → false 且 _resolveGen 仍 +1', () => {
|
||||
const gen0 = viewer._resolveGen;
|
||||
expect(viewer.loadContentLive('<p>x</p>')).toEqual({ changed: true });
|
||||
const gen1 = viewer._resolveGen;
|
||||
// 第一次调用 bump 了 generation(loadContentLive 自身 +1;
|
||||
// 收尾的 resolveRelativeImages 入口也会 +1 —— 不锁精确值,只锁「有 bump」)
|
||||
expect(gen1).toBeGreaterThan(gen0);
|
||||
expect(viewer.loadContentLive('<p>x</p>')).toEqual({ changed: false });
|
||||
// M-series Phase M-H3:同内容早返回也恰好 bump 一次(让 in-flight 的
|
||||
// resolveRelativeImages 失配放弃),这里锁精确 +1
|
||||
expect(viewer._resolveGen).toBe(gen1 + 1);
|
||||
});
|
||||
|
||||
it('小文档(≤ 200 children)同步落地:返回后 DOM 立即可用', () => {
|
||||
const r = viewer.loadContentLive('<p>a</p><p>b</p><p>c</p>');
|
||||
expect(r).toEqual({ changed: true });
|
||||
// fast-path:不经过 rAF,同步完成 —— 与原同步 innerHTML 行为等价
|
||||
expect(body.querySelectorAll('p').length).toBe(3);
|
||||
expect(body.textContent).toBe('abc');
|
||||
});
|
||||
|
||||
it('大文档(> 200 children)分批:同步返回时 body 已清空,await 后完整', async () => {
|
||||
const html = makeParas(500);
|
||||
const r = viewer.loadContentLive(html);
|
||||
expect(r).toEqual({ changed: true });
|
||||
// 分批路径:首帧只做「清空 + 解析」,append 在后续 rAF 帧里
|
||||
expect(body.querySelectorAll('p').length).toBe(0);
|
||||
|
||||
await viewer._test_liveInsertDone();
|
||||
const ps = body.querySelectorAll('p');
|
||||
expect(ps.length).toBe(500);
|
||||
// 顺序保持(chunk 从 fragment 头部依序取)
|
||||
expect(ps[0].textContent).toBe('p-0');
|
||||
expect(ps[499].textContent).toBe('p-499');
|
||||
});
|
||||
|
||||
it('单飞行:连续两次 loadContentLive,第一次的剩余 chunk 不会混进第二次', async () => {
|
||||
// 第一次:500 段(走分批,同步返回时什么都还没 append)
|
||||
viewer.loadContentLive(makeParas(500, 'old'));
|
||||
// 不 await,立刻第二次:3 段小文档
|
||||
viewer.loadContentLive('<p>B1</p><p>B2</p><p>B3</p>');
|
||||
|
||||
await viewer._test_liveInsertDone();
|
||||
const ps = body.querySelectorAll('p');
|
||||
// 关键断言:最终只有第二次的 3 段;第一次的 500 段一段都不残留
|
||||
expect(ps.length).toBe(3);
|
||||
expect(ps[0].textContent).toBe('B1');
|
||||
expect(ps[2].textContent).toBe('B3');
|
||||
});
|
||||
|
||||
it('heading observer 与 resolveRelativeImages 推迟到全部 chunk 落地后', async () => {
|
||||
const rebuildSpy = vi.spyOn(viewer, '_rebuildHeadingObserver');
|
||||
const resolveSpy = vi.spyOn(viewer, 'resolveRelativeImages');
|
||||
|
||||
// 500 段 + 末尾一个带 id 的 h1(childNodes = 501 > 200 → 分批)
|
||||
viewer.loadContentLive(makeParas(500) + '<h1 id="end-h">E</h1>');
|
||||
// 同步返回时:observer / resolve 都不该被调(DOM 还没拼完)
|
||||
expect(rebuildSpy).not.toHaveBeenCalled();
|
||||
expect(resolveSpy).not.toHaveBeenCalled();
|
||||
|
||||
await viewer._test_liveInsertDone();
|
||||
expect(rebuildSpy).toHaveBeenCalledTimes(1);
|
||||
expect(resolveSpy).toHaveBeenCalledTimes(1);
|
||||
// heading 快查表已含新 h1(observer 是在完整 DOM 上重建的)
|
||||
expect(viewer._headingMap.get('end-h')).toMatchObject({ level: 1, text: 'E' });
|
||||
});
|
||||
|
||||
it('dispose 取消 in-flight insert:不再 append,也不跑收尾回调', async () => {
|
||||
const rebuildSpy = vi.spyOn(viewer, '_rebuildHeadingObserver');
|
||||
viewer.loadContentLive(makeParas(500));
|
||||
viewer.dispose();
|
||||
|
||||
await viewer._test_liveInsertDone();
|
||||
// 首帧清空后从未 append(dispose 在第一个 rAF 帧之前 abort)
|
||||
expect(body.querySelectorAll('p').length).toBe(0);
|
||||
expect(rebuildSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('loadContent 取消 in-flight live insert:旧 chunk 不污染新文件 DOM', async () => {
|
||||
viewer.loadContentLive(makeParas(500, 'stale'));
|
||||
// 立刻切文件(loadContent 是冷启动路径,保持同步 innerHTML)
|
||||
await viewer.loadContent({ path: '/notes/b.md', name: 'b.md', size: 3 }, '# B');
|
||||
|
||||
await viewer._test_liveInsertDone();
|
||||
// 新文件内容(mock render 是 pass-through → '# B' 文本节点)
|
||||
expect(body.textContent).toBe('# B');
|
||||
// 旧的 500 段一个都不剩
|
||||
expect(body.querySelectorAll('p').length).toBe(0);
|
||||
});
|
||||
|
||||
it('clear 取消 in-flight live insert', async () => {
|
||||
viewer.loadContentLive(makeParas(500));
|
||||
viewer.clear();
|
||||
|
||||
await viewer._test_liveInsertDone();
|
||||
expect(body.querySelectorAll('p').length).toBe(0);
|
||||
expect(body.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('性能:5MB html 连续 50 次 loadContentLive,DOM 提交推迟到 rAF(同步段不阻塞)', async () => {
|
||||
// audit P1-16 验收标准:5MB fixture + 50 次 load,调用方视角的同步段
|
||||
// 必须不阻塞主线程 —— 真正的 parse + append 全部推迟到 rAF 帧。
|
||||
// (jsdom 的 parse5 与 Chromium 原生 parser 性能不可比,真机 5MB 实测
|
||||
// 在 Electron 里手动验证;此处锁定的是「不再同步提交 DOM」的语义。)
|
||||
const para = '<p>lorem ipsum dolor sit amet, consectetur adipiscing elit</p>';
|
||||
const bigHtml = para.repeat(Math.ceil((5 * 1024 * 1024) / para.length));
|
||||
expect(bigHtml.length).toBeGreaterThan(5 * 1024 * 1024);
|
||||
|
||||
// 同步段:50 次调用都走「大文档推迟」路径(> 256KB → 首帧先 await rAF
|
||||
// 才碰 body)。loadContentLive 同步返回时 body 不应被大子树同步污染 ——
|
||||
// 这是 P1-16 锁死的核心不变量,且不受机器 / V8 版本性能差异影响。
|
||||
const t0 = performance.now();
|
||||
for (let i = 0; i < 50; i++) {
|
||||
// 每次内容都略不同(绕开「同内容早返回」分支),但差异极小不影响测量。
|
||||
viewer.loadContentLive(bigHtml + `<!-- ${i} -->`);
|
||||
}
|
||||
const wall = performance.now() - t0;
|
||||
|
||||
// 关键断言(精确、机器无关):同步返回后 body 还没有被大 html 同步插入。
|
||||
expect(body.querySelectorAll('p').length).toBe(0);
|
||||
|
||||
// 绝对 wall 在不同机器 / V8 版本上差异较大(循环里 50 次 × 5MB 字符串分配
|
||||
// 主导,与真实 DOM 提交无关),仅作回归粗筛:不应出现秒级卡顿。
|
||||
expect(wall).toBeLessThan(2000);
|
||||
|
||||
// 收尾:abort 掉最后一次还没开始解析的 insert(5MB 的 parse5 解析在
|
||||
// jsdom 里要秒级,aborted 时首帧直接 return,不会真跑)。
|
||||
viewer.clear();
|
||||
await viewer._test_liveInsertDone();
|
||||
});
|
||||
});
|
||||
|
||||
// P2-17(audit R8):_applyHtmlLive 两端 diff —— 未变更顶级块保留 DOM 节点
|
||||
// 引用,只删/插真正变化的中段;属性顺序差异走 isEqualNode 等价 → 零变更。
|
||||
// P2-14:_finishLiveInsert 把变更块作为 roots 传给 resolveRelativeImages,
|
||||
// 图片解析只扫新插入的块;loadContent 等非 diff 路径不传 roots(全量扫描)。
|
||||
describe('MarkdownViewer live diff 节点保留 + resolve scope (audit R8 P2-17 / P2-14)', () => {
|
||||
let body;
|
||||
let empty;
|
||||
let viewer;
|
||||
let savedApi;
|
||||
let RealIO;
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '<article class="body"></article><div class="empty"></div>';
|
||||
body = document.querySelector('.body');
|
||||
empty = document.querySelector('.empty');
|
||||
savedApi = window.api;
|
||||
window.api = { renderMarkdown: (md) => md };
|
||||
// 与 P1-16 块相同:jsdom 无 IntersectionObserver,不 mock 会让
|
||||
// _finishLiveInsert 里的 _rebuildHeadingObserver 抛 ReferenceError。
|
||||
RealIO = window.IntersectionObserver;
|
||||
window.IntersectionObserver = vi.fn(function () {
|
||||
return { observe: vi.fn(), disconnect: vi.fn(), unobserve: vi.fn() };
|
||||
});
|
||||
viewer = new MarkdownViewer({
|
||||
bodyElement: body,
|
||||
emptyElement: empty,
|
||||
statusPath: document.createElement('div'),
|
||||
statusMeta: document.createElement('div'),
|
||||
});
|
||||
viewer.currentFile = { path: '/notes/a.md', name: 'a.md' };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (viewer) viewer.dispose();
|
||||
if (savedApi === undefined) delete window.api;
|
||||
else window.api = savedApi;
|
||||
window.IntersectionObserver = RealIO;
|
||||
});
|
||||
|
||||
it('P2-17 尾部追加:前缀节点引用保留,只插新块', async () => {
|
||||
viewer.loadContentLive('<p>a</p><p>b</p>');
|
||||
await viewer._test_liveInsertDone();
|
||||
const a = body.childNodes[0];
|
||||
const b = body.childNodes[1];
|
||||
|
||||
viewer.loadContentLive('<p>a</p><p>b</p><p>c</p>');
|
||||
await viewer._test_liveInsertDone();
|
||||
|
||||
expect(body.childNodes.length).toBe(3);
|
||||
expect(body.childNodes[0]).toBe(a);
|
||||
expect(body.childNodes[1]).toBe(b);
|
||||
expect(body.childNodes[2].textContent).toBe('c');
|
||||
});
|
||||
|
||||
it('P2-17 中间编辑:只替换变化的块,前后节点引用不变', async () => {
|
||||
viewer.loadContentLive('<p>a</p><p>b</p><p>c</p>');
|
||||
await viewer._test_liveInsertDone();
|
||||
const a = body.childNodes[0];
|
||||
const mid = body.childNodes[1];
|
||||
const c = body.childNodes[2];
|
||||
|
||||
viewer.loadContentLive('<p>a</p><p>B</p><p>c</p>');
|
||||
await viewer._test_liveInsertDone();
|
||||
|
||||
expect(body.childNodes.length).toBe(3);
|
||||
expect(body.childNodes[0]).toBe(a);
|
||||
expect(body.childNodes[2]).toBe(c);
|
||||
expect(body.childNodes[1]).not.toBe(mid);
|
||||
expect(body.childNodes[1].textContent).toBe('B');
|
||||
});
|
||||
|
||||
it('P2-17 中间插入与删除:前后缀节点引用保留', async () => {
|
||||
viewer.loadContentLive('<p>a</p><p>c</p>');
|
||||
await viewer._test_liveInsertDone();
|
||||
const a = body.childNodes[0];
|
||||
const c = body.childNodes[1];
|
||||
|
||||
// 中间插入一个块
|
||||
viewer.loadContentLive('<p>a</p><p>b</p><p>c</p>');
|
||||
await viewer._test_liveInsertDone();
|
||||
expect(body.childNodes.length).toBe(3);
|
||||
expect(body.childNodes[0]).toBe(a);
|
||||
expect(body.childNodes[2]).toBe(c);
|
||||
expect(body.childNodes[1].textContent).toBe('b');
|
||||
|
||||
// 再删掉它(回到原状)—— a/c 仍然是同一批节点
|
||||
viewer.loadContentLive('<p>a</p><p>c</p>');
|
||||
await viewer._test_liveInsertDone();
|
||||
expect(body.childNodes.length).toBe(2);
|
||||
expect(body.childNodes[0]).toBe(a);
|
||||
expect(body.childNodes[1]).toBe(c);
|
||||
});
|
||||
|
||||
it('P2-17 属性顺序差异 → isEqualNode 等价:零 DOM 变更,resolve 收到空 roots (P2-14)', async () => {
|
||||
viewer.loadContentLive('<p id="a" class="x">t</p>');
|
||||
await viewer._test_liveInsertDone();
|
||||
const p = body.childNodes[0];
|
||||
|
||||
const resolveSpy = vi.spyOn(viewer, 'resolveRelativeImages');
|
||||
// 字符串不同(绕过 content 早返回),但 DOM 语义完全等价
|
||||
viewer.loadContentLive('<p class="x" id="a">t</p>');
|
||||
await viewer._test_liveInsertDone();
|
||||
|
||||
expect(body.childNodes.length).toBe(1);
|
||||
expect(body.childNodes[0]).toBe(p); // 节点没有被替换
|
||||
expect(resolveSpy).toHaveBeenCalledTimes(1);
|
||||
// P2-14:零变更路径传 roots=[] → resolve 内部一张 img 都不扫
|
||||
expect(resolveSpy.mock.calls[0][1]).toEqual([]);
|
||||
});
|
||||
|
||||
it('P2-17 保留前后缀 + 大中段(500 块):仍走分批插入,前后缀节点不动', async () => {
|
||||
viewer.loadContentLive('<p>keep-0</p><p>keep-1</p><p>keep-2</p>');
|
||||
await viewer._test_liveInsertDone();
|
||||
const k0 = body.childNodes[0];
|
||||
const k1 = body.childNodes[1];
|
||||
const k2 = body.childNodes[2];
|
||||
|
||||
const mid = [];
|
||||
for (let i = 0; i < 500; i++) mid.push(`<p>mid-${i}</p>`);
|
||||
viewer.loadContentLive('<p>keep-0</p>' + mid.join('') + '<p>keep-1</p><p>keep-2</p>');
|
||||
// 中段 500 > 200 → 分批:同步返回时中段还没开始插(body 仍是 3 个 keep)
|
||||
expect(body.childNodes.length).toBe(3);
|
||||
|
||||
await viewer._test_liveInsertDone();
|
||||
expect(body.childNodes.length).toBe(503);
|
||||
expect(body.childNodes[0]).toBe(k0);
|
||||
expect(body.childNodes[501]).toBe(k1);
|
||||
expect(body.childNodes[502]).toBe(k2);
|
||||
expect(body.childNodes[1].textContent).toBe('mid-0');
|
||||
expect(body.childNodes[500].textContent).toBe('mid-499');
|
||||
});
|
||||
|
||||
it('P2-17 abort 自愈:分批插入中途被接管,最终 DOM 收敛到新内容', async () => {
|
||||
const paras = [];
|
||||
for (let i = 0; i < 500; i++) paras.push(`<p>A-${i}</p>`);
|
||||
viewer.loadContentLive(paras.join(''));
|
||||
// 等一帧(rAF 按请求顺序执行:apply 的回调先跑完第一批插入,我们的才
|
||||
// resolve)—— 此时 A 只落地了第一批 200 段,body 处于「半截 A」态。
|
||||
await viewer._nextFrame();
|
||||
const partial = body.querySelectorAll('p').length;
|
||||
expect(partial).toBeGreaterThan(0);
|
||||
expect(partial).toBeLessThan(500);
|
||||
|
||||
// 新内容接管:abort 旧的,diff 的旧侧读当前真实 DOM(半截 A),
|
||||
// 不做任何回滚也能收敛到 B —— 这是「abort 自愈」语义的核心断言。
|
||||
viewer.loadContentLive('<p>B1</p><p>B2</p><p>B3</p>');
|
||||
await viewer._test_liveInsertDone();
|
||||
|
||||
const ps = body.querySelectorAll('p');
|
||||
expect(ps.length).toBe(3);
|
||||
expect(ps[0].textContent).toBe('B1');
|
||||
expect(ps[2].textContent).toBe('B3');
|
||||
});
|
||||
|
||||
it('P2-14 live diff 后 resolve 只扫变更块:roots 恰好是新插入的那个块', async () => {
|
||||
viewer.loadContentLive('<p>one</p><p>two</p><p>three</p>');
|
||||
await viewer._test_liveInsertDone();
|
||||
|
||||
const resolveSpy = vi.spyOn(viewer, 'resolveRelativeImages');
|
||||
viewer.loadContentLive('<p>one</p><p>TWO</p><p>three</p>');
|
||||
await viewer._test_liveInsertDone();
|
||||
|
||||
expect(resolveSpy).toHaveBeenCalledTimes(1);
|
||||
const roots = resolveSpy.mock.calls[0][1];
|
||||
expect(roots.length).toBe(1);
|
||||
expect(roots[0].textContent).toBe('TWO');
|
||||
// roots 里的就是当前 body 里那个新块;保留的前后缀不在其中
|
||||
expect(roots[0]).toBe(body.childNodes[1]);
|
||||
expect(roots).not.toContain(body.childNodes[0]);
|
||||
expect(roots).not.toContain(body.childNodes[2]);
|
||||
});
|
||||
|
||||
it('P2-14 loadContent 路径不传 roots → 维持全 body 扫描原语义', async () => {
|
||||
const resolveSpy = vi.spyOn(viewer, 'resolveRelativeImages');
|
||||
await viewer.loadContent({ path: '/notes/b.md', name: 'b.md' }, 'plain text');
|
||||
expect(resolveSpy).toHaveBeenCalledTimes(1);
|
||||
expect(resolveSpy.mock.calls[0][1]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('P2-14 变更块里的图片在 roots scope 下照常解析成 file://', async () => {
|
||||
viewer.getDataDir = () => posixPath('/notes');
|
||||
viewer.loadContentLive('<p>txt</p><p><img src="pic.png"></p>');
|
||||
await viewer._test_liveInsertDone();
|
||||
// 同步 getDataDir → resolve 在 _finishLiveInsert 里同步跑完,
|
||||
// _test_liveInsertDone 返回时图片已解析
|
||||
const img = body.querySelector('img');
|
||||
expect(img.getAttribute('src')).toMatch(/^file:/);
|
||||
expect(img.getAttribute('src')).toMatch(/pic\.png$/);
|
||||
expect(img.getAttribute('loading')).toBe('lazy');
|
||||
expect(img.getAttribute('decoding')).toBe('async');
|
||||
});
|
||||
|
||||
it('P2-17 已知行为:含已解析 file:// 图片的块下轮 diff 失配 → 替换 + scoped 重解析', async () => {
|
||||
// resolveRelativeImages 会把 img src 改写成 file:// 并加 loading/decoding,
|
||||
// 而下一轮渲染产物里仍是原始相对 src → isEqualNode 为 false,含图块
|
||||
// 无法享受节点保留(退化为替换 + scoped 重解析)。这与 P1-16 前的全量
|
||||
// 替换基线行为一致(图片块本来就每轮重解析),不是回归 —— 锁语义防误改。
|
||||
viewer.getDataDir = () => posixPath('/notes');
|
||||
viewer.loadContentLive('<p><img src="pic.png"></p><p>one</p>');
|
||||
await viewer._test_liveInsertDone();
|
||||
const imgBlock = body.childNodes[0];
|
||||
const textBlock = body.childNodes[1];
|
||||
expect(imgBlock.querySelector('img').getAttribute('src')).toMatch(/^file:/);
|
||||
|
||||
const resolveSpy = vi.spyOn(viewer, 'resolveRelativeImages');
|
||||
viewer.loadContentLive('<p><img src="pic.png"></p><p>two</p>');
|
||||
await viewer._test_liveInsertDone();
|
||||
|
||||
// 文本块变了 + 含图块因 src 改写失配 → 两个块都被替换,都进 roots
|
||||
expect(body.childNodes[0]).not.toBe(imgBlock);
|
||||
expect(body.childNodes[1]).not.toBe(textBlock);
|
||||
expect(resolveSpy.mock.calls[0][1].length).toBe(2);
|
||||
// 重解析后新块的 img 仍是合法 file://(scoped resolve 覆盖到位)
|
||||
const img = body.querySelector('img');
|
||||
expect(img.getAttribute('src')).toMatch(/^file:/);
|
||||
expect(img.getAttribute('src')).toMatch(/pic\.png$/);
|
||||
});
|
||||
});
|
||||
|
||||
// 状态栏 aria-label 与 textContent 必须同源 —— 防止「SR 听到短名,视觉看到全路径」
|
||||
// 的可访问性漂移。同时占位 fallback 也走同一函数,不再硬编码 '—' 字面量。
|
||||
describe('MarkdownViewer 状态栏 aria-label (audit R7a a11y)', () => {
|
||||
let body;
|
||||
let empty;
|
||||
let statusPath;
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '<article class="body"></article><div class="empty"></div>';
|
||||
body = document.querySelector('.body');
|
||||
empty = document.querySelector('.empty');
|
||||
statusPath = document.createElement('div');
|
||||
});
|
||||
|
||||
function makeViewer(opts = {}) {
|
||||
return new MarkdownViewer({
|
||||
bodyElement: body,
|
||||
emptyElement: empty,
|
||||
statusPath,
|
||||
statusMeta: document.createElement('div'),
|
||||
...opts,
|
||||
});
|
||||
}
|
||||
|
||||
it('clear() 在无 getCurrentDir 时:textContent + aria-label 都走占位分支', () => {
|
||||
const v = makeViewer();
|
||||
v.clear();
|
||||
expect(statusPath.textContent).toBe('—');
|
||||
// 占位分支的 aria-label 是固定的描述性短句,描述按钮行为而不是"显示 —"
|
||||
expect(statusPath.getAttribute('aria-label')).toBe('在文件管理器中显示当前文件夹');
|
||||
});
|
||||
|
||||
it('clear() 有 currentDir 时:aria-label 把目录路径带上', () => {
|
||||
const v = makeViewer({ getCurrentDir: () => '/data/notes/sub' });
|
||||
v.clear();
|
||||
expect(statusPath.textContent).toBe('/data/notes/sub');
|
||||
expect(statusPath.getAttribute('aria-label')).toBe('在文件管理器中显示 /data/notes/sub');
|
||||
});
|
||||
|
||||
it('refreshCurrentDir 跟 clear 同源:用 STATUS_PATH_PLACEHOLDER 判占位', () => {
|
||||
// 拿不到 dir → 占位路径 + 占位 aria-label
|
||||
let cur = '';
|
||||
const v = makeViewer({ getCurrentDir: () => cur });
|
||||
v.clear();
|
||||
cur = '/data/notes';
|
||||
v.refreshCurrentDir();
|
||||
expect(statusPath.textContent).toBe('/data/notes');
|
||||
expect(statusPath.getAttribute('aria-label')).toBe('在文件管理器中显示 /data/notes');
|
||||
});
|
||||
|
||||
it('updateStatusBar:aria-label 与 textContent 完全一致(不是只听文件名)', () => {
|
||||
// 原 bug:file.name=foo.md,file.path=/a/b/foo.md → 视觉看到全路径,
|
||||
// SR 只听到"在文件管理器中显示 foo.md"。现在改成用 path 统一。
|
||||
const v = makeViewer();
|
||||
v.updateStatusBar({ name: 'foo.md', path: '/a/b/foo.md', size: 100 });
|
||||
expect(statusPath.textContent).toBe('/a/b/foo.md');
|
||||
expect(statusPath.getAttribute('aria-label')).toBe('在文件管理器中显示 /a/b/foo.md');
|
||||
});
|
||||
|
||||
it('updateStatusBar:缺 name 但有 path → aria-label 仍带 path', () => {
|
||||
const v = makeViewer();
|
||||
v.updateStatusBar({ path: '/only/path.md' });
|
||||
expect(statusPath.textContent).toBe('/only/path.md');
|
||||
expect(statusPath.getAttribute('aria-label')).toBe('在文件管理器中显示 /only/path.md');
|
||||
});
|
||||
|
||||
it('updateStatusBar:file={} → 占位 aria-label(不暴「显示 —」)', () => {
|
||||
const v = makeViewer();
|
||||
v.updateStatusBar({});
|
||||
expect(statusPath.textContent).toBe('—');
|
||||
expect(statusPath.getAttribute('aria-label')).toBe('在文件管理器中显示当前文件夹');
|
||||
});
|
||||
});
|
||||
|
||||
// 空状态 CTA 委托:app.js init 把 click listener 挂在 emptyElement 上而不是按钮本身上。
|
||||
// 因为 markdown-viewer.js 的 showUnsupportedFile / _resetEmptyState 会 innerHTML 重写
|
||||
// emptyElement —— 监听挂在被 detached 的 button 上没意义;委托给父容器才稳。
|
||||
// 这个 describe 模拟 app.js 的注册逻辑,确保 innerHTML 重写后按钮仍被响应。
|
||||
describe('空状态 CTA 委托模式 (R7a a11y/correctness)', () => {
|
||||
it('innerHTML 重写后,委托到父容器的 listener 仍能命中新按钮', () => {
|
||||
document.body.innerHTML = '<div id="empty-state"><button id="empty-state-new-btn">新建笔记</button></div>';
|
||||
const empty = document.getElementById('empty-state');
|
||||
const initialBtn = document.getElementById('empty-state-new-btn');
|
||||
|
||||
// 模拟 app.js 的真实注册:挂在 emptyElement 上,closest 取按钮
|
||||
const handler = vi.fn();
|
||||
empty.addEventListener('click', (ev) => {
|
||||
if (ev.target.closest('#empty-state-new-btn')) handler();
|
||||
});
|
||||
|
||||
// 第一次点击:原始按钮,被 handler 响应
|
||||
initialBtn.click();
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
|
||||
// 模拟 markdown-viewer.js 改写 innerHTML(showUnsupportedFile 会做)
|
||||
empty.innerHTML = '<p>该文件不支持预览</p>';
|
||||
// 再 _resetEmptyState 恢复默认空态 —— button 是新建的节点
|
||||
empty.innerHTML = '<button id="empty-state-new-btn">新建笔记</button>';
|
||||
const newBtn = document.getElementById('empty-state-new-btn');
|
||||
expect(newBtn).not.toBe(initialBtn); // 确认是新节点
|
||||
|
||||
// 第二次点击新按钮 —— 委托仍在 empty 上,必须仍能命中
|
||||
newBtn.click();
|
||||
expect(handler).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user