This commit is contained in:
2026-10-21 14:42:59 +08:00
commit 954236d3f9
399 changed files with 85885 additions and 0 deletions

View File

@@ -0,0 +1,190 @@
// tests/unit/renderer/layout/folder-row-callback-stability.test.tsx
//
// 守 FolderRowOption 的**拖拽四件套回调契约** + **渲染稳定性**
// (src/renderer/src/components/common/FolderChrome.tsx r136/r166 头注指向本文件)。
//
// 契约四条,每条都对应一个**用户可见**的后果:
//
// 1. dragstart 交出的是**当前** folder 对象。
// 退化(闭包捕获了旧对象)⇒ 拖走的还是上一次的目录,落点算错,
// 文件被搬进错误的位置。
// 2. dragover / dragleave / drop 交出的是 `folder.relPath` —— 它就是
// DiskFolderNode 的「id」语义别名(r161 disk-folder 之后没有独立 id 字段)。
// 写成 `folder.id` ⇒ 父级收到 undefined ⇒ 落点区收不到任何目录 id,
// 拖拽文件夹整条链路静默失效(用户看到「拖了没反应」)。
// 3. relPath 变了以后,回调必须跟着变(不许 stale closure)。
// 退化(useMemo 依赖写漏)⇒ 文件夹改名 / 搬移后,拖拽仍按旧路径上报,
// 文件被搬到一个已不存在的位置,用户「东西不见了」。
// 4. props 不变时父级重渲不得让行重渲 —— 行是 memo 组件,每次重渲都要
// 重建四个 handler 并让整棵侧栏树重渲(手柄拖拽时尤其明显)。
// 退化(去掉 memo / 每次造新 props)⇒ 侧栏滚动与拖拽掉帧。
//
// 变异思路(下列任一改动会红):
// - handleDragOver / handleDragLeave / handleDrop 里把 folder.relPath
// 换成 folder.id(⇒ undefined)或 folder.name(⇒ 名字当 id)
// - handleDragStart 的 useMemo 依赖去掉 folder
// - handleDragOver 等的 useMemo 依赖去掉 folder.relPath
// - 去掉外层 memo(⇒ 渲染稳定性用例红)
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { render, fireEvent, cleanup } from '@testing-library/react';
import { FolderRowOption } from '../../../../src/renderer/src/components/common/FolderChrome';
import type { DiskFolderNode } from '../../../../src/shared/types';
// 渲染计数探针:FolderRowOption 每次渲染都会调一次 useTranslation,
// 计数变化 = 行真的重渲了(不是「重新渲染了但输出一样」)。
const renderProbe = vi.hoisted(() => ({ count: 0 }));
// i18n:只关心交互,不关心文案;顺带当渲染探针。
vi.mock('react-i18next', () => ({
useTranslation: () => {
renderProbe.count += 1;
return { t: (k: string) => k };
},
}));
const FOLDER: DiskFolderNode = { relPath: 'a/b', name: 'b', parentRelPath: 'a' };
type RowProps = React.ComponentProps<typeof FolderRowOption>;
function makeProps(overrides: Partial<RowProps> = {}): RowProps {
return {
folder: FOLDER,
domId: 'folder-row-a/b',
isActive: false,
isCut: false,
isDropHot: false,
isRenaming: false,
depth: 1,
expanded: false,
hasChildren: true,
isTarget: false,
posInSet: 1,
setSize: 3,
onActivate: vi.fn(),
onContextMenu: vi.fn(),
onContextMenuAt: vi.fn(),
onHover: vi.fn(),
onDragStart: vi.fn(),
onDragOver: vi.fn(),
onDragLeave: vi.fn(),
onDrop: vi.fn(),
onCommitRename: vi.fn().mockResolvedValue(true),
onCancelRename: vi.fn(),
...overrides,
};
}
function renderRow(props: RowProps) {
const utils = render(<FolderRowOption {...props} />);
const row = utils.container.querySelector('[data-folder-relpath]') as HTMLElement;
return { ...utils, row };
}
beforeEach(() => {
renderProbe.count = 0;
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe('FolderRowOption 拖拽四件套:回调载荷契约', () => {
it('dragstart 把当前 folder 对象交给父级', () => {
const props = makeProps();
const { row } = renderRow(props);
fireEvent.dragStart(row);
expect(props.onDragStart).toHaveBeenCalledTimes(1);
expect((props.onDragStart as ReturnType<typeof vi.fn>).mock.calls[0]![0]).toBe(FOLDER);
});
it('dragover / dragleave / drop 收到的是 folder.relPath(不是 name / parentRelPath)', () => {
const props = makeProps();
const { row } = renderRow(props);
fireEvent.dragOver(row);
fireEvent.dragLeave(row);
fireEvent.drop(row);
expect(props.onDragOver).toHaveBeenCalledTimes(1);
expect(props.onDragLeave).toHaveBeenCalledTimes(1);
expect(props.onDrop).toHaveBeenCalledTimes(1);
for (const cb of [props.onDragOver, props.onDragLeave, props.onDrop]) {
const [id] = (cb as ReturnType<typeof vi.fn>).mock.calls[0]!;
// DiskFolderNode 没有 id 字段 —— relPath 就是它的 id 语义别名。
expect(id).toBe('a/b');
expect(id).not.toBeUndefined();
expect(id).not.toBe('b'); // name
expect(id).not.toBe('a'); // parentRelPath
}
});
it('行 DOM 上的 data-folder-relpath 与 folder.relPath 一致(父级靠它定位行)', () => {
const { row } = renderRow(makeProps());
expect(row.getAttribute('data-folder-relpath')).toBe('a/b');
});
});
describe('FolderRowOption 拖拽回调:不许 stale closure', () => {
it('folder 换成新对象(改名)后,dragstart 交出的是新对象', () => {
const props = makeProps();
const { row, rerender } = renderRow(props);
const renamed: DiskFolderNode = { relPath: 'a/b', name: 'b2', parentRelPath: 'a' };
rerender(<FolderRowOption {...props} folder={renamed} />);
fireEvent.dragStart(row);
const [f] = (props.onDragStart as ReturnType<typeof vi.fn>).mock.calls[0]!;
expect(f).toBe(renamed);
expect(f).not.toBe(FOLDER);
});
it('relPath 变了(文件夹被搬移)后,drop 收到的是**新** relPath', () => {
const props = makeProps();
const { row, rerender } = renderRow(props);
const moved: DiskFolderNode = { relPath: 'x/c', name: 'c', parentRelPath: 'x' };
rerender(<FolderRowOption {...props} folder={moved} />);
fireEvent.drop(row);
expect(props.onDrop).toHaveBeenCalledTimes(1);
const [id] = (props.onDrop as ReturnType<typeof vi.fn>).mock.calls[0]!;
expect(id).toBe('x/c');
});
it('relPath 变了之后,dragover / dragleave 也跟着走新路径', () => {
const props = makeProps();
const { row, rerender } = renderRow(props);
const moved: DiskFolderNode = { relPath: 'x/c', name: 'c', parentRelPath: 'x' };
rerender(<FolderRowOption {...props} folder={moved} />);
fireEvent.dragOver(row);
fireEvent.dragLeave(row);
expect((props.onDragOver as ReturnType<typeof vi.fn>).mock.calls[0]![0]).toBe('x/c');
expect((props.onDragLeave as ReturnType<typeof vi.fn>).mock.calls[0]![0]).toBe('x/c');
});
});
describe('FolderRowOption 渲染稳定性(memo 命中)', () => {
it('props 引用不变时,父级重渲不会让行重渲', () => {
const props = makeProps();
const { rerender } = renderRow(props);
expect(renderProbe.count).toBe(1);
rerender(<FolderRowOption {...props} />);
expect(renderProbe.count).toBe(1);
rerender(<FolderRowOption {...props} />);
expect(renderProbe.count).toBe(1);
});
it('folder 变了时行确实重渲(不能被过度 memo 锁死成旧数据)', () => {
const props = makeProps();
const { rerender } = renderRow(props);
expect(renderProbe.count).toBe(1);
const renamed: DiskFolderNode = { relPath: 'a/b', name: 'b2', parentRelPath: 'a' };
rerender(<FolderRowOption {...props} folder={renamed} />);
expect(renderProbe.count).toBe(2);
});
});