This commit is contained in:
2026-10-16 16:34:15 +08:00
parent 7a3769670b
commit ff366a4daf
80 changed files with 9961 additions and 888 deletions

View File

@@ -0,0 +1,125 @@
// P2-16(audit):heading IntersectionObserver 实例持久化。
//
// 旧实现:_rebuildHeadingObserver 每次 new IntersectionObserver(cb) ——
// loadContentLive 在 split 模式每个 debounced 击键都跑一次重建,observer
// 对象 + 闭包反复分配。
//
// 新契约:
// - observer 实例只建一次(多次 _rebuildHeadingObserver 不重复 new)
// - 重建仍 disconnect + 全量 re-observe(M1 历史教训:innerHTML 替换
// detach 旧节点,增量 observe 会让追踪冻死,不能省)
// - dispose 仍 disconnect + 置 null(Round 12 P3 契约不变)
// @vitest-environment jsdom
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { MarkdownViewer } from '../../src/markdown-viewer.js';
describe('heading observer 持久化(P2-16)', () => {
let body;
beforeEach(() => {
document.body.innerHTML = '';
body = document.createElement('div');
body.id = 'markdown-body';
document.body.appendChild(body);
});
function makeViewer() {
return new MarkdownViewer({
bodyElement: body,
emptyElement: document.createElement('div'),
statusPath: document.createElement('div'),
statusMeta: document.createElement('div'),
});
}
function addHeadings(...tags) {
for (const t of tags) {
const h = document.createElement(t);
h.id = `${t}-${Math.random().toString(36).slice(2, 6)}`;
h.textContent = 'title';
body.appendChild(h);
}
}
it('多次 _rebuildHeadingObserver 只 new 一次 IntersectionObserver', () => {
const constructCalls = [];
const fakeObserver = {
observe: vi.fn(),
disconnect: vi.fn(),
unobserve: vi.fn(),
};
const RealIO = window.IntersectionObserver;
window.IntersectionObserver = vi.fn(function () {
constructCalls.push(true);
return fakeObserver;
});
try {
const viewer = makeViewer();
addHeadings('h1', 'h2');
viewer._rebuildHeadingObserver();
viewer._rebuildHeadingObserver();
viewer._rebuildHeadingObserver();
expect(constructCalls.length).toBe(1);
// 每次重建都 disconnect + 全量 re-observe(detach 节点不能增量);
// 第一次重建时 observer 尚不存在、无可 disconnect,故 3 次重建 = 2 次 disconnect
expect(fakeObserver.disconnect.mock.calls.length).toBe(2);
expect(fakeObserver.observe.mock.calls.length).toBe(6); // 2 headings × 3 次
viewer.dispose();
} finally {
window.IntersectionObserver = RealIO;
}
});
it('重建后观察的是新 DOM 节点,不是 detach 的旧节点', () => {
const observed = [];
const fakeObserver = {
observe: vi.fn(function (node) { observed.push(node); }),
disconnect: vi.fn(),
unobserve: vi.fn(),
};
const RealIO = window.IntersectionObserver;
window.IntersectionObserver = vi.fn(function () { return fakeObserver; });
try {
const viewer = makeViewer();
addHeadings('h1');
viewer._rebuildHeadingObserver();
const firstNode = observed[0];
// 模拟 innerHTML 全量替换:旧节点 detach,新节点进 DOM
body.innerHTML = '';
observed.length = 0;
addHeadings('h1');
viewer._rebuildHeadingObserver();
const secondNode = observed[0];
expect(secondNode).not.toBe(firstNode);
expect(secondNode.isConnected).toBe(true);
expect(firstNode.isConnected).toBe(false);
viewer.dispose();
} finally {
window.IntersectionObserver = RealIO;
}
});
it('dispose 仍 disconnect 一次并置 null(Round 12 P3 契约不变)', () => {
const fakeObserver = {
observe: vi.fn(),
disconnect: vi.fn(),
unobserve: vi.fn(),
};
const RealIO = window.IntersectionObserver;
window.IntersectionObserver = vi.fn(function () { return fakeObserver; });
try {
const viewer = makeViewer();
addHeadings('h1');
viewer._rebuildHeadingObserver();
viewer.dispose();
expect(fakeObserver.disconnect.mock.calls.length).toBeGreaterThanOrEqual(1);
expect(viewer._headingObserver).toBe(null);
} finally {
window.IntersectionObserver = RealIO;
}
});
});