mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-09 09:46:44 +08:00
test: introduce frontend unit testing foundation (#529)
This commit is contained in:
@@ -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
|
||||||
@@ -12,6 +12,7 @@ node_modules
|
|||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
dev-dist
|
dev-dist
|
||||||
|
coverage
|
||||||
*.local
|
*.local
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,15 @@ yarn dev
|
|||||||
yarn build
|
yarn build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 单元测试
|
||||||
|
|
||||||
|
```sh
|
||||||
|
yarn test:run
|
||||||
|
yarn test:coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
测试文件组织、共享测试设施、HTTP mock、覆盖率门禁和新增用例规范见[单元测试架构](docs/testing.md)。
|
||||||
|
|
||||||
### 静态运行
|
### 静态运行
|
||||||
|
|
||||||
1. 使用 `nginx` 等Web服务器托管 `dist` 静态文件,nginx配置参考 `public/nginx.conf`。
|
1. 使用 `nginx` 等Web服务器托管 `dist` 静态文件,nginx配置参考 `public/nginx.conf`。
|
||||||
|
|||||||
@@ -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 和无渲染模块直接使用 Vitest;Vue 组件使用标准渲染入口。
|
||||||
|
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 错误。
|
||||||
+19
-1
@@ -9,6 +9,9 @@
|
|||||||
"prebuild": "npm run build:icons",
|
"prebuild": "npm run build:icons",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview --port 5050",
|
"preview": "vite preview --port 5050",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:run": "vitest run",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
"typecheck": "vue-tsc --noEmit",
|
"typecheck": "vue-tsc --noEmit",
|
||||||
"lint": "eslint . -c .eslintrc.js --fix --ext .ts,.js,.vue,.tsx,.jsx",
|
"lint": "eslint . -c .eslintrc.js --fix --ext .ts,.js,.vue,.tsx,.jsx",
|
||||||
"build:icons": "tsc -b src/@iconify && node src/@iconify/build-icons.js",
|
"build:icons": "tsc -b src/@iconify && node src/@iconify/build-icons.js",
|
||||||
@@ -20,6 +23,10 @@
|
|||||||
"dist/**/*"
|
"dist/**/*"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"resolutions": {
|
||||||
|
"vitest/**/vite": "5.4.18",
|
||||||
|
"vitest/vite": "5.4.18"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fullcalendar/core": "^6.1.15",
|
"@fullcalendar/core": "^6.1.15",
|
||||||
"@fullcalendar/daygrid": "^6.1.15",
|
"@fullcalendar/daygrid": "^6.1.15",
|
||||||
@@ -57,7 +64,7 @@
|
|||||||
"markdown-it-link-attributes": "^4.0.1",
|
"markdown-it-link-attributes": "^4.0.1",
|
||||||
"mousetrap": "^1.6.5",
|
"mousetrap": "^1.6.5",
|
||||||
"nprogress": "^0.2.0",
|
"nprogress": "^0.2.0",
|
||||||
"pinia": "^3.0.1",
|
"pinia": "^3.0.4",
|
||||||
"pinia-plugin-persistedstate": "^4.2.0",
|
"pinia-plugin-persistedstate": "^4.2.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"sass": "^1.83.4",
|
"sass": "^1.83.4",
|
||||||
@@ -82,7 +89,12 @@
|
|||||||
"@iconify/vue": "^4.3.0",
|
"@iconify/vue": "^4.3.0",
|
||||||
"@intlify/unplugin-vue-i18n": "^6.0.3",
|
"@intlify/unplugin-vue-i18n": "^6.0.3",
|
||||||
"@originjs/vite-plugin-federation": "^1.4.1",
|
"@originjs/vite-plugin-federation": "^1.4.1",
|
||||||
|
"@pinia/testing": "1.0.3",
|
||||||
"@tailwindcss/aspect-ratio": "^0.4.2",
|
"@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/body-scroll-lock": "^3.1.2",
|
||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"@types/markdown-it": "^14.1.2",
|
"@types/markdown-it": "^14.1.2",
|
||||||
@@ -96,6 +108,9 @@
|
|||||||
"@typescript-eslint/parser": "^8.20.0",
|
"@typescript-eslint/parser": "^8.20.0",
|
||||||
"@vitejs/plugin-vue": "^5.0.4",
|
"@vitejs/plugin-vue": "^5.0.4",
|
||||||
"@vitejs/plugin-vue-jsx": "^4.1.1",
|
"@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",
|
"autoprefixer": "^10.4.14",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
"eslint-import-resolver-typescript": "^3.5.1",
|
"eslint-import-resolver-typescript": "^3.5.1",
|
||||||
@@ -105,6 +120,8 @@
|
|||||||
"eslint-plugin-sonarjs": "^3.0.1",
|
"eslint-plugin-sonarjs": "^3.0.1",
|
||||||
"eslint-plugin-unicorn": "^56.0.1",
|
"eslint-plugin-unicorn": "^56.0.1",
|
||||||
"eslint-plugin-vue": "^9.12.0",
|
"eslint-plugin-vue": "^9.12.0",
|
||||||
|
"jsdom": "26.1.0",
|
||||||
|
"msw": "2.15.0",
|
||||||
"postcss": "^8.5.1",
|
"postcss": "^8.5.1",
|
||||||
"postcss-html": "^1.5.0",
|
"postcss-html": "^1.5.0",
|
||||||
"stylelint": "^16.13.2",
|
"stylelint": "^16.13.2",
|
||||||
@@ -123,6 +140,7 @@
|
|||||||
"vite-plugin-top-level-await": "^1.5.0",
|
"vite-plugin-top-level-await": "^1.5.0",
|
||||||
"vite-plugin-vue-layouts": "^0.11.0",
|
"vite-plugin-vue-layouts": "^0.11.0",
|
||||||
"vite-plugin-vuetify": "2.0.4",
|
"vite-plugin-vuetify": "2.0.4",
|
||||||
|
"vitest": "3.2.7",
|
||||||
"vue-shepherd": "^4.1.0",
|
"vue-shepherd": "^4.1.0",
|
||||||
"vue-tsc": "^2.0.10",
|
"vue-tsc": "^2.0.10",
|
||||||
"workbox-build": "^7.3.0",
|
"workbox-build": "^7.3.0",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
+32
-9
@@ -97,6 +97,16 @@ function initializeColors() {
|
|||||||
// 额外的数据源
|
// 额外的数据源
|
||||||
const extraRecommendSources = ref<RecommendSource[]>([])
|
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() {
|
async function loadExtraRecommendSources() {
|
||||||
try {
|
try {
|
||||||
@@ -109,16 +119,29 @@ async function loadExtraRecommendSources() {
|
|||||||
|
|
||||||
// 加载面板配置
|
// 加载面板配置
|
||||||
async function loadConfig() {
|
async function loadConfig() {
|
||||||
// 显示配置
|
const localEnable = localStorage.getItem('MP_RECOMMEND')
|
||||||
const local_enable = localStorage.getItem('MP_RECOMMEND')
|
if (localEnable) {
|
||||||
if (local_enable) {
|
try {
|
||||||
enableConfig.value = JSON.parse(local_enable)
|
const localConfig = normalizeEnableConfig(JSON.parse(localEnable))
|
||||||
} else {
|
if (localConfig) {
|
||||||
const response = await api.get('/user/config/Recommend')
|
enableConfig.value = localConfig
|
||||||
if (response && response.data && response.data.value) {
|
return
|
||||||
enableConfig.value = response.data.value
|
|
||||||
localStorage.setItem('MP_RECOMMEND', JSON.stringify(response.data.value))
|
|
||||||
}
|
}
|
||||||
|
} 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,14 +2,15 @@
|
|||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import type { MediaInfo } from '@/api/types'
|
import type { MediaInfo } from '@/api/types'
|
||||||
import { getMediaSubscribeId } from '@/composables/useMediaSubscribe'
|
import { getMediaSubscribeId } from '@/composables/useMediaSubscribe'
|
||||||
import router from '@/router'
|
|
||||||
import { useGlobalSettingsStore } from '@/stores'
|
import { useGlobalSettingsStore } from '@/stores'
|
||||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||||
import { createBuiltInRecommendSources, type RecommendViewSource } from '@/utils/recommendSources'
|
import { createBuiltInRecommendSources, type RecommendViewSource } from '@/utils/recommendSources'
|
||||||
import noImage from '@images/no-image.jpeg'
|
import noImage from '@images/no-image.jpeg'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const router = useRouter()
|
||||||
const globalSettingsStore = useGlobalSettingsStore()
|
const globalSettingsStore = useGlobalSettingsStore()
|
||||||
const RECOMMEND_SOURCE_STORAGE_KEY = 'MP_DASHBOARD_RECOMMEND_SOURCE'
|
const RECOMMEND_SOURCE_STORAGE_KEY = 'MP_DASHBOARD_RECOMMEND_SOURCE'
|
||||||
const RECOMMEND_SLIDE_COUNT = 5
|
const RECOMMEND_SLIDE_COUNT = 5
|
||||||
@@ -29,10 +30,12 @@ const mediaCache = new Map<string, MediaInfo[]>()
|
|||||||
const activeIndex = ref(0)
|
const activeIndex = ref(0)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const loadFailed = ref(false)
|
const loadFailed = ref(false)
|
||||||
const isPaused = ref(false)
|
const isHovered = ref(false)
|
||||||
|
const isFocusWithin = ref(false)
|
||||||
const touchStartX = ref<number | null>(null)
|
const touchStartX = ref<number | null>(null)
|
||||||
let requestId = 0
|
let requestId = 0
|
||||||
let autoplayTimer: number | null = null
|
let autoplayTimer: number | null = null
|
||||||
|
let isComponentActive = false
|
||||||
|
|
||||||
const selectedSource = computed(
|
const selectedSource = computed(
|
||||||
() => sources.value.find(source => source.apipath === selectedSourcePath.value) ?? sources.value[0],
|
() => 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) {
|
async function loadMedia(sourcePath = selectedSourcePath.value) {
|
||||||
|
const currentRequestId = ++requestId
|
||||||
const cachedItems = mediaCache.get(sourcePath)
|
const cachedItems = mediaCache.get(sourcePath)
|
||||||
if (cachedItems) {
|
if (cachedItems) {
|
||||||
mediaItems.value = cachedItems
|
mediaItems.value = cachedItems
|
||||||
activeIndex.value = 0
|
activeIndex.value = 0
|
||||||
loading.value = false
|
loading.value = false
|
||||||
loadFailed.value = false
|
loadFailed.value = false
|
||||||
|
resumeAutoplayIfReady()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentRequestId = ++requestId
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
loadFailed.value = false
|
loadFailed.value = false
|
||||||
try {
|
try {
|
||||||
@@ -101,7 +105,10 @@ async function loadMedia(sourcePath = selectedSourcePath.value) {
|
|||||||
mediaItems.value = []
|
mediaItems.value = []
|
||||||
loadFailed.value = true
|
loadFailed.value = true
|
||||||
} finally {
|
} finally {
|
||||||
if (currentRequestId === requestId) loading.value = false
|
if (currentRequestId === requestId) {
|
||||||
|
loading.value = false
|
||||||
|
resumeAutoplayIfReady()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,13 +186,21 @@ function handleTouchEnd(event: TouchEvent) {
|
|||||||
else showNext()
|
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() {
|
function startAutoplay() {
|
||||||
stopAutoplay()
|
stopAutoplay()
|
||||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
|
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
|
||||||
|
|
||||||
autoplayTimer = window.setInterval(() => {
|
autoplayTimer = window.setInterval(() => {
|
||||||
if (!isPaused.value) showNext()
|
if (!isHovered.value && !isFocusWithin.value) showNext()
|
||||||
}, RECOMMEND_AUTOPLAY_INTERVAL)
|
}, RECOMMEND_AUTOPLAY_INTERVAL)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,25 +211,46 @@ function stopAutoplay() {
|
|||||||
autoplayTimer = null
|
autoplayTimer = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 组件可见且媒体加载完成时确保轮播计时器存在。 */
|
||||||
|
function resumeAutoplayIfReady() {
|
||||||
|
if (isComponentActive && !loading.value && autoplayTimer === null) startAutoplay()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 标记组件活跃并按当前加载状态恢复自动播放。 */
|
||||||
|
function activateAutoplay() {
|
||||||
|
isComponentActive = true
|
||||||
|
resumeAutoplayIfReady()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 停用组件时阻止异步加载续体重新创建定时器。 */
|
||||||
|
function deactivateAutoplay() {
|
||||||
|
isComponentActive = false
|
||||||
|
stopAutoplay()
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
activateAutoplay()
|
||||||
localStorage.setItem(RECOMMEND_SOURCE_STORAGE_KEY, selectedSourcePath.value)
|
localStorage.setItem(RECOMMEND_SOURCE_STORAGE_KEY, selectedSourcePath.value)
|
||||||
await loadMedia()
|
await loadMedia()
|
||||||
startAutoplay()
|
resumeAutoplayIfReady()
|
||||||
})
|
})
|
||||||
|
|
||||||
onActivated(startAutoplay)
|
onActivated(activateAutoplay)
|
||||||
onDeactivated(stopAutoplay)
|
onDeactivated(deactivateAutoplay)
|
||||||
onBeforeUnmount(stopAutoplay)
|
onBeforeUnmount(() => {
|
||||||
|
requestId += 1
|
||||||
|
deactivateAutoplay()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VCard
|
<VCard
|
||||||
class="dashboard-recommend dashboard-grid-adaptive-size dashboard-grid-fill dashboard-grid-no-drag"
|
class="dashboard-recommend dashboard-grid-adaptive-size dashboard-grid-fill dashboard-grid-no-drag"
|
||||||
:class="{ 'is-loading': loading }"
|
:class="{ 'is-loading': loading }"
|
||||||
@mouseenter="isPaused = true"
|
@mouseenter="isHovered = true"
|
||||||
@mouseleave="isPaused = false"
|
@mouseleave="isHovered = false"
|
||||||
@focusin="isPaused = true"
|
@focusin="isFocusWithin = true"
|
||||||
@focusout="isPaused = false"
|
@focusout="handleFocusOut"
|
||||||
@touchstart.passive="handleTouchStart"
|
@touchstart.passive="handleTouchStart"
|
||||||
@touchend.passive="handleTouchEnd"
|
@touchend.passive="handleTouchEnd"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { setupServer } from 'msw/node'
|
||||||
|
|
||||||
|
export const server = setupServer()
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -41,6 +41,9 @@
|
|||||||
"@styles/*": [
|
"@styles/*": [
|
||||||
"src/styles/*"
|
"src/styles/*"
|
||||||
],
|
],
|
||||||
|
"@tests/*": [
|
||||||
|
"tests/*"
|
||||||
|
],
|
||||||
},
|
},
|
||||||
"lib": [
|
"lib": [
|
||||||
"esnext",
|
"esnext",
|
||||||
@@ -62,6 +65,7 @@
|
|||||||
"shims.d.ts",
|
"shims.d.ts",
|
||||||
"src/**/*",
|
"src/**/*",
|
||||||
"src/**/*.vue",
|
"src/**/*.vue",
|
||||||
|
"tests/**/*.ts",
|
||||||
"themeConfig.ts",
|
"themeConfig.ts",
|
||||||
"auto-imports.d.ts",
|
"auto-imports.d.ts",
|
||||||
"components.d.ts",
|
"components.d.ts",
|
||||||
|
|||||||
+48
-3
@@ -1,3 +1,5 @@
|
|||||||
|
/// <reference types="vitest/config" />
|
||||||
|
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
import vueJsx from '@vitejs/plugin-vue-jsx'
|
import vueJsx from '@vitejs/plugin-vue-jsx'
|
||||||
@@ -16,9 +18,10 @@ import { responsiveInputCoreComponentNames } from './src/plugins/vuetify/respons
|
|||||||
// 读取 package.json 获取版本号
|
// 读取 package.json 获取版本号
|
||||||
const packageJson = JSON.parse(readFileSync('./package.json', 'utf-8'))
|
const packageJson = JSON.parse(readFileSync('./package.json', 'utf-8'))
|
||||||
const buildTime = new Date().getTime().toString()
|
const buildTime = new Date().getTime().toString()
|
||||||
|
const isTestMode = (mode: string) => mode === 'test' || process.env.VITEST === 'true'
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => ({
|
||||||
base: './',
|
base: './',
|
||||||
plugins: [
|
plugins: [
|
||||||
vue(),
|
vue(),
|
||||||
@@ -34,15 +37,17 @@ export default defineConfig({
|
|||||||
}),
|
}),
|
||||||
Components({
|
Components({
|
||||||
dirs: ['src/@core/components'],
|
dirs: ['src/@core/components'],
|
||||||
dts: true,
|
dts: !isTestMode(mode),
|
||||||
}),
|
}),
|
||||||
AutoImport({
|
AutoImport({
|
||||||
imports: ['vue', 'vue-router', '@vueuse/core', '@vueuse/math', 'pinia', 'vue-i18n'],
|
imports: ['vue', 'vue-router', '@vueuse/core', '@vueuse/math', 'pinia', 'vue-i18n'],
|
||||||
vueTemplate: true,
|
vueTemplate: true,
|
||||||
|
dts: !isTestMode(mode),
|
||||||
}),
|
}),
|
||||||
VueI18n({
|
VueI18n({
|
||||||
include: [resolve(__dirname, 'src/locales/*.ts')],
|
include: [resolve(__dirname, 'src/locales/*.ts')],
|
||||||
}),
|
}),
|
||||||
|
!isTestMode(mode) &&
|
||||||
federation({
|
federation({
|
||||||
name: 'MoviePilot',
|
name: 'MoviePilot',
|
||||||
filename: 'remoteEntry.js',
|
filename: 'remoteEntry.js',
|
||||||
@@ -56,6 +61,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
shared: ['vue', 'vuetify'],
|
shared: ['vue', 'vuetify'],
|
||||||
}),
|
}),
|
||||||
|
!isTestMode(mode) &&
|
||||||
VitePWA({
|
VitePWA({
|
||||||
injectRegister: 'script',
|
injectRegister: 'script',
|
||||||
registerType: 'autoUpdate',
|
registerType: 'autoUpdate',
|
||||||
@@ -186,6 +192,7 @@ export default defineConfig({
|
|||||||
'related_applications': [],
|
'related_applications': [],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
!isTestMode(mode) &&
|
||||||
topLevelAwait({
|
topLevelAwait({
|
||||||
// The export name of top-level await promise for each chunk module
|
// The export name of top-level await promise for each chunk module
|
||||||
promiseExportName: '__mp_tla',
|
promiseExportName: '__mp_tla',
|
||||||
@@ -205,6 +212,7 @@ export default defineConfig({
|
|||||||
'@layouts': fileURLToPath(new URL('./src/@layouts', import.meta.url)),
|
'@layouts': fileURLToPath(new URL('./src/@layouts', import.meta.url)),
|
||||||
'@images': fileURLToPath(new URL('./src/assets/images/', import.meta.url)),
|
'@images': fileURLToPath(new URL('./src/assets/images/', import.meta.url)),
|
||||||
'@styles': fileURLToPath(new URL('./src/styles/', 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)),
|
'@configured-variables': fileURLToPath(new URL('./src/styles/variables/_template.scss', import.meta.url)),
|
||||||
'apexcharts': fileURLToPath(new URL('node_modules/apexcharts', 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|||||||
Reference in New Issue
Block a user