test: introduce frontend unit testing foundation (#529)

This commit is contained in:
InfinityPacer
2026-07-16 14:53:18 +08:00
committed by GitHub
parent 32b0c1c00c
commit 199693abf8
21 changed files with 2720 additions and 220 deletions

1
.env.test Normal file
View File

@@ -0,0 +1 @@
VITE_API_BASE_URL=/api/v1/

37
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,37 @@
name: Frontend Tests
on:
pull_request:
branches:
- v2
permissions:
contents: read
concurrency:
group: frontend-tests-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
unit-tests:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: '24'
cache: yarn
cache-dependency-path: yarn.lock
- name: Install dependencies
run: yarn --frozen-lockfile
- name: Typecheck
run: yarn typecheck
- name: Unit tests with coverage
run: yarn test:coverage

1
.gitignore vendored
View File

@@ -12,6 +12,7 @@ node_modules
dist
dist-ssr
dev-dist
coverage
*.local
package-lock.json

View File

@@ -40,6 +40,15 @@ yarn dev
yarn build
```
### 单元测试
```sh
yarn test:run
yarn test:coverage
```
测试文件组织、共享测试设施、HTTP mock、覆盖率门禁和新增用例规范见[单元测试架构](docs/testing.md)。
### 静态运行
1. 使用 `nginx` 等Web服务器托管 `dist` 静态文件nginx配置参考 `public/nginx.conf`

94
docs/testing.md Normal file
View File

@@ -0,0 +1,94 @@
# 单元测试架构
MoviePilot-Frontend 使用 Vitest 运行单元测试和组件测试,使用 jsdom 提供 DOM 环境。测试代码参与 TypeScript 类型检查,但不作为生产构建入口。
## 测试类型
- 单元测试覆盖纯函数、store、composable、路由规则和独立模块的输入、输出及副作用。
- 组件测试挂载 Vue 组件或页面,覆盖 props、emits、用户交互、可见 DOM、Router、Pinia、HTTP 请求和生命周期清理。
- PWA、Service Worker、模块联邦远程入口、真实布局、拖拽和浏览器原生能力由真实浏览器验证不由 jsdom 测试单独证明。
## 目录结构
业务 spec 与源码共置在对应责任域的 `__tests__/` 目录中,文件名与被测源码保持一致并使用 `*.spec.ts`
```text
src/
├── pages/
│ ├── recommend.vue
│ └── __tests__/recommend.spec.ts
├── stores/
│ ├── auth.ts
│ └── __tests__/auth.spec.ts
├── utils/
│ ├── permission.ts
│ └── __tests__/permission.spec.ts
└── views/dashboard/
├── MediaRecommend.vue
└── __tests__/MediaRecommend.spec.ts
```
跨业务 spec 复用的测试设施位于 `tests/`
```text
tests/
├── setup.ts
└── support/
├── render.ts
├── factories/
└── msw/
├── server.ts
└── handlers/
```
- `tests/setup.ts` 注册 DOM matcher、MSW 生命周期、浏览器 API stub 和每例清理逻辑。
- `tests/support/render.ts` 提供带 Vuetify、i18n、Router 和 Pinia 的标准渲染入口。
- `tests/support/factories/` 按业务对象提供最小有效测试数据工厂。
- `tests/support/msw/handlers/` 按业务域定义 HTTP handler`server.ts` 只负责 MSW server 实例。
- spec 通过 `@tests/*` 访问共享测试设施,通过 `@/*` 访问生产源码。
## 工具职责
- Vitest 提供 runner、断言、mock、fake timers 和覆盖率执行入口。
- Vue Test Utils 用于 Vue 特有的 props、emits、slots 和局部组件控制。
- Testing Library、jest-dom 和 user-event 用于按角色、可访问名称和用户操作验证可见行为。
- MSW 在 HTTP 边界拦截真实 API 客户端请求。未声明请求会使测试失败,测试不得访问真实后端或外网。
- `@pinia/testing` 用于依赖 store 的组件测试store 自身使用真实 `createPinia()` 测试。
## 编写规范
- 一个 spec 对应一个主要源码文件;测试名称描述可观察行为或业务规则。
- 组件测试断言可见 DOM、emits、路由、请求和持久化结果不读取组件私有状态或私有方法。
- 纯逻辑优先直接调用依赖生命周期、provide 或 inject 的 composable 通过宿主组件挂载。
- HTTP handler 和 factory 按业务域拆分,不建立包含所有接口或所有数据字段的全局万能 mock。
- 只在与当前断言无关或无法由 jsdom 正确执行时 stub 子组件、浏览器能力或第三方重型组件。
- 每个用例保持独立不依赖文件执行顺序timer、mock、storage、DOM 和未完成请求由全局 setup 恢复。
- 不使用大面积快照或覆盖率占位用例。
## 新增测试
1. 在被测源码所在目录的 `__tests__/` 中创建同名 `*.spec.ts`
2. 纯函数、store 和无渲染模块直接使用 VitestVue 组件使用标准渲染入口。
3. 需要 HTTP 请求时,在 `tests/support/msw/handlers/<domain>.ts` 增加对应 handler。
4. 需要结构化业务数据时,在 `tests/support/factories/` 增加最小工厂。
5. 核心覆盖范围发生变化时,同步更新 `vite.config.ts``coverage.include`
6. 提交前运行测试、覆盖率、类型检查、lint 和生产构建。
## 配置边界
Vitest 只收集 `src/**/__tests__/**/*.spec.ts`。测试模式保留 Vue、Vue JSX、Vuetify、自动导入、自动组件和 i18n 插件,并禁用 PWA、模块联邦和 top-level-await 构建插件。
当前核心覆盖范围在 `vite.config.ts``coverage.include` 中显式维护。聚合门槛为 Lines、Statements、Functions 不低于 80%Branches 不低于 75%。覆盖率报告写入 `coverage/`
## 命令与 CI
```sh
yarn test # watch 模式
yarn test:run # 单次运行
yarn test:coverage # 单次运行并检查覆盖率
yarn typecheck
yarn lint
yarn build
```
Pull Request 测试工作流使用 Node 24 LTS 和 frozen lockfile依次执行类型检查和覆盖率门禁。现有 lint 基线问题按仓库当前维护约定单独处理,新增测试代码不得引入新的 lint 错误。

View File

@@ -9,6 +9,9 @@
"prebuild": "npm run build:icons",
"build": "vite build",
"preview": "vite preview --port 5050",
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"typecheck": "vue-tsc --noEmit",
"lint": "eslint . -c .eslintrc.js --fix --ext .ts,.js,.vue,.tsx,.jsx",
"build:icons": "tsc -b src/@iconify && node src/@iconify/build-icons.js",
@@ -20,6 +23,10 @@
"dist/**/*"
]
},
"resolutions": {
"vitest/**/vite": "5.4.18",
"vitest/vite": "5.4.18"
},
"dependencies": {
"@fullcalendar/core": "^6.1.15",
"@fullcalendar/daygrid": "^6.1.15",
@@ -57,7 +64,7 @@
"markdown-it-link-attributes": "^4.0.1",
"mousetrap": "^1.6.5",
"nprogress": "^0.2.0",
"pinia": "^3.0.1",
"pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.2.0",
"qrcode": "^1.5.4",
"sass": "^1.83.4",
@@ -82,7 +89,12 @@
"@iconify/vue": "^4.3.0",
"@intlify/unplugin-vue-i18n": "^6.0.3",
"@originjs/vite-plugin-federation": "^1.4.1",
"@pinia/testing": "1.0.3",
"@tailwindcss/aspect-ratio": "^0.4.2",
"@testing-library/dom": "9.3.4",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/user-event": "14.6.1",
"@testing-library/vue": "8.1.0",
"@types/body-scroll-lock": "^3.1.2",
"@types/lodash-es": "^4.17.12",
"@types/markdown-it": "^14.1.2",
@@ -96,6 +108,9 @@
"@typescript-eslint/parser": "^8.20.0",
"@vitejs/plugin-vue": "^5.0.4",
"@vitejs/plugin-vue-jsx": "^4.1.1",
"@vitest/coverage-v8": "3.2.7",
"@vue/compiler-dom": "3.5.13",
"@vue/test-utils": "2.4.11",
"autoprefixer": "^10.4.14",
"eslint": "^9.18.0",
"eslint-import-resolver-typescript": "^3.5.1",
@@ -105,6 +120,8 @@
"eslint-plugin-sonarjs": "^3.0.1",
"eslint-plugin-unicorn": "^56.0.1",
"eslint-plugin-vue": "^9.12.0",
"jsdom": "26.1.0",
"msw": "2.15.0",
"postcss": "^8.5.1",
"postcss-html": "^1.5.0",
"stylelint": "^16.13.2",
@@ -123,6 +140,7 @@
"vite-plugin-top-level-await": "^1.5.0",
"vite-plugin-vue-layouts": "^0.11.0",
"vite-plugin-vuetify": "2.0.4",
"vitest": "3.2.7",
"vue-shepherd": "^4.1.0",
"vue-tsc": "^2.0.10",
"workbox-build": "^7.3.0",

View File

@@ -0,0 +1,227 @@
import RecommendPage from '@/pages/recommend.vue'
import { DEFAULT_PERMISSIONS } from '@/utils/permission'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { renderWithProviders } from '@tests/support/render'
import {
recommendConfigHandler,
recommendSourcesHandler,
saveRecommendConfigHandler,
} from '@tests/support/msw/handlers/recommend'
import { server } from '@tests/support/msw/server'
import { defineComponent } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
closeDialog: vi.fn(),
openSharedDialog: vi.fn(),
registerHeaderTab: vi.fn(),
useDynamicButton: vi.fn(),
}))
vi.mock('@/composables/useDynamicHeaderTab', () => ({
useDynamicHeaderTab: () => ({ registerHeaderTab: mocks.registerHeaderTab }),
}))
vi.mock('@/composables/useDynamicButton', () => ({
useDynamicButton: (options: unknown) => mocks.useDynamicButton(options),
}))
vi.mock('@/composables/usePWA', async () => {
const { ref } = await import('vue')
return {
usePWA: () => ({ appMode: ref(false) }),
}
})
vi.mock('@/composables/useSharedDialog', () => ({
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
}))
const MediaCardSlideViewStub = defineComponent({
name: 'MediaCardSlideView',
props: {
apipath: { type: String, required: true },
ready: { type: Boolean, required: true },
title: { type: String, required: true },
},
template: '<section data-testid="recommend-view" :data-api-path="apipath" :data-ready="ready">{{ title }}</section>',
})
interface SharedDialogEvents {
save: (payload?: { enabled?: Record<string, boolean> }) => Promise<void>
}
async function renderRecommend(options: { superUser?: boolean; discovery?: boolean } = {}) {
return renderWithProviders(RecommendPage, {
initialRoute: '/recommend',
initialState: {
user: {
permissions: { ...DEFAULT_PERMISSIONS, discovery: options.discovery ?? true },
superUser: options.superUser ?? false,
},
},
global: {
stubs: {
MediaCardSlideView: MediaCardSlideViewStub,
VScrollToTopBtn: true,
},
},
})
}
describe('recommend page', () => {
beforeEach(() => {
mocks.openSharedDialog.mockReturnValue({
close: mocks.closeDialog,
id: 1,
updateProps: vi.fn(),
})
})
it('uses local configuration and merges extra sources without duplicates', async () => {
let remoteConfigRequests = 0
localStorage.setItem('MP_RECOMMEND', JSON.stringify({ '流行趋势': true, '自定义来源': true }))
server.use(
recommendConfigHandler({}, 200, () => {
remoteConfigRequests += 1
}),
recommendSourcesHandler([
{ api_path: 'recommend/tmdb_trending', name: '重复来源', type: '榜单' },
{ api_path: 'recommend/custom', name: '自定义来源', type: '扩展' },
]),
)
await renderRecommend()
expect(await screen.findByText('自定义来源')).toBeInTheDocument()
expect(screen.getAllByTestId('recommend-view')).toHaveLength(2)
expect(screen.queryByText('重复来源')).not.toBeInTheDocument()
expect(remoteConfigRequests).toBe(0)
})
it('loads remote configuration when local configuration is absent', async () => {
const remoteConfig = { '流行趋势': false, '正在热映': true }
const configRequested = vi.fn()
const sourcesRequested = vi.fn()
server.use(
recommendConfigHandler(remoteConfig, 200, configRequested),
recommendSourcesHandler([], 200, sourcesRequested),
)
await renderRecommend()
await waitFor(() => expect(configRequested).toHaveBeenCalledOnce())
await waitFor(() => expect(sourcesRequested).toHaveBeenCalledOnce())
await waitFor(() => expect(screen.queryByText('流行趋势')).not.toBeInTheDocument())
expect(screen.getByText('正在热映')).toBeInTheDocument()
expect(JSON.parse(localStorage.getItem('MP_RECOMMEND') || '{}')).toEqual(remoteConfig)
})
it.each([
['damaged JSON', '{damaged'],
['null', 'null'],
['an array', '[]'],
['a non-boolean field', JSON.stringify({ '流行趋势': 'enabled' })],
])('clears %s local configuration and falls back to the server', async (_case, storedConfig) => {
const configRequested = vi.fn()
const sourcesRequested = vi.fn()
localStorage.setItem('MP_RECOMMEND', storedConfig)
server.use(
recommendConfigHandler({ '流行趋势': true }, 200, configRequested),
recommendSourcesHandler([], 200, sourcesRequested),
)
await renderRecommend()
await waitFor(() => expect(configRequested).toHaveBeenCalledOnce())
await waitFor(() => expect(sourcesRequested).toHaveBeenCalledOnce())
await waitFor(() => expect(localStorage.getItem('MP_RECOMMEND')).toBe(JSON.stringify({ '流行趋势': true })))
expect(screen.getByText('流行趋势')).toBeInTheDocument()
})
it('keeps defaults and does not persist an invalid remote configuration', async () => {
const configRequested = vi.fn()
const sourcesRequested = vi.fn()
server.use(
recommendConfigHandler({ '流行趋势': 'enabled' }, 200, configRequested),
recommendSourcesHandler([], 200, sourcesRequested),
)
await renderRecommend()
await waitFor(() => expect(configRequested).toHaveBeenCalledOnce())
await waitFor(() => expect(sourcesRequested).toHaveBeenCalledOnce())
expect(screen.getByText('流行趋势')).toBeInTheDocument()
expect(localStorage.getItem('MP_RECOMMEND')).toBeNull()
})
it('saves settings through the shared dialog boundary', async () => {
const savedConfig = vi.fn()
const sourcesRequested = vi.fn()
const user = userEvent.setup()
localStorage.setItem('MP_RECOMMEND', JSON.stringify({ '流行趋势': true }))
server.use(recommendSourcesHandler([], 200, sourcesRequested), saveRecommendConfigHandler(savedConfig))
await renderRecommend()
await waitFor(() => expect(sourcesRequested).toHaveBeenCalledOnce())
await waitFor(() => expect(document.querySelector('.compact-fab')).not.toBeNull())
const settingsButton = document.querySelector<HTMLButtonElement>('.compact-fab')
expect(settingsButton).not.toBeNull()
await user.click(settingsButton as HTMLButtonElement)
expect(mocks.openSharedDialog).toHaveBeenCalledOnce()
const dialogEvents = mocks.openSharedDialog.mock.calls[0][2] as SharedDialogEvents
const nextConfig = { '流行趋势': false, '正在热映': true }
await dialogEvents.save({ enabled: nextConfig })
expect(savedConfig).toHaveBeenCalledWith(nextConfig)
expect(localStorage.getItem('MP_RECOMMEND')).toBe(JSON.stringify(nextConfig))
expect(mocks.closeDialog).toHaveBeenCalledOnce()
})
it.each([
{ discovery: false, superUser: false, visible: false },
{ discovery: false, superUser: true, visible: true },
])('applies discovery permission to the desktop settings entry', async ({ discovery, superUser, visible }) => {
const sourcesRequested = vi.fn()
localStorage.setItem('MP_RECOMMEND', JSON.stringify({ '流行趋势': true }))
server.use(recommendSourcesHandler([], 200, sourcesRequested))
await renderRecommend({ discovery, superUser })
await waitFor(() => expect(sourcesRequested).toHaveBeenCalledOnce())
expect(Boolean(document.querySelector('.compact-fab'))).toBe(visible)
})
it('keeps built-in content when remote requests fail', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {})
server.use(recommendConfigHandler({}, 500), recommendSourcesHandler([], 500))
await renderRecommend()
await waitFor(() => expect(consoleError).toHaveBeenCalled())
await waitFor(() => expect(consoleLog).toHaveBeenCalled())
expect(screen.getByText('流行趋势')).toBeInTheDocument()
})
it('clears its delayed-render timer when unmounted', async () => {
const setTimeout = vi.spyOn(window, 'setTimeout')
const clearTimeout = vi.spyOn(window, 'clearTimeout')
const sourcesRequested = vi.fn()
localStorage.setItem('MP_RECOMMEND', JSON.stringify({ '流行趋势': true }))
server.use(recommendSourcesHandler([], 200, sourcesRequested))
const { unmount } = await renderRecommend()
await waitFor(() => expect(sourcesRequested).toHaveBeenCalledOnce())
await fireEvent.click(screen.getByText('流行趋势'))
const componentTimerIndexes = setTimeout.mock.calls
.map(([, delay], index) => ({ delay, index }))
.filter(({ delay }) => delay === 400)
expect(componentTimerIndexes).toHaveLength(1)
const componentTimer = setTimeout.mock.results[componentTimerIndexes[0].index].value
unmount()
expect(clearTimeout).toHaveBeenCalledWith(componentTimer)
})
})

View File

@@ -97,6 +97,16 @@ function initializeColors() {
// 额外的数据源
const extraRecommendSources = ref<RecommendSource[]>([])
/** 只接受以标题为键、布尔值为开关的推荐配置。 */
function normalizeEnableConfig(value: unknown): Record<string, boolean> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
const entries = Object.entries(value)
if (entries.some(([, enabled]) => typeof enabled !== 'boolean')) return null
return Object.fromEntries(entries)
}
// 加载额外的发现数据源
async function loadExtraRecommendSources() {
try {
@@ -109,16 +119,29 @@ async function loadExtraRecommendSources() {
// 加载面板配置
async function loadConfig() {
// 显示配置
const local_enable = localStorage.getItem('MP_RECOMMEND')
if (local_enable) {
enableConfig.value = JSON.parse(local_enable)
} else {
const response = await api.get('/user/config/Recommend')
if (response && response.data && response.data.value) {
enableConfig.value = response.data.value
localStorage.setItem('MP_RECOMMEND', JSON.stringify(response.data.value))
const localEnable = localStorage.getItem('MP_RECOMMEND')
if (localEnable) {
try {
const localConfig = normalizeEnableConfig(JSON.parse(localEnable))
if (localConfig) {
enableConfig.value = localConfig
return
}
} catch {
// 损坏的本地值按未配置处理,继续尝试服务端配置。
}
localStorage.removeItem('MP_RECOMMEND')
}
try {
const response = await api.get('/user/config/Recommend')
const remoteConfig = normalizeEnableConfig(response?.data?.value)
if (remoteConfig) {
enableConfig.value = remoteConfig
localStorage.setItem('MP_RECOMMEND', JSON.stringify(remoteConfig))
}
} catch (error) {
console.error(error)
}
}

View File

@@ -0,0 +1,67 @@
import { useAuthStore } from '@/stores/auth'
import { usePluginSidebarNavStore } from '@/stores/pluginSidebarNav'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
describe('auth store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('starts with the unauthenticated state and matching getters', () => {
const authStore = useAuthStore()
expect(authStore.$state).toEqual({ token: null, remember: false, originalPath: null })
expect(authStore.getToken).toBeNull()
expect(authStore.getRemember).toBe(false)
expect(authStore.getOriginalPath).toBeNull()
})
it('logs in and updates independent authentication fields', () => {
const authStore = useAuthStore()
authStore.setOriginalPath('/recommend')
authStore.login({ token: 'test-token', remember: true, originalPath: '/ignored' })
expect(authStore.token).toBe('test-token')
expect(authStore.remember).toBe(true)
expect(authStore.originalPath).toBe('/recommend')
authStore.setRemember(false)
authStore.clearToken()
expect(authStore.getRemember).toBe(false)
expect(authStore.getToken).toBeNull()
})
it('logs out and clears plugin navigation state', () => {
const authStore = useAuthStore()
const pluginNavStore = usePluginSidebarNavStore()
const pendingRequest = Promise.resolve()
authStore.login({ token: 'test-token', remember: true })
authStore.setOriginalPath('/plugins')
pluginNavStore.$patch({
inflight: pendingRequest,
items: [
{
icon: 'mdi-test-tube',
nav_key: 'main',
order: 1,
plugin_id: 'demo',
section: 'system',
title: 'Demo',
},
],
loaded: true,
})
authStore.logout()
expect(authStore.token).toBeNull()
expect(authStore.originalPath).toBeNull()
expect(authStore.remember).toBe(true)
expect(pluginNavStore.items).toEqual([])
expect(pluginNavStore.loaded).toBe(false)
expect(pluginNavStore.inflight).toBeNull()
})
})

View File

@@ -0,0 +1,106 @@
import {
ADMIN_PERMISSIONS,
buildDefaultFeaturePermissions,
buildPluginPermissionFeatureKey,
buildUserPermissionContext,
DEFAULT_PERMISSIONS,
filterItemsByPermission,
filterMenusByPermission,
hasAllPermissions,
hasAnyPermission,
hasFeaturePermission,
hasItemPermission,
hasPermission,
normalizeUserPermissions,
PERMISSION_FEATURE,
USER_PERMISSION_FEATURES,
type UserPermissionFeatureMap,
} from '@/utils/permission'
import { describe, expect, it } from 'vitest'
describe('permission utilities', () => {
it('normalizes legacy permissions and filters invalid feature values', () => {
const normalized = normalizeUserPermissions({
discovery: false,
features: {
enabled: true,
invalid: 'yes',
} as unknown as UserPermissionFeatureMap,
})
expect(normalized).toEqual({
...DEFAULT_PERMISSIONS,
discovery: false,
features: { enabled: true },
})
expect(normalizeUserPermissions(null)).toEqual(DEFAULT_PERMISSIONS)
})
it('builds default and plugin feature contracts', () => {
const disabledFeatures = buildDefaultFeaturePermissions(false)
expect(Object.keys(disabledFeatures)).toHaveLength(USER_PERMISSION_FEATURES.length)
expect(Object.values(disabledFeatures).every(enabled => enabled === false)).toBe(true)
expect(buildPluginPermissionFeatureKey('demo')).toBe('plugin.demo.main')
expect(buildPluginPermissionFeatureKey('demo', 'settings')).toBe('plugin.demo.settings')
expect(ADMIN_PERMISSIONS.features?.[PERMISSION_FEATURE.MANAGE_SITE]).toBe(true)
})
it('grants every category and admin entry only to superusers', () => {
const superuser = buildUserPermissionContext(true, {})
expect(hasPermission(superuser, 'admin')).toBe(true)
expect(hasPermission(superuser, 'manage')).toBe(true)
expect(hasFeaturePermission(superuser, PERMISSION_FEATURE.MANAGE_SITE, 'manage')).toBe(true)
expect(hasPermission({ admin: true, manage: true }, 'admin')).toBe(false)
})
it('checks category permissions as explicit booleans', () => {
const permissions = { discovery: true, search: false, subscribe: 1, manage: undefined }
expect(hasPermission(permissions, 'discovery')).toBe(true)
expect(hasPermission(permissions, 'search')).toBe(false)
expect(hasPermission(permissions, 'subscribe')).toBe(false)
expect(hasPermission(null, 'manage')).toBe(false)
expect(hasAnyPermission(permissions, ['search', 'discovery'])).toBe(true)
expect(hasAllPermissions(permissions, ['discovery', 'search'])).toBe(false)
})
it('inherits missing feature flags but honors explicit denial and parent categories', () => {
const legacyUser = { discovery: true }
const restrictedUser = {
discovery: true,
features: { [PERMISSION_FEATURE.DISCOVERY_RECOMMEND]: false },
}
expect(hasFeaturePermission(legacyUser)).toBe(true)
expect(hasFeaturePermission(legacyUser, PERMISSION_FEATURE.DISCOVERY_RECOMMEND, 'discovery')).toBe(true)
expect(hasFeaturePermission(restrictedUser, PERMISSION_FEATURE.DISCOVERY_RECOMMEND, 'discovery')).toBe(false)
expect(
hasFeaturePermission(
{ discovery: false, features: { [PERMISSION_FEATURE.DISCOVERY_RECOMMEND]: true } },
PERMISSION_FEATURE.DISCOVERY_RECOMMEND,
'discovery',
),
).toBe(false)
})
it('checks and filters permission-protected items consistently', () => {
const permissions = {
discovery: true,
manage: false,
features: { [PERMISSION_FEATURE.DISCOVERY_EXPLORE]: false },
}
const items = [
{ id: 'open' },
{ id: 'recommend', permission: 'discovery' as const, feature: PERMISSION_FEATURE.DISCOVERY_RECOMMEND },
{ id: 'explore', permission: 'discovery' as const, feature: PERMISSION_FEATURE.DISCOVERY_EXPLORE },
{ id: 'manage', permission: 'manage' as const },
]
expect(hasItemPermission(items[1], permissions)).toBe(true)
expect(hasItemPermission(items[2], permissions)).toBe(false)
expect(filterItemsByPermission(items, permissions).map(item => item.id)).toEqual(['open', 'recommend'])
expect(filterMenusByPermission(items, permissions).map(item => item.id)).toEqual(['open', 'recommend'])
})
})

View File

@@ -0,0 +1,61 @@
import type { RecommendSource } from '@/api/types'
import {
createBuiltInRecommendSources,
mergeExtraRecommendSources,
type RecommendViewSource,
} from '@/utils/recommendSources'
import { describe, expect, it } from 'vitest'
const translate = (key: string) => `translated:${key}`
describe('recommendSources', () => {
it('creates the complete built-in source contract', () => {
const sources = createBuiltInRecommendSources(translate)
expect(sources).toHaveLength(13)
expect(sources[0]).toEqual({
apipath: 'recommend/tmdb_trending',
linkurl: '/browse/recommend/tmdb_trending?title=translated:recommend.trendingNow',
title: 'translated:recommend.trendingNow',
type: 'translated:recommend.categoryRankings',
})
expect(sources).toContainEqual(
expect.objectContaining({
apipath: 'recommend/tmdb_tvs?with_original_language=zh|en|ja|ko',
linkurl:
'/browse/recommend/tmdb_tvs?with_original_language=zh|en|ja|ko&title=translated:recommend.tmdbHotTVShows',
}),
)
})
it('appends extra sources in order and skips duplicate API paths', () => {
const target = createBuiltInRecommendSources(translate).slice(0, 1)
const extras: RecommendSource[] = [
{ api_path: 'recommend/tmdb_trending', name: '重复来源', type: '榜单' },
{ api_path: 'recommend/custom', name: '自定义来源', type: '扩展' },
{ api_path: 'recommend/custom', name: '重复扩展', type: '扩展' },
]
mergeExtraRecommendSources(target, extras)
expect(target).toHaveLength(2)
expect(target[1]).toMatchObject({
apipath: 'recommend/custom',
title: '自定义来源',
type: '扩展',
})
})
it('uses the correct query separator and encodes source names', () => {
const target: RecommendViewSource[] = []
const extras: RecommendSource[] = [
{ api_path: 'recommend/custom', name: '中文 & special', type: '扩展' },
{ api_path: 'recommend/filtered?genre=1', name: '筛选/来源', type: '扩展' },
]
mergeExtraRecommendSources(target, extras)
expect(target[0].linkurl).toBe('/browse/recommend/custom?title=%E4%B8%AD%E6%96%87%20%26%20special')
expect(target[1].linkurl).toBe('/browse/recommend/filtered?genre=1&title=%E7%AD%9B%E9%80%89%2F%E6%9D%A5%E6%BA%90')
})
})

View File

@@ -2,14 +2,15 @@
import api from '@/api'
import type { MediaInfo } from '@/api/types'
import { getMediaSubscribeId } from '@/composables/useMediaSubscribe'
import router from '@/router'
import { useGlobalSettingsStore } from '@/stores'
import { getDisplayImageUrl } from '@/utils/imageUtils'
import { createBuiltInRecommendSources, type RecommendViewSource } from '@/utils/recommendSources'
import noImage from '@images/no-image.jpeg'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
const { t } = useI18n()
const router = useRouter()
const globalSettingsStore = useGlobalSettingsStore()
const RECOMMEND_SOURCE_STORAGE_KEY = 'MP_DASHBOARD_RECOMMEND_SOURCE'
const RECOMMEND_SLIDE_COUNT = 5
@@ -29,10 +30,12 @@ const mediaCache = new Map<string, MediaInfo[]>()
const activeIndex = ref(0)
const loading = ref(true)
const loadFailed = ref(false)
const isPaused = ref(false)
const isHovered = ref(false)
const isFocusWithin = ref(false)
const touchStartX = ref<number | null>(null)
let requestId = 0
let autoplayTimer: number | null = null
let isComponentActive = false
const selectedSource = computed(
() => sources.value.find(source => source.apipath === selectedSourcePath.value) ?? sources.value[0],
@@ -75,16 +78,17 @@ function getMediaKey(item: MediaInfo) {
/** 加载指定推荐来源,并缓存当前会话已获取的数据。 */
async function loadMedia(sourcePath = selectedSourcePath.value) {
const currentRequestId = ++requestId
const cachedItems = mediaCache.get(sourcePath)
if (cachedItems) {
mediaItems.value = cachedItems
activeIndex.value = 0
loading.value = false
loadFailed.value = false
resumeAutoplayIfReady()
return
}
const currentRequestId = ++requestId
loading.value = true
loadFailed.value = false
try {
@@ -101,7 +105,10 @@ async function loadMedia(sourcePath = selectedSourcePath.value) {
mediaItems.value = []
loadFailed.value = true
} finally {
if (currentRequestId === requestId) loading.value = false
if (currentRequestId === requestId) {
loading.value = false
resumeAutoplayIfReady()
}
}
}
@@ -179,13 +186,21 @@ function handleTouchEnd(event: TouchEvent) {
else showNext()
}
/** 仅在焦点离开整个卡片时恢复自动播放。 */
function handleFocusOut(event: FocusEvent) {
const card = event.currentTarget as HTMLElement | null
const nextTarget = event.relatedTarget as Node | null
if (card && nextTarget && card.contains(nextTarget)) return
isFocusWithin.value = false
}
/** 启动轮播自动播放,系统减少动态效果时保持静态。 */
function startAutoplay() {
stopAutoplay()
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
autoplayTimer = window.setInterval(() => {
if (!isPaused.value) showNext()
if (!isHovered.value && !isFocusWithin.value) showNext()
}, RECOMMEND_AUTOPLAY_INTERVAL)
}
@@ -196,25 +211,46 @@ function stopAutoplay() {
autoplayTimer = null
}
/** 组件可见且媒体加载完成时确保轮播计时器存在。 */
function resumeAutoplayIfReady() {
if (isComponentActive && !loading.value && autoplayTimer === null) startAutoplay()
}
/** 标记组件活跃并按当前加载状态恢复自动播放。 */
function activateAutoplay() {
isComponentActive = true
resumeAutoplayIfReady()
}
/** 停用组件时阻止异步加载续体重新创建定时器。 */
function deactivateAutoplay() {
isComponentActive = false
stopAutoplay()
}
onMounted(async () => {
activateAutoplay()
localStorage.setItem(RECOMMEND_SOURCE_STORAGE_KEY, selectedSourcePath.value)
await loadMedia()
startAutoplay()
resumeAutoplayIfReady()
})
onActivated(startAutoplay)
onDeactivated(stopAutoplay)
onBeforeUnmount(stopAutoplay)
onActivated(activateAutoplay)
onDeactivated(deactivateAutoplay)
onBeforeUnmount(() => {
requestId += 1
deactivateAutoplay()
})
</script>
<template>
<VCard
class="dashboard-recommend dashboard-grid-adaptive-size dashboard-grid-fill dashboard-grid-no-drag"
:class="{ 'is-loading': loading }"
@mouseenter="isPaused = true"
@mouseleave="isPaused = false"
@focusin="isPaused = true"
@focusout="isPaused = false"
@mouseenter="isHovered = true"
@mouseleave="isHovered = false"
@focusin="isFocusWithin = true"
@focusout="handleFocusOut"
@touchstart.passive="handleTouchStart"
@touchend.passive="handleTouchEnd"
>

View File

@@ -0,0 +1,392 @@
import MediaRecommend from '@/views/dashboard/MediaRecommend.vue'
import { getActiveRequestsCount } from '@/utils/requestOptimizer'
import { fireEvent, screen, waitFor, within } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createMediaInfo } from '@tests/support/factories/media'
import { recommendApiUrls, recommendMediaHandler } from '@tests/support/msw/handlers/recommend'
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { http, HttpResponse } from 'msw'
import { defineComponent, ref } from 'vue'
import { describe, expect, it, vi } from 'vitest'
const DEFAULT_SOURCE = 'recommend/tmdb_trending'
const MOVIE_SOURCE = 'recommend/tmdb_movies'
const SOURCE_MENU_LABEL = '选择推荐媒体来源'
function getSourceMenuButton() {
return screen.getByRole('button', { name: SOURCE_MENU_LABEL })
}
async function renderMediaRecommend(
response: unknown,
options: { sourcePath?: string; status?: number; onRequest?: () => void } = {},
) {
const sourcePath = options.sourcePath ?? DEFAULT_SOURCE
server.use(
recommendMediaHandler(
sourcePath,
response as Record<string, unknown>,
options.status ?? 200,
options.onRequest,
),
)
return renderWithProviders(MediaRecommend, {
initialRoute: '/dashboard',
initialState: {
globalSettings: {
data: { GLOBAL_IMAGE_CACHE: false },
initialized: true,
loading: false,
},
},
})
}
describe('MediaRecommend', () => {
it.each([
['array', (media: ReturnType<typeof createMediaInfo>) => [media]],
['data array', (media: ReturnType<typeof createMediaInfo>) => ({ data: [media] })],
['data list', (media: ReturnType<typeof createMediaInfo>) => ({ data: { list: [media] } })],
])('normalizes the %s response shape', async (_shape, wrapResponse) => {
const media = createMediaInfo({ title: `响应-${_shape}` })
const requested = vi.fn()
await renderMediaRecommend(wrapResponse(media), { onRequest: requested })
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
expect(await screen.findByText(media.title || '')).toBeInTheDocument()
})
it('filters unusable media and limits the carousel to five items', async () => {
const validMedia = Array.from({ length: 6 }, (_, index) => createMediaInfo({ title: `有效媒体 ${index + 1}` }))
const response = {
data: {
list: [
createMediaInfo({ title: undefined }),
createMediaInfo({ backdrop_path: undefined, poster_path: undefined, title: '无图片' }),
createMediaInfo({ collection_id: undefined, title: '无标识', tmdb_id: undefined }),
...validMedia,
],
},
}
const requested = vi.fn()
const { container } = await renderMediaRecommend(response, { onRequest: requested })
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
await screen.findByText('有效媒体 1')
expect(container.querySelectorAll('.dashboard-recommend-slide')).toHaveLength(5)
expect(screen.getAllByRole('button', { name: /查看第 \d+ 项推荐/ })).toHaveLength(5)
expect(screen.queryByText('无图片')).not.toBeInTheDocument()
expect(screen.queryByText('无标识')).not.toBeInTheDocument()
expect(screen.queryByText('有效媒体 6')).not.toBeInTheDocument()
})
it('restores a valid source and replaces an invalid stored source', async () => {
const movieRequested = vi.fn()
localStorage.setItem('MP_DASHBOARD_RECOMMEND_SOURCE', MOVIE_SOURCE)
await renderMediaRecommend([createMediaInfo({ title: '电影来源内容' })], {
onRequest: movieRequested,
sourcePath: MOVIE_SOURCE,
})
await waitFor(() => expect(movieRequested).toHaveBeenCalledOnce())
expect(await screen.findByText('电影来源内容')).toBeInTheDocument()
expect(within(getSourceMenuButton()).getByText('TMDB热门电影')).toBeInTheDocument()
expect(localStorage.getItem('MP_DASHBOARD_RECOMMEND_SOURCE')).toBe(MOVIE_SOURCE)
})
it('falls back to the first source when persisted data is invalid', async () => {
const requested = vi.fn()
localStorage.setItem('MP_DASHBOARD_RECOMMEND_SOURCE', 'recommend/removed')
await renderMediaRecommend([createMediaInfo({ title: '默认来源内容' })], { onRequest: requested })
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
expect(await screen.findByText('默认来源内容')).toBeInTheDocument()
expect(localStorage.getItem('MP_DASHBOARD_RECOMMEND_SOURCE')).toBe(DEFAULT_SOURCE)
})
it('switches sources, persists the choice, and reuses the session cache', async () => {
const user = userEvent.setup()
const trendingRequested = vi.fn()
const moviesRequested = vi.fn()
server.use(
recommendMediaHandler(MOVIE_SOURCE, [createMediaInfo({ title: '热门电影内容' })], 200, moviesRequested),
)
await renderMediaRecommend([createMediaInfo({ title: '趋势内容' })], { onRequest: trendingRequested })
await waitFor(() => expect(trendingRequested).toHaveBeenCalledOnce())
await user.click(getSourceMenuButton())
await user.click(await screen.findByText('TMDB热门电影'))
await waitFor(() => expect(moviesRequested).toHaveBeenCalledOnce())
expect(await screen.findByText('热门电影内容')).toBeInTheDocument()
expect(localStorage.getItem('MP_DASHBOARD_RECOMMEND_SOURCE')).toBe(MOVIE_SOURCE)
await user.click(getSourceMenuButton())
await user.click(await screen.findByText('流行趋势'))
expect(await screen.findByText('趋势内容')).toBeInTheDocument()
expect(trendingRequested).toHaveBeenCalledOnce()
})
it('supports arrows, pagination, touch gestures, and detail routes', async () => {
const first = createMediaInfo({ title: '普通媒体', tmdb_id: 101, type: '电影', year: '2025' })
const second = createMediaInfo({ collection_id: 202, title: '媒体合集', tmdb_id: undefined, type: '合集' })
const third = createMediaInfo({ title: '第三项媒体', tmdb_id: 303 })
const { container, router } = await renderMediaRecommend([first, second, third])
await screen.findByText('普通媒体')
await fireEvent.click(screen.getByRole('button', { name: '下一项推荐' }))
expect(await screen.findByText('媒体合集')).toBeInTheDocument()
await fireEvent.click(screen.getByRole('button', { name: '查看详情' }))
await waitFor(() => expect(router.currentRoute.value.path).toBe('/browse/tmdb/collection/202'))
expect(router.currentRoute.value.query.title).toBe('媒体合集')
await fireEvent.click(screen.getByRole('button', { name: '查看第 3 项推荐' }))
expect(await screen.findByText('第三项媒体')).toBeInTheDocument()
const card = container.querySelector<HTMLElement>('.dashboard-recommend')
expect(card).not.toBeNull()
await fireEvent.touchStart(card as HTMLElement, { changedTouches: [{ clientX: 200 }] })
await fireEvent.touchEnd(card as HTMLElement, { changedTouches: [{ clientX: 170 }] })
expect(screen.getByText('第三项媒体')).toBeInTheDocument()
await fireEvent.touchStart(card as HTMLElement, { changedTouches: [{ clientX: 200 }] })
await fireEvent.touchEnd(card as HTMLElement, { changedTouches: [{ clientX: 280 }] })
expect(await screen.findByText('媒体合集')).toBeInTheDocument()
await fireEvent.click(screen.getByRole('button', { name: '上一项推荐' }))
expect(await screen.findByText('普通媒体')).toBeInTheDocument()
await fireEvent.keyDown(screen.getByRole('link'), { key: 'Enter' })
await waitFor(() => expect(router.currentRoute.value.path).toBe('/media'))
expect(router.currentRoute.value.query).toMatchObject({
mediaid: 'tmdb:101',
title: '普通媒体',
type: '电影',
year: '2025',
})
})
it('pauses autoplay for interaction and clears the interval on unmount', async () => {
let autoplay: (() => void) | undefined
const autoplayTimer = {} as ReturnType<typeof globalThis.setInterval>
const requestOptimizerTimer = {} as ReturnType<typeof globalThis.setInterval>
const setInterval = vi
.spyOn(window, 'setInterval')
.mockImplementation((handler: TimerHandler, timeout?: number) => {
if (timeout === 8000 && typeof handler === 'function') autoplay = handler as () => void
return timeout === 8000 ? autoplayTimer : requestOptimizerTimer
})
const clearInterval = vi.spyOn(window, 'clearInterval')
const { container, unmount } = await renderMediaRecommend([
createMediaInfo({ title: '自动播放一' }),
createMediaInfo({ title: '自动播放二' }),
])
await screen.findByText('自动播放一')
expect(setInterval).toHaveBeenCalledWith(expect.any(Function), 8000)
const card = container.querySelector<HTMLElement>('.dashboard-recommend') as HTMLElement
await fireEvent.focusIn(card)
await fireEvent.mouseEnter(card)
await fireEvent.mouseLeave(card)
autoplay?.()
expect(screen.getByText('自动播放一')).toBeInTheDocument()
await fireEvent.mouseEnter(card)
await fireEvent.focusOut(card)
autoplay?.()
expect(screen.getByText('自动播放一')).toBeInTheDocument()
await fireEvent.mouseLeave(card)
autoplay?.()
expect(await screen.findByText('自动播放二')).toBeInTheDocument()
unmount()
expect(clearInterval).toHaveBeenCalledWith(autoplayTimer)
})
it('does not start autoplay when reduced motion is requested', async () => {
const reducedMotion = { ...window.matchMedia(''), matches: true }
vi.spyOn(window, 'matchMedia').mockReturnValue(reducedMotion)
const setInterval = vi.spyOn(window, 'setInterval')
await renderMediaRecommend([createMediaInfo({ title: '静态推荐' })])
await screen.findByText('静态推荐')
expect(setInterval).not.toHaveBeenCalledWith(expect.any(Function), 8000)
})
it('clears autoplay when a kept-alive instance is deactivated', async () => {
const autoplayTimer = {} as ReturnType<typeof globalThis.setInterval>
const requestOptimizerTimer = {} as ReturnType<typeof globalThis.setInterval>
vi.spyOn(window, 'setInterval').mockImplementation((_handler: TimerHandler, timeout?: number) =>
timeout === 8000 ? autoplayTimer : requestOptimizerTimer,
)
const clearInterval = vi.spyOn(window, 'clearInterval')
const KeepAliveHarness = defineComponent({
components: { MediaRecommend },
setup() {
const active = ref(true)
return { active }
},
template:
'<button type="button" @click="active = false">停用推荐</button><KeepAlive><MediaRecommend v-if="active" /></KeepAlive>',
})
server.use(recommendMediaHandler(DEFAULT_SOURCE, [createMediaInfo({ title: '可停用推荐' })]))
await renderWithProviders(KeepAliveHarness, {
initialRoute: '/dashboard',
initialState: {
globalSettings: {
data: { GLOBAL_IMAGE_CACHE: false },
initialized: true,
loading: false,
},
},
})
await screen.findByText('可停用推荐')
clearInterval.mockClear()
await fireEvent.click(screen.getByRole('button', { name: '停用推荐' }))
expect(clearInterval).toHaveBeenCalledWith(autoplayTimer)
})
it('does not restart autoplay when deactivated before the initial request settles', async () => {
let resolveRequest: ((response: Response) => void) | undefined
server.use(
http.get(recommendApiUrls.media(DEFAULT_SOURCE), () => new Promise<Response>(resolve => {
resolveRequest = resolve
})),
)
const KeepAliveHarness = defineComponent({
components: { MediaRecommend },
setup() {
const active = ref(true)
return { active }
},
template:
'<button type="button" @click="active = false">停用慢请求推荐</button><KeepAlive><MediaRecommend v-if="active" /></KeepAlive>',
})
await renderWithProviders(KeepAliveHarness, {
initialRoute: '/dashboard',
initialState: {
globalSettings: {
data: { GLOBAL_IMAGE_CACHE: false },
initialized: true,
loading: false,
},
},
})
await waitFor(() => expect(resolveRequest).toBeTypeOf('function'))
const setInterval = vi.spyOn(window, 'setInterval')
await fireEvent.click(screen.getByRole('button', { name: '停用慢请求推荐' }))
setInterval.mockClear()
resolveRequest?.(HttpResponse.json([createMediaInfo({ title: '迟到推荐' })]))
await waitFor(() => expect(getActiveRequestsCount()).toBe(0))
await new Promise(resolve => window.setTimeout(resolve, 0))
expect(setInterval).not.toHaveBeenCalledWith(expect.any(Function), 8000)
})
it('restarts autoplay when reactivated before a source request settles', async () => {
const autoplayTimer = {} as ReturnType<typeof globalThis.setInterval>
const requestOptimizerTimer = {} as ReturnType<typeof globalThis.setInterval>
const setInterval = vi
.spyOn(window, 'setInterval')
.mockImplementation((_handler: TimerHandler, timeout?: number) =>
timeout === 8000 ? autoplayTimer : requestOptimizerTimer,
)
let resolveMovies: ((response: Response) => void) | undefined
server.use(
recommendMediaHandler(DEFAULT_SOURCE, [createMediaInfo({ title: '初始推荐' })]),
http.get(recommendApiUrls.media(MOVIE_SOURCE), () => new Promise<Response>(resolve => {
resolveMovies = resolve
})),
)
const KeepAliveHarness = defineComponent({
components: { MediaRecommend },
setup() {
const active = ref(true)
return { active }
},
template:
'<button type="button" @click="active = !active">{{ active ? "停用切源推荐" : "恢复切源推荐" }}</button><KeepAlive><MediaRecommend v-if="active" /></KeepAlive>',
})
await renderWithProviders(KeepAliveHarness, {
initialRoute: '/dashboard',
initialState: {
globalSettings: {
data: { GLOBAL_IMAGE_CACHE: false },
initialized: true,
loading: false,
},
},
})
await screen.findByText('初始推荐')
const user = userEvent.setup()
await user.click(getSourceMenuButton())
await user.click(await screen.findByText('TMDB热门电影'))
await waitFor(() => expect(resolveMovies).toBeTypeOf('function'))
await fireEvent.click(screen.getByRole('button', { name: '停用切源推荐' }))
setInterval.mockClear()
await fireEvent.click(screen.getByRole('button', { name: '恢复切源推荐' }))
expect(setInterval).not.toHaveBeenCalledWith(expect.any(Function), 8000)
resolveMovies?.(HttpResponse.json([createMediaInfo({ title: '切源完成' })]))
await waitFor(() => expect(getActiveRequestsCount()).toBe(0))
await screen.findByText('切源完成')
expect(setInterval).toHaveBeenCalledWith(expect.any(Function), 8000)
})
it('shows empty data and retries a failed request', async () => {
const user = userEvent.setup()
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
await renderMediaRecommend({}, { status: 500 })
expect(await screen.findByText('推荐媒体加载失败')).toBeInTheDocument()
expect(consoleError).toHaveBeenCalled()
server.use(recommendMediaHandler(DEFAULT_SOURCE, [createMediaInfo({ title: '重试成功' })]))
await user.click(screen.getByRole('button', { name: '重试' }))
expect(await screen.findByText('重试成功')).toBeInTheDocument()
})
it('renders the non-error empty state for invalid responses', async () => {
const requested = vi.fn()
await renderMediaRecommend(null, { onRequest: requested })
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
expect(await screen.findByText('当前来源暂无推荐媒体')).toBeInTheDocument()
})
it('invalidates a pending request when switching back to a cached source', async () => {
await renderMediaRecommend([createMediaInfo({ title: '初始结果' })])
expect(await screen.findByText('初始结果')).toBeInTheDocument()
let resolveMovies: ((response: Response) => void) | undefined
server.use(
http.get(recommendApiUrls.media(MOVIE_SOURCE), () => new Promise<Response>(resolve => {
resolveMovies = resolve
})),
)
const user = userEvent.setup()
await user.click(getSourceMenuButton())
const sourceList = await screen.findByRole('listbox', { name: SOURCE_MENU_LABEL })
const moviesOption = within(sourceList).getByText('TMDB热门电影')
const trendingOption = within(sourceList).getByText('流行趋势')
moviesOption.click()
trendingOption.click()
expect(await screen.findByText('初始结果')).toBeInTheDocument()
await waitFor(() => expect(resolveMovies).toBeTypeOf('function'))
resolveMovies?.(HttpResponse.json([createMediaInfo({ title: '过期结果' })]))
await waitFor(() => expect(getActiveRequestsCount()).toBe(0))
await new Promise(resolve => window.setTimeout(resolve, 0))
expect(screen.queryByText('过期结果')).not.toBeInTheDocument()
expect(screen.getByText('初始结果')).toBeInTheDocument()
})
})

70
tests/setup.ts Normal file
View File

@@ -0,0 +1,70 @@
import '@testing-library/jest-dom/vitest'
import { abortAllRequests } from '@/utils/requestOptimizer'
import { cleanup } from '@testing-library/vue'
import { afterAll, afterEach, beforeAll, vi } from 'vitest'
import { server } from './support/msw/server'
class ResizeObserverStub implements ResizeObserver {
disconnect() {}
observe() {}
unobserve() {}
}
class IntersectionObserverStub implements IntersectionObserver {
readonly root = null
readonly rootMargin = '0px'
readonly thresholds = [0]
disconnect() {}
observe() {}
takeRecords(): IntersectionObserverEntry[] {
return []
}
unobserve() {}
}
Object.defineProperty(globalThis, 'ResizeObserver', {
configurable: true,
value: ResizeObserverStub,
writable: true,
})
Object.defineProperty(globalThis, 'IntersectionObserver', {
configurable: true,
value: IntersectionObserverStub,
writable: true,
})
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: (query: string): MediaQueryList => ({
addEventListener: vi.fn(),
addListener: vi.fn(),
dispatchEvent: vi.fn(),
matches: false,
media: query,
onchange: null,
removeEventListener: vi.fn(),
removeListener: vi.fn(),
}),
writable: true,
})
beforeAll(() => {
server.listen({ onUnhandledRequest: 'error' })
})
afterEach(() => {
cleanup()
abortAllRequests()
server.resetHandlers()
localStorage.clear()
sessionStorage.clear()
vi.useRealTimers()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
afterAll(() => {
server.close()
})

View File

@@ -0,0 +1,19 @@
import type { MediaInfo } from '@/api/types'
let mediaSeed = 0
export function createMediaInfo(overrides: Partial<MediaInfo> = {}): MediaInfo {
mediaSeed += 1
return {
backdrop_path: `/images/media-${mediaSeed}.jpg`,
episode_run_time: [],
genres: ['剧情', '冒险'],
origin_country: [],
source: 'themoviedb',
title: `测试媒体 ${mediaSeed}`,
tmdb_id: mediaSeed,
type: '电影',
year: '2026',
...overrides,
}
}

View File

@@ -0,0 +1,55 @@
import type { RecommendSource } from '@/api/types'
import { HttpResponse, http, type JsonBodyType } from 'msw'
const API_BASE_URL = 'http://localhost/api/v1/'
export const recommendApiUrls = {
config: new URL('user/config/Recommend', API_BASE_URL).href,
media: (sourcePath: string) => new URL(sourcePath.replace(/^\//, ''), API_BASE_URL).href,
sources: new URL('recommend/source', API_BASE_URL).href,
}
export function recommendSourcesHandler(
sources: RecommendSource[],
status = 200,
onRequest: () => void = () => {},
) {
return http.get(recommendApiUrls.sources, () => {
onRequest()
return HttpResponse.json(sources, { status })
})
}
export function recommendConfigHandler(
config: JsonBodyType,
status = 200,
onRequest: () => void = () => {},
) {
return http.get(recommendApiUrls.config, () => {
onRequest()
return HttpResponse.json({ data: { value: config } }, { status })
})
}
export function saveRecommendConfigHandler(
onSave: (config: Record<string, boolean>) => void = () => {},
status = 200,
) {
return http.post(recommendApiUrls.config, async ({ request }) => {
const config = (await request.json()) as Record<string, boolean>
onSave(config)
return HttpResponse.json({ success: status < 400 }, { status })
})
}
export function recommendMediaHandler(
sourcePath: string,
response: JsonBodyType | JsonBodyType[],
status = 200,
onRequest: () => void = () => {},
) {
return http.get(recommendApiUrls.media(sourcePath), () => {
onRequest()
return HttpResponse.json(response as JsonBodyType, { status })
})
}

View File

@@ -0,0 +1,3 @@
import { setupServer } from 'msw/node'
export const server = setupServer()

57
tests/support/render.ts Normal file
View File

@@ -0,0 +1,57 @@
import i18n from '@/plugins/i18n'
import vuetify from '@/plugins/vuetify'
import { createTestingPinia } from '@pinia/testing'
import { render } from '@testing-library/vue'
import { setActivePinia } from 'pinia'
import { defineComponent, h, type Component } from 'vue'
import { createMemoryHistory, createRouter, type RouteLocationRaw } from 'vue-router'
import { vi } from 'vitest'
type TestingLibraryRenderOptions = NonNullable<Parameters<typeof render>[1]>
export interface RenderWithProvidersOptions extends Omit<TestingLibraryRenderOptions, 'global'> {
global?: TestingLibraryRenderOptions['global']
initialRoute?: RouteLocationRaw
initialState?: Record<string, Record<string, unknown>>
stubActions?: boolean
}
const EmptyRoute = defineComponent({
name: 'EmptyTestRoute',
setup: () => () => h('div'),
})
/** 使用独立 Router、Pinia 和生产 UI 插件渲染业务组件。 */
export async function renderWithProviders(component: Component, options: RenderWithProvidersOptions = {}) {
const {
global: globalOptions,
initialRoute = '/',
initialState = {},
stubActions = true,
...renderOptions
} = options
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/:pathMatch(.*)*', component: EmptyRoute }],
})
await router.push(initialRoute)
i18n.global.locale.value = 'zh-CN'
const pinia = createTestingPinia({
createSpy: vi.fn,
initialState,
stubActions,
})
setActivePinia(pinia)
const result = render(component, {
...renderOptions,
global: {
...globalOptions,
plugins: [vuetify, i18n, pinia, router, ...(globalOptions?.plugins ?? [])],
},
})
await router.isReady()
return { ...result, pinia, router }
}

View File

@@ -41,6 +41,9 @@
"@styles/*": [
"src/styles/*"
],
"@tests/*": [
"tests/*"
],
},
"lib": [
"esnext",
@@ -62,6 +65,7 @@
"shims.d.ts",
"src/**/*",
"src/**/*.vue",
"tests/**/*.ts",
"themeConfig.ts",
"auto-imports.d.ts",
"components.d.ts",
@@ -73,4 +77,4 @@
"node_modules",
"src/@iconify/*"
]
}
}

View File

@@ -1,3 +1,5 @@
/// <reference types="vitest/config" />
import { fileURLToPath } from 'node:url'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
@@ -16,9 +18,10 @@ import { responsiveInputCoreComponentNames } from './src/plugins/vuetify/respons
// 读取 package.json 获取版本号
const packageJson = JSON.parse(readFileSync('./package.json', 'utf-8'))
const buildTime = new Date().getTime().toString()
const isTestMode = (mode: string) => mode === 'test' || process.env.VITEST === 'true'
// https://vitejs.dev/config/
export default defineConfig({
export default defineConfig(({ mode }) => ({
base: './',
plugins: [
vue(),
@@ -34,164 +37,168 @@ export default defineConfig({
}),
Components({
dirs: ['src/@core/components'],
dts: true,
dts: !isTestMode(mode),
}),
AutoImport({
imports: ['vue', 'vue-router', '@vueuse/core', '@vueuse/math', 'pinia', 'vue-i18n'],
vueTemplate: true,
dts: !isTestMode(mode),
}),
VueI18n({
include: [resolve(__dirname, 'src/locales/*.ts')],
}),
federation({
name: 'MoviePilot',
filename: 'remoteEntry.js',
// @ts-ignore
remotes: {
// 动态remotes将在运行时注入
dummy: {
external: '',
format: 'var',
!isTestMode(mode) &&
federation({
name: 'MoviePilot',
filename: 'remoteEntry.js',
// @ts-ignore
remotes: {
// 动态remotes将在运行时注入
dummy: {
external: '',
format: 'var',
},
},
},
shared: ['vue', 'vuetify'],
}),
VitePWA({
injectRegister: 'script',
registerType: 'autoUpdate',
strategies: 'injectManifest',
srcDir: 'src',
filename: 'service-worker.ts',
injectManifest: {
rollupFormat: 'iife',
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
globPatterns: ['**/*.{js,css,html,ico,png,svg,jpg,jpeg,webp,woff,woff2,ttf,otf,eot}'],
},
devOptions: {
enabled: true,
type: 'module',
},
manifest: {
'name': 'MoviePilot',
'short_name': 'MoviePilot',
'description': 'MoviePilot - 智能影视媒体库管理工具',
'start_url': './',
'scope': './',
'display': 'standalone',
'display_override': ['window-controls-overlay', 'standalone'],
'orientation': 'portrait-primary',
'lang': 'zh-CN',
'dir': 'ltr',
'categories': ['entertainment', 'multimedia', 'utilities'],
'icons': [
{
'src': './android-chrome-192x192.png',
'sizes': '192x192',
'type': 'image/png',
'purpose': 'any',
},
{
'src': './android-chrome-192x192_maskable.png',
'sizes': '192x192',
'type': 'image/png',
'purpose': 'maskable',
},
{
'src': './android-chrome-512x512.png',
'sizes': '512x512',
'type': 'image/png',
'purpose': 'any',
},
{
'src': './android-chrome-512x512_maskable.png',
'sizes': '512x512',
'type': 'image/png',
'purpose': 'maskable',
},
],
'theme_color': '#0E1116',
'background_color': '#0E1116',
'edge_side_panel': {
'preferred_width': 320,
shared: ['vue', 'vuetify'],
}),
!isTestMode(mode) &&
VitePWA({
injectRegister: 'script',
registerType: 'autoUpdate',
strategies: 'injectManifest',
srcDir: 'src',
filename: 'service-worker.ts',
injectManifest: {
rollupFormat: 'iife',
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
globPatterns: ['**/*.{js,css,html,ico,png,svg,jpg,jpeg,webp,woff,woff2,ttf,otf,eot}'],
},
'launch_handler': {
'client_mode': 'navigate-existing',
devOptions: {
enabled: true,
type: 'module',
},
'handle_links': 'preferred',
'id': 'moviepilot-app',
'shortcuts': [
{
'name': '推荐',
'short_name': '推荐',
'description': '查看推荐内容',
'url': './recommend',
'icons': [
{
'src': './sparkles-icon-192x192.png',
'sizes': '192x192',
'type': 'image/png',
},
],
manifest: {
'name': 'MoviePilot',
'short_name': 'MoviePilot',
'description': 'MoviePilot - 智能影视媒体库管理工具',
'start_url': './',
'scope': './',
'display': 'standalone',
'display_override': ['window-controls-overlay', 'standalone'],
'orientation': 'portrait-primary',
'lang': 'zh-CN',
'dir': 'ltr',
'categories': ['entertainment', 'multimedia', 'utilities'],
'icons': [
{
'src': './android-chrome-192x192.png',
'sizes': '192x192',
'type': 'image/png',
'purpose': 'any',
},
{
'src': './android-chrome-192x192_maskable.png',
'sizes': '192x192',
'type': 'image/png',
'purpose': 'maskable',
},
{
'src': './android-chrome-512x512.png',
'sizes': '512x512',
'type': 'image/png',
'purpose': 'any',
},
{
'src': './android-chrome-512x512_maskable.png',
'sizes': '512x512',
'type': 'image/png',
'purpose': 'maskable',
},
],
'theme_color': '#0E1116',
'background_color': '#0E1116',
'edge_side_panel': {
'preferred_width': 320,
},
{
'name': '探索',
'short_name': '探索',
'description': '探索新内容',
'url': './discover',
'icons': [
{
'src': './clock-icon-192x192.png',
'sizes': '192x192',
'type': 'image/png',
},
],
'launch_handler': {
'client_mode': 'navigate-existing',
},
{
'name': '更多',
'short_name': '更多',
'description': '更多功能',
'url': './apps',
'icons': [
{
'src': './cog-icon-192x192.png',
'sizes': '192x192',
'type': 'image/png',
},
],
},
],
'screenshots': [
{
'src': './android-chrome-512x512.png',
'sizes': '512x512',
'type': 'image/png',
'form_factor': 'wide',
'label': 'MoviePilot 主界面',
},
{
'src': './android-chrome-192x192.png',
'sizes': '192x192',
'type': 'image/png',
'form_factor': 'narrow',
'label': 'MoviePilot 移动端',
},
],
'protocol_handlers': [
{
'protocol': 'web+moviepilot',
'url': './?handler=%s',
},
],
'prefer_related_applications': false,
'related_applications': [],
},
}),
topLevelAwait({
// The export name of top-level await promise for each chunk module
promiseExportName: '__mp_tla',
// The function to generate import names of top-level await promise in each chunk module
promiseImportName: i => `__mp_tla_${i}`,
}),
'handle_links': 'preferred',
'id': 'moviepilot-app',
'shortcuts': [
{
'name': '推荐',
'short_name': '推荐',
'description': '查看推荐内容',
'url': './recommend',
'icons': [
{
'src': './sparkles-icon-192x192.png',
'sizes': '192x192',
'type': 'image/png',
},
],
},
{
'name': '探索',
'short_name': '探索',
'description': '探索新内容',
'url': './discover',
'icons': [
{
'src': './clock-icon-192x192.png',
'sizes': '192x192',
'type': 'image/png',
},
],
},
{
'name': '更多',
'short_name': '更多',
'description': '更多功能',
'url': './apps',
'icons': [
{
'src': './cog-icon-192x192.png',
'sizes': '192x192',
'type': 'image/png',
},
],
},
],
'screenshots': [
{
'src': './android-chrome-512x512.png',
'sizes': '512x512',
'type': 'image/png',
'form_factor': 'wide',
'label': 'MoviePilot 主界面',
},
{
'src': './android-chrome-192x192.png',
'sizes': '192x192',
'type': 'image/png',
'form_factor': 'narrow',
'label': 'MoviePilot 移动端',
},
],
'protocol_handlers': [
{
'protocol': 'web+moviepilot',
'url': './?handler=%s',
},
],
'prefer_related_applications': false,
'related_applications': [],
},
}),
!isTestMode(mode) &&
topLevelAwait({
// The export name of top-level await promise for each chunk module
promiseExportName: '__mp_tla',
// The function to generate import names of top-level await promise in each chunk module
promiseImportName: i => `__mp_tla_${i}`,
}),
],
define: {
'process.env': {},
@@ -205,6 +212,7 @@ export default defineConfig({
'@layouts': fileURLToPath(new URL('./src/@layouts', import.meta.url)),
'@images': fileURLToPath(new URL('./src/assets/images/', import.meta.url)),
'@styles': fileURLToPath(new URL('./src/styles/', import.meta.url)),
'@tests': fileURLToPath(new URL('./tests', import.meta.url)),
'@configured-variables': fileURLToPath(new URL('./src/styles/variables/_template.scss', import.meta.url)),
'apexcharts': fileURLToPath(new URL('node_modules/apexcharts', import.meta.url)),
},
@@ -243,4 +251,41 @@ export default defineConfig({
},
},
},
})
test: {
clearMocks: true,
environment: 'jsdom',
environmentOptions: {
jsdom: {
pretendToBeVisual: true,
url: 'http://localhost/',
},
},
include: ['src/**/__tests__/**/*.spec.ts'],
restoreMocks: true,
server: {
deps: {
inline: ['vuetify'],
},
},
setupFiles: ['./tests/setup.ts'],
unstubGlobals: true,
coverage: {
include: [
'src/utils/recommendSources.ts',
'src/utils/permission.ts',
'src/stores/auth.ts',
'src/pages/recommend.vue',
'src/views/dashboard/MediaRecommend.vue',
],
provider: 'v8',
reporter: ['text', 'json-summary', 'html'],
reportsDirectory: 'coverage',
thresholds: {
branches: 75,
functions: 80,
lines: 80,
statements: 80,
},
},
},
}))

1273
yarn.lock

File diff suppressed because it is too large Load Diff