98 lines
4.2 KiB
TypeScript
98 lines
4.2 KiB
TypeScript
// tests/unit/renderer/components/reader-decode-memo.test.tsx
|
|
//
|
|
// 守 r247「Reader 每次重渲都重解一遍整个文件」的修复契约 ——
|
|
// 文本类文件的 UTF-8 解码必须**只在内容真的变了**时做一次。
|
|
//
|
|
// 背景(用户诉求「使用流畅,不要有卡顿」):
|
|
// 旧实现在**每次 render** 都执行 `TEXT_DECODER.decode(state.data)` ——
|
|
// 这是 O(字节数) 的 UTF-8 解码 + 一份新字符串分配。state.data 本身是
|
|
// 稳定引用,内容没变却在每次重渲时重解一遍:
|
|
// 1MB 的 txt/md = 每次 render 造一个 1M 字符的字符串。
|
|
// Reader 会在窗口 resize / 重试 nonce / 主题切换 / 父级任何 state 变化
|
|
// 时重渲 ⇒「拖着窗口边缘抖一下」就能把主线程塞满解码。
|
|
//
|
|
// 修法:useMemo([ext, state.data])—— ext / data 都没变 ⇒ 不重解。
|
|
//
|
|
// 本测锁:① 首屏解码一次;② 父级用**新对象**重渲(内容没变)不再解码;
|
|
// ③ 换文件(内容真变了)必须再解一次(短路不能把新文件吃掉)。
|
|
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
|
import { render, waitFor } from '@testing-library/react';
|
|
|
|
vi.mock('react-i18next', () => ({
|
|
useTranslation: () => ({ t: (k: string) => k }),
|
|
}));
|
|
|
|
const readFileForReader = vi.fn();
|
|
vi.mock('../../../../src/renderer/src/lib/api', () => ({
|
|
api: { readFileForReader: (...args: unknown[]) => readFileForReader(...args) },
|
|
unwrap: (r: unknown) => r,
|
|
IpcError: class IpcError extends Error {},
|
|
asIpcError: () => null,
|
|
hasApi: () => true,
|
|
}));
|
|
|
|
import { Reader } from '../../../../src/renderer/src/components/reader/Reader';
|
|
|
|
const FILE = {
|
|
id: 'f1',
|
|
extension: '.txt',
|
|
absPath: '/tmp/rkms-perf/a.txt',
|
|
filename: 'a.txt',
|
|
};
|
|
|
|
function bytesOf(text: string): Uint8Array {
|
|
// jsdom 陷阱(实测):`new TextEncoder().encode()` 返回的是**另一个 realm** 的
|
|
// Uint8Array —— `b instanceof Uint8Array === false` ⇒ Reader 的
|
|
// `rawData instanceof Uint8Array` 守卫会走「文件格式损坏」分支。
|
|
// 这里用本 realm 的构造器直接造字节(手写 UTF-8 编码,不走 TextEncoder)。
|
|
const enc = encodeURIComponent(text).replace(
|
|
/%([0-9A-F]{2})/g,
|
|
(_m, h: string) => String.fromCharCode(parseInt(h, 16)),
|
|
);
|
|
const out = new Uint8Array(enc.length);
|
|
for (let i = 0; i < enc.length; i += 1) out[i] = enc.charCodeAt(i);
|
|
return out;
|
|
}
|
|
|
|
describe('perf r247:Reader 文本解码必须 memo 化', () => {
|
|
beforeEach(() => {
|
|
readFileForReader.mockReset();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('首屏解码一次;父级用新对象重渲(内容未变)不再解码', async () => {
|
|
const decodeSpy = vi.spyOn(TextDecoder.prototype, 'decode');
|
|
readFileForReader.mockResolvedValue({ ok: true, data: { data: bytesOf('hello 世界') } });
|
|
|
|
const { container, rerender } = render(<Reader file={{ ...FILE }} />);
|
|
|
|
// 正文渲染出来 ⇒ 解码确实发生了
|
|
await waitFor(() => expect(container.textContent).toContain('hello 世界'));
|
|
expect(decodeSpy, '首屏只应解码一次').toHaveBeenCalledTimes(1);
|
|
|
|
// 父级重渲,但 file 是**新对象**(内容一模一样)—— 模拟 resize / 主题切换等
|
|
const before = decodeSpy.mock.calls.length;
|
|
rerender(<Reader file={{ ...FILE }} />);
|
|
expect(decodeSpy.mock.calls.length, '内容没变就不该再解一遍').toBe(before);
|
|
expect(decodeSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('换文件(内容真变了)⇒ 必须再解一次', async () => {
|
|
const decodeSpy = vi.spyOn(TextDecoder.prototype, 'decode');
|
|
readFileForReader.mockResolvedValueOnce({ ok: true, data: { data: bytesOf('第一份') } });
|
|
const { container, rerender } = render(<Reader file={{ ...FILE }} />);
|
|
await waitFor(() => expect(container.textContent).toContain('第一份'));
|
|
expect(decodeSpy).toHaveBeenCalledTimes(1);
|
|
|
|
readFileForReader.mockResolvedValueOnce({ ok: true, data: { data: bytesOf('第二份') } });
|
|
rerender(
|
|
<Reader file={{ ...FILE, id: 'f2', absPath: '/tmp/rkms-perf/b.txt', filename: 'b.txt' }} />,
|
|
);
|
|
await waitFor(() => expect(container.textContent).toContain('第二份'));
|
|
expect(decodeSpy, '新文件必须重新解码').toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|