Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
268 changes: 264 additions & 4 deletions web/src/components/ui/path-chips-input.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// @vitest-environment jsdom

import React, { act } from "react";
import React, { act, useState } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createRoot, type Root } from "react-dom/client";
import PathChipsInput, { type PathChip } from "./path-chips-input";
Expand Down Expand Up @@ -30,7 +30,26 @@ vi.mock("@/components/ui/dialog", () => ({
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;

/**
* 中文说明:卸载并清理 React Root,避免测试间 DOM 相互污染。
* 中文说明:在单测中将 requestAnimationFrame 改为同步执行,确保撤回后的选区恢复及时生效。
*/
function installSyncRequestAnimationFrame(): () => void {
const originalRaf = (window as any).requestAnimationFrame as ((cb: FrameRequestCallback) => number) | undefined;
const originalCancel = (window as any).cancelAnimationFrame as ((id: number) => void) | undefined;
let seq = 0;
(window as any).requestAnimationFrame = (cb: FrameRequestCallback) => {
seq += 1;
try { cb(0); } catch {}
return seq;
};
(window as any).cancelAnimationFrame = () => {};
return () => {
(window as any).requestAnimationFrame = originalRaf;
(window as any).cancelAnimationFrame = originalCancel;
};
}

/**
* 中文说明:卸载并清理 React Root,避免不同用例之间相互污染。
*/
function safeUnmountRoot(root: Root, host: HTMLElement): void {
try {
Expand All @@ -44,7 +63,7 @@ function safeUnmountRoot(root: Root, host: HTMLElement): void {
}

/**
* 中文说明:创建并挂载一个 React Root,便于在 jsdom 中验证组件渲染结果
* 中文说明:创建并挂载一个独立的 React Root。
*/
function createMountedRoot(): { host: HTMLDivElement; root: Root; unmount: () => void } {
const host = document.createElement("div");
Expand All @@ -60,7 +79,7 @@ function createMountedRoot(): { host: HTMLDivElement; root: Root; unmount: () =>
}

/**
* 中文说明:渲染最小化的 `PathChipsInput` 场景,只保留本次验证所需的受控属性
* 中文说明:渲染最小化的 `PathChipsInput` 场景,只保留复制文件名验证所需的受控属性
*/
async function renderPathChipsInput(chips: PathChip[]): Promise<() => void> {
const mounted = createMountedRoot();
Expand Down Expand Up @@ -91,6 +110,108 @@ function createPathChip(overrides: Partial<PathChip> & { isDir?: boolean }): Pat
} as PathChip;
}

/**
* 中文说明:测试用受控包装器,模拟真实页面里 `draft/chips` 由父组件托管的场景。
*/
function Harness(props: { initialDraft?: string; initialChips?: PathChip[] }): React.ReactElement {
const [draft, setDraft] = useState(props.initialDraft ?? "");
const [chips, setChips] = useState<PathChip[]>(props.initialChips ?? []);
return (
<div>
<PathChipsInput
draft={draft}
onDraftChange={setDraft}
chips={chips}
onChipsChange={setChips}
multiline
/>
<div data-testid="chips-count">{chips.length}</div>
</div>
);
}

/**
* 中文说明:从容器中获取实际编辑器(当前组件在测试里使用 textarea)。
*/
function getEditor(host: HTMLElement): HTMLTextAreaElement | HTMLInputElement {
const editor = host.querySelector("textarea, input");
if (!editor) throw new Error("missing editor");
return editor as HTMLTextAreaElement | HTMLInputElement;
}

/**
* 中文说明:读取当前 Chip 数量,便于断言删除/撤回结果。
*/
function getChipCount(host: HTMLElement): number {
const el = host.querySelector("[data-testid=\"chips-count\"]");
if (!el) throw new Error("missing chips count");
return Number(el.textContent || "0");
}

/**
* 中文说明:查找当前可见的 Chip 删除按钮。
*/
function getChipRemoveButton(host: HTMLElement): HTMLButtonElement {
const buttons = Array.from(host.querySelectorAll("button")) as HTMLButtonElement[];
const button = buttons.find((candidate) => (candidate.textContent || "").includes("×"));
if (!button) throw new Error("missing chip remove button");
return button;
}

/**
* 中文说明:获取 Chip 上显示的缩略图元素。
*/
function getChipPreviewImage(host: HTMLElement): HTMLImageElement {
const image = host.querySelector("img");
if (!image) throw new Error("missing chip preview image");
return image as HTMLImageElement;
}

/**
* 中文说明:派发一次键盘事件,用于模拟 Backspace / Ctrl+Z / Ctrl+Y。
*/
async function dispatchKeyDown(
target: HTMLElement,
init: KeyboardEventInit,
): Promise<void> {
await act(async () => {
target.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ...init }));
});
}

/**
* 中文说明:派发一次输入事件,并附带 inputType 供历史合并逻辑识别。
*/
async function dispatchInput(
editor: HTMLTextAreaElement | HTMLInputElement,
nextValue: string,
inputType: string,
): Promise<void> {
await act(async () => {
const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(editor), "value")?.set;
if (!setter) throw new Error("missing native value setter");
setter.call(editor, nextValue);
try { editor.setSelectionRange(nextValue.length, nextValue.length); } catch {}
const event = typeof InputEvent === "function"
? new InputEvent("input", { bubbles: true, cancelable: true, inputType })
: new Event("input", { bubbles: true, cancelable: true });
if (!("inputType" in event)) {
Object.defineProperty(event, "inputType", { value: inputType });
}
editor.dispatchEvent(event);
});
}

/**
* 中文说明:依次派发 mousedown + click,模拟用户点击删除按钮。
*/
async function clickElement(target: HTMLElement): Promise<void> {
await act(async () => {
target.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true }));
target.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
});
}

describe("PathChipsInput(复制文件名按钮)", () => {
let cleanup: (() => void) | null = null;

Expand Down Expand Up @@ -130,3 +251,142 @@ describe("PathChipsInput(复制文件名按钮)", () => {
expect(copyButton).toBeNull();
});
});

describe("PathChipsInput 撤回历史", () => {
let cleanup: (() => void) | null = null;
let restoreRaf: (() => void) | null = null;

afterEach(() => {
try { restoreRaf?.(); } catch {}
restoreRaf = null;
try { cleanup?.(); } catch {}
cleanup = null;
});

it("Backspace 删除的 chip 可以通过 Ctrl+Z 撤回", async () => {
restoreRaf = installSyncRequestAnimationFrame();
const mounted = createMountedRoot();
cleanup = mounted.unmount;

const initialChip: PathChip = {
id: "file-1",
blob: new Blob(),
previewUrl: "",
type: "text/path",
size: 0,
saved: true,
fromPaste: false,
wslPath: "/repo/README.md",
fileName: "README.md",
chipKind: "file",
} as PathChip;

await act(async () => {
mounted.root.render(<Harness initialChips={[initialChip]} />);
});

const editor = getEditor(mounted.host);
editor.focus();

await dispatchKeyDown(editor, { key: "Backspace" });
expect(getChipCount(mounted.host)).toBe(0);
expect(mounted.host.textContent || "").not.toContain("README.md");

await dispatchKeyDown(editor, { key: "z", ctrlKey: true });
expect(getChipCount(mounted.host)).toBe(1);
expect(mounted.host.textContent || "").toContain("README.md");
});

it("鼠标删除的图片 chip 可以撤回,且焦点保持在输入框", async () => {
restoreRaf = installSyncRequestAnimationFrame();
const mounted = createMountedRoot();
cleanup = mounted.unmount;

const imageChip: PathChip = {
id: "image-1",
blob: new Blob(),
previewUrl: "blob:test-image",
type: "image/png",
size: 12,
saved: true,
fromPaste: true,
wslPath: "/repo/image.png",
winPath: "C:\\repo\\image.png",
fileName: "image.png",
chipKind: "image",
} as PathChip;

await act(async () => {
mounted.root.render(<Harness initialChips={[imageChip]} />);
});

const editor = getEditor(mounted.host);
editor.focus();
const removeButton = getChipRemoveButton(mounted.host);

await clickElement(removeButton);
expect(getChipCount(mounted.host)).toBe(0);
expect(document.activeElement).toBe(editor);

await dispatchKeyDown(editor, { key: "z", ctrlKey: true });
expect(getChipCount(mounted.host)).toBe(1);
expect(mounted.host.textContent || "").toContain("image.png");
});

it("文字输入支持连续撤回与 Ctrl+Y 重做", async () => {
restoreRaf = installSyncRequestAnimationFrame();
const mounted = createMountedRoot();
cleanup = mounted.unmount;

await act(async () => {
mounted.root.render(<Harness />);
});

const editor = getEditor(mounted.host);
editor.focus();

await dispatchInput(editor, "a", "insertText");
await dispatchInput(editor, "ab", "insertText");
await dispatchInput(editor, "abc", "insertText");
expect(editor.value).toBe("abc");

await dispatchKeyDown(editor, { key: "z", ctrlKey: true });
expect(editor.value).toBe("");

await dispatchKeyDown(editor, { key: "y", ctrlKey: true });
expect(editor.value).toBe("abc");
});

it("图片 blob 预览失效后应回退到 file 预览", async () => {
restoreRaf = installSyncRequestAnimationFrame();
const mounted = createMountedRoot();
cleanup = mounted.unmount;

const imageChip: PathChip = {
id: "image-fallback-1",
blob: new Blob(),
previewUrl: "blob:revoked-image",
type: "image/png",
size: 12,
saved: true,
fromPaste: true,
wslPath: "/repo/image.png",
winPath: "C:\\repo\\image.png",
fileName: "image.png",
chipKind: "image",
} as PathChip;

await act(async () => {
mounted.root.render(<Harness initialChips={[imageChip]} />);
});

const image = getChipPreviewImage(mounted.host);
expect(image.getAttribute("src")).toBe("blob:revoked-image");

await act(async () => {
image.dispatchEvent(new Event("error", { bubbles: false, cancelable: false }));
});

expect(image.getAttribute("src")).toBe("file:///C:/repo/image.png");
});
});
Loading