306 lines
11 KiB
TypeScript
306 lines
11 KiB
TypeScript
/**
|
||
* tests/mocks/index.ts — 集中式 Mock 工厂
|
||
*
|
||
* 为 vitest 提供统一的 mock 对象,避免每个测试文件重复造轮子。
|
||
* 所有 mock 都是纯函数,每次调用返回全新的独立对象。
|
||
*/
|
||
import { vi } from 'vitest'
|
||
import type { SystemSettings } from '../../src/composables/useSettings'
|
||
import { DEFAULT_SETTINGS } from '../../src/composables/useSettings'
|
||
|
||
// ============================================================
|
||
// FS Mock
|
||
// ============================================================
|
||
export interface MockFSOptions {
|
||
/** 初始文件树,key 为路径,value 为内容 */
|
||
files?: Record<string, string>
|
||
/** 初始目录列表 */
|
||
dirs?: string[]
|
||
}
|
||
|
||
export function createMockFS(opts: MockFSOptions = {}) {
|
||
const HOME = '/Users/guest'
|
||
const files = new Map<string, { data: string; mtime: number }>()
|
||
|
||
// 默认种子数据
|
||
const defaults: Record<string, string> = {
|
||
[HOME + '/Desktop/welcome.txt']: '欢迎使用 macOS 网页版',
|
||
[HOME + '/Documents/购物清单.txt']: '苹果\n香蕉\n牛奶',
|
||
}
|
||
|
||
for (const [path, data] of Object.entries({ ...defaults, ...opts.files })) {
|
||
files.set(path, { data, mtime: Date.now() })
|
||
}
|
||
|
||
const defaultDirs = [HOME, HOME + '/Desktop', HOME + '/Documents', HOME + '/Downloads',
|
||
HOME + '/Pictures', HOME + '/Music', HOME + '/Applications', HOME + '/.Trash']
|
||
const dirs = new Set([...defaultDirs, ...(opts.dirs || [])])
|
||
|
||
return {
|
||
HOME,
|
||
TRASH: HOME + '/.Trash',
|
||
|
||
exists: vi.fn((path: string) => files.has(path) || dirs.has(path)),
|
||
isDir: vi.fn((path: string) => dirs.has(path)),
|
||
read: vi.fn((path: string) => {
|
||
const f = files.get(path)
|
||
if (!f) throw new Error('文件不存在: ' + path)
|
||
return f.data
|
||
}),
|
||
write: vi.fn((path: string, data: string) => {
|
||
// 确保父目录存在
|
||
const parentPath = path.substring(0, path.lastIndexOf('/'))
|
||
if (parentPath && !dirs.has(parentPath)) {
|
||
dirs.add(parentPath)
|
||
}
|
||
files.set(path, { data, mtime: Date.now() })
|
||
}),
|
||
list: vi.fn((path: string) => {
|
||
const prefix = path.endsWith('/') ? path : path + '/'
|
||
const result: { name: string; path: string }[] = []
|
||
for (const [p] of files) { if (p.startsWith(prefix) && p.indexOf('/', prefix.length) === -1) result.push({ name: p.slice(prefix.length), path: p }) }
|
||
for (const d of dirs) { if (d.startsWith(prefix) && d !== path && d.indexOf('/', prefix.length) === -1) result.push({ name: d.slice(prefix.length), path: d }) }
|
||
return result
|
||
}),
|
||
mkdir: vi.fn((path: string) => { dirs.add(path) }),
|
||
remove: vi.fn((path: string) => { files.delete(path); dirs.delete(path) }),
|
||
rename: vi.fn((oldPath: string, newName: string) => {
|
||
const f = files.get(oldPath)
|
||
if (!f) throw new Error('文件不存在')
|
||
const dir = oldPath.substring(0, oldPath.lastIndexOf('/'))
|
||
const newPath = dir + '/' + newName
|
||
files.set(newPath, f)
|
||
files.delete(oldPath)
|
||
}),
|
||
move: vi.fn((src: string, destDir: string) => {
|
||
const name = src.split('/').pop()!
|
||
const dest = destDir + '/' + name
|
||
const f = files.get(src)
|
||
if (f) { files.set(dest, f); files.delete(src) }
|
||
}),
|
||
copy: vi.fn((src: string, destDir: string) => {
|
||
const name = src.split('/').pop()!
|
||
const dest = destDir + '/' + name
|
||
const f = files.get(src)
|
||
if (f) { files.set(dest, { ...f }) }
|
||
return dest
|
||
}),
|
||
trash: vi.fn(),
|
||
restore: vi.fn(),
|
||
emptyTrash: vi.fn(),
|
||
search: vi.fn(() => []),
|
||
baseName: vi.fn((p: string) => p.split('/').pop() || ''),
|
||
dirName: vi.fn((p: string) => p.substring(0, p.lastIndexOf('/'))),
|
||
join: vi.fn((...parts: string[]) => parts.join('/').replace(/\/+/g, '/')),
|
||
normalize: vi.fn((p: string) => p),
|
||
iconFor: vi.fn(() => '/assets/icons/txt.png'),
|
||
kindOf: vi.fn(() => '文件'),
|
||
mimeOf: vi.fn(() => 'text/plain'),
|
||
size: vi.fn(() => 1024),
|
||
node: vi.fn(() => null),
|
||
save: vi.fn(),
|
||
init: vi.fn(),
|
||
walk: vi.fn((_home: string, cb: Function) => {}),
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// WM Mock
|
||
// ============================================================
|
||
export function createMockWM() {
|
||
return {
|
||
windows: [] as any[],
|
||
activeWin: { value: null as any },
|
||
zTop: { value: 100 },
|
||
cascade: { value: 0 },
|
||
|
||
openWindow: vi.fn(() => ({ id: 'win-1', el: document.createElement('div'), body: document.createElement('div') })),
|
||
close: vi.fn(async () => 'closed'),
|
||
focus: vi.fn(),
|
||
minimize: vi.fn(),
|
||
restore: vi.fn(),
|
||
toggleZoom: vi.fn(),
|
||
toggleFullscreen: vi.fn(),
|
||
setTitle: vi.fn(),
|
||
clampAll: vi.fn(),
|
||
windowsForApp: vi.fn(() => []),
|
||
anyVisible: vi.fn(() => false),
|
||
usableRect: vi.fn(() => ({ x: 0, y: 30, w: 1024, h: 738 })),
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// Sys Mock
|
||
// ============================================================
|
||
export function createMockSys(overrides: Partial<SystemSettings> = {}) {
|
||
const settings: SystemSettings = { ...DEFAULT_SETTINGS, ...overrides }
|
||
return {
|
||
settings,
|
||
unlocked: true,
|
||
fullscreenWin: null,
|
||
activeApp: 'finder',
|
||
mediaEls: new Set<any>(),
|
||
|
||
init: vi.fn(),
|
||
save: vi.fn(),
|
||
applyAll: vi.fn(),
|
||
applyAppearance: vi.fn(),
|
||
applyWallpaper: vi.fn(),
|
||
applyVolume: vi.fn(),
|
||
renderDock: vi.fn(),
|
||
layoutDock: vi.fn(),
|
||
buildMenubar: vi.fn(),
|
||
buildDock: vi.fn(),
|
||
tickClock: vi.fn(),
|
||
renderDesktopIcons: vi.fn(),
|
||
registerMedia: vi.fn((el: any) => { settings.volume = el.volume || 0.6 }),
|
||
unregisterMedia: vi.fn(),
|
||
boot: vi.fn(),
|
||
unlock: vi.fn(),
|
||
showLock: vi.fn(),
|
||
sleep: vi.fn(),
|
||
powerOff: vi.fn(),
|
||
restart: vi.fn(),
|
||
resetAll: vi.fn(),
|
||
initIdleWatch: vi.fn(),
|
||
globalKeys: vi.fn(),
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// Notify Mock
|
||
// ============================================================
|
||
export function createMockNotify() {
|
||
return {
|
||
list: [] as any[],
|
||
init: vi.fn(),
|
||
save: vi.fn(),
|
||
send: vi.fn((opts: any) => {
|
||
const n = { id: 'n-' + Date.now(), ...opts, ts: Date.now(), read: false }
|
||
return n
|
||
}),
|
||
markRead: vi.fn(),
|
||
remove: vi.fn(),
|
||
clearAll: vi.fn(function (this: any) { this.list = [] }),
|
||
badgeCount: vi.fn(() => 0),
|
||
allowed: vi.fn(() => true),
|
||
updateBadges: vi.fn(),
|
||
renderCenter: vi.fn(),
|
||
banner: vi.fn(),
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// Store Mock
|
||
// ============================================================
|
||
export function createMockStore() {
|
||
const data = new Map<string, any>()
|
||
return {
|
||
PREFIX: 'macos-web:',
|
||
get: vi.fn((key: string, fallback: any) => data.has(key) ? structuredClone(data.get(key)) : structuredClone(fallback)),
|
||
set: vi.fn((key: string, val: any) => { data.set(key, val) }),
|
||
remove: vi.fn((key: string) => { data.delete(key) }),
|
||
clearAll: vi.fn(() => { data.clear() }),
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// DOM 环境设置
|
||
// ============================================================
|
||
export function setupTestDOM() {
|
||
document.body.innerHTML = `
|
||
<div id="overlay-brightness" aria-hidden="true"></div>
|
||
<div id="overlay-nightshift" aria-hidden="true"></div>
|
||
<div id="desktop" class="hidden" aria-label="桌面">
|
||
<div id="desktop-icons"></div>
|
||
</div>
|
||
<div id="window-layer"></div>
|
||
<header id="menubar" class="hidden">
|
||
<div id="menubar-left"></div>
|
||
<div id="menubar-right"></div>
|
||
</header>
|
||
<div id="dock-hotzone" aria-hidden="true"></div>
|
||
<nav id="dock" class="hidden" aria-label="程序坞"></nav>
|
||
<div id="control-center" class="popover hidden" role="dialog" aria-label="控制中心"></div>
|
||
<aside id="notification-center" class="hidden" aria-label="通知中心"></aside>
|
||
<div id="spotlight" class="hidden" role="dialog" aria-label="聚焦搜索">
|
||
<div id="spotlight-box">
|
||
<div id="spotlight-input-row">
|
||
<svg viewBox="0 0 24 24" width="20" height="20" class="sp-icon"><circle cx="10.5" cy="10.5" r="6.5" fill="none" stroke="currentColor" stroke-width="2.4"/><line x1="15.5" y1="15.5" x2="21" y2="21" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/></svg>
|
||
<input id="spotlight-input" type="text" placeholder="聚焦搜索" autocomplete="off" spellcheck="false">
|
||
</div>
|
||
<div id="spotlight-results"></div>
|
||
</div>
|
||
</div>
|
||
<div id="banner-container"></div>
|
||
<div id="lockscreen" class="hidden">
|
||
<div id="lockscreen-bg" style="background-image:none"></div>
|
||
<div id="lockscreen-main">
|
||
<div id="lock-date"></div>
|
||
<div id="lock-time"></div>
|
||
</div>
|
||
<div id="lockscreen-user">
|
||
<img id="lock-avatar" src="/assets/icons/avatar.svg" alt="用户头像">
|
||
<div id="lock-username">客人用户</div>
|
||
<div id="lock-password-row" class="hidden">
|
||
<input id="lock-password" type="password" placeholder="输入密码" aria-label="密码">
|
||
</div>
|
||
<div id="lock-hint">点按或按 Enter 键解锁</div>
|
||
<div id="lock-error" class="hidden">密码不正确,请重试</div>
|
||
</div>
|
||
</div>
|
||
<div id="screensaver" class="hidden"></div>
|
||
<div id="bootscreen" class="hidden">
|
||
<div id="boot-apple"><svg viewBox="0 0 384 512" width="72" height="96"><path fill="#fff" d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z"/></svg></div>
|
||
<div id="boot-progress"><div id="boot-progress-fill"></div></div>
|
||
</div>
|
||
<div id="poweroff" class="hidden">
|
||
<div id="poweroff-text">已关机<br><span>点按屏幕以开机</span></div>
|
||
</div>
|
||
`
|
||
}
|
||
|
||
// ============================================================
|
||
// Mock Window 工厂(Vue 组件的 win prop)
|
||
// ============================================================
|
||
export interface MockWin {
|
||
id: string
|
||
appId: string
|
||
el: HTMLElement
|
||
body: HTMLElement
|
||
titleEl: HTMLElement | null
|
||
timers: any[]
|
||
data: any
|
||
appState: any
|
||
rect: { x: number; y: number; w: number; h: number }
|
||
minW: number; minH: number
|
||
state: string
|
||
title: string; icon: string
|
||
}
|
||
|
||
export function createMockWin(opts: Partial<Pick<MockWin, 'id' | 'appId' | 'data'>> = {}): MockWin {
|
||
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: opts.id || 'test-win',
|
||
appId: opts.appId || 'finder',
|
||
el,
|
||
body,
|
||
titleEl: null,
|
||
timers: [],
|
||
data: opts.data || {},
|
||
appState: {},
|
||
rect: { x: 0, y: 0, w: 800, h: 600 },
|
||
prevRect: null,
|
||
minW: 200, minH: 120,
|
||
state: 'normal',
|
||
title: '', icon: '',
|
||
}
|
||
}
|