11// @vitest -environment jsdom
22
3- import React , { act } from "react" ;
3+ import React , { act , useState } from "react" ;
44import { afterEach , describe , expect , it , vi } from "vitest" ;
55import { createRoot , type Root } from "react-dom/client" ;
66import PathChipsInput , { type PathChip } from "./path-chips-input" ;
@@ -30,7 +30,26 @@ vi.mock("@/components/ui/dialog", () => ({
3030( globalThis as any ) . IS_REACT_ACT_ENVIRONMENT = true ;
3131
3232/**
33- * 中文说明:卸载并清理 React Root,避免测试间 DOM 相互污染。
33+ * 中文说明:在单测中将 requestAnimationFrame 改为同步执行,确保撤回后的选区恢复及时生效。
34+ */
35+ function installSyncRequestAnimationFrame ( ) : ( ) => void {
36+ const originalRaf = ( window as any ) . requestAnimationFrame as ( ( cb : FrameRequestCallback ) => number ) | undefined ;
37+ const originalCancel = ( window as any ) . cancelAnimationFrame as ( ( id : number ) => void ) | undefined ;
38+ let seq = 0 ;
39+ ( window as any ) . requestAnimationFrame = ( cb : FrameRequestCallback ) => {
40+ seq += 1 ;
41+ try { cb ( 0 ) ; } catch { }
42+ return seq ;
43+ } ;
44+ ( window as any ) . cancelAnimationFrame = ( ) => { } ;
45+ return ( ) => {
46+ ( window as any ) . requestAnimationFrame = originalRaf ;
47+ ( window as any ) . cancelAnimationFrame = originalCancel ;
48+ } ;
49+ }
50+
51+ /**
52+ * 中文说明:卸载并清理 React Root,避免不同用例之间相互污染。
3453 */
3554function safeUnmountRoot ( root : Root , host : HTMLElement ) : void {
3655 try {
@@ -44,7 +63,7 @@ function safeUnmountRoot(root: Root, host: HTMLElement): void {
4463}
4564
4665/**
47- * 中文说明:创建并挂载一个 React Root,便于在 jsdom 中验证组件渲染结果 。
66+ * 中文说明:创建并挂载一个独立的 React Root。
4867 */
4968function createMountedRoot ( ) : { host : HTMLDivElement ; root : Root ; unmount : ( ) => void } {
5069 const host = document . createElement ( "div" ) ;
@@ -60,7 +79,7 @@ function createMountedRoot(): { host: HTMLDivElement; root: Root; unmount: () =>
6079}
6180
6281/**
63- * 中文说明:渲染最小化的 `PathChipsInput` 场景,只保留本次验证所需的受控属性 。
82+ * 中文说明:渲染最小化的 `PathChipsInput` 场景,只保留复制文件名验证所需的受控属性 。
6483 */
6584async function renderPathChipsInput ( chips : PathChip [ ] ) : Promise < ( ) => void > {
6685 const mounted = createMountedRoot ( ) ;
@@ -91,6 +110,108 @@ function createPathChip(overrides: Partial<PathChip> & { isDir?: boolean }): Pat
91110 } as PathChip ;
92111}
93112
113+ /**
114+ * 中文说明:测试用受控包装器,模拟真实页面里 `draft/chips` 由父组件托管的场景。
115+ */
116+ function Harness ( props : { initialDraft ?: string ; initialChips ?: PathChip [ ] } ) : React . ReactElement {
117+ const [ draft , setDraft ] = useState ( props . initialDraft ?? "" ) ;
118+ const [ chips , setChips ] = useState < PathChip [ ] > ( props . initialChips ?? [ ] ) ;
119+ return (
120+ < div >
121+ < PathChipsInput
122+ draft = { draft }
123+ onDraftChange = { setDraft }
124+ chips = { chips }
125+ onChipsChange = { setChips }
126+ multiline
127+ />
128+ < div data-testid = "chips-count" > { chips . length } </ div >
129+ </ div >
130+ ) ;
131+ }
132+
133+ /**
134+ * 中文说明:从容器中获取实际编辑器(当前组件在测试里使用 textarea)。
135+ */
136+ function getEditor ( host : HTMLElement ) : HTMLTextAreaElement | HTMLInputElement {
137+ const editor = host . querySelector ( "textarea, input" ) ;
138+ if ( ! editor ) throw new Error ( "missing editor" ) ;
139+ return editor as HTMLTextAreaElement | HTMLInputElement ;
140+ }
141+
142+ /**
143+ * 中文说明:读取当前 Chip 数量,便于断言删除/撤回结果。
144+ */
145+ function getChipCount ( host : HTMLElement ) : number {
146+ const el = host . querySelector ( "[data-testid=\"chips-count\"]" ) ;
147+ if ( ! el ) throw new Error ( "missing chips count" ) ;
148+ return Number ( el . textContent || "0" ) ;
149+ }
150+
151+ /**
152+ * 中文说明:查找当前可见的 Chip 删除按钮。
153+ */
154+ function getChipRemoveButton ( host : HTMLElement ) : HTMLButtonElement {
155+ const buttons = Array . from ( host . querySelectorAll ( "button" ) ) as HTMLButtonElement [ ] ;
156+ const button = buttons . find ( ( candidate ) => ( candidate . textContent || "" ) . includes ( "×" ) ) ;
157+ if ( ! button ) throw new Error ( "missing chip remove button" ) ;
158+ return button ;
159+ }
160+
161+ /**
162+ * 中文说明:获取 Chip 上显示的缩略图元素。
163+ */
164+ function getChipPreviewImage ( host : HTMLElement ) : HTMLImageElement {
165+ const image = host . querySelector ( "img" ) ;
166+ if ( ! image ) throw new Error ( "missing chip preview image" ) ;
167+ return image as HTMLImageElement ;
168+ }
169+
170+ /**
171+ * 中文说明:派发一次键盘事件,用于模拟 Backspace / Ctrl+Z / Ctrl+Y。
172+ */
173+ async function dispatchKeyDown (
174+ target : HTMLElement ,
175+ init : KeyboardEventInit ,
176+ ) : Promise < void > {
177+ await act ( async ( ) => {
178+ target . dispatchEvent ( new KeyboardEvent ( "keydown" , { bubbles : true , cancelable : true , ...init } ) ) ;
179+ } ) ;
180+ }
181+
182+ /**
183+ * 中文说明:派发一次输入事件,并附带 inputType 供历史合并逻辑识别。
184+ */
185+ async function dispatchInput (
186+ editor : HTMLTextAreaElement | HTMLInputElement ,
187+ nextValue : string ,
188+ inputType : string ,
189+ ) : Promise < void > {
190+ await act ( async ( ) => {
191+ const setter = Object . getOwnPropertyDescriptor ( Object . getPrototypeOf ( editor ) , "value" ) ?. set ;
192+ if ( ! setter ) throw new Error ( "missing native value setter" ) ;
193+ setter . call ( editor , nextValue ) ;
194+ try { editor . setSelectionRange ( nextValue . length , nextValue . length ) ; } catch { }
195+ const event = typeof InputEvent === "function"
196+ ? new InputEvent ( "input" , { bubbles : true , cancelable : true , inputType } )
197+ : new Event ( "input" , { bubbles : true , cancelable : true } ) ;
198+ if ( ! ( "inputType" in event ) ) {
199+ Object . defineProperty ( event , "inputType" , { value : inputType } ) ;
200+ }
201+ editor . dispatchEvent ( event ) ;
202+ } ) ;
203+ }
204+
205+ /**
206+ * 中文说明:依次派发 mousedown + click,模拟用户点击删除按钮。
207+ */
208+ async function clickElement ( target : HTMLElement ) : Promise < void > {
209+ await act ( async ( ) => {
210+ target . dispatchEvent ( new MouseEvent ( "mousedown" , { bubbles : true , cancelable : true } ) ) ;
211+ target . dispatchEvent ( new MouseEvent ( "click" , { bubbles : true , cancelable : true } ) ) ;
212+ } ) ;
213+ }
214+
94215describe ( "PathChipsInput(复制文件名按钮)" , ( ) => {
95216 let cleanup : ( ( ) => void ) | null = null ;
96217
@@ -130,3 +251,142 @@ describe("PathChipsInput(复制文件名按钮)", () => {
130251 expect ( copyButton ) . toBeNull ( ) ;
131252 } ) ;
132253} ) ;
254+
255+ describe ( "PathChipsInput 撤回历史" , ( ) => {
256+ let cleanup : ( ( ) => void ) | null = null ;
257+ let restoreRaf : ( ( ) => void ) | null = null ;
258+
259+ afterEach ( ( ) => {
260+ try { restoreRaf ?.( ) ; } catch { }
261+ restoreRaf = null ;
262+ try { cleanup ?.( ) ; } catch { }
263+ cleanup = null ;
264+ } ) ;
265+
266+ it ( "Backspace 删除的 chip 可以通过 Ctrl+Z 撤回" , async ( ) => {
267+ restoreRaf = installSyncRequestAnimationFrame ( ) ;
268+ const mounted = createMountedRoot ( ) ;
269+ cleanup = mounted . unmount ;
270+
271+ const initialChip : PathChip = {
272+ id : "file-1" ,
273+ blob : new Blob ( ) ,
274+ previewUrl : "" ,
275+ type : "text/path" ,
276+ size : 0 ,
277+ saved : true ,
278+ fromPaste : false ,
279+ wslPath : "/repo/README.md" ,
280+ fileName : "README.md" ,
281+ chipKind : "file" ,
282+ } as PathChip ;
283+
284+ await act ( async ( ) => {
285+ mounted . root . render ( < Harness initialChips = { [ initialChip ] } /> ) ;
286+ } ) ;
287+
288+ const editor = getEditor ( mounted . host ) ;
289+ editor . focus ( ) ;
290+
291+ await dispatchKeyDown ( editor , { key : "Backspace" } ) ;
292+ expect ( getChipCount ( mounted . host ) ) . toBe ( 0 ) ;
293+ expect ( mounted . host . textContent || "" ) . not . toContain ( "README.md" ) ;
294+
295+ await dispatchKeyDown ( editor , { key : "z" , ctrlKey : true } ) ;
296+ expect ( getChipCount ( mounted . host ) ) . toBe ( 1 ) ;
297+ expect ( mounted . host . textContent || "" ) . toContain ( "README.md" ) ;
298+ } ) ;
299+
300+ it ( "鼠标删除的图片 chip 可以撤回,且焦点保持在输入框" , async ( ) => {
301+ restoreRaf = installSyncRequestAnimationFrame ( ) ;
302+ const mounted = createMountedRoot ( ) ;
303+ cleanup = mounted . unmount ;
304+
305+ const imageChip : PathChip = {
306+ id : "image-1" ,
307+ blob : new Blob ( ) ,
308+ previewUrl : "blob:test-image" ,
309+ type : "image/png" ,
310+ size : 12 ,
311+ saved : true ,
312+ fromPaste : true ,
313+ wslPath : "/repo/image.png" ,
314+ winPath : "C:\\repo\\image.png" ,
315+ fileName : "image.png" ,
316+ chipKind : "image" ,
317+ } as PathChip ;
318+
319+ await act ( async ( ) => {
320+ mounted . root . render ( < Harness initialChips = { [ imageChip ] } /> ) ;
321+ } ) ;
322+
323+ const editor = getEditor ( mounted . host ) ;
324+ editor . focus ( ) ;
325+ const removeButton = getChipRemoveButton ( mounted . host ) ;
326+
327+ await clickElement ( removeButton ) ;
328+ expect ( getChipCount ( mounted . host ) ) . toBe ( 0 ) ;
329+ expect ( document . activeElement ) . toBe ( editor ) ;
330+
331+ await dispatchKeyDown ( editor , { key : "z" , ctrlKey : true } ) ;
332+ expect ( getChipCount ( mounted . host ) ) . toBe ( 1 ) ;
333+ expect ( mounted . host . textContent || "" ) . toContain ( "image.png" ) ;
334+ } ) ;
335+
336+ it ( "文字输入支持连续撤回与 Ctrl+Y 重做" , async ( ) => {
337+ restoreRaf = installSyncRequestAnimationFrame ( ) ;
338+ const mounted = createMountedRoot ( ) ;
339+ cleanup = mounted . unmount ;
340+
341+ await act ( async ( ) => {
342+ mounted . root . render ( < Harness /> ) ;
343+ } ) ;
344+
345+ const editor = getEditor ( mounted . host ) ;
346+ editor . focus ( ) ;
347+
348+ await dispatchInput ( editor , "a" , "insertText" ) ;
349+ await dispatchInput ( editor , "ab" , "insertText" ) ;
350+ await dispatchInput ( editor , "abc" , "insertText" ) ;
351+ expect ( editor . value ) . toBe ( "abc" ) ;
352+
353+ await dispatchKeyDown ( editor , { key : "z" , ctrlKey : true } ) ;
354+ expect ( editor . value ) . toBe ( "" ) ;
355+
356+ await dispatchKeyDown ( editor , { key : "y" , ctrlKey : true } ) ;
357+ expect ( editor . value ) . toBe ( "abc" ) ;
358+ } ) ;
359+
360+ it ( "图片 blob 预览失效后应回退到 file 预览" , async ( ) => {
361+ restoreRaf = installSyncRequestAnimationFrame ( ) ;
362+ const mounted = createMountedRoot ( ) ;
363+ cleanup = mounted . unmount ;
364+
365+ const imageChip : PathChip = {
366+ id : "image-fallback-1" ,
367+ blob : new Blob ( ) ,
368+ previewUrl : "blob:revoked-image" ,
369+ type : "image/png" ,
370+ size : 12 ,
371+ saved : true ,
372+ fromPaste : true ,
373+ wslPath : "/repo/image.png" ,
374+ winPath : "C:\\repo\\image.png" ,
375+ fileName : "image.png" ,
376+ chipKind : "image" ,
377+ } as PathChip ;
378+
379+ await act ( async ( ) => {
380+ mounted . root . render ( < Harness initialChips = { [ imageChip ] } /> ) ;
381+ } ) ;
382+
383+ const image = getChipPreviewImage ( mounted . host ) ;
384+ expect ( image . getAttribute ( "src" ) ) . toBe ( "blob:revoked-image" ) ;
385+
386+ await act ( async ( ) => {
387+ image . dispatchEvent ( new Event ( "error" , { bubbles : false , cancelable : false } ) ) ;
388+ } ) ;
389+
390+ expect ( image . getAttribute ( "src" ) ) . toBe ( "file:///C:/repo/image.png" ) ;
391+ } ) ;
392+ } ) ;
0 commit comments