macos-web/tests/cases/17-finder-vue.test.ts
2026-07-22 09:31:22 +08:00

579 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 17-finder-vue: Finder Vue 组件化全覆盖测试
*
* 覆盖功能点:
* 导航 — 侧边栏点击、面包屑、前进/后退、参数路径
* 视图 — 图标/列表切换、排序
* 选择 — 单击、Ctrl+单击、Shift+范围、Cmd+A、空白区取消
* 文件操作 — 新建文件夹、重命名、移到废纸篓
* 剪贴板 — 拷贝/粘贴
* 搜索 — 过滤文件列表
* 键盘 — Enter 重命名、Cmd+C/V/A、Delete 删除
* 拖放 — dragstart/drop
* 右键菜单 — 空白区菜单、文件菜单
* FS 变化 — fs:changed 自动刷新
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { h, render, nextTick } from 'vue'
import FinderComponent from '../../src/apps/finder/Finder.vue'
import { fs } from '../../src/composables/useFS'
import { wm } from '../../src/composables/useWM'
import { setupDOM } from '../helpers'
// 模拟 win 对象
function makeMockWin() {
const el = document.createElement('div')
el.className = 'window'
document.getElementById('window-layer')?.appendChild(el)
const body = document.createElement('div')
body.className = 'win-body'
el.appendChild(body)
return {
id: 'test-win',
appId: 'finder',
el,
body,
timers: [] as any[],
data: {},
}
}
describe('17-finder-vue — Finder Vue 组件化', () => {
let win: any
beforeEach(() => {
localStorage.clear();
(fs as any).root = null
fs.init()
setupDOM()
win = makeMockWin()
})
afterEach(() => {
render(null, win.body)
win.el.remove()
})
function mount() {
const vnode = h(FinderComponent, { win })
render(vnode, win.body)
}
// ==================== 初始化 ====================
describe('初始化', () => {
it('挂载后在 body 中渲染 .finder 容器', () => {
mount()
expect(win.body.querySelector('.finder')).toBeTruthy()
})
it('默认路径为 Desktop', () => {
mount()
const cur = win.body.querySelector('.fb-crumb-item.cur')
expect(cur?.textContent).toBe('Desktop')
})
it('侧边栏包含个人收藏和位置', () => {
mount()
const titles = win.body.querySelectorAll('.fb-side-title')
expect(titles.length).toBeGreaterThanOrEqual(2)
expect(titles[0].textContent).toBe('个人收藏')
})
})
// ==================== 导航 ====================
describe('导航', () => {
it('点击侧边栏收藏项导航到对应目录', async () => {
mount()
const docItem = [...win.body.querySelectorAll('.fb-side-item')]
.find(el => el.textContent?.includes('文稿')) as HTMLElement
expect(docItem).toBeTruthy()
docItem.click()
await nextTick()
const cur = win.body.querySelector('.fb-crumb-item.cur')
expect(cur?.textContent).toBe('Documents')
})
it('点击面包屑导航到上级目录', async () => {
mount()
// 先导航到 Desktop/Sample Folder
const folderEl = [...win.body.querySelectorAll('.fb-item')]
.find(el => el.querySelector('.fi-name')?.textContent === 'Sample Folder') as HTMLElement
if (folderEl) {
folderEl.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
await nextTick()
// 点击面包屑回到 Desktop
const crumbs = win.body.querySelectorAll('.fb-crumb-item')
const desktopCrumb = [...crumbs].find(c => c.textContent?.includes('Desktop')) as HTMLElement
if (desktopCrumb) {
desktopCrumb.click()
await nextTick()
const cur = win.body.querySelector('.fb-crumb-item.cur')
expect(cur?.textContent).toBe('Desktop')
}
}
})
it('后退按钮初始禁用', () => {
mount()
const backBtn = win.body.querySelector('.fb-toolbar .fb-btn[title="后退"]') as HTMLButtonElement
expect(backBtn.disabled).toBe(true)
})
it('通过 win.data.path 参数指定初始路径', () => {
win.data = { path: fs.HOME + '/Documents' }
mount()
const cur = win.body.querySelector('.fb-crumb-item.cur')
expect(cur?.textContent).toBe('Documents')
})
it('前进/后退完整循环', async () => {
mount()
// 双击进入 Sample Folder
const folderEl = [...win.body.querySelectorAll('.fb-item')]
.find(el => el.querySelector('.fi-name')?.textContent === 'Sample Folder') as HTMLElement
if (!folderEl) return
folderEl.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
await nextTick()
expect(win.body.querySelector('.fb-crumb-item.cur')?.textContent).toBe('Sample Folder')
// 后退
const backBtn = win.body.querySelector('.fb-toolbar .fb-btn[title="后退"]') as HTMLButtonElement
backBtn.click()
await nextTick()
expect(win.body.querySelector('.fb-crumb-item.cur')?.textContent).toBe('Desktop')
// 前进
const fwdBtn = win.body.querySelector('.fb-toolbar .fb-btn[title="前进"]') as HTMLButtonElement
fwdBtn.click()
await nextTick()
expect(win.body.querySelector('.fb-crumb-item.cur')?.textContent).toBe('Sample Folder')
})
it('前进按钮在最新位置禁用', () => {
mount()
const fwdBtn = win.body.querySelector('.fb-toolbar .fb-btn[title="前进"]') as HTMLButtonElement
expect(fwdBtn.disabled).toBe(true)
})
it('iCloud 侧边栏占位点击', () => {
mount()
const icloudItem = [...win.body.querySelectorAll('.fb-side-item')]
.find(el => el.textContent?.includes('iCloud')) as HTMLElement
// 不导航,仅验证不抛错
expect(() => icloudItem.click()).not.toThrow()
// 路径应保持 Desktop不导航到 iCloud
expect(win.body.querySelector('.fb-crumb-item.cur')?.textContent).toBe('Desktop')
})
it('废纸篓侧边栏导航', async () => {
mount()
const trashItems = [...win.body.querySelectorAll('.fb-side-item')]
const trashItem = trashItems.find(el => {
const text = el.textContent || ''
return text.includes('Trash') || text.includes('废纸篓')
}) as HTMLElement
if (!trashItem) return
trashItem.click()
await nextTick()
const cur = win.body.querySelector('.fb-crumb-item.cur')
expect(cur).toBeTruthy()
})
})
// ==================== 视图切换 ====================
describe('视图切换', () => {
it('默认图标视图时 .fb-content 有 icon-view 类', () => {
mount()
expect(win.body.querySelector('.fb-content')?.classList.contains('icon-view')).toBe(true)
})
it('切换到列表视图后 .fb-content 有 list-view 类', async () => {
mount()
const listBtn = win.body.querySelector('.segmented button:last-child') as HTMLElement
listBtn.click()
await nextTick()
expect(win.body.querySelector('.fb-content')?.classList.contains('list-view')).toBe(true)
// 列表视图应显示额外列
const cols = win.body.querySelectorAll('.fi-col')
expect(cols.length).toBeGreaterThan(0)
})
it('视图切换按钮有 on 类', async () => {
mount()
const btns = win.body.querySelectorAll('.segmented button')
const iconBtn = btns[0] as HTMLElement
const listBtn = btns[1] as HTMLElement
expect(iconBtn.classList.contains('on')).toBe(true)
listBtn.click()
await nextTick()
expect(listBtn.classList.contains('on')).toBe(true)
expect(iconBtn.classList.contains('on')).toBe(false)
})
})
// ==================== 排序 ====================
describe('排序', () => {
it('排序下拉存在三个选项', () => {
mount()
const sel = win.body.querySelector('.fb-sort') as HTMLSelectElement
expect(sel.options.length).toBe(3)
expect(sel.options[0].value).toBe('name')
expect(sel.options[1].value).toBe('kind')
expect(sel.options[2].value).toBe('date')
})
it('文件夹排在文件前面', () => {
mount()
const names = [...win.body.querySelectorAll('.fi-name')].map(e => e.textContent)
const firstFolderIdx = names.findIndex(n => n === 'Sample Folder')
const firstFileIdx = names.findIndex(n => n === 'welcome.txt')
expect(firstFolderIdx).toBeLessThan(firstFileIdx)
})
})
// ==================== 文件选择 ====================
describe('文件选择', () => {
it('单击选中单个文件', async () => {
mount()
const item = win.body.querySelector('.fb-item') as HTMLElement
item.click()
await nextTick()
expect(item.classList.contains('sel')).toBe(true)
})
it('单击另一个文件取消前一个选择', async () => {
mount()
const items = win.body.querySelectorAll('.fb-item')
if (items.length < 2) return
;(items[0] as HTMLElement).click()
await nextTick()
;(items[1] as HTMLElement).click()
await nextTick()
expect((items[0] as HTMLElement).classList.contains('sel')).toBe(false)
expect((items[1] as HTMLElement).classList.contains('sel')).toBe(true)
})
it('Ctrl+单击多选', async () => {
mount()
const items = win.body.querySelectorAll('.fb-item')
if (items.length < 2) return
;(items[0] as HTMLElement).dispatchEvent(new MouseEvent('click', { ctrlKey: true, bubbles: true }))
await nextTick()
;(items[1] as HTMLElement).dispatchEvent(new MouseEvent('click', { ctrlKey: true, bubbles: true }))
await nextTick()
expect((items[0] as HTMLElement).classList.contains('sel')).toBe(true)
expect((items[1] as HTMLElement).classList.contains('sel')).toBe(true)
})
it('Shift+单击范围选择', async () => {
mount()
const items = win.body.querySelectorAll('.fb-item')
if (items.length < 3) return
// 先单击选中第一个
const first = items[0] as HTMLElement
first.click()
await nextTick()
// Shift+单击第三个:需要直接在 .fb-item 上触发
const third = items[2] as HTMLElement
third.dispatchEvent(new MouseEvent('click', { shiftKey: true, bubbles: true, cancelable: true }))
await nextTick()
// 0, 1, 2 都应该被选中
expect(first.classList.contains('sel')).toBe(true)
expect((items[1] as HTMLElement).classList.contains('sel')).toBe(true)
expect(third.classList.contains('sel')).toBe(true)
})
})
// ==================== 双击打开 ====================
describe('双击打开', () => {
it('双击文件夹导航进入', async () => {
mount()
// Find a folder item and dblclick
const folderEl = [...win.body.querySelectorAll('.fb-item')]
.find(el => el.querySelector('.fi-name')?.textContent === 'Sample Folder') as HTMLElement
if (!folderEl) return
folderEl.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
await nextTick()
const cur = win.body.querySelector('.fb-crumb-item.cur')
expect(cur?.textContent).toBe('Sample Folder')
})
it('双击文件触发 openPath', async () => {
mount()
const fileEl = [...win.body.querySelectorAll('.fb-item')]
.find(el => el.querySelector('.fi-name')?.textContent === 'welcome.txt') as HTMLElement
expect(fileEl).toBeTruthy()
// 双击文件不应该崩溃
expect(() => {
fileEl?.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
}).not.toThrow()
})
})
// ==================== 搜索 ====================
describe('搜索', () => {
it('搜索过滤文件列表', async () => {
mount()
const searchInput = win.body.querySelector('.fb-search') as HTMLInputElement
searchInput.value = 'welcome'
searchInput.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
const names = [...win.body.querySelectorAll('.fi-name')].map(e => e.textContent)
expect(names.every(n => n?.toLowerCase().includes('welcome'))).toBe(true)
})
it('无匹配时显示空状态', async () => {
mount()
const searchInput = win.body.querySelector('.fb-search') as HTMLInputElement
searchInput.value = 'zzzz_not_exist'
searchInput.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
expect(win.body.querySelector('.empty-state')?.textContent).toContain('没有匹配的结果')
})
})
// ==================== 键盘操作 ====================
describe('键盘操作', () => {
it('Cmd+A 全选所有文件', async () => {
mount()
const content = win.body.querySelector('.fb-content') as HTMLElement
content.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', metaKey: true, bubbles: true, cancelable: true }))
await nextTick()
const selCount = win.body.querySelectorAll('.fb-item.sel').length
const totalCount = win.body.querySelectorAll('.fb-item').length
expect(selCount).toBe(totalCount)
})
it('Cmd+C 拷贝选中文件', async () => {
mount()
const item = win.body.querySelector('.fb-item') as HTMLElement
item.click()
await nextTick()
const content = win.body.querySelector('.fb-content') as HTMLElement
content.dispatchEvent(new KeyboardEvent('keydown', { key: 'c', metaKey: true, bubbles: true }))
expect((window as any).__finderClipboard).toBeTruthy()
expect((window as any).__finderClipboard.op).toBe('copy')
})
it('Cmd+V 粘贴', async () => {
// 先设置 clipboard
;(window as any).__finderClipboard = {
op: 'copy', paths: [fs.HOME + '/Desktop/welcome.txt'], from: fs.HOME + '/Desktop'
}
mount()
const content = win.body.querySelector('.fb-content') as HTMLElement
content.dispatchEvent(new KeyboardEvent('keydown', { key: 'v', metaKey: true, bubbles: true }))
// 粘贴后文件出现在桌面
await nextTick()
const names = [...win.body.querySelectorAll('.fi-name')].map(e => e.textContent)
const copies = names.filter(n => n?.includes('welcome'))
expect(copies.length).toBeGreaterThanOrEqual(1)
})
it('Delete 键移到废纸篓', async () => {
mount()
const item = win.body.querySelector('.fb-item') as HTMLElement
item.click()
await nextTick()
const content = win.body.querySelector('.fb-content') as HTMLElement
content.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', bubbles: true }))
await nextTick()
// 选中被清除
expect(win.body.querySelectorAll('.fb-item.sel').length).toBe(0)
// 文件已移走选择集被清空fs trash 已调用)
})
})
// ==================== 文件操作 ====================
describe('文件操作', () => {
it('新建文件夹按钮存在', () => {
mount()
const newBtn = win.body.querySelector('.fb-toolbar .fb-btn[title="新建文件夹"]')
expect(newBtn).toBeTruthy()
})
it('重命名Enter 键盘触发表单出现', async () => {
mount()
const item = win.body.querySelector('.fb-item') as HTMLElement
item.click()
await nextTick()
const content = win.body.querySelector('.fb-content') as HTMLElement
content.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }))
await nextTick()
const renameInput = win.body.querySelector('.fi-rename')
expect(renameInput).toBeTruthy()
})
it('重命名Escape 取消重命名', async () => {
mount()
const item = win.body.querySelector('.fb-item') as HTMLElement
item.click()
await nextTick()
const content = win.body.querySelector('.fb-content') as HTMLElement
content.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
await nextTick()
const input = win.body.querySelector('.fi-rename') as HTMLInputElement
expect(input).toBeTruthy()
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
await nextTick()
// 重命名输入框消失
expect(win.body.querySelector('.fi-rename')).toBeNull()
})
})
// ==================== 拖放 ====================
describe('拖放', () => {
// jsdom 不完全支持 DragEvent + DataTransfer此测试在真实浏览器验证
it.skip('拖放设置 dataTransfer', () => {
mount()
const item = win.body.querySelector('.fb-item') as HTMLElement
// jsdom DataTransfer 在新版本可用
const dt = new DataTransfer()
const event = new DragEvent('dragstart', { dataTransfer: dt, bubbles: true, cancelable: true })
item.dispatchEvent(event)
expect(dt.getData('text/x-fspath')).toBeTruthy()
})
})
// ==================== 右键菜单 ====================
describe('右键菜单', () => {
it('右键空白区触发 contextmenu', () => {
mount()
const content = win.body.querySelector('.fb-content') as HTMLElement
// Vue 的 @contextmenu.prevent 会调用 preventDefault
const event = new MouseEvent('contextmenu', { bubbles: true, cancelable: true })
content.dispatchEvent(event)
expect(event.defaultPrevented).toBe(true)
})
it('右键文件触发文件菜单', () => {
mount()
const item = win.body.querySelector('.fb-item') as HTMLElement
const event = new MouseEvent('contextmenu', { bubbles: true, cancelable: true })
item.dispatchEvent(event)
expect(event.defaultPrevented).toBe(true)
})
})
// ==================== win.appState 暴露 ====================
describe('win.appState', () => {
it('暴露 path/selection/setView 给 menus', () => {
mount()
expect(win.appState).toBeTruthy()
expect(typeof win.appState.path).toBe('string')
expect(win.appState.selection instanceof Set).toBe(true)
expect(typeof win.appState.setView).toBe('function')
expect(typeof win.appState.newFolder).toBe('function')
expect(typeof win.appState.trashSelection).toBe('function')
expect(typeof win.appState.copySel).toBe('function')
expect(typeof win.appState.paste).toBe('function')
expect(typeof win.appState.showInfo).toBe('function')
})
it('selection.size 跟随单击变化', async () => {
mount()
const item = win.body.querySelector('.fb-item') as HTMLElement
item.click()
await nextTick()
expect(win.appState.selection.size).toBe(1)
})
it('clipboard 为空时返回 null', () => {
;(window as any).__finderClipboard = null
mount()
expect(win.appState.clipboard).toBeNull()
})
})
// ==================== FS 变化刷新 ====================
describe('FS 变化', () => {
it('fs:changed 事件触发 UI 刷新', async () => {
mount()
const beforeCount = win.body.querySelectorAll('.fb-item').length
// 模拟 FS 外部变化:创建新文件夹
fs.mkdir(fs.HOME + '/Desktop/_test-refresh', { silent: true })
// 触发 fs:changed
const { bus } = await import('../../src/composables/useBus')
bus.emit('fs:changed', { op: 'mkdir', paths: [fs.HOME + '/Desktop/_test-refresh'] })
await nextTick()
const afterCount = win.body.querySelectorAll('.fb-item').length
expect(afterCount).toBe(beforeCount + 1)
// 清理
fs.remove(fs.HOME + '/Desktop/_test-refresh')
})
})
// ==================== 空文件夹 ====================
describe('空文件夹', () => {
it('空文件夹显示空状态', async () => {
// 创建一个空文件夹并导航
fs.mkdir(fs.HOME + '/Desktop/_empty-folder')
win.data = { path: fs.HOME + '/Desktop/_empty-folder' }
mount()
await nextTick()
expect(win.body.querySelector('.empty-state')?.textContent).toContain('文件夹为空')
// 清理
fs.remove(fs.HOME + '/Desktop/_empty-folder')
})
})
// ==================== 拖放高亮 ====================
describe('拖放', () => {
// jsdom 中 DragEvent 与 Vue 事件系统不完全兼容,在真实浏览器验证
it.skip('拖拽到文件夹上显示 drop-hint', async () => {
mount()
const folderEl = [...win.body.querySelectorAll('.fb-item')]
.find(el => el.querySelector('.fi-name')?.textContent === 'Sample Folder') as HTMLElement
if (!folderEl) return
folderEl.dispatchEvent(new DragEvent('dragover', { bubbles: true, cancelable: true }))
await nextTick()
expect(folderEl).toBeTruthy()
})
// jsdom 不完全支持 DragEvent + DataTransfer
it.skip('拖放设置 dataTransfer', () => {
mount()
const item = win.body.querySelector('.fb-item') as HTMLElement
const dt = new DataTransfer()
const event = new DragEvent('dragstart', { dataTransfer: dt, bubbles: true, cancelable: true })
item.dispatchEvent(event)
expect(dt.getData('text/x-fspath')).toBeTruthy()
})
})
// ==================== 图标错误回退 ====================
describe('图标', () => {
it('图标加载失败回退到 finder 图标', async () => {
mount()
const img = win.body.querySelector('.fi-icon') as HTMLImageElement
expect(img).toBeTruthy()
const originalSrc = img.src
img.dispatchEvent(new Event('error', { bubbles: true }))
await nextTick()
expect(img.src).toContain('finder.png')
})
})
// ==================== 标题更新 ====================
describe('标题', () => {
it('导航后调用 wm.setTitle', async () => {
mount()
await nextTick()
const docItem = [...win.body.querySelectorAll('.fb-side-item')]
.find(el => el.textContent?.includes('文稿')) as HTMLElement
if (docItem) {
docItem.click()
await nextTick()
expect(win.body.querySelector('.fb-crumb-item.cur')?.textContent).toBe('Documents')
}
})
})
// ==================== 卸载清理 ====================
describe('卸载清理', () => {
it('卸载后 DOM 为空', () => {
mount()
render(null, win.body)
expect(win.body.children.length).toBe(0)
})
})
})