Compare commits

..

27 Commits

Author SHA1 Message Date
InfinityPacer
4da856fb6d fix(plugin): resolve remote entry URLs from document base (#566) 2026-07-20 15:05:58 +08:00
jxxghp
03126d32c1 feat(plugin): expose host toast to federated views 2026-07-20 12:35:14 +08:00
InfinityPacer
ad555b3b36 fix(site): preserve dialog failure states (#565) 2026-07-19 19:24:45 +08:00
InfinityPacer
7f8e6952df test(site): cover site user data dialog (#564) 2026-07-19 17:33:53 +08:00
InfinityPacer
2542339733 test(site): cover site resource dialog (#563) 2026-07-19 17:05:56 +08:00
InfinityPacer
0d094b01b5 test(site): cover site maintenance dialogs (#562) 2026-07-19 16:12:17 +08:00
InfinityPacer
47f007480f test(site): cover site card and icon cache (#561) 2026-07-19 12:39:53 +08:00
InfinityPacer
374a0866be test(site): cover site list flows (#560) 2026-07-19 08:57:44 +08:00
InfinityPacer
126a204b2a test(media): cover media detail flows (#559) 2026-07-18 20:46:38 +08:00
InfinityPacer
c491195b19 test(media): cover cards and virtual slides (#558) 2026-07-18 18:03:39 +08:00
InfinityPacer
395401e493 ci: check changed-file formatting (#557) 2026-07-18 16:48:25 +08:00
InfinityPacer
52479ebbd5 test(discover): cover source and media list flows (#556) 2026-07-18 15:41:40 +08:00
InfinityPacer
f6d8ab8aeb chore: add incremental Prettier tooling (#555) 2026-07-18 15:41:23 +08:00
jxxghp
67e78d0eb1 Fix transfer history mobile record layout 2026-07-18 11:45:12 +08:00
jxxghp
ef7f346e7a feat: 优化历史记录页面小屏幕显示 (#554)
Co-authored-by: traeagent <traeagent@users.noreply.github.com>
2026-07-18 11:17:13 +08:00
jxxghp
707bfc87a1 feat: 优化历史记录页面小屏幕显示 (#553)
Co-authored-by: traeagent <traeagent@users.noreply.github.com>
2026-07-18 10:58:06 +08:00
jxxghp
3416c879de feat: 优化历史记录页面小屏幕显示 (#552)
Co-authored-by: traeagent <traeagent@users.noreply.github.com>
2026-07-18 10:42:08 +08:00
jxxghp
bbed5bf0ae feat: 优化历史记录页面小屏幕显示 (#551) 2026-07-18 10:24:46 +08:00
InfinityPacer
985874506e ci: add observational ESLint check (#550) 2026-07-18 10:24:00 +08:00
InfinityPacer
8434b75af3 chore: migrate frontend linting to ESLint 10 (#549) 2026-07-18 09:42:34 +08:00
InfinityPacer
8a1c10bbf7 test(recommend): cover source lifecycle and slides (#548) 2026-07-18 09:37:45 +08:00
jxxghp
e4e5bce234 更新 package.json 2026-07-18 08:06:59 +08:00
jxxghp
ae4f304cc2 feat: 优化小屏幕订阅日历界面 (#547) 2026-07-18 08:05:31 +08:00
秋澪Akimio
9a838647fe feat: 为词表编辑器增加可选行号与语法高亮 (#546) 2026-07-17 23:34:49 +08:00
InfinityPacer
435e9ecfdd test(subscribe): cover popular and share workflows (#545) 2026-07-17 19:54:24 +08:00
InfinityPacer
2b310c9a97 test(subscribe): cover season, files and history dialogs (#544) 2026-07-17 18:25:28 +08:00
InfinityPacer
7d2c98c5ae fix(layout): register cleanup hook synchronously (#543) 2026-07-17 18:25:05 +08:00
103 changed files with 15313 additions and 1592 deletions

View File

@@ -1,69 +0,0 @@
module.exports = {
env: {
browser: true,
es2021: true,
},
extends: ['@antfu/eslint-config-vue', 'plugin:sonarjs/recommended'],
ignorePatterns: ['src/@iconify/*.js', 'node_modules', 'dist', '*.d.ts'],
plugins: ['regex'],
rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'sonarjs/no-duplicate-string': 'warn',
'vue/valid-v-slot': ['error', {
allowModifiers: true,
}],
// https://github.com/gmullerb/eslint-plugin-regex
'regex/invalid': [
'error',
[
{
regex: '@/assets/images',
replacement: '@images',
message: 'Use \'@images\' path alias for image imports',
},
{
regex: '@/styles',
replacement: '@styles',
message: 'Use \'@styles\' path alias for importing styles from \'src/styles\'',
},
// {
// id: 'Disallow icon of icon library',
// regex: 'tabler-\\w',
// message: 'Only \'mdi\' icons are allowed',
// },
{
regex: '@core/\\w',
message: 'You can\'t use @core when you are in @layouts module',
files: {
inspect: '@layouts/.*',
},
},
{
regex: 'useLayouts\\(',
message:
'`useLayouts` composable is only allowed in @layouts & @core directory. Please use `useThemeConfig` composable instead.',
files: {
inspect: '^(?!.*(@core|@layouts)).*',
},
},
],
// Ignore files
'.eslintrc.js',
],
},
settings: {
'import/resolver': {
node: {
extensions: ['.ts', '.js', '.tsx', '.jsx', '.mjs', '.png', '.jpg'],
},
typescript: {},
},
},
}

View File

@@ -13,6 +13,51 @@ concurrency:
cancel-in-progress: true
jobs:
format:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 0
- 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: Check changed-file formatting
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: yarn format:check --base "$BASE_SHA" --head "$HEAD_SHA"
lint:
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: Lint
run: yarn lint
unit-tests:
runs-on: ubuntu-latest
timeout-minutes: 20

1
.node-version Normal file
View File

@@ -0,0 +1 @@
24

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
24

View File

@@ -1,2 +1,8 @@
dist
node_modules
node_modules
# 自动生成文件的格式由各自生成器决定。
auto-imports.d.ts
components.d.ts
eslint-suppressions.json
/vite.config.*.timestamp-*.mjs

View File

@@ -3,7 +3,7 @@
"bracketSpacing": true,
"htmlWhitespaceSensitivity": "css",
"insertPragma": false,
"jsxBracketSameLine": false,
"bracketSameLine": false,
"jsxSingleQuote": true,
"printWidth": 120,
"proseWrap": "preserve",

View File

@@ -2,7 +2,7 @@
*中文 | [English](README_EN.md)*
[MoviePilot](https://github.com/jxxghp/MoviePilot) 的前端项目,NodeJS版本>= `v20.12.1`
[MoviePilot](https://github.com/jxxghp/MoviePilot) 的前端项目,最低支持 Node.js `20.19`,推荐使用 Node.js `24`
## 特性
@@ -49,6 +49,8 @@ yarn test:coverage
测试文件组织、共享测试设施、HTTP mock、覆盖率门禁和新增用例规范见[单元测试架构](docs/testing.md)。
ESLint、Prettier、Node 兼容范围及渐进式 CI 门禁见[前端代码质量工具链演进](docs/code-quality.md)。
### 静态运行
1. 使用 `nginx` 等Web服务器托管 `dist` 静态文件nginx配置参考 `public/nginx.conf`

179
docs/code-quality.md Normal file
View File

@@ -0,0 +1,179 @@
# 前端代码质量工具链演进
本文档定义 MoviePilot-Frontend 的代码质量工具链、运行入口和渐进式演进路线。目标是让人工开发者和自动化 Agent 都通过同一组项目命令完成格式化、静态检查、类型检查、测试和构建,并由 CI 在干净环境中复现结果;编辑器及其扩展只提供即时反馈,不作为项目行为的事实源。
## 工具职责
| 工具 | 职责 | 不负责 |
| ---------------------- | ------------------------------------------------------------ | -------------------------------------- |
| ESLint | JavaScript、TypeScript、Vue 代码质量,框架约束和项目模块边界 | 缩进、换行、引号、属性布局等代码格式 |
| Prettier | 可确定、可重复的代码格式 | 未使用变量、Vue 规则、复杂度和模块边界 |
| TypeScript / `vue-tsc` | TypeScript 与 Vue 模板类型检查 | 代码格式和业务行为测试 |
| Vitest | 单元测试、组件测试和覆盖率门槛 | 生产构建与真实浏览器行为 |
| Vite | 生产构建和构建期集成验证 | 类型完整性和代码质量规则 |
ESLint 配置由仓库显式维护,不继承 Antfu 等覆盖面较大的个人风格预设。JavaScript、TypeScript 与 Vue 使用各自面向正确性的 recommended/essential 基线SonarJS 不整包展开 recommended而只显式启用安全和正确性的高信号规则避免插件升级隐式扩大检查范围。
历史规则迁移前必须核对当前源码和运行职责,不能仅因为旧配置出现过就写入 baseline。当前保留 Vuetify 点号 slot 兼容、`@layouts` 不反向依赖 `@core`、浏览器与 Node globals 分域及统一禁止 `debugger`。图片/样式 alias 强制只约束 import 拼写且已与当前实践脱节;`useLayouts` 目录限制所针对的 API 从未存在于源码;重复字符串属于高噪声风格治理;伪随机数限制会把界面效果误判为安全问题,因此这四类历史规则不再作为门禁。
Prettier 独立运行ESLint 中与格式重叠的规则保持关闭。任何编辑器都可以调用项目内 ESLint 和 Prettier但保存时是否自动执行不影响 CI 结果。
## Node 与依赖边界
- 最低兼容目标为 Node.js 20.19Node.js 20.19、22 和 24 的开发环境可以安装依赖并运行项目命令。
- 推荐开发环境和 CI 主环境使用 Node.js 24。
- CI 若继续声明兼容 Node.js 20.19,应至少在该版本验证 frozen lockfile 安装、lint、typecheck 和 build完整覆盖率门禁在 Node.js 24 执行。
- Yarn 1 与 `yarn.lock` 继续作为依赖安装事实源CI 使用 `yarn --frozen-lockfile`
- Node 只用于依赖安装、开发、测试和前端构建MoviePilot 正式 Docker 镜像使用预构建前端产物,不因开发工具链升级而增加 Node 运行时。
最低 Node 版本变化属于开发环境兼容性变更,必须独立说明并在 CI 验证。不得仅提高 `package.json``engines.node` 而不更新 README、版本文件和 workflow。
## 统一命令
工具链完成演进后,仓库提供以下入口:
```sh
yarn lint # 只读 ESLint 检查,不修改文件
yarn lint:fix # 显式执行 ESLint 安全修复
yarn lint:suppressions:prune # 修复存量问题后裁剪过期 baseline
yarn format # 格式化当前分支、暂存区、工作区和未跟踪文件中的受支持变更文件
yarn format:check # 检查相同变更文件,不修改文件
yarn format:all:check # 只读测量全仓格式收敛情况
yarn typecheck
yarn test:run
yarn test:coverage
yarn build
```
`yarn lint` 不得包含 `--fix`。CI 只运行只读命令,任何自动修改都由开发者或 Agent 在提交前显式执行。
## ESLint 演进:先恢复可执行门禁
ESLint 迁移优先于全仓格式治理,基础设施迁移不夹带批量业务代码修复。
第一阶段完成以下事项:
1. 使用 ESLint 10 flat config移除旧 `.eslintrc`
2. 不恢复已删除的 `@antfu/eslint-config-vue`
3. 显式组合 JavaScript、TypeScript、Vue 和经过审计的 SonarJS 规则。
4. 使用 ESLint 原生 AST 限制维护仍成立的模块边界,并删除只约束 import 拼写或已无现实对象的历史 regex 规则。
5. 只检查 JavaScript、TypeScript、Vue 与相关测试文件Markdown、JSON、YAML、TOML、构建产物和生成文件不在首阶段扩展范围内。
6. 默认 lint 只读,并提供单独的 `lint:fix`
7. 对确有价值但存在存量的问题使用明确基线或分阶段启用,不通过全仓自动修复制造大面积混合变更。
8. 项目边界与新增问题门禁使用 Vitest 配置契约测试保护,并随现有 `test:coverage` CI 自动执行。
`eslint-suppressions.json` 只冻结迁移时已经确认的文件、规则和数量。新增问题不得加入 baseline修复存量问题后运行 `yarn lint:suppressions:prune`,并把裁剪结果与代码修复一同提交。日常开发和 CI 不得使用 `--suppress-all`
迁移完成的最低验收为:
```sh
yarn --frozen-lockfile
yarn lint
yarn typecheck
yarn test:coverage
yarn build
```
若新配置导致大量与业务目标无关的格式、排序或额外文件类型问题,应停止扩大规则范围,先调整配置职责;不得以一次性自动修复数百个文件作为通过迁移的手段。
## ESLint CI先观察再强制
第二阶段在 Pull Request workflow 中增加独立的全仓 `yarn lint` job
1. lint 与既有 `unit-tests` 使用不同 job避免改变现有测试 check 的名称和职责。
2. 初始阶段作为普通 check 运行,不立即配置 required check。
3. workflow 使用 Node 24、frozen lockfile 和只读 `yarn lint`,不执行自动修复或更新 baseline。
4. 观察 fork PR、依赖缓存、执行时间、误报和路径范围。
5. 连续多个 PR 稳定通过后,再由维护者决定是否设为 required。
ESLint CI 必须检查全仓受管源码,而不是只检查 PR 变更文件。已有问题通过规则选择或受控基线管理,保证新问题不能借由“只检查改动行”绕过项目约束。
## Prettier 演进:改到即格式化
Prettier 采用与后端 `unittest → pytest` 相同的渐进原则:不为转换而转换,修改到存量文件时再纳入统一格式。
- **新文件**:必须通过当前 Prettier 配置。
- **修改文件**Agent 或开发者在提交前对本次触及的受支持文件运行 Prettier。
- **未修改存量文件**:暂时保持原样,不建立一次性全仓格式化任务。
- **大型或高冲突文件**:若格式化产生大量机械差异,格式调整与业务修改拆分为独立 commit必要时暂缓该文件并记录原因。
- **生成文件**:按生成器事实源管理,不直接用 Prettier 修补生成结果;需要统一格式时修改生成器或生成配置。
Prettier 初始 CI 只检查 Pull Request 新增或修改的受支持文件,并作为变更范围门禁运行。检查脚本应:
1. 从 PR base 与 head 计算文件集合。
2. 排除已删除文件、构建产物、覆盖率报告和生成文件。
3. 仅把 Prettier 支持且由仓库管理的文件传给 `prettier --check`
4. 文件集合为空时正常通过。
本地直接运行 `yarn format``yarn format:check` 时,脚本按 `refs/remotes/upstream/v2``refs/remotes/origin/v2``refs/heads/v2` 的顺序选择与当前提交具有共同祖先的基线并合并当前工作区与未跟踪文件。完整引用可避免同名标签造成歧义CI 使用显式提交 SHA 保证输入可复现:
```sh
yarn format:check --base <base-sha> --head <head-sha>
```
文件名通过 Git 的 NUL 分隔输出和 Node 参数数组传递,不经过 shell 字符串拼接重命名只检查新路径删除文件、ignore 文件和 Prettier 无法推断 parser 的文件不进入格式检查。
该门禁不要求未修改的存量文件通过 Prettier但所有进入 PR diff 的受支持文件都必须通过。Prettier 是文件级格式化工具因此修改存量文件时检查的是整个文件而不是仅检查变更行。Agent 或开发者应在提交前运行同一项目脚本完成格式化CI 只验证结果,不自动提交修改。
禁止在普通业务 PR 中直接运行 `prettier . --write` 或其他全仓格式写入命令。`yarn format` 只处理选择器返回的变更文件;单个存量文件被触及时仍可能整体重排,差异过大时应拆分 commit便于 reviewer 区分机械格式与业务逻辑。
具体交付拆分为:
1. Prettier 基础设施 PR安装项目 CLI定义配置、命令、变更文件选择脚本和回归测试不修改 workflow。
2. 变更文件门禁 PR复用已合并脚本接入 Pull Request workflow验证 fork PR、文件重命名、删除文件、空文件集和特殊文件名。初期作为普通 check 观察,稳定后再由维护者通过仓库规则独立配置为 required。
3. 全仓门禁 PR仅在存量收敛条件满足后择机实施不与变更文件门禁绑定。
## 全仓格式门禁的启用条件
不单独安排大爆炸式格式化阶段。存量通过日常“改到即格式化”逐步收敛,并定期执行全仓 `yarn format:all:check` 观察剩余范围。
Prettier 3.9.5 基础设施接入时,全仓只读检查在 `v2` 基线报告 203 个存量文件需要格式化。该数字是收敛起点而不是忽略白名单;变更文件仍必须完整通过检查,存量数量随日常修改逐步下降。
变更文件 CI 接入前在 `v2` 再次测量为 206 个文件,较基础设施合并后的 201 个文件出现回升。这说明仅提供本地命令不足以阻止新改动扩大存量;变更文件 check 用于守住新增 diff不因此要求在本阶段批量格式化已有文件。
变更文件 required check 不需要等待全仓收敛。只有同时满足以下条件,才把 Prettier 从变更文件 required check 切换为全仓 required check
- 全仓 `yarn format:all:check` 已通过,或仅剩少量可在独立机械提交中安全处理的文件。
- 最近的活跃分支已合并或完成同步,避免集中格式变化制造冲突。
- `yarn lint``yarn typecheck``yarn test:coverage``yarn build` 在格式收敛后全部通过。
- Prettier 与 ESLint 不存在反复改写同一文件的规则冲突。
- workflow 已以非 required 状态稳定运行多个 PR。
最终 Pull Request 门禁目标为:
```sh
yarn --frozen-lockfile
yarn format:all:check
yarn lint
yarn typecheck
yarn test:coverage
yarn build
```
PR-Agent、编辑器诊断和人工 review 可以补充判断,但不能替代可重复执行的项目命令。
## Agent 与开发者提交规范
无论改动由 Agent 还是人工完成,提交前遵循同一顺序:
1. 只对本次新增或修改的文件执行 Prettier。
2. 运行全仓只读 lint。
3. 运行 typecheck。
4. 按影响面运行聚焦测试;提交 PR 前运行覆盖率门禁。
5. 涉及构建配置、依赖、Vue SFC 或模块联邦时运行生产 build。
编辑器设置不得成为通过验证的前提。文档、Agent 指令和 PR 说明应引用 `yarn` 命令,而不是“在 VS Code 保存一次”或依赖某个 IDE 扩展完成修复。
## 风险与回滚
| 风险 | 控制方式 | 回滚边界 |
| ---------------------------- | ------------------------------------------ | ------------------------------ |
| ESLint 自动修复改变语义 | 默认 lint 只读;基础设施迁移不批量 fix | 独立回退规则或依赖提交 |
| 格式化制造大面积冲突 | 改到即格式化;大型文件拆分机械 commit | 回退单文件格式 commit |
| 新规则让所有 PR 变红 | 先测量、再启用;使用受控基线 | 暂停单条规则,不回退整套工具链 |
| 检查范围扩展到生成物或文档 | 首阶段限制文件类型并显式 ignore | 收窄 flat config 文件匹配 |
| Node 最低版本阻断安装 | 最低保持 20.19Node 24 作为推荐和主 CI | 保持兼容 job版本提升独立处理 |
| 全仓 required check 配置过早 | 先强制变更文件,持续观察全仓剩余范围 | 保留变更文件门禁,暂缓全仓切换 |
| ESLint 与 Prettier 循环改写 | ESLint 关闭格式规则Prettier 单独负责格式 | 关闭冲突规则并增加回归样例 |
依赖迁移、规则迁移、存量代码修复、格式化和 GitHub required checks 应保持可独立评审、验证和回退,禁止合并成一个难以定位风险的大型变更。

View File

@@ -6,7 +6,6 @@ MoviePilot前端采用模块联邦(Module Federation)技术实现插件的动态
关联阅读后端插件开发文档:[第三方插件开发说明](https://github.com/jxxghp/MoviePilot-Plugins/blob/main/README.md)
## 2. 技术要求
- Node.js 20+
@@ -18,13 +17,13 @@ MoviePilot前端采用模块联邦(Module Federation)技术实现插件的动态
每个 Vue 联邦插件需要提供下列标准组件(`AppPage` 为可选,用于主界面侧栏全页入口):
| 组件名称 | 暴露名 | 文件名 | 用途 |
|---------|--------|--------|------|
| Page | `./Page` | Page.vue | 插件管理中的详情弹窗 |
| Config | `./Config` | Config.vue | 插件配置页面 |
| Dashboard | `./Dashboard` | Dashboard.vue | 仪表盘小组件 |
| AppPage | `./AppPage` | AppPage.vue | 主界面侧栏独立全页(主内容区由插件完全绘制) |
| (可选) | `./AppPage{Xxx}` | 如 AppPageSettings.vue | 多 `nav_key` 时按名优先加载,见下文「多界面」 |
| 组件名称 | 暴露名 | 文件名 | 用途 |
| --------- | ---------------- | ---------------------- | --------------------------------------------- |
| Page | `./Page` | Page.vue | 插件管理中的详情弹窗 |
| Config | `./Config` | Config.vue | 插件配置页面 |
| Dashboard | `./Dashboard` | Dashboard.vue | 仪表盘小组件 |
| AppPage | `./AppPage` | AppPage.vue | 主界面侧栏独立全页(主内容区由插件完全绘制) |
| (可选) | `./AppPage{Xxx}` | 如 AppPageSettings.vue | 多 `nav_key` 时按名优先加载,见下文「多界面」 |
主应用在侧栏全页路由中按 `nav_key` 解析暴露名(如 `AppPageSettings`),再回退 `AppPage``Page``nav_key``main` 时仅尝试 `AppPage``Page`
@@ -79,55 +78,52 @@ export default defineConfig({
singleton: true,
},
},
format: 'esm'
})
format: 'esm',
}),
],
build: {
target: 'esnext', // 必须设置为esnext以支持顶层await
minify: false, // 开发阶段建议关闭混淆
target: 'esnext', // 必须设置为esnext以支持顶层await
minify: false, // 开发阶段建议关闭混淆
cssCodeSplit: true, // 改为true以便能分离样式文件
},
css: {
preprocessorOptions: {
scss: {
additionalData: '/* 覆盖vuetify样式 */',
}
},
},
postcss: {
plugins: [
{
postcssPlugin: 'internal:charset-removal',
AtRule: {
charset: (atRule) => {
charset: atRule => {
if (atRule.name === 'charset') {
atRule.remove();
atRule.remove()
}
}
}
},
},
},
{
postcssPlugin: 'vuetify-filter',
Root(root) {
// 过滤掉所有vuetify相关的CSS
root.walkRules(rule => {
if (rule.selector && (
rule.selector.includes('.v-') ||
rule.selector.includes('.mdi-'))) {
rule.remove();
if (rule.selector && (rule.selector.includes('.v-') || rule.selector.includes('.mdi-'))) {
rule.remove()
}
});
}
}
]
}
})
},
},
],
},
},
server: {
port: 5001, // 使用不同于主应用的端口
cors: true, // 启用CORS
origin: 'http://localhost:5001'
port: 5001, // 使用不同于主应用的端口
cors: true, // 启用CORS
origin: 'http://localhost:5001',
},
})
})
```
## 5. 组件开发规范
@@ -143,8 +139,8 @@ const emit = defineEmits(['action', 'switch', 'close'])
const props = defineProps({
api: {
type: Object,
default: () => {}
}
default: () => {},
},
})
// 页面逻辑代码...
@@ -183,16 +179,16 @@ function notifyClose() {
const props = defineProps({
initialConfig: {
type: Object,
default: () => ({})
default: () => ({}),
},
api: {
type: Object,
default: () => {}
}
default: () => {},
},
})
// 配置数据
const config = ref({...props.initialConfig})
const config = ref({ ...props.initialConfig })
// 自定义事件,用于保存配置
const emit = defineEmits(['save', 'close', 'switch'])
@@ -217,7 +213,7 @@ function notifyClose() {
<div class="plugin-config">
<!-- 配置表单示例 -->
<v-text-field v-model="config.someField" label="配置项"></v-text-field>
<!-- 保存按钮示例 -->
<v-btn color="primary" @click="saveConfig">保存配置</v-btn>
@@ -238,12 +234,12 @@ function notifyClose() {
const props = defineProps({
config: {
type: Object,
default: () => ({})
default: () => ({}),
},
allowRefresh: {
type: Boolean,
default: true
}
default: true,
},
})
// 仪表板逻辑...
@@ -276,11 +272,11 @@ const props = defineProps({
主应用传入的 props
| 属性 | 说明 |
|------|------|
| `api` | 与 `Page` 相同,用于 `bear` 认证的插件 HTTP 调用 |
| `navKey` | 与侧栏声明的 `nav_key` 一致,同一插件多入口时用于区分 |
| `pluginId` | 当前插件 ID |
| 属性 | 说明 |
| ---------- | ----------------------------------------------------- |
| `api` | 与 `Page` 相同,用于 `bear` 认证的插件 HTTP 调用 |
| `navKey` | 与侧栏声明的 `nav_key` 一致,同一插件多入口时用于区分 |
| `pluginId` | 当前插件 ID |
```vue
<script setup lang="ts">
@@ -300,18 +296,37 @@ const emit = defineEmits(['action'])
</template>
```
### 5.5 调用主应用 Toast
`Page``Config``Dashboard``AppPage` 的宿主容器会通过固定键提供主应用 Toast。远程组件应复用该实例不要自行渲染 `VSnackbar` 或创建另一套 Toast 容器:
```vue
<script setup lang="ts">
import { inject } from 'vue'
const toast = inject<any>('moviepilot:toast', null)
// 保存完成后调用主应用的统一通知。
function saveComplete() {
toast?.success('保存成功')
}
</script>
```
可用方法与主项目 `vue-toastification` 一致,包括 `success``info``warning``error`。注入不存在时应静默降级,关键错误仍需保留页面内状态提示。
#### 后端:注册侧栏入口
插件需为 **Vue** 渲染模式(`get_render_mode` 返回 `vue`),并实现 `get_sidebar_nav`,返回列表项字段与主应用 `GET /api/v1/plugin/sidebar_nav` 一致:
| 字段 | 说明 |
|------|------|
| `nav_key` | URL 路径段,唯一标识本入口(同一插件可多入口) |
| `title` | 侧栏显示标题 |
| `icon` | MDI 图标名,如 `mdi-rss` |
| `section` | 分组:`start` / `discovery` / `subscribe` / `organize` / `system` |
| 字段 | 说明 |
| ------------ | ------------------------------------------------------------------------------------- |
| `nav_key` | URL 路径段,唯一标识本入口(同一插件可多入口) |
| `title` | 侧栏显示标题 |
| `icon` | MDI 图标名,如 `mdi-rss` |
| `section` | 分组:`start` / `discovery` / `subscribe` / `organize` / `system` |
| `permission` | 可选:`subscribe` / `discovery` / `search` / `manage` / `admin`,与主应用菜单权限一致 |
| `order` | 可选:同组内排序,数值越小越靠前 |
| `order` | 可选:同组内排序,数值越小越靠前 |
```python
def get_sidebar_nav(self) -> List[Dict[str, Any]]:
@@ -333,16 +348,16 @@ def get_sidebar_nav(self) -> List[Dict[str, Any]]:
前端加载远程组件的顺序为:
| `nav_key` | 依次尝试的联邦暴露名 |
|-----------|----------------------|
| `main` 或省略 | `./AppPage``./Page` |
| `nav_key` | 依次尝试的联邦暴露名 |
| -------------------------------- | ------------------------------------------------ |
| `main` 或省略 | `./AppPage``./Page` |
| 其它(如 `settings``my_tool` | `./AppPage{PascalCase}``./AppPage``./Page` |
`PascalCase` 规则:按 `-``_`、空格分段后首字母大写并拼接。例如 `nav_key=settings` → 先试 `./AppPageSettings``my_tool``./AppPageMyTool`
**两种实现方式(二选一或混用):**
1. **单文件分支**:只暴露 `./AppPage`,在组件内根据 `navKey` prop 用 `v-if` / `<component>` 切换子界面。
1. **单文件分支**:只暴露 `./AppPage`,在组件内根据 `navKey` prop 用 `v-if` / `<component>` 切换子界面。
2. **多文件**:为某个入口单独暴露 `./AppPageSettings.vue` 等,主应用会优先加载对应模块,失败再回退到 `AppPage`
`vite.config` 多暴露示例:
@@ -365,8 +380,7 @@ yarn build
- 将生成的dist文件夹上传到插件后端目录下默认为`dist/assets`
**注意: `__federation_shared_vuetify` 目录以及 `index-`、`date-`、`runtime-` 开头的文件不需要上传**,只需要上传以下命名格式文件:`__federation_*``_plugin-vue_export-helper-*``remoteEntry.js`
**注意: `__federation_shared_vuetify` 目录以及 `index-`、`date-`、`runtime-` 开头的文件不需要上传**,只需要上传以下命名格式文件:`__federation_*``_plugin-vue_export-helper-*``remoteEntry.js`
- 在插件的后端python代码中实现以下方法来集成远程组件
@@ -380,7 +394,8 @@ def get_render_mode() -> Tuple[str, str]:
return "vue", "dist/assets"
```
- 需要在插件前端页面调用后端接口时通过传入的api模块发起调用后端api接口声明认证类型为`bear`
- 需要在插件前端页面调用后端接口时通过传入的api模块发起调用后端api接口声明认证类型为`bear`
```typescript
// 演示使用api模块调用插件接口
recentItems.value = await props.api.get(`plugin/MyPlugin/history`)
@@ -402,7 +417,6 @@ def get_api(self) -> List[Dict[str, Any]]:
]
```
## 7. 调试与排错
### 常见问题
@@ -461,7 +475,7 @@ yarn dev
## 9. 示例代码
- [插件远程组件示例](../examples/plugin-component/) - 开发插件组件的完整示例项目
- [插件远程组件示例](../examples/plugin-component/) - 开发插件组件的完整示例项目
- [模块联邦问题排查指南](./federation-troubleshooting.md) - 常见问题排查
## 10. 参考资料
@@ -471,4 +485,4 @@ yarn dev
---
如有问题请提交Issue。
如有问题请提交Issue。

View File

@@ -67,7 +67,7 @@ tests/
## 新增测试
1. 在被测源码所在目录的 `__tests__/` 中创建同名 `*.spec.ts`
1. 业务测试在被测源码所在目录的 `__tests__/` 中创建同名 `*.spec.ts`;工具链配置契约测试放在 `tests/config/`
2. 纯函数、store 和无渲染模块直接使用 VitestVue 组件使用标准渲染入口。
3. 需要 HTTP 请求时,在 `tests/support/msw/handlers/<domain>.ts` 增加对应 handler。
4. 需要结构化业务数据时,在 `tests/support/factories/` 增加最小工厂。
@@ -76,7 +76,7 @@ tests/
## 配置边界
Vitest 收集 `src/**/__tests__/**/*.spec.ts`。测试模式保留 Vue、Vue JSX、Vuetify、自动导入、自动组件和 i18n 插件,并禁用 PWA、模块联邦和 top-level-await 构建插件。
Vitest 收集 `src/**/__tests__/**/*.spec.ts``tests/config/**/*.spec.ts`。测试模式保留 Vue、Vue JSX、Vuetify、自动导入、自动组件和 i18n 插件,并禁用 PWA、模块联邦和 top-level-await 构建插件。配置契约测试随全量测试执行,但不加入业务源码覆盖率统计范围。
当前核心覆盖范围在 `vite.config.ts``coverage.include` 中显式维护。聚合门槛为 Lines、Statements、Functions 不低于 85%Branches 不低于 80%;每个显式核心文件的 Lines、Statements、Functions 不低于 80%Branches 不低于 75%。覆盖率报告写入 `coverage/`
@@ -86,9 +86,10 @@ Vitest 只收集 `src/**/__tests__/**/*.spec.ts`。测试模式保留 Vue、Vue
yarn test # watch 模式
yarn test:run # 单次运行
yarn test:coverage # 单次运行并检查覆盖率
yarn test:lint-config # 聚焦执行 ESLint 配置契约测试
yarn typecheck
yarn lint
yarn build
```
Pull Request 测试工作流使用 Node 24 LTS 和 frozen lockfile,依次执行类型检查和覆盖率门禁。现有 lint 基线问题按仓库当前维护约定单独处理,新增测试代码不得引入新的 lint 错误
Pull Request 工作流使用 Node 24 LTS 和 frozen lockfile`unit-tests` job 依次执行类型检查和覆盖率门禁;独立的 `lint` job 执行全仓只读 ESLint 检查,当前处于普通 check 观察阶段,不改变既有 required checks。Prettier 和 Node 兼容范围按[前端代码质量工具链演进](code-quality.md)继续渐进接入,新增测试代码不得引入新的 lint 或格式问题

1240
eslint-suppressions.json Normal file

File diff suppressed because it is too large Load Diff

195
eslint.config.js Normal file
View File

@@ -0,0 +1,195 @@
import js from '@eslint/js'
import sonarjs from 'eslint-plugin-sonarjs'
import pluginVue from 'eslint-plugin-vue'
import globals from 'globals'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
const javascriptFiles = ['**/*.{js,mjs,cjs,jsx}']
const typescriptFiles = ['**/*.{ts,tsx,mts,cts}', '**/*.vue']
const managedScriptExtensions = 'js,mjs,cjs,jsx,ts,tsx,mts,cts'
const managedFiles = [...javascriptFiles, ...typescriptFiles]
const browserFiles = [
`src/**/*.{${managedScriptExtensions},vue}`,
`examples/**/src/**/*.{${managedScriptExtensions},vue}`,
]
const nodeFiles = ['**/*.config.{js,mjs,cjs,ts,mts,cts}', 'public/service.js', 'scripts/**/*.{js,mjs,cjs,ts,mts,cts}']
// TypeScript 关闭核心 no-undef显式禁止浏览器域中的 Node-only globals保证 JS、TS 与 Vue 使用同一运行时边界。
const browserGlobalNames = new Set(Object.keys(globals.browser))
const nodeOnlyGlobalRestrictions = Object.keys(globals.node)
.filter(name => !browserGlobalNames.has(name))
.sort()
.map(name => ({
name,
message: `'${name}' is only available in Node.js code.`,
}))
const restrictedCoreSourcePattern = '/(?:^|\\/)@core(?:\\/|$)/'
// @layouts 是 @core 的下游依赖;禁止反向引用,避免两层形成直接循环。
const layoutCoreImportRestrictions = [
'ImportDeclaration',
'ExportNamedDeclaration',
'ExportAllDeclaration',
'ImportExpression',
].map(nodeType => ({
selector: `${nodeType}[source.value=${restrictedCoreSourcePattern}]`,
message: "The '@layouts' module must not import from '@core'.",
}))
const nonLiteralLayoutDynamicImportRestriction = {
selector: "ImportExpression:not([source.type='Literal'])",
message: 'Dynamic imports in @layouts must use a literal path so module boundaries remain statically verifiable.',
}
const viteGlobCallSelectors = [
"CallExpression[callee.object.type='MetaProperty'][callee.object.meta.name='import'][callee.object.property.name='meta'][callee.property.name=/^glob(?:Eager)?$/]",
"CallExpression[callee.object.type='MetaProperty'][callee.object.meta.name='import'][callee.object.property.name='meta'][callee.property.value=/^glob(?:Eager)?$/]",
]
const layoutGlobRestrictions = viteGlobCallSelectors.map(callSelector => ({
selector: callSelector,
message:
'import.meta.glob is not allowed in @layouts because wildcard targets cannot be proven to stay within the module boundary.',
}))
const sonarRules = {
'sonarjs/array-callback-without-return': 'error',
'sonarjs/code-eval': 'error',
'sonarjs/empty-string-repetition': 'error',
'sonarjs/no-all-duplicated-branches': 'error',
'sonarjs/no-dead-store': 'error',
'sonarjs/no-duplicated-branches': 'error',
'sonarjs/no-element-overwrite': 'error',
'sonarjs/no-hardcoded-passwords': 'error',
'sonarjs/no-hardcoded-secrets': 'error',
'sonarjs/no-identical-conditions': 'error',
'sonarjs/no-identical-expressions': 'error',
'sonarjs/no-ignored-exceptions': 'error',
'sonarjs/no-unthrown-error': 'error',
'sonarjs/no-use-of-empty-return-value': 'error',
'sonarjs/reduce-initial-value': 'error',
'sonarjs/slow-regex': 'error',
'sonarjs/stateful-regex': 'error',
'sonarjs/super-linear-regex': 'error',
}
const typescriptConfigs = tseslint.configs.recommended.map(config => ({
...config,
files: typescriptFiles,
}))
const vueConfigs = pluginVue.configs['flat/essential'].map(config => ({
...config,
files: ['**/*.vue'],
}))
export default defineConfig([
globalIgnores([
'**/node_modules/**',
'**/dist/**',
'**/dev-dist/**',
'**/coverage/**',
'**/.worktrees/**',
'**/vite.config.*.timestamp-*.mjs',
'public/plugin_icon/**',
'src/@iconify/**',
'**/*.d.ts',
]),
{
...js.configs.recommended,
name: 'moviepilot/javascript',
files: managedFiles,
},
...typescriptConfigs,
...vueConfigs,
{
name: 'moviepilot/vue-contracts',
files: ['**/*.vue'],
rules: {
'vue/multi-word-component-names': 'off',
'vue/valid-v-slot': [
'error',
{
allowModifiers: true,
},
],
},
},
{
name: 'moviepilot/language-options',
files: managedFiles,
linterOptions: {
reportUnusedDisableDirectives: 'off',
},
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
parserOptions: {
parser: tseslint.parser,
},
},
},
{
name: 'moviepilot/browser-globals',
files: browserFiles,
languageOptions: {
globals: globals.browser,
},
rules: {
'no-restricted-globals': ['error', ...nodeOnlyGlobalRestrictions],
},
},
{
name: 'moviepilot/node-globals',
files: nodeFiles,
languageOptions: {
globals: globals.node,
},
// Node 配置可能位于浏览器源码目录;显式覆盖浏览器专用限制,避免 flat config 合并后误报。
rules: {
'no-restricted-globals': 'off',
},
},
{
name: 'moviepilot/sonarjs',
files: managedFiles,
plugins: {
sonarjs,
},
rules: sonarRules,
},
{
name: 'moviepilot/sonarjs-locales',
files: [`src/locales/**/*.{${managedScriptExtensions}}`],
rules: {
'sonarjs/no-hardcoded-passwords': 'off',
'sonarjs/no-hardcoded-secrets': 'off',
},
},
{
name: 'moviepilot/source-hygiene',
files: managedFiles,
rules: {
'no-debugger': 'error',
},
},
{
name: 'moviepilot/layout-boundary',
files: [`src/@layouts/**/*.{${managedScriptExtensions},vue}`],
rules: {
'no-restricted-syntax': [
'error',
...layoutCoreImportRestrictions,
...layoutGlobRestrictions,
nonLiteralLayoutDynamicImportRestriction,
],
},
},
])

View File

@@ -1,6 +1,6 @@
{
"name": "moviepilot",
"version": "2.14.5",
"version": "2.14.6",
"private": true,
"type": "module",
"bin": "dist/service.js",
@@ -12,8 +12,15 @@
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"test:lint-config": "vitest run tests/config/eslint-config.spec.ts",
"typecheck": "vue-tsc --noEmit",
"lint": "eslint . -c .eslintrc.js --fix --ext .ts,.js,.vue,.tsx,.jsx",
"lint": "eslint . --max-warnings=0",
"lint:fix": "eslint . --fix",
"lint:suppressions:prune": "eslint . --prune-suppressions",
"format": "node scripts/format-changed.mjs --write",
"format:check": "node scripts/format-changed.mjs --check",
"format:all:check": "prettier . --check",
"test:format-config": "vitest run tests/config/format-changed.spec.ts tests/config/frontend-workflow.spec.ts",
"build:icons": "tsc -b src/@iconify && node src/@iconify/build-icons.js",
"postinstall": "npm run build:icons",
"pkg": "pkg . -t node18-win-x64 -o MoviePilot-Frontend.exe"
@@ -82,6 +89,7 @@
"webfontloader": "^1.6.28"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@iconify-json/line-md": "^1.2.13",
"@iconify-json/lucide": "^1.2.85",
"@iconify-json/material-symbols": "^1.2.51",
@@ -107,26 +115,21 @@
"@types/qrcode": "^1.5.6",
"@types/three": "^0.185.1",
"@types/webfontloader": "^1.6.34",
"@typescript-eslint/eslint-plugin": "^8.20.0",
"@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",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-promise": "^7.2.1",
"eslint-plugin-regex": "^1.10.0",
"eslint-plugin-sonarjs": "^3.0.1",
"eslint-plugin-unicorn": "^56.0.1",
"eslint-plugin-vue": "^9.12.0",
"eslint": "^10.7.0",
"eslint-plugin-sonarjs": "^4.2.0",
"eslint-plugin-vue": "^10.9.2",
"globals": "^17.3.0",
"jsdom": "26.1.0",
"msw": "2.15.0",
"postcss": "^8.5.1",
"postcss-html": "^1.5.0",
"prettier": "^3.9.5",
"stylelint": "^16.13.2",
"stylelint-config-idiomatic-order": "^10.0.0",
"stylelint-config-standard-scss": "^14.0.0",
@@ -134,6 +137,7 @@
"terser": "^5.36.0",
"type-fest": "^4.15.0",
"typescript": "^5.0.4",
"typescript-eslint": "^8.64.0",
"unplugin-auto-import": "^19.0.0",
"unplugin-vue-components": "^28.0.0",
"unplugin-vue-define-options": "^1.5.3",
@@ -144,10 +148,14 @@
"vite-plugin-vue-layouts": "^0.11.0",
"vite-plugin-vuetify": "2.0.4",
"vitest": "3.2.7",
"vue-eslint-parser": "^10.4.1",
"vue-shepherd": "^4.1.0",
"vue-tsc": "^2.0.10",
"workbox-build": "^7.3.0",
"workbox-window": "^7.3.0"
},
"packageManager": "yarn@1.22.18"
"packageManager": "yarn@1.22.18",
"engines": {
"node": ">=20.19"
}
}

208
scripts/format-changed.mjs Normal file
View File

@@ -0,0 +1,208 @@
import { execFileSync, spawnSync } from 'node:child_process'
import { existsSync, lstatSync } from 'node:fs'
import { resolve } from 'node:path'
import process from 'node:process'
import { fileURLToPath, pathToFileURL } from 'node:url'
import * as prettier from 'prettier'
const DEFAULT_BASE_REFS = ['refs/remotes/upstream/v2', 'refs/remotes/origin/v2', 'refs/heads/v2']
const GIT_OUTPUT_MAX_BUFFER = 64 * 1024 * 1024
const PRETTIER_ARGUMENT_BUDGET = 24_000
const prettierCliPath = fileURLToPath(new URL('../node_modules/prettier/bin/prettier.cjs', import.meta.url))
/** 解析 Git 的 NUL 分隔输出,保留文件名中的空格、换行和通配符字符。 */
function parseNullDelimited(output) {
return output.toString('utf8').split('\0').filter(Boolean)
}
/** 运行只返回仓库相对路径的 Git 查询。 */
function gitPaths(cwd, args) {
return parseNullDelimited(
execFileSync('git', args, {
cwd,
encoding: 'buffer',
maxBuffer: GIT_OUTPUT_MAX_BUFFER,
}),
)
}
/** 尝试把引用解析为提交 SHA完整引用可避免分支与标签同名时产生歧义。 */
function tryResolveCommit(cwd, ref) {
const result = spawnSync('git', ['rev-parse', '--verify', '--quiet', '--end-of-options', `${ref}^{commit}`], {
cwd,
encoding: 'utf8',
})
return result.status === 0 ? result.stdout.trim() : undefined
}
/** 把外部引用解析为提交 SHA后续 Git diff 不再解释用户提供的选项样式字符串。 */
function resolveCommit(cwd, ref) {
const commit = tryResolveCommit(cwd, ref)
if (commit) return commit
throw new Error(`无法解析 Git 提交引用:${ref}`)
}
/** 判断两个提交能否进行三点比较。 */
function hasMergeBase(cwd, baseCommit, headCommit) {
return spawnSync('git', ['merge-base', baseCommit, headCommit], { cwd, stdio: 'ignore' }).status === 0
}
/** 为本地命令选择与当前提交具有共同祖先的 v2 基线。 */
function resolveDefaultBase(cwd, headCommit) {
for (const ref of DEFAULT_BASE_REFS) {
const commit = tryResolveCommit(cwd, ref)
if (commit && hasMergeBase(cwd, commit, headCommit)) return commit
}
throw new Error('无法定位可比较的 v2 基线,请通过 --base <ref> 显式指定比较起点。')
}
/**
* 收集当前分支相对基线的文件集合。
* 显式 base/head 模式只读取提交区间,默认本地模式还会合并暂存区、工作区和未跟踪文件。
*/
function collectChangedPaths({ cwd, base, head }) {
if (Boolean(base) !== Boolean(head)) {
throw new Error('--base 与 --head 必须一起使用。')
}
const explicitRange = Boolean(base)
const headCommit = resolveCommit(cwd, head ?? 'HEAD')
const baseCommit = base ? resolveCommit(cwd, base) : resolveDefaultBase(cwd, headCommit)
if (!hasMergeBase(cwd, baseCommit, headCommit)) {
throw new Error('Git 比较起点与终点没有共同祖先,无法执行三点比较。')
}
const paths = new Set(
gitPaths(cwd, ['diff', '--name-only', '--diff-filter=ACMRT', '-z', `${baseCommit}...${headCommit}`]),
)
if (!explicitRange) {
for (const filePath of gitPaths(cwd, ['diff', '--name-only', '--diff-filter=ACMRT', '-z', 'HEAD'])) {
paths.add(filePath)
}
for (const filePath of gitPaths(cwd, ['ls-files', '--others', '--exclude-standard', '-z'])) {
paths.add(filePath)
}
}
return [...paths]
.filter(filePath => {
const absolutePath = resolve(cwd, filePath)
if (!existsSync(absolutePath)) return false
try {
return lstatSync(absolutePath).isFile()
} catch {
return false
}
})
.sort()
}
/** 使用仓库 ignore 文件和 Prettier parser 推断筛出真正受格式化工具管理的文件。 */
async function selectPrettierFiles(filePaths, cwd) {
const ignorePaths = ['.gitignore', '.prettierignore'].map(filePath => resolve(cwd, filePath)).filter(existsSync)
const selected = []
for (const filePath of filePaths) {
const absolutePath = resolve(cwd, filePath)
const fileInfo = await prettier.getFileInfo(absolutePath)
if (!fileInfo.inferredParser) continue
let ignored = false
for (const ignorePath of ignorePaths) {
if ((await prettier.getFileInfo(absolutePath, { ignorePath })).ignored) {
ignored = true
break
}
}
if (!ignored) selected.push(filePath)
}
return selected
}
/** 按保守的参数长度预算拆批,避免大型 PR 超过操作系统命令行上限。 */
function batchPaths(filePaths) {
const batches = []
let batch = []
let length = 0
for (const filePath of filePaths) {
if (batch.length > 0 && length + filePath.length + 1 > PRETTIER_ARGUMENT_BUDGET) {
batches.push(batch)
batch = []
length = 0
}
batch.push(filePath)
length += filePath.length + 1
}
if (batch.length > 0) batches.push(batch)
return batches
}
/** 通过参数数组调用项目内 Prettier避免 shell 展开或拼接特殊文件名。 */
function runPrettier(filePaths, mode, cwd) {
if (filePaths.length === 0) {
console.log('没有需要 Prettier 处理的变更文件。')
return
}
for (const batch of batchPaths(filePaths)) {
const result = spawnSync(process.execPath, [prettierCliPath, mode, '--ignore-unknown', '--', ...batch], {
cwd,
stdio: 'inherit',
})
if (result.status !== 0) {
throw new Error(`Prettier ${mode} 失败,退出码:${result.status ?? 'unknown'}`)
}
}
}
/** 解析面向开发者和 CI 的稳定命令行契约。 */
function parseArguments(args) {
const options = { action: undefined, base: undefined, head: undefined }
for (let index = 0; index < args.length; index += 1) {
const argument = args[index]
if (['--check', '--write', '--list'].includes(argument)) {
if (options.action) throw new Error('只能指定 --check、--write 或 --list 中的一种操作。')
options.action = argument
continue
}
if (argument === '--base' || argument === '--head') {
const value = args[index + 1]
if (!value) throw new Error(`${argument} 缺少引用值。`)
options[argument.slice(2)] = value
index += 1
continue
}
throw new Error(`未知参数:${argument}`)
}
if (!options.action) throw new Error('必须指定 --check、--write 或 --list。')
return options
}
/** 执行变更文件选择与格式化;显式引用模式为 CI 提供确定输入。 */
async function main() {
const cwd = process.cwd()
const { action, base, head } = parseArguments(process.argv.slice(2))
const changedPaths = collectChangedPaths({ cwd, base, head })
const selectedPaths = await selectPrettierFiles(changedPaths, cwd)
if (action === '--list') {
process.stdout.write(`${JSON.stringify(selectedPaths)}\n`)
return
}
runPrettier(selectedPaths, action, cwd)
}
if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
main().catch(error => {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
})
}

View File

@@ -0,0 +1,62 @@
import ace from 'ace-builds'
import { describe, expect, it } from 'vitest'
import '@/ace-config'
interface AceToken {
type: string
value: string
}
interface WordListSyntaxMode {
getTokenizer: () => {
getLineTokens: (line: string, state: string) => { tokens: AceToken[] }
}
}
const WordListSyntaxMode = ace.require('ace/mode/word_list_syntax').Mode as new () => WordListSyntaxMode
const tokenizer = new WordListSyntaxMode().getTokenizer()
function tokenize(line: string) {
return tokenizer.getLineTokens(line, 'start').tokens
}
describe('word list syntax mode', () => {
it('highlights a block word as one field', () => {
expect(tokenize('屏蔽词')).toEqual([{ type: 'word_list_block', value: '屏蔽词' }])
})
it('separates replaced and replacement fields without parsing their content', () => {
expect(tokenize('旧名.* => 新名 {[tmdbid=123;type=tv]}')).toEqual([
{ type: 'word_list_replaced', value: '旧名.*' },
{ type: 'keyword.operator.word-list', value: ' => ' },
{ type: 'word_list_replacement', value: '新名 {[tmdbid=123;type=tv]}' },
])
})
it('separates front, back, and episode offset fields', () => {
expect(tokenize('第 <> 集 >> EP+1')).toEqual([
{ type: 'word_list_front', value: '第' },
{ type: 'keyword.operator.word-list', value: ' <> ' },
{ type: 'word_list_back', value: '集' },
{ type: 'keyword.operator.word-list', value: ' >> ' },
{ type: 'word_list_offset', value: 'EP+1' },
])
})
it('uses all six field types in combined and standalone rules', () => {
const combinedTokens = tokenize('旧名 => 新名 && 第 <> 集 >> 2*EP-1')
expect(combinedTokens).toEqual([
{ type: 'word_list_replaced', value: '旧名' },
{ type: 'keyword.operator.word-list', value: ' => ' },
{ type: 'word_list_replacement', value: '新名' },
{ type: 'keyword.operator.word-list', value: ' && ' },
{ type: 'word_list_front', value: '第' },
{ type: 'keyword.operator.word-list', value: ' <> ' },
{ type: 'word_list_back', value: '集' },
{ type: 'keyword.operator.word-list', value: ' >> ' },
{ type: 'word_list_offset', value: '2*EP-1' },
])
expect(tokenize('屏蔽词')[0].type).toBe('word_list_block')
})
})

View File

@@ -578,6 +578,90 @@ function registerWordListMode() {
exports.Mode = Mode
},
)
aceModule.define?.(
'ace/mode/word_list_syntax_highlight_rules',
['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text_highlight_rules'],
(require: any, exports: any) => {
const oop = require('../lib/oop')
const TextHighlightRules = require('./text_highlight_rules').TextHighlightRules
const WordListSyntaxHighlightRules = function (this: any) {
this.$rules = {
start: [
{
token: 'comment.word-list',
regex: /^#.*/,
},
{
token: [
'text',
'word_list_replaced',
'keyword.operator.word-list',
'word_list_replacement',
'keyword.operator.word-list',
'word_list_front',
'keyword.operator.word-list',
'word_list_back',
'keyword.operator.word-list',
'word_list_offset',
'text',
],
regex: /^(\s*)(.*?)( +=> +)(.*?)( +&& +)(.*?)( +<> +)(.*?)( +>> +)(.*?)(\s*)$/,
},
{
token: ['text', 'word_list_replaced', 'keyword.operator.word-list', 'word_list_replacement', 'text'],
regex: /^(\s*)(.*?)( +=> +)(.*?)(\s*)$/,
},
{
token: [
'text',
'word_list_front',
'keyword.operator.word-list',
'word_list_back',
'keyword.operator.word-list',
'word_list_offset',
'text',
],
regex: /^(\s*)(.*?)( +<> +)(.*?)( +>> +)(.*?)(\s*)$/,
},
{
token: ['text', 'word_list_block', 'text'],
regex: /^(\s*)(\S(?:.*?\S)?)(\s*)$/,
},
],
}
this.normalizeRules()
}
oop.inherits(WordListSyntaxHighlightRules, TextHighlightRules)
exports.WordListSyntaxHighlightRules = WordListSyntaxHighlightRules
},
)
aceModule.define?.(
'ace/mode/word_list_syntax',
['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text', 'ace/mode/word_list_syntax_highlight_rules'],
(require: any, exports: any) => {
const oop = require('../lib/oop')
const TextMode = require('./text').Mode
const WordListSyntaxHighlightRules = require('./word_list_syntax_highlight_rules').WordListSyntaxHighlightRules
const Mode = function (this: any) {
TextMode.call(this)
this.HighlightRules = WordListSyntaxHighlightRules
}
oop.inherits(Mode, TextMode)
;(function (this: any) {
this.$id = 'ace/mode/word_list_syntax'
}).call(Mode.prototype)
exports.Mode = Mode
},
)
}
ace.config.setModuleUrl('ace/mode/json', modeJsonUrl)

View File

@@ -114,6 +114,8 @@ export interface SubscribeShare {
tmdbid?: number
// 豆瓣ID
doubanid?: string
// Bangumi ID
bangumiid?: number
// 季号
season?: number
// 海报

View File

@@ -4,7 +4,7 @@ import { getLogoUrl } from '@/utils/imageUtils'
import { useToast } from 'vue-toastification'
import { useI18n } from 'vue-i18n'
import api from '@/api'
import type { Site, SiteStatistic, SiteUserData } from '@/api/types'
import type { ApiResponse, Site, SiteStatistic, SiteUserData } from '@/api/types'
import { isNullOrEmptyObject } from '@/@core/utils'
import { formatFileSize } from '@/@core/utils/formatters'
import { useConfirm } from '@/composables/useConfirm'
@@ -79,17 +79,17 @@ async function testSite() {
testButtonText.value = t('site.testing')
testButtonDisable.value = true
const result: { [key: string]: any } = await api.get(`site/test/${cardProps.site?.id}`)
const result = (await api.get(`site/test/${cardProps.site?.id}`)) as ApiResponse<unknown>
if (result.success) $toast.success(t('site.testSuccess', { name: cardProps.site?.name }))
else $toast.error(t('site.testFailed', { name: cardProps.site?.name, message: result.message }))
testButtonText.value = t('site.testConnectivity')
testButtonDisable.value = false
// 测试完成后刷新统计数据
emit('refresh-stats', cardProps.site?.domain)
} catch (error) {
console.error(error)
} finally {
testButtonText.value = t('site.testConnectivity')
testButtonDisable.value = false
}
}
@@ -166,7 +166,7 @@ async function deleteSiteInfo() {
if (!isConfirmed) return
try {
const result: { [key: string]: any } = await api.delete(`site/${cardProps.site?.id}`)
const result = (await api.delete(`site/${cardProps.site?.id}`)) as ApiResponse<unknown>
if (result.success) emit('remove')
else $toast.error(t('site.deleteFailed', { name: cardProps.site?.name, message: result.message }))
} catch (error) {
@@ -260,167 +260,182 @@ onMounted(() => {
:hover="!cardProps.sortable"
@click="handleCardClick"
>
<!-- 装饰性状态指示器 -->
<div v-if="cardProps.site?.is_active" class="site-status-indicator" :class="statColor"></div>
<!-- 装饰性状态指示器 -->
<div v-if="cardProps.site?.is_active" class="site-status-indicator" :class="statColor"></div>
<!-- 主体部分 -->
<div class="relative z-1 flex flex-1 flex-col p-3 pr-12">
<!-- 顶部图标和站点名称 -->
<div class="mb-1 flex min-w-0 items-center gap-2">
<!-- 站点图标 -->
<VAvatar
tile
rounded="lg"
size="32"
class="shrink-0"
:class="{ 'cursor-move': cardProps.sortable && display.mdAndUp.value }"
>
<VImg :src="siteIcon" class="w-full h-full" :alt="cardProps.site?.name" cover>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-square" />
<!-- 主体部分 -->
<div class="relative z-1 flex flex-1 flex-col p-3 pr-12">
<!-- 顶部图标和站点名称 -->
<div class="mb-1 flex min-w-0 items-center gap-2">
<!-- 站点图标 -->
<VAvatar
tile
rounded="lg"
size="32"
class="shrink-0"
:class="{ 'cursor-move': cardProps.sortable && display.mdAndUp.value }"
>
<VImg :src="siteIcon" class="w-full h-full" :alt="cardProps.site?.name" cover>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-square" />
</div>
</template>
</VImg>
</VAvatar>
<!-- 站点名称和特性图标 -->
<div class="flex min-w-0 flex-1 items-center gap-2">
<h3 class="min-w-0 flex-1 truncate text-lg font-semibold leading-tight">{{ cardProps.site?.name }}</h3>
<!-- 站点特性图标 -->
<div class="ml-auto flex shrink-0 items-center gap-2">
<div
v-if="cardProps.site?.limit_interval"
:class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'"
>
<VIcon
icon="mdi-speedometer"
size="16"
color="primary"
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
/>
</div>
<div
v-if="cardProps.site?.proxy"
:class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'"
>
<VIcon
icon="mdi-network-outline"
size="16"
color="primary"
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
/>
</div>
<div
v-if="cardProps.site?.render"
:class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'"
>
<VIcon
icon="mdi-apple-safari"
size="16"
color="primary"
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
/>
</div>
<div
v-if="cardProps.site?.filter"
:class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'"
>
<VIcon
icon="mdi-filter-cog-outline"
size="16"
color="primary"
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
/>
</div>
</template>
</VImg>
</VAvatar>
<!-- 站点名称和特性图标 -->
<div class="flex min-w-0 flex-1 items-center gap-2">
<h3 class="min-w-0 flex-1 truncate text-lg font-semibold leading-tight">{{ cardProps.site?.name }}</h3>
<!-- 站点特性图标 -->
<div class="ml-auto flex shrink-0 items-center gap-2">
<div v-if="cardProps.site?.limit_interval" :class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'">
<VIcon
icon="mdi-speedometer"
size="16"
color="primary"
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
/>
</div>
<div v-if="cardProps.site?.proxy" :class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'">
<VIcon
icon="mdi-network-outline"
size="16"
color="primary"
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
/>
</div>
<div v-if="cardProps.site?.render" :class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'">
<VIcon
icon="mdi-apple-safari"
size="16"
color="primary"
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
/>
</div>
<div v-if="cardProps.site?.filter" :class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'">
<VIcon
icon="mdi-filter-cog-outline"
size="16"
color="primary"
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
/>
</div>
</div>
</div>
</div>
<!-- 中间部分网址 -->
<div class="my-3">
<!-- 中间部分网址 -->
<div class="my-3">
<div class="min-w-0 truncate text-sm text-medium-emphasis" @click.stop="handleSiteUrlClick">
{{ cardProps.site?.url }}
</div>
</div>
<!-- 底部数据统计 -->
<div class="flex-1 flex flex-col justify-end">
<!-- 更直观的上传下载数据条 -->
<div class="border-t mt-1.5 pt-1.5">
<!-- 上传数据 -->
<div class="flex items-center justify-between gap-3 mb-1.5">
<div class="text-sm text-medium-emphasis min-w-[70px]">
<VIcon icon="mdi-arrow-up" size="14" color="info" class="mr-1" />
<span>{{ formatFileSize(cardProps.data?.upload || 0) }}</span>
<!-- 底部数据统计 -->
<div class="flex-1 flex flex-col justify-end">
<!-- 更直观的上传下载数据条 -->
<div class="border-t mt-1.5 pt-1.5">
<!-- 上传数据 -->
<div class="flex items-center justify-between gap-3 mb-1.5">
<div class="text-sm text-medium-emphasis min-w-[70px]">
<VIcon icon="mdi-arrow-up" size="14" color="info" class="mr-1" />
<span>{{ formatFileSize(cardProps.data?.upload || 0) }}</span>
</div>
<div class="flex-grow h-1 rounded bg-on-surface/8 relative overflow-hidden">
<VProgressLinear :model-value="getUploadPercent" color="info" height="4" rounded="lg" />
</div>
</div>
<div class="flex-grow h-1 rounded bg-on-surface/8 relative overflow-hidden">
<VProgressLinear :model-value="getUploadPercent" color="info" height="4" rounded="lg" />
</div>
</div>
<!-- 下载数据 -->
<div class="flex items-center justify-between gap-3">
<div class="flex items-center text-[0.8rem] text-medium-emphasis min-w-[70px]">
<VIcon icon="mdi-arrow-down" size="14" color="success" class="mr-1" />
<span>{{ formatFileSize(cardProps.data?.download || 0) }}</span>
</div>
<div class="flex-grow h-1 rounded bg-on-surface/8 relative overflow-hidden">
<VProgressLinear :model-value="getDownloadPercent" color="warning" height="4" rounded="lg" />
<!-- 下载数据 -->
<div class="flex items-center justify-between gap-3">
<div class="flex items-center text-[0.8rem] text-medium-emphasis min-w-[70px]">
<VIcon icon="mdi-arrow-down" size="14" color="success" class="mr-1" />
<span>{{ formatFileSize(cardProps.data?.download || 0) }}</span>
</div>
<div class="flex-grow h-1 rounded bg-on-surface/8 relative overflow-hidden">
<VProgressLinear :model-value="getDownloadPercent" color="warning" height="4" rounded="lg" />
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 右侧操作按钮区 -->
<VSheet v-if="!cardProps.sortable" class="site-card-actions absolute inset-y-0 right-0 z-20 flex flex-col py-2 px-1">
<!-- 测试按钮 -->
<VBtn
icon
variant="text"
density="comfortable"
class="mb-1 relative flex items-center justify-center rounded-full mx-auto"
:disabled="testButtonDisable"
@click.stop="testSite"
size="36"
<!-- 右侧操作按钮区 -->
<VSheet
v-if="!cardProps.sortable"
class="site-card-actions absolute inset-y-0 right-0 z-20 flex flex-col py-2 px-1"
>
<div class="relative flex items-center justify-center w-full h-full">
<div
class="w-[20px] h-[20px] rounded-full shadow-[inset_0_0_0_2px_rgba(var(--v-theme-on-surface),0.1)] pulse-dot"
:class="statColor"
></div>
</div>
<div
v-if="testButtonDisable"
class="absolute inset-0 flex flex-col items-center justify-center bg-surface/95 rounded-full shadow-md animate-fade-in"
<!-- 测试按钮 -->
<VBtn
icon
variant="text"
density="comfortable"
class="mb-1 relative flex items-center justify-center rounded-full mx-auto"
:disabled="testButtonDisable"
@click.stop="testSite"
size="36"
>
<div class="relative w-6 h-6">
<div class="spinner-circle"></div>
<div class="relative flex items-center justify-center w-full h-full">
<div
class="w-[20px] h-[20px] rounded-full shadow-[inset_0_0_0_2px_rgba(var(--v-theme-on-surface),0.1)] pulse-dot"
:class="statColor"
></div>
</div>
</div>
</VBtn>
<div
v-if="testButtonDisable"
class="absolute inset-0 flex flex-col items-center justify-center bg-surface/95 rounded-full shadow-md animate-fade-in"
>
<div class="relative w-6 h-6">
<div class="spinner-circle"></div>
</div>
</div>
</VBtn>
<!-- 用户数据按钮 -->
<VBtn icon variant="text" @click.stop="handleSiteUserData" size="36">
<VIcon icon="mdi-chart-bell-curve" size="20" />
</VBtn>
<!-- 用户数据按钮 -->
<VBtn icon variant="text" @click.stop="handleSiteUserData" size="36">
<VIcon icon="mdi-chart-bell-curve" size="20" />
</VBtn>
<!-- 更新按钮 -->
<VBtn icon variant="text" @click.stop="handleSiteUpdate" size="36">
<VIcon icon="mdi-refresh" size="20" />
</VBtn>
<!-- 更新按钮 -->
<VBtn icon variant="text" @click.stop="handleSiteUpdate" size="36">
<VIcon icon="mdi-refresh" size="20" />
</VBtn>
<!-- 更多选项按钮 -->
<VBtn icon variant="text" class="mt-auto" size="36" @click.stop>
<VIcon icon="mdi-dots-vertical" size="20" />
<VMenu :activator="'parent'" :close-on-content-click="true" :location="'left'">
<VList>
<VListItem @click="handleSiteEdit" base-color="info">
<template #prepend>
<VIcon icon="mdi-file-edit-outline" size="20" />
</template>
<VListItemTitle>{{ t('site.actions.edit') }}</VListItemTitle>
</VListItem>
<VListItem @click="deleteSiteInfo">
<template #prepend>
<VIcon icon="mdi-delete-outline" size="20" color="error" />
</template>
<VListItemTitle class="text-error">{{ t('site.deleteSite') }}</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</VBtn>
</VSheet>
<!-- 更多选项按钮 -->
<VBtn icon variant="text" class="mt-auto" size="36" @click.stop>
<VIcon icon="mdi-dots-vertical" size="20" />
<VMenu :activator="'parent'" :close-on-content-click="true" :location="'left'">
<VList>
<VListItem @click="handleSiteEdit" base-color="info">
<template #prepend>
<VIcon icon="mdi-file-edit-outline" size="20" />
</template>
<VListItemTitle>{{ t('site.actions.edit') }}</VListItemTitle>
</VListItem>
<VListItem @click="deleteSiteInfo">
<template #prepend>
<VIcon icon="mdi-delete-outline" size="20" color="error" />
</template>
<VListItemTitle class="text-error">{{ t('site.deleteSite') }}</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</VBtn>
</VSheet>
</VCard>
</div>
</div>
@@ -442,7 +457,9 @@ onMounted(() => {
inset-block-start: 0;
inset-inline: 0;
opacity: 0.5;
transition: block-size 0.3s ease, opacity 0.3s ease;
transition:
block-size 0.3s ease,
opacity 0.3s ease;
}
.site-status-indicator.error {

View File

@@ -49,6 +49,7 @@ const posterUrl = computed(() => {
function getMediaId() {
if (props.media?.tmdbid) return `tmdb:${props.media?.tmdbid}`
else if (props.media?.doubanid) return `douban:${props.media?.doubanid}`
else if (props.media?.bangumiid) return `bangumi:${props.media?.bangumiid}`
}
// 查看媒体详情
@@ -152,7 +153,7 @@ function doDelete() {
</div>
</VCardText>
<VCardText class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300">
<VIcon icon="mdi-calcdar" class="me-1" />
<VIcon icon="mdi-calendar" class="me-1" />
{{ dateText }}
</VCardText>
</div>

View File

@@ -0,0 +1,539 @@
import type { MediaInfo } from '@/api/types'
import MediaCard from '@/components/cards/MediaCard.vue'
import { clearCachedMediaSubscribeStatuses } from '@/utils/mediaStatusCache'
import { fireEvent, waitFor } from '@testing-library/vue'
import { createMediaInfo } from '@tests/support/factories/media'
import { mediaExistsHandler } from '@tests/support/msw/handlers/media'
import { querySubscribeByMediaHandler, subscribeListHandler } from '@tests/support/msw/handlers/subscribe'
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { HttpResponse, http } from 'msw'
import { defineComponent, h } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
openSharedDialog: vi.fn(),
routerPush: vi.fn(),
}))
vi.mock('@/composables/useSharedDialog', () => ({
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
}))
vi.mock('@/router', () => ({
default: {
push: (...args: unknown[]) => mocks.routerPush(...args),
},
}))
const API_BASE_URL = 'http://localhost/api/v1/'
const siteListUrl = new URL('site/', API_BASE_URL).href
const selectedSitesUrl = new URL('system/setting/public/IndexerSites', API_BASE_URL).href
let intersectionObservers: IntersectionObserverMock[] = []
class IntersectionObserverMock implements IntersectionObserver {
readonly root: Element | Document | null
readonly rootMargin: string
readonly thresholds: readonly number[]
readonly disconnect = vi.fn()
readonly observe = vi.fn((target: Element) => {
this.target = target
})
readonly unobserve = vi.fn()
private target: Element = document.body
constructor(
private readonly callback: IntersectionObserverCallback,
options: IntersectionObserverInit = {},
) {
this.root = options.root ?? null
this.rootMargin = options.rootMargin ?? '0px'
this.thresholds = Array.isArray(options.threshold) ? options.threshold : [options.threshold ?? 0]
intersectionObservers.push(this)
}
takeRecords(): IntersectionObserverEntry[] {
return []
}
trigger(isIntersecting = true) {
const bounds = this.target.getBoundingClientRect()
this.callback(
[
{
boundingClientRect: bounds,
intersectionRatio: isIntersecting ? 1 : 0,
intersectionRect: bounds,
isIntersecting,
rootBounds: null,
target: this.target,
time: 0,
},
],
this,
)
}
}
interface RenderCardOptions {
permissions?: Record<string, boolean>
superUser?: boolean
}
async function renderCard(media: MediaInfo, options: RenderCardOptions = {}) {
return renderWithProviders(MediaCard, {
props: {
media,
width: '9rem',
},
initialState: {
user: {
permissions: options.permissions ?? {
discovery: true,
manage: false,
search: true,
subscribe: true,
},
superUser: options.superUser ?? true,
},
},
})
}
function getCard(container: Element) {
const card = container.querySelector<HTMLElement>('.media-card')
expect(card).not.toBeNull()
return card as HTMLElement
}
function getHoverArea(container: Element) {
const area = container.querySelector<HTMLElement>('.media-card-hover-area')
expect(area).not.toBeNull()
return area as HTMLElement
}
function getActionButtons(container: Element) {
return [...container.querySelectorAll<HTMLButtonElement>('.media-card .v-card-text button')]
}
function getSearchButton(container: Element) {
const button = getActionButtons(container)[0]
expect(button).toBeDefined()
return button
}
function getStatusObservers() {
return intersectionObservers.filter(observer => observer.thresholds.includes(0.1))
}
function installSearchHandlers(sites: Record<string, unknown>[], selected: number[]) {
server.use(
http.get(siteListUrl, () => HttpResponse.json(sites)),
http.get(selectedSitesUrl, () => HttpResponse.json({ data: { value: selected }, success: true })),
)
}
describe('MediaCard', () => {
beforeEach(() => {
intersectionObservers = []
clearCachedMediaSubscribeStatuses()
vi.stubGlobal('IntersectionObserver', IntersectionObserverMock)
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.spyOn(console, 'log').mockImplementation(() => {})
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
})
it('loads exact subscribe and exists status only after entering the viewport', async () => {
const media = createMediaInfo({ season: 2, title: '视口状态剧集', tmdb_id: 9101, type: '电视剧' })
const subscribeRequest = vi.fn<(url: URL) => void>()
const existsRequest = vi.fn<(url: URL) => void>()
server.use(
querySubscribeByMediaHandler('tmdb:9101', { id: 71, season: 2 }, 200, subscribeRequest),
mediaExistsHandler({ data: { item: { id: 'library-item' } }, success: true }, 200, existsRequest),
)
const { container } = await renderCard(media)
expect(subscribeRequest).not.toHaveBeenCalled()
expect(existsRequest).not.toHaveBeenCalled()
getStatusObservers()[0]?.trigger()
await waitFor(() => {
expect(subscribeRequest).toHaveBeenCalledOnce()
expect(existsRequest).toHaveBeenCalledOnce()
})
expect(subscribeRequest.mock.calls[0][0].searchParams.get('season')).toBe('2')
expect(subscribeRequest.mock.calls[0][0].searchParams.get('title')).toBe('视口状态剧集')
expect(Object.fromEntries(existsRequest.mock.calls[0][0].searchParams)).toEqual({
mtype: '电视剧',
season: '2',
title: '视口状态剧集',
tmdbid: '9101',
year: '2026',
})
await waitFor(() => expect(getActionButtons(container).at(-1)).toHaveClass('text-error'))
expect(getStatusObservers()[0]?.disconnect).toHaveBeenCalledOnce()
})
it('coalesces status requests for duplicate cards while updating both card states', async () => {
const media = createMediaInfo({ title: '重复媒体', tmdb_id: 9102 })
const subscribeRequest = vi.fn<(url: URL) => void>()
const existsRequest = vi.fn<(url: URL) => void>()
server.use(
querySubscribeByMediaHandler('tmdb:9102', { id: 72 }, 200, subscribeRequest),
mediaExistsHandler({ data: { item: {} }, success: false }, 200, existsRequest),
)
const Harness = {
components: { MediaCard },
data: () => ({ media }),
template: '<div><MediaCard :media="media" width="9rem" /><MediaCard :media="media" width="9rem" /></div>',
}
const { container } = await renderWithProviders(Harness, {
initialState: { user: { superUser: true } },
})
expect(getStatusObservers()).toHaveLength(2)
getStatusObservers().forEach(observer => observer.trigger())
await waitFor(() => {
expect(subscribeRequest).toHaveBeenCalledOnce()
expect(existsRequest).toHaveBeenCalledOnce()
expect(container.querySelectorAll('.media-card .v-card-text button.text-error')).toHaveLength(2)
})
})
it.each([
['TMDB', createMediaInfo({ season: 3, tmdb_id: 9201, type: '电视剧' }), 'tmdb:9201', '3'],
[
'Douban',
createMediaInfo({ douban_id: 'db-9202', season: undefined, tmdb_id: undefined }),
'douban:db-9202',
null,
],
[
'Bangumi',
createMediaInfo({ bangumi_id: '9203', season: 1, tmdb_id: undefined, type: '电视剧' }),
'bangumi:9203',
'1',
],
[
'extension',
createMediaInfo({ media_id: 'item-9204', mediaid_prefix: 'custom', tmdb_id: undefined }),
'custom:item-9204',
null,
],
])('queries the current %s media identifier and season', async (_source, media, mediaId, season) => {
const subscribeRequest = vi.fn<(url: URL) => void>()
server.use(
querySubscribeByMediaHandler(mediaId, {}, 200, subscribeRequest),
mediaExistsHandler({ data: { item: {} }, success: false }),
)
await renderCard(media)
getStatusObservers()[0]?.trigger()
await waitFor(() => expect(subscribeRequest).toHaveBeenCalledOnce())
expect(subscribeRequest.mock.calls[0][0].searchParams.get('season')).toBe(season)
})
it('skips status requests for collections and releases observer and touch listeners on unmount', async () => {
vi.spyOn(window, 'matchMedia').mockReturnValue({
...window.matchMedia(''),
matches: true,
})
const subscribeRequest = vi.fn<(url: URL) => void>()
const existsRequest = vi.fn<(url: URL) => void>()
const addListener = vi.spyOn(document, 'addEventListener')
const removeListener = vi.spyOn(document, 'removeEventListener')
server.use(
querySubscribeByMediaHandler('tmdb:9301', {}, 200, subscribeRequest),
mediaExistsHandler({ data: { item: {} }, success: false }, 200, existsRequest),
)
const { unmount } = await renderCard(createMediaInfo({ collection_id: 44, tmdb_id: 9301 }))
getStatusObservers()[0]?.trigger()
await Promise.resolve()
expect(subscribeRequest).not.toHaveBeenCalled()
expect(existsRequest).not.toHaveBeenCalled()
expect(addListener).toHaveBeenCalledWith('pointerdown', expect.any(Function))
unmount()
expect(getStatusObservers()[0]?.disconnect).toHaveBeenCalledOnce()
expect(removeListener).toHaveBeenCalledWith('pointerdown', expect.any(Function))
})
it.each([
[
'media details',
createMediaInfo({ title: '详情电影', tmdb_id: 9401 }),
'/media',
{ mediaid: 'tmdb:9401', title: '详情电影', type: '电影', year: '2026' },
],
[
'collection browse',
createMediaInfo({ collection_id: 88, title: '合集入口', tmdb_id: 9402 }),
'/browse/tmdb/collection/88',
{ title: '合集入口' },
],
])('opens %s from the desktop hover state', async (_case, media, path, query) => {
const { container } = await renderCard(media)
await fireEvent.mouseEnter(getHoverArea(container))
await waitFor(() => expect(getCard(container)).toHaveClass('app-hover-lift-card--hovering'))
await fireEvent.click(getCard(container))
await waitFor(() => expect(mocks.routerPush).toHaveBeenCalledWith({ path, query }))
})
it('routes directly to resource search when no active sites are available', async () => {
installSearchHandlers([], [3, 5])
const media = createMediaInfo({ season: 4, title: '直接搜索剧集', tmdb_id: 9501, type: '电视剧' })
const { container } = await renderCard(media)
await fireEvent.mouseEnter(getHoverArea(container))
await fireEvent.click(getSearchButton(container))
await waitFor(() =>
expect(mocks.routerPush).toHaveBeenCalledWith({
path: '/resource',
query: {
area: 'title',
keyword: 'tmdb:9501',
season: 4,
sites: '3,5',
title: '直接搜索剧集',
type: '电视剧',
year: '2026',
},
}),
)
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
})
it('falls back to global search when site settings cannot provide active selections', async () => {
server.use(
http.get(siteListUrl, () => HttpResponse.json({ message: 'temporary failure' }, { status: 500 })),
http.get(selectedSitesUrl, () => HttpResponse.json({ success: true })),
)
const media = createMediaInfo({ title: '站点失败搜索', tmdb_id: 9503 })
const { container } = await renderCard(media)
await fireEvent.mouseEnter(getHoverArea(container))
await fireEvent.click(getSearchButton(container))
await waitFor(() =>
expect(mocks.routerPush).toHaveBeenCalledWith(
expect.objectContaining({ path: '/resource', query: expect.objectContaining({ sites: '' }) }),
),
)
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
})
it('opens site selection and routes with the selected sites when active sites exist', async () => {
installSearchHandlers(
[
{
domain: 'tracker.example',
downloader: 'default',
id: 7,
is_active: true,
name: '测试站点',
url: 'https://tracker.example',
},
],
[7],
)
const media = createMediaInfo({ title: '多站搜索电影', tmdb_id: 9502 })
const { container } = await renderCard(media)
await fireEvent.mouseEnter(getHoverArea(container))
await fireEvent.click(getSearchButton(container))
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
const [, dialogProps, dialogEvents] = mocks.openSharedDialog.mock.calls[0] as [
unknown,
{ selected: number[]; sites: Array<{ id: number }> },
{ search: (sites: number[]) => void },
]
expect(dialogProps.selected).toEqual([7])
expect(dialogProps.sites.map(site => site.id)).toEqual([7])
dialogEvents.search([7, 9])
await waitFor(() =>
expect(mocks.routerPush).toHaveBeenCalledWith(
expect.objectContaining({
path: '/resource',
query: expect.objectContaining({ sites: '7,9' }),
}),
),
)
})
it('opens active sites with an empty selection when the saved setting fails', async () => {
server.use(
http.get(siteListUrl, () =>
HttpResponse.json([
{
domain: 'fallback.example',
downloader: 'default',
id: 8,
is_active: true,
name: '备用站点',
url: 'https://fallback.example',
},
]),
),
http.get(selectedSitesUrl, () => HttpResponse.json({ message: 'temporary failure' }, { status: 500 })),
)
const { container } = await renderCard(createMediaInfo({ tmdb_id: 9504 }))
await fireEvent.mouseEnter(getHoverArea(container))
await fireEvent.click(getSearchButton(container))
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
const [, dialogProps] = mocks.openSharedDialog.mock.calls[0] as [unknown, { selected: number[] }]
expect(dialogProps.selected).toEqual([])
})
it('loads matching TV seasons before opening the subscription dialog', async () => {
const media = createMediaInfo({ season: 2, title: '多季剧集', tmdb_id: 9551, type: '电视剧' })
server.use(
querySubscribeByMediaHandler('tmdb:9551', { id: 81, season: 2 }),
mediaExistsHandler({ data: { item: {} }, success: false }),
subscribeListHandler([
{ best_version: 0, id: 81, season: 3, tmdbid: 9551, type: '电视剧' },
{ best_version: 1, best_version_full: 1, id: 82, season: 1, tmdbid: 9551, type: '电视剧' },
{ id: 83, season: 4, tmdbid: 9999, type: '电视剧' },
{ id: 84, tmdbid: 9551, type: '电影' },
]),
http.get(new URL('system/setting/public/DefaultTvSubscribeConfig', API_BASE_URL).href, () =>
HttpResponse.json({ data: { value: { best_version: 0 } }, success: true }),
),
)
const { container } = await renderCard(media)
getStatusObservers()[0]?.trigger()
await waitFor(() => expect(getActionButtons(container).at(-1)).toHaveClass('text-error'))
await fireEvent.mouseEnter(getHoverArea(container))
await fireEvent.click(getActionButtons(container).at(-1) as HTMLButtonElement)
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
const [, dialogProps] = mocks.openSharedDialog.mock.calls[0] as [unknown, Record<string, unknown>]
expect(dialogProps).toMatchObject({
selectedSeason: undefined,
subscribedSeasonModes: { 1: 'best_version_full', 3: 'normal' },
subscribedSeasons: [1, 3],
})
})
it('matches custom media IDs when collecting subscribed TV seasons', async () => {
const media = createMediaInfo({
media_id: 'series-9553',
mediaid_prefix: 'custom',
season: 2,
tmdb_id: undefined,
type: '电视剧',
})
server.use(
querySubscribeByMediaHandler('custom:series-9553', { id: 91, season: 2 }),
mediaExistsHandler({ data: { item: {} }, success: false }),
subscribeListHandler([
{ id: 91, mediaid: 'custom:series-9553', season: 2, type: '电视剧' },
{ id: 92, mediaid: 'custom:other', season: 5, type: '电视剧' },
]),
http.get(new URL('system/setting/public/DefaultTvSubscribeConfig', API_BASE_URL).href, () =>
HttpResponse.json({ data: { value: {} }, success: true }),
),
)
const { container } = await renderCard(media)
getStatusObservers()[0]?.trigger()
await waitFor(() => expect(getActionButtons(container).at(-1)).toHaveClass('text-error'))
await fireEvent.mouseEnter(getHoverArea(container))
await fireEvent.click(getActionButtons(container).at(-1) as HTMLButtonElement)
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
const [, dialogProps] = mocks.openSharedDialog.mock.calls[0] as [unknown, Record<string, unknown>]
expect(dialogProps).toMatchObject({ subscribedSeasons: [2] })
})
it('updates image badges on load and falls back after an image error', async () => {
const media = createMediaInfo({
poster_path: '/original/poster.jpg',
source: 'themoviedb',
tmdb_id: 9552,
type: '电视剧',
vote_average: 8.6,
})
const VImgStub = defineComponent({
name: 'VImg',
emits: ['error', 'load'],
props: { src: String },
setup(props, { emit, slots }) {
return () =>
h('div', { 'data-src': props.src }, [
h('button', { 'aria-label': '图片加载成功', onClick: () => emit('load') }),
h('button', { 'aria-label': '图片加载失败', onClick: () => emit('error') }),
slots.default?.(),
])
},
})
const { container } = await renderWithProviders(MediaCard, {
props: { media, width: '9rem' },
initialState: { user: { superUser: true } },
global: { stubs: { VImg: VImgStub } },
})
await fireEvent.click(container.querySelector('[aria-label="图片加载成功"]') as HTMLElement)
await waitFor(() => expect(container.querySelector('.media-card')).toHaveClass('ring-1'))
expect(container).toHaveTextContent('TV')
expect(container).toHaveTextContent('8.6')
await fireEvent.click(container.querySelector('[aria-label="图片加载失败"]') as HTMLElement)
await waitFor(() =>
expect(container.querySelector('.media-card-title')?.parentElement).not.toHaveStyle({ display: 'none' }),
)
})
it('hides search and subscribe actions when the user lacks both permissions', async () => {
const { container } = await renderCard(createMediaInfo({ tmdb_id: 9601 }), {
permissions: {
discovery: true,
manage: false,
search: false,
subscribe: false,
},
superUser: false,
})
await fireEvent.mouseEnter(getHoverArea(container))
expect(getActionButtons(container)).toHaveLength(0)
})
it('uses first tap to reveal details, second tap to route, and outside pointerdown to collapse', async () => {
vi.spyOn(window, 'matchMedia').mockReturnValue({
...window.matchMedia(''),
matches: true,
})
const media = createMediaInfo({ title: '触摸卡片', tmdb_id: 9701 })
const { container } = await renderCard(media)
const detail = container.querySelector<HTMLElement>('.media-card-title')?.parentElement
expect(detail).not.toBeNull()
await fireEvent.click(getCard(container))
expect(detail).not.toHaveStyle({ display: 'none' })
expect(mocks.routerPush).not.toHaveBeenCalled()
await fireEvent.pointerDown(document.body)
expect(detail).toHaveStyle({ display: 'none' })
await fireEvent.click(getCard(container))
await fireEvent.click(getCard(container))
await waitFor(() => expect(mocks.routerPush).toHaveBeenCalledWith(expect.objectContaining({ path: '/media' })))
})
})

View File

@@ -0,0 +1,253 @@
import type { Site, SiteStatistic, SiteUserData } from '@/api/types'
import SiteCard from '@/components/cards/SiteCard.vue'
import { getActiveRequestsCount } from '@/utils/requestOptimizer'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import { createSite, createSiteStatistic, createSiteUserData } from '@tests/support/factories/site'
import { deleteSiteHandler, siteIconHandler, testSiteConnectionHandler } from '@tests/support/msw/handlers/site'
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { defineComponent, h } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
confirm: vi.fn(),
openSharedDialog: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
}))
vi.mock('vue-toastification', () => ({
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
}))
vi.mock('@/composables/useConfirm', () => ({
useConfirm: () => mocks.confirm,
}))
vi.mock('@/composables/useSharedDialog', () => ({
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
}))
const ImageStub = defineComponent({
inheritAttrs: false,
props: {
alt: String,
src: String,
},
setup: props => () => h('img', { alt: props.alt, src: props.src }),
})
const imageStubs = { VImg: ImageStub }
async function renderCard(
siteOverrides: Partial<Site> = {},
props: Partial<{ data: SiteUserData; sortable: boolean; stats: SiteStatistic }> = {},
) {
const site = createSite(siteOverrides)
server.use(siteIconHandler(site.id, `https://images.example.com/site-${site.id}.png`))
const result = await renderWithProviders(SiteCard, {
global: { stubs: imageStubs },
props: { site, ...props },
})
await waitFor(() => {
expect(result.container.querySelector<HTMLImageElement>('img')?.src).toContain(`site-${site.id}.png`)
})
await waitFor(() => expect(getActiveRequestsCount()).toBe(0))
return { ...result, site }
}
function getActionButton(container: Element, index: number) {
const button = container.querySelectorAll<HTMLButtonElement>('.site-card-actions > button')[index]
if (!button) throw new Error(`Missing action button ${index}`)
return button
}
function getTestButton(container: Element) {
const button = container.querySelector('.pulse-dot')?.closest('button')
if (!button) throw new Error('Missing connectivity test button')
return button
}
function getDialogCall(index = 0) {
const [, props, events, options] = mocks.openSharedDialog.mock.calls[index] as [
unknown,
Record<string, unknown>,
Record<string, () => void>,
Record<string, unknown>,
]
return { events, options, props }
}
describe('SiteCard display', () => {
beforeEach(() => {
mocks.confirm.mockResolvedValue(true)
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
})
it('renders active site metadata, transfer values, feature flags, and a healthy border', async () => {
const { container, site } = await renderCard(
{ filter: 'free', limit_interval: 10, proxy: true, render: true },
{
data: createSiteUserData({ download: 1024, upload: 2048 }),
stats: createSiteStatistic({ lst_state: 0, seconds: 2 }),
},
)
expect(screen.getByText(site.name)).toBeInTheDocument()
expect(screen.getByText(site.url)).toBeInTheDocument()
expect(screen.getByText('2.00 KB')).toBeInTheDocument()
expect(screen.getByText('1.00 KB')).toBeInTheDocument()
expect(container.querySelector('.site-card')).toHaveClass('border-success')
expect(container.querySelectorAll('.ml-auto.flex.shrink-0.items-center.gap-2 > div')).toHaveLength(4)
expect(
[...container.querySelectorAll('.border-t .v-progress-linear')].map(progress =>
progress.getAttribute('aria-valuenow'),
),
).toEqual(['100', '50'])
})
it.each([
['failed', createSiteStatistic({ lst_state: 1 }), 'border-error'],
['slow', createSiteStatistic({ lst_state: 0, seconds: 5 }), 'border-warning'],
['unknown without stats', undefined, null],
['unknown without duration', createSiteStatistic({ lst_state: 0, seconds: 0 }), null],
] as const)('projects %s connection state without inventing status', async (_case, stats, borderClass) => {
const { container } = await renderCard({}, stats ? { stats } : {})
const card = container.querySelector('.site-card')
if (borderClass) expect(card).toHaveClass(borderClass)
else expect(card).not.toHaveClass('border-error', 'border-warning', 'border-success')
})
it('keeps zero transfer data visible with stable minimum progress', async () => {
await renderCard({ is_active: false }, { data: createSiteUserData({ download: 0, upload: 0 }) })
expect(screen.getAllByText('0.00 B')).toHaveLength(2)
expect(
[...document.querySelectorAll('.border-t .v-progress-linear')].map(progress =>
progress.getAttribute('aria-valuenow'),
),
).toEqual(['3', '3'])
})
})
describe('SiteCard interactions', () => {
beforeEach(() => {
mocks.confirm.mockResolvedValue(true)
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
vi.spyOn(console, 'error').mockImplementation(() => {})
})
it.each([
['success', 200, { success: true }, 'success'],
['business failure', 200, { message: '认证失败', success: false }, 'error'],
] as const)(
'reports connectivity %s and refreshes the current domain',
async (_case, status, response, toastType) => {
const requested = vi.fn()
const { container, emitted, site } = await renderCard()
server.use(testSiteConnectionHandler(site.id, response, status, requested))
await fireEvent.click(getTestButton(container))
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
const toast = toastType === 'success' ? mocks.toastSuccess : mocks.toastError
await waitFor(() => expect(toast).toHaveBeenCalledOnce())
expect(emitted('refresh-stats')).toEqual([[site.domain]])
expect(getTestButton(container)).not.toBeDisabled()
},
)
it('restores connectivity controls after an HTTP failure', async () => {
const requested = vi.fn()
const { container, emitted, site } = await renderCard()
server.use(testSiteConnectionHandler(site.id, { message: 'server down', success: false }, 500, requested))
await fireEvent.click(getTestButton(container))
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
await waitFor(() => expect(getTestButton(container)).not.toBeDisabled())
expect(emitted('refresh-stats') ?? []).toHaveLength(0)
})
it.each([
['cancelled', false, 200, { success: true }, false, null],
['success', true, 200, { success: true }, true, null],
['business failure', true, 200, { message: '仍在使用', success: false }, false, '仍在使用'],
['HTTP failure', true, 500, { message: 'server down', success: false }, false, null],
] as const)('handles deletion when %s', async (_case, confirmed, status, response, removed, expectedMessage) => {
const requested = vi.fn()
mocks.confirm.mockResolvedValue(confirmed)
const { container, emitted, site } = await renderCard()
server.use(deleteSiteHandler(site.id, response, status, requested))
await fireEvent.click(getActionButton(container, 3))
await fireEvent.click(await screen.findByText('删除站点'))
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledOnce())
if (confirmed) await waitFor(() => expect(requested).toHaveBeenCalledOnce())
else expect(requested).not.toHaveBeenCalled()
expect(emitted('remove') ?? []).toHaveLength(removed ? 1 : 0)
if (expectedMessage) expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining(expectedMessage))
if (_case === 'HTTP failure') expect(mocks.toastError).toHaveBeenCalledOnce()
})
it('opens each shared dialog with exact props, close events, and refresh ownership', async () => {
const { container, emitted, site } = await renderCard()
await fireEvent.click(container.querySelector('.site-card') as Element)
expect(getDialogCall().props).toEqual({ site })
expect(getDialogCall().options).toEqual({ closeOn: ['close'] })
getDialogCall().events.close()
expect(emitted('refresh-stats')).toEqual([[site.domain]])
await fireEvent.click(getActionButton(container, 1))
expect(getDialogCall(1).props).toEqual({ site })
expect(getDialogCall(1).options).toEqual({ closeOn: ['close'] })
await fireEvent.click(getActionButton(container, 2))
expect(getDialogCall(2).props).toEqual({ site })
expect(getDialogCall(2).options).toEqual({ closeOn: ['close', 'done'] })
getDialogCall(2).events.done()
expect(emitted('refresh-stats')).toEqual([[site.domain], [site.domain]])
await fireEvent.click(getActionButton(container, 3))
await fireEvent.click(await screen.findByText('编辑站点'))
expect(getDialogCall(3).props).toEqual({ siteid: site.id })
expect(getDialogCall(3).options).toEqual({ closeOn: ['close', 'save', 'remove'] })
getDialogCall(3).events.save()
getDialogCall(3).events.remove()
expect(emitted('update')).toHaveLength(1)
expect(emitted('remove')).toHaveLength(1)
})
it('opens the site URL normally and isolates every card action in sortable mode', async () => {
const open = vi.spyOn(window, 'open').mockImplementation(() => null)
const { container, rerender, site } = await renderCard()
await fireEvent.click(screen.getByText(site.url))
expect(open).toHaveBeenCalledWith(site.url, '_blank')
await rerender({ site, sortable: true })
expect(container.querySelector('.site-card-actions')).not.toBeInTheDocument()
await fireEvent.click(screen.getByText(site.url))
await fireEvent.click(container.querySelector('.site-card') as Element)
expect(open).toHaveBeenCalledOnce()
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
})
it('falls back to the default icon when the icon request fails', async () => {
const site = createSite()
const requested = vi.fn()
server.use(siteIconHandler(site.id, null, 500, requested))
const { container } = await renderWithProviders(SiteCard, {
global: { stubs: imageStubs },
props: { site },
})
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
await waitFor(() => expect(getActiveRequestsCount()).toBe(0))
await waitFor(() => expect(container.querySelector<HTMLImageElement>('img')?.src).toContain('/site.webp'))
})
})

View File

@@ -0,0 +1,180 @@
import { formatDateDifference } from '@/@core/utils/formatters'
import type { SubscribeShare } from '@/api/types'
import SubscribeShareCard from '@/components/cards/SubscribeShareCard.vue'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import { createSubscribeShare } from '@tests/support/factories/subscribe'
import { renderWithProviders } from '@tests/support/render'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
openSharedDialog: vi.fn(),
routerPush: vi.fn(),
}))
vi.mock('@/composables/useSharedDialog', () => ({
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
}))
vi.mock('@/router', () => ({
default: { push: (...args: unknown[]) => mocks.routerPush(...args) },
}))
vi.mock('@iconify/vue', () => ({
Icon: {
props: ['icon'],
template: '<span :data-icon="icon" />',
},
}))
type ShareOverrides = Partial<SubscribeShare>
function observeElementsImmediately() {
class ImmediateIntersectionObserver {
readonly root = null
readonly rootMargin = '0px'
readonly thresholds = [0]
constructor(private readonly callback: IntersectionObserverCallback) {}
disconnect() {}
observe(target: Element) {
this.callback([{ intersectionRatio: 1, isIntersecting: true, target } as IntersectionObserverEntry], this)
}
takeRecords(): IntersectionObserverEntry[] {
return []
}
unobserve() {}
}
vi.stubGlobal('IntersectionObserver', ImmediateIntersectionObserver)
}
async function renderCard(overrides: ShareOverrides = {}, globalImageCache = false) {
const media = createSubscribeShare(overrides)
const result = await renderWithProviders(SubscribeShareCard, {
initialState: {
globalSettings: {
data: { GLOBAL_IMAGE_CACHE: globalImageCache },
initialized: true,
loading: false,
},
},
props: { media },
})
return { ...result, media }
}
async function loadPoster(container: Element) {
const backdrop = container.querySelector<HTMLImageElement>('img')
expect(backdrop).not.toBeNull()
await fireEvent.load(backdrop as HTMLImageElement)
await waitFor(() => expect(container.querySelectorAll('img')).toHaveLength(2))
return container.querySelectorAll<HTMLImageElement>('img')[1]
}
function getDialogCall(index = 0) {
const [, props, events, options] = mocks.openSharedDialog.mock.calls[index] as [
unknown,
Record<string, unknown>,
Record<string, (...args: unknown[]) => void>,
Record<string, unknown>,
]
return { events, options, props }
}
describe('SubscribeShareCard', () => {
beforeEach(() => {
observeElementsImmediately()
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
})
it('renders sharing metadata and reveals the cached poster after the backdrop loads', async () => {
const { container, media } = await renderCard({}, true)
expect(screen.getByText(media.share_title!)).toBeInTheDocument()
expect(screen.getByText(media.share_comment!)).toBeInTheDocument()
expect(screen.getByText(media.share_user!)).toBeInTheDocument()
expect(screen.getByText(media.count!.toLocaleString())).toBeInTheDocument()
expect(screen.getByText(formatDateDifference(media.date!))).toBeInTheDocument()
const backdrop = container.querySelector<HTMLImageElement>('img')
expect(backdrop).not.toBeNull()
expect((backdrop as HTMLImageElement).src).toContain('system/cache/image?url=')
expect((backdrop as HTMLImageElement).src).toContain(encodeURIComponent(media.backdrop!))
const poster = await loadPoster(container)
expect(poster.src).toContain('system/cache/image?url=')
expect(poster.src).toContain(encodeURIComponent(media.poster!))
const dateMetadata = screen.getByText(formatDateDifference(media.date!)).closest('.v-card-text')
expect(dateMetadata?.querySelector('[data-icon="mdi-calendar"], [data-icon="mdi:calendar"]')).not.toBeNull()
})
it('falls back to the poster when the backdrop is missing and hides a zero reuse count', async () => {
const { container, media } = await renderCard({ backdrop: undefined, count: 0 })
const backdrop = container.querySelector<HTMLImageElement>('img')
expect(backdrop).not.toBeNull()
expect((backdrop as HTMLImageElement).src).toContain(media.poster!)
expect(screen.queryByText('0')).not.toBeInTheDocument()
})
it.each([
['TMDB before Douban', { doubanid: '2202', tmdbid: 1101 }, 'tmdb:1101'],
['Douban without TMDB', { doubanid: '2202', tmdbid: undefined }, 'douban:2202'],
['Bangumi without TMDB or Douban', { bangumiid: 3303, doubanid: undefined, tmdbid: undefined }, 'bangumi:3303'],
] as const)('routes media details with %s while keeping the fork dialog closed', async (_case, ids, mediaid) => {
const { container, media } = await renderCard(ids)
const poster = await loadPoster(container)
await fireEvent.click(poster)
expect(mocks.routerPush).toHaveBeenCalledWith({
path: '/media',
query: {
mediaid,
title: media.name,
type: media.type,
year: media.year,
},
})
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
})
it('opens the fork dialog with the exact media and replaces it with editing after fork success', async () => {
const { container, media } = await renderCard()
const card = container.querySelector<HTMLElement>('.v-card')
expect(card).not.toBeNull()
await fireEvent.click(card as HTMLElement)
expect(mocks.openSharedDialog).toHaveBeenCalledOnce()
const forkDialog = getDialogCall()
expect(forkDialog.props).toEqual({ media })
expect(forkDialog.options).toEqual({ closeOn: ['close', 'fork', 'delete'] })
forkDialog.events.fork(4701)
expect(mocks.openSharedDialog).toHaveBeenCalledTimes(2)
const editDialog = getDialogCall(1)
expect(editDialog.props).toEqual({ subid: 4701 })
expect(editDialog.events).toEqual({})
expect(editDialog.options).toEqual({ closeOn: ['close', 'save', 'remove'] })
})
it('forwards deletion from the fork dialog without depending on its response payload', async () => {
const { container, emitted } = await renderCard()
const card = container.querySelector<HTMLElement>('.v-card')
expect(card).not.toBeNull()
await fireEvent.click(card as HTMLElement)
getDialogCall().events.delete({ id: 9999 })
expect(emitted('delete')).toHaveLength(1)
expect(emitted('delete')?.[0]).toEqual([])
})
})

View File

@@ -54,7 +54,8 @@ async function queryFollowUsers() {
const result: { [key: string]: any } = await api.get('system/setting/public/FollowSubscribers')
followUsers.value = result.data?.value ?? []
} catch (error) {
console.log(error)
console.error(error)
$toast.error(t('subscribe.requestFailed'))
}
}
@@ -66,7 +67,8 @@ async function followUser() {
queryFollowUsers()
}
} catch (error) {
console.log(error)
console.error(error)
$toast.error(t('subscribe.requestFailed'))
}
}
@@ -82,7 +84,8 @@ async function unfollowUser() {
queryFollowUsers()
}
} catch (error) {
console.log(error)
console.error(error)
$toast.error(t('subscribe.requestFailed'))
}
}
@@ -96,6 +99,7 @@ const posterUrl = computed(() => {
function getMediaId() {
if (props.media?.tmdbid) return `tmdb:${props.media?.tmdbid}`
else if (props.media?.doubanid) return `douban:${props.media?.doubanid}`
else if (props.media?.bangumiid) return `bangumi:${props.media?.bangumiid}`
}
// 查看媒体详情
@@ -129,6 +133,12 @@ async function doFork() {
}
} catch (error) {
console.error(error)
$toast.error(
t('subscribe.addFailed', {
name: props.media?.share_title,
message: t('subscribe.requestFailed'),
}),
)
} finally {
processing.value = false
doneNProgress()
@@ -151,12 +161,13 @@ async function doDelete() {
if (result.success) {
$toast.success(t('subscribe.cancelSuccess'))
// 完成
emit('delete', result.data.id)
emit('delete')
} else {
$toast.error(t('subscribe.cancelFailed', { message: result.message }))
}
} catch (error) {
console.error(error)
$toast.error(t('subscribe.cancelFailed', { message: t('subscribe.requestFailed') }))
} finally {
deleting.value = false
doneNProgress()

View File

@@ -40,6 +40,9 @@ const progressText = ref('')
// 提示框
const $toast = useToast()
// 向联邦插件提供主应用 Toast避免远程组件自行创建通知容器。
provide('moviepilot:toast', $toast)
// 是否刷新
const isRefreshed = ref(false)

View File

@@ -5,6 +5,7 @@ import PageRender from '@/components/render/PageRender.vue'
import api from '@/api'
import { loadRemoteComponent } from '@/utils/federationLoader'
import { usePWA } from '@/composables/usePWA'
import { useToast } from 'vue-toastification'
// 输入参数
const props = defineProps({
@@ -26,6 +27,10 @@ const display = useDisplay()
// PWA模式检测
const { appMode } = usePWA()
// 向联邦插件提供主应用 Toast确保通知沿用统一主题和路由逻辑。
const $toast = useToast()
provide('moviepilot:toast', $toast)
// 是否刷新
const isRefreshed = ref(false)
// 组件是否已加载成功

View File

@@ -45,6 +45,11 @@ const resourcePage = ref(1)
// 加载状态
const resourceLoading = ref(false)
const resourceError = ref(false)
// 只有最后发起的搜索可以更新资源数据、请求状态和移动端搜索面板。
let resourceRequestId = 0
// 移动端搜索栏是否展开
const mobileSearchExpanded = ref(false)
@@ -134,35 +139,46 @@ async function addDownload(_torrent: TorrentInfo) {
}
// 添加下载成功
function addDownloadSuccess(_url: string) {
function addDownloadSuccess() {
addDownloadDialog.value = false
}
// 添加下载失败
function addDownloadError(_error: string) {
function addDownloadError() {
addDownloadDialog.value = false
}
// 调用API查询站点资源
async function getResourceList() {
const requestId = ++resourceRequestId
resourceLoading.value = true
resourceError.value = false
resourcePage.value = 1
try {
resourceDataList.value = await api.get(`site/resource/${props.site?.id}`, {
const resources: TorrentInfo[] = await api.get(`site/resource/${props.site?.id}`, {
params: {
keyword: keyword.value,
cat: selectCategory.value?.join(','),
},
})
if (requestId === resourceRequestId) {
resourceDataList.value = resources
}
} catch (error) {
console.error(error)
}
if (requestId === resourceRequestId) {
console.error(error)
resourceError.value = true
}
} finally {
if (requestId === resourceRequestId) {
resourceLoading.value = false
resourceLoading.value = false
if (isMobileLayout.value) {
mobileSearchExpanded.value = false
if (isMobileLayout.value) {
mobileSearchExpanded.value = false
}
}
}
}
@@ -280,10 +296,7 @@ onMounted(() => {
</VCol>
</VRow>
<div
v-if="resourceTotalItems > 0"
class="d-flex justify-space-between align-center flex-wrap gap-2 mt-3"
>
<div v-if="resourceTotalItems > 0" class="d-flex justify-space-between align-center flex-wrap gap-2 mt-3">
<div class="text-body-2 text-medium-emphasis">
{{ resultSummaryText }}
</div>
@@ -350,7 +363,14 @@ onMounted(() => {
/>
</VCol>
<VCol cols="12" class="d-flex gap-2">
<VBtn color="primary" variant="flat" block rounded="lg" class="site-resource-search-btn" @click="getResourceList">
<VBtn
color="primary"
variant="flat"
block
rounded="lg"
class="site-resource-search-btn"
@click="getResourceList"
>
{{ t('dialog.siteResource.search') }}
</VBtn>
<VBtn variant="text" rounded="lg" @click="closeMobileSearch">
@@ -366,8 +386,22 @@ onMounted(() => {
</div>
<VCardText class="site-resource-content px-0 py-0 my-0">
<VAlert
v-if="resourceError && !resourceLoading"
type="error"
variant="tonal"
class="mx-3 mb-3"
:text="t('dialog.siteResource.loadFailed')"
>
<template #append>
<VBtn variant="text" color="error" @click="getResourceList">
{{ t('common.retry') }}
</VBtn>
</template>
</VAlert>
<VDataTable
v-if="display.mdAndUp.value"
v-if="display.mdAndUp.value && (!resourceError || resourceDataList.length > 0)"
v-model:page="resourcePage"
v-model:items-per-page="resourceItemsPerPage"
:headers="resourceHeaders"
@@ -629,7 +663,7 @@ onMounted(() => {
</ProgressiveCardGrid>
</div>
<div v-else class="px-4 py-10 text-center text-medium-emphasis">
<div v-else-if="!resourceError" class="px-4 py-10 text-center text-medium-emphasis">
{{ t('dialog.siteResource.noData') }}
</div>
</div>

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import type { Site, SiteUserData } from '@/api/types'
import type { ApiResponse, Site, SiteUserData } from '@/api/types'
import api from '@/api'
import { useDisplay, useTheme } from 'vuetify'
import { formatFileSize } from '@/@core/utils/formatters'
@@ -23,6 +23,9 @@ const emit = defineEmits(['close'])
// 进度框
const progressDialog = ref(false)
// 失败状态保留对应操作,重试时不改变已展示的站点数据。
const failedOperation = ref<'load' | 'refresh'>()
const vuetifyTheme = useTheme()
const currentTheme = controlledComputed(
@@ -33,6 +36,9 @@ const currentTheme = controlledComputed(
// 站点数据列表
const siteDatas = ref<SiteUserData[]>([])
// 只有最近一次加载或刷新操作可以更新弹窗状态,避免异步响应覆盖较新的操作结果。
let operationGeneration = 0
// 最新一天的数据
const siteData = computed(() => siteDatas.value[siteDatas.value.length - 1])
@@ -58,8 +64,8 @@ const historyChartOptions = computed(() => {
parentHeightOffset: 0,
toolbar: { show: false },
animations: { enabled: true },
background: currentTheme.value.surface, // 新增背景色同步
foreColor: currentTheme.value.onSurface, // 新增文字颜色同步
background: currentTheme.value.surface, // 图表背景随应用主题切换
foreColor: currentTheme.value.onSurface, // 图表文字随应用主题切换
dataLabels: {
enabled: true,
},
@@ -139,10 +145,12 @@ const historyChartOptions = computed(() => {
// 做种分布列seeding_info的格式为[[x, y], [x, y], ...]x为做种数y为做种体积做种体积需要转换为GB
const seedingSeries = computed(() => {
const seedingInfo = siteData.value?.seeding_info as [number?, number?][] | undefined
return [
{
name: t('dialog.siteUserData.volumeTitle'),
data: siteData.value?.seeding_info?.map(item => [item[0] ?? 0, Math.round((item[1] ?? 0) / 1024 / 1024 / 1024)]),
data: seedingInfo?.map(item => [item[0] ?? 0, Math.round((item[1] ?? 0) / 1024 / 1024 / 1024)]) ?? [],
},
]
})
@@ -155,8 +163,8 @@ const seedingChartOptions = computed(() => {
parentHeightOffset: 0,
toolbar: { show: false },
animations: { enabled: true },
background: currentTheme.value.surface, // 新增背景色同步
foreColor: currentTheme.value.onSurface, // 新增文字颜色同步
background: currentTheme.value.surface, // 图表背景随应用主题切换
foreColor: currentTheme.value.onSurface, // 图表文字随应用主题切换
zoom: {
enabled: false,
allowMouseWheelZoom: false,
@@ -214,7 +222,7 @@ const seedingChartOptions = computed(() => {
})
// 根据传入属性,计算列表数据中第一条与第二条的差值,如果没有第二条则差值为全部
const diffData: { [key: string]: any } = computed(() => {
const diffData = computed(() => {
if (siteDatas.value.length < 2) {
return siteData.value
}
@@ -253,33 +261,67 @@ function getDiffClass(diff: number | undefined) {
}
// 查询站点用户数据
async function fetchSiteUserData() {
async function fetchSiteUserData(failureOperation: 'load' | 'refresh' = 'load', generation?: number) {
const activeGeneration = generation ?? ++operationGeneration
try {
const result: { [key: string]: any } = await api.get(`site/userdata/${props.site?.id}`)
const result = await api.get<ApiResponse<SiteUserData[]>, ApiResponse<SiteUserData[]>>(
`site/userdata/${props.site?.id}`,
)
if (activeGeneration !== operationGeneration) return false
if (result.success) {
// 使用nextTick确保DOM更新完成后再更新图表数据
await nextTick()
siteDatas.value = result.data.sort((a: { updated_day: any }, b: { updated_day: any }) =>
(a.updated_day || '').localeCompare(b.updated_day || ''),
)
if (activeGeneration !== operationGeneration) return false
siteDatas.value = result.data.sort((a, b) => (a.updated_day || '').localeCompare(b.updated_day || ''))
}
failedOperation.value = undefined
return true
} catch (error) {
if (activeGeneration !== operationGeneration) return false
console.error(error)
failedOperation.value = failureOperation
}
return false
}
// 刷新站点数据
async function refreshSiteData() {
const generation = ++operationGeneration
progressDialog.value = true
try {
const result: { [key: string]: any } = await api.post(`site/userdata/${props.site?.id}`)
const result = await api.post<ApiResponse<unknown>, ApiResponse<unknown>>(`site/userdata/${props.site?.id}`)
if (generation !== operationGeneration) return
if (result.success) {
await fetchSiteUserData()
await fetchSiteUserData('refresh', generation)
} else {
failedOperation.value = 'refresh'
}
} catch (error) {
console.log(error)
if (generation !== operationGeneration) return
console.error(error)
failedOperation.value = 'refresh'
} finally {
if (generation === operationGeneration) {
progressDialog.value = false
}
}
}
// 重试最近失败的请求,刷新失败时继续保留当前数据。
function retryFailedOperation() {
if (failedOperation.value === 'refresh') {
refreshSiteData()
} else {
fetchSiteUserData()
}
progressDialog.value = false
}
onBeforeMount(() => {
@@ -302,6 +344,20 @@ onBeforeMount(() => {
</VCardItem>
<VDivider />
<VCardText class="pt-5">
<VAlert v-if="failedOperation" type="error" variant="tonal" class="mb-5">
<div class="d-flex flex-wrap align-center justify-space-between gap-3">
<span>
{{
failedOperation === 'refresh'
? t('dialog.siteUserData.refreshFailed')
: t('dialog.siteUserData.loadFailed')
}}
</span>
<VBtn size="small" variant="tonal" prepend-icon="mdi-refresh" @click="retryFailedOperation">
{{ t('common.retry') }}
</VBtn>
</div>
</VAlert>
<VRow class="match-height">
<!-- 用户信息 -->
<VCol cols="12" md="3">

View File

@@ -64,15 +64,19 @@ const subScribeInfo = ref<SubscrbieInfo>()
// 是否加载中
const loading = ref(false)
const loadError = ref(false)
/**
* 调用 API 查询订阅文件信息。
*/
async function loadSubscribeFilesInfo() {
try {
loading.value = true
loadError.value = false
subScribeInfo.value = await api.get(`subscribe/files/${props.subid}`)
} catch (e) {
console.log(e)
loadError.value = true
} finally {
loading.value = false
}
@@ -294,7 +298,11 @@ const episodeGroups = computed<SubscribeEpisodeGroup[]>(() => {
const totalCount = computed(() => {
const subscribeTotal = subscribe.value?.total_episode ?? 0
if (subscribe.value?.type === '电影') return Math.max(episodeGroups.value.length, 1)
return Math.max(subscribeTotal, episodeGroups.value.length)
// 电视剧以起始集到总集数作为目标范围,自定义起始集需要换算为实际集数。
const startEpisode = subscribe.value?.start_episode || 1
const targetCount = subscribeTotal >= startEpisode ? subscribeTotal - startEpisode + 1 : 0
return Math.max(targetCount, episodeGroups.value.length)
})
// 已下载集数
@@ -402,7 +410,15 @@ onBeforeMount(() => {
<LoadingBanner v-if="loading" />
<VCardText v-else class="subscribe-files-dialog__body">
<div v-if="subScribeInfo?.subscribe" class="subscribe-files-shell">
<div v-if="loadError" class="subscribe-files-empty subscribe-files-empty--standalone">
<VIcon icon="mdi-folder-alert-outline" size="40" />
<div>{{ t('error.serverError') }}</div>
<VBtn color="primary" prepend-icon="mdi-refresh" @click="loadSubscribeFilesInfo">
{{ t('common.retry') }}
</VBtn>
</div>
<div v-else-if="subScribeInfo?.subscribe" class="subscribe-files-shell">
<section class="subscribe-files-hero" :style="heroStyle">
<div class="subscribe-files-hero__shade" />
<div class="subscribe-files-hero__content">
@@ -424,7 +440,7 @@ onBeforeMount(() => {
</div>
<h2 class="subscribe-files-hero__title">{{ subscribe?.name }}</h2>
<div class="subscribe-files-hero__chips">
<VChip v-if="subscribe?.season" color="primary" variant="flat" size="small">
<VChip v-if="subscribe?.season != null" color="primary" variant="flat" size="small">
{{ t('dialog.subscribeFiles.season', { number: subscribe.season }) }}
</VChip>
<VChip v-if="subscribe?.year" variant="tonal" size="small">{{ subscribe.year }}</VChip>

View File

@@ -6,10 +6,13 @@ import { useDisplay } from 'vuetify'
import ProgressDialog from './ProgressDialog.vue'
import { useI18n } from 'vue-i18n'
import { mediaTypeDict } from '@/api/constants'
import { useToast } from 'vue-toastification'
// 国际化
const { t } = useI18n()
const $toast = useToast()
// 显示器宽度
const display = useDisplay()
@@ -97,9 +100,12 @@ async function reSubscribe(item: Subscribe) {
const result: { [key: string]: any } = await api.post('subscribe/', item)
if (result.success) {
emit('save')
} else {
$toast.error(t('subscribe.requestFailed'))
}
} catch (e) {
console.error(e)
$toast.error(t('subscribe.requestFailed'))
}
progressDialog.value = false
}
@@ -113,6 +119,7 @@ async function deleteHistory(item: Subscribe) {
}
} catch (e) {
console.error(e)
$toast.error(t('subscribe.requestFailed'))
}
}
@@ -158,6 +165,14 @@ function getMediaTypeText(type: string | undefined) {
<template #loading>
<LoadingBanner />
</template>
<template #error="{ props: retryProps }">
<div class="d-flex flex-column align-center ga-2 py-4" role="alert">
<span class="text-medium-emphasis">{{ t('subscribe.requestFailed') }}</span>
<VBtn v-bind="retryProps" prepend-icon="mdi-refresh" size="small" variant="tonal">
{{ t('common.retry') }}
</VBtn>
</div>
</template>
<template #empty />
<VVirtualScroll v-if="historyList.length > 0" renderless :items="historyList" :item-height="104">
<template #default="{ item, itemRef }">

View File

@@ -185,8 +185,9 @@ async function getEpisodeGroups() {
}
}
// 查询TMDB的所有季信息
// 查询媒体的季信息
async function getMediaSeasons() {
isRefreshed.value = false
try {
seasonInfos.value = await api.get('media/seasons', {
params: {
@@ -196,9 +197,10 @@ async function getMediaSeasons() {
season: props.media?.season,
},
})
isRefreshed.value = true
} catch (error) {
console.error(error)
} finally {
isRefreshed.value = true
}
}
@@ -414,6 +416,7 @@ function syncSelectedSeason() {
})
}
// 季信息与缺失状态共享剧集组上下文,剧集组变化时统一刷新两者。
watchEffect(() => {
if (episodeGroup.value) getGroupSeasons()
else getMediaSeasons()
@@ -434,10 +437,7 @@ watch(episodeGroupOptions, () => nextTick(updateEpisodeGroupScrollState), { flus
onMounted(async () => {
window.addEventListener('resize', updateEpisodeGroupScrollState)
// 自定义剧集组由 watchEffect 首次加载,避免默认季数据异步覆盖它。
if (!episodeGroup.value) getMediaSeasons()
getEpisodeGroups()
checkSeasonsNotExists()
})
onBeforeUnmount(() => {

View File

@@ -27,7 +27,9 @@ const shareDoing = ref(false)
// 订阅编辑表单
const shareForm = ref<SubscribeShare>({
subscribe_id: props.sub?.id ?? 0,
share_title: `${props.sub?.name} ${formatSeason(props.sub?.season ? props.sub?.season.toString() : '')}`,
share_title: `${props.sub?.name} ${formatSeason(
props.sub?.season === null || props.sub?.season === undefined ? '' : props.sub.season.toString(),
)}`,
})
// 分享订阅
@@ -36,7 +38,6 @@ async function doShare() {
try {
shareDoing.value = true
const result: { [key: string]: any } = await api.post('subscribe/share', shareForm.value)
shareDoing.value = false
// 提示
if (result.success) {
$toast.success(t('dialog.subscribeShare.shareSuccess', { name: props.sub?.name }))
@@ -46,7 +47,15 @@ async function doShare() {
$toast.error(t('dialog.subscribeShare.shareFailed', { name: props.sub?.name, message: result.message }))
}
} catch (e) {
console.log(e)
console.error(e)
$toast.error(
t('dialog.subscribeShare.shareFailed', {
name: props.sub?.name,
message: t('subscribe.requestFailed'),
}),
)
} finally {
shareDoing.value = false
}
}
@@ -64,7 +73,11 @@ const $toast = useToast()
<VCardTitle>{{ t('dialog.subscribeShare.shareSubscription') }}</VCardTitle>
<VCardSubtitle>
{{ props.sub?.name }}
{{ props.sub?.season ? t('dialog.subscribeShare.season', { number: props.sub?.season }) : '' }}
{{
props.sub?.season === null || props.sub?.season === undefined
? ''
: t('dialog.subscribeShare.season', { number: props.sub.season })
}}
</VCardSubtitle>
</VCardItem>
<VDivider />

View File

@@ -23,14 +23,19 @@ const statistics = ref<SubscribeShareStatistics[]>([])
// 是否加载中
const loading = ref(false)
// 本地统计接口是否加载失败;合法空数组仍使用空数据状态。
const loadError = ref(false)
// 获取统计数据
async function fetchStatistics() {
try {
loading.value = true
loadError.value = false
const data: SubscribeShareStatistics[] = await api.get('subscribe/share/statistics')
statistics.value = data
} catch (error) {
console.error('获取分享统计数据失败:', error)
loadError.value = true
} finally {
loading.value = false
}
@@ -130,6 +135,13 @@ onMounted(() => {
<VDivider />
<VCardText class="pa-0">
<LoadingBanner v-if="loading" class="mt-4" />
<div v-else-if="loadError" class="text-center py-8">
<VIcon icon="mdi-alert-circle-outline" size="64" color="error" class="mb-4" />
<div class="text-h6 text-error mb-4">{{ t('subscribe.requestFailed') }}</div>
<VBtn color="primary" variant="tonal" prepend-icon="mdi-refresh" @click="fetchStatistics">
{{ t('common.retry') }}
</VBtn>
</div>
<div v-else-if="rankedStatistics.length === 0" class="text-center py-8">
<VIcon icon="mdi-chart-line" size="64" color="grey" class="mb-4" />
<div class="text-h6 text-grey">{{ t('subscribe.noStatisticsData') }}</div>

View File

@@ -0,0 +1,137 @@
import DiscoverTabOrderDialog from '@/components/dialog/DiscoverTabOrderDialog.vue'
import type { DiscoverSource } from '@/api/types'
import { screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { renderWithProviders } from '@tests/support/render'
import { defineComponent, h, type PropType } from 'vue'
import { describe, expect, it, vi } from 'vitest'
vi.mock('vuedraggable', async () => {
const { defineComponent, h } = await import('vue')
return {
default: defineComponent({
name: 'DraggableStub',
props: {
modelValue: { type: Array as PropType<DiscoverSource[]>, required: true },
},
emits: ['update:modelValue'],
setup(props, { emit, slots }) {
return () =>
h('div', [
h(
'button',
{
onClick: () => emit('update:modelValue', [...props.modelValue].reverse()),
type: 'button',
},
'反转顺序',
),
...props.modelValue.map(element => slots.item?.({ element })),
])
},
}),
}
})
const DialogCloseButtonStub = defineComponent({
name: 'VDialogCloseBtn',
props: {
modelValue: { type: Boolean, default: true },
},
emits: ['update:modelValue'],
setup(_props, { emit }) {
return () => h('button', { onClick: () => emit('update:modelValue', false), type: 'button' }, '关闭')
},
})
function createSource(name: string, prefix: string): DiscoverSource {
return {
api_path: `discover/${prefix}`,
filter_params: { type: prefix },
filter_ui: [],
mediaid_prefix: prefix,
name,
}
}
async function renderDialog(tabs: DiscoverSource[]) {
const close = vi.fn()
const save = vi.fn()
const updateModelValue = vi.fn()
const result = await renderWithProviders(DiscoverTabOrderDialog, {
props: {
modelValue: true,
onClose: close,
onSave: save,
'onUpdate:modelValue': updateModelValue,
tabs,
},
global: {
components: {
VDialogCloseBtn: DialogCloseButtonStub,
},
},
})
return { ...result, close, save, updateModelValue }
}
describe('DiscoverTabOrderDialog', () => {
it('reorders a shallow local copy without mutating the parent tabs', async () => {
const tabs = [createSource('来源甲', 'source-a'), createSource('来源乙', 'source-b')]
const originalOrder = [...tabs]
const user = userEvent.setup()
const { save } = await renderDialog(tabs)
await user.click(screen.getByRole('button', { name: '反转顺序' }))
await user.click(screen.getByRole('button', { name: '保存' }))
expect(tabs).toEqual(originalOrder)
expect(save).toHaveBeenCalledOnce()
const savedTabs = save.mock.calls[0][0] as DiscoverSource[]
expect(savedTabs.map(item => item.mediaid_prefix)).toEqual(['source-b', 'source-a'])
expect(savedTabs[0]).not.toBe(tabs[1])
expect(savedTabs[0].filter_params).toStrictEqual(tabs[1].filter_params)
})
it('resets the local order when the tabs prop changes', async () => {
const user = userEvent.setup()
const { rerender, save } = await renderDialog([
createSource('来源甲', 'source-a'),
createSource('来源乙', 'source-b'),
])
await user.click(screen.getByRole('button', { name: '反转顺序' }))
await rerender({
modelValue: true,
tabs: [createSource('来源丙', 'source-c'), createSource('来源丁', 'source-d')],
})
await user.click(screen.getByRole('button', { name: '保存' }))
const savedTabs = save.mock.calls[0][0] as DiscoverSource[]
expect(savedTabs.map(item => item.mediaid_prefix)).toEqual(['source-c', 'source-d'])
})
it('emits both model closure and close from the close control', async () => {
const user = userEvent.setup()
const { close, updateModelValue } = await renderDialog([createSource('来源甲', 'source-a')])
await user.click(screen.getByRole('button', { name: '关闭' }))
expect(updateModelValue).toHaveBeenCalledOnce()
expect(updateModelValue).toHaveBeenCalledWith(false)
expect(close).toHaveBeenCalledOnce()
})
it('emits only the current local order when saving', async () => {
const user = userEvent.setup()
const { close, save, updateModelValue } = await renderDialog([createSource('来源甲', 'source-a')])
await user.click(screen.getByRole('button', { name: '保存' }))
expect(save).toHaveBeenCalledOnce()
expect(close).not.toHaveBeenCalled()
expect(updateModelValue).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,395 @@
import type { SubscribeShare } from '@/api/types'
import ForkSubscribeDialog from '@/components/dialog/ForkSubscribeDialog.vue'
import { screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createSubscribeShare } from '@tests/support/factories/subscribe'
import {
deleteSubscribeShareHandler,
followSubscriberHandler,
followSubscribersSettingHandler,
forkSubscribeHandler,
unfollowSubscriberHandler,
} from '@tests/support/msw/handlers/subscribe'
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { flushPromises } from '@vue/test-utils'
import { defineComponent, h } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
routerPush: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
}))
vi.mock('@/router', () => ({
default: {
push: (...args: unknown[]) => mocks.routerPush(...args),
},
}))
vi.mock('@/api/nprogress', () => ({
doneNProgress: vi.fn(),
startNProgress: vi.fn(),
}))
vi.mock('vue-toastification', () => ({
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
}))
const DialogCloseButtonStub = defineComponent({
name: 'VDialogCloseBtn',
emits: ['click'],
setup(_props, { emit }) {
return () => h('button', { type: 'button', onClick: () => emit('click') }, '关闭')
},
})
const PosterStub = defineComponent({
name: 'VImg',
emits: ['click'],
setup(_props, { emit }) {
return () =>
h(
'button',
{
'aria-label': '查看媒体详情',
type: 'button',
onClick: () => emit('click'),
},
'查看媒体详情',
)
},
})
interface MediaIdentifiers {
bangumiid?: number
doubanid?: string
tmdbid?: number
}
const mediaDetailCases: Array<[string, MediaIdentifiers, string]> = [
['TMDB', { tmdbid: 6301 }, 'tmdb:6301'],
['Douban', { doubanid: 'db-6302', tmdbid: undefined }, 'douban:db-6302'],
['Bangumi', { bangumiid: 6303, doubanid: undefined, tmdbid: undefined }, 'bangumi:6303'],
]
function createDeferred() {
let resolve!: () => void
const promise = new Promise<void>(done => {
resolve = done
})
return { promise, resolve }
}
async function renderDialog(
media: SubscribeShare = createSubscribeShare(),
settings: Record<string, unknown> = {},
) {
const events = {
close: vi.fn(),
delete: vi.fn(),
fork: vi.fn(),
}
const result = await renderWithProviders(ForkSubscribeDialog, {
global: {
components: {
VDialogCloseBtn: DialogCloseButtonStub,
},
stubs: {
VImg: PosterStub,
},
},
initialState: {
globalSettings: {
data: {
GLOBAL_IMAGE_CACHE: false,
SUBSCRIBE_SHARE_MANAGE: false,
USER_UNIQUE_ID: 'current-user',
...settings,
},
},
},
props: {
media,
modelValue: true,
onClose: events.close,
onDelete: events.delete,
onFork: events.fork,
},
})
await flushPromises()
await flushPromises()
return { ...result, events, media }
}
describe('ForkSubscribeDialog follow behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.spyOn(console, 'log').mockImplementation(() => {})
})
it('loads the followed users and shows the current action', async () => {
const media = createSubscribeShare({ share_uid: 'followed-user' })
const requested = vi.fn()
server.use(followSubscribersSettingHandler(['followed-user'], 200, requested))
await renderDialog(media)
expect(await screen.findByRole('button', { name: '取消关注' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '关注' })).not.toBeInTheDocument()
expect(requested).toHaveBeenCalledOnce()
})
it('follows a share user and refreshes the action from the server setting', async () => {
const media = createSubscribeShare({ share_uid: 'new-follow-user' })
const users: string[] = []
const writeRequest = vi.fn((url: URL) => {
users.push(url.searchParams.get('share_uid') || '')
})
server.use(
followSubscribersSettingHandler(users),
followSubscriberHandler({ success: true }, 200, writeRequest),
)
const user = userEvent.setup()
await renderDialog(media)
await user.click(await screen.findByRole('button', { name: '关注' }))
expect(await screen.findByRole('button', { name: '取消关注' })).toBeInTheDocument()
expect(writeRequest).toHaveBeenCalledOnce()
expect(writeRequest.mock.calls[0][0].pathname).toBe('/api/v1/subscribe/follow')
expect(writeRequest.mock.calls[0][0].searchParams.get('share_uid')).toBe('new-follow-user')
})
it('unfollows a share user and refreshes the action from the server setting', async () => {
const media = createSubscribeShare({ share_uid: 'old-follow-user' })
const users = ['old-follow-user']
const writeRequest = vi.fn((url: URL) => {
users.splice(users.indexOf(url.searchParams.get('share_uid') || ''), 1)
})
server.use(
followSubscribersSettingHandler(users),
unfollowSubscriberHandler({ success: true }, 200, writeRequest),
)
const user = userEvent.setup()
await renderDialog(media)
await user.click(await screen.findByRole('button', { name: '取消关注' }))
expect(await screen.findByRole('button', { name: '关注' })).toBeInTheDocument()
expect(writeRequest).toHaveBeenCalledOnce()
expect(writeRequest.mock.calls[0][0].pathname).toBe('/api/v1/subscribe/follow')
expect(writeRequest.mock.calls[0][0].searchParams.get('share_uid')).toBe('old-follow-user')
})
it('does not show follow actions when the share has no UID', async () => {
const media = createSubscribeShare({ share_uid: undefined })
server.use(followSubscribersSettingHandler([]))
await renderDialog(media)
await waitFor(() => expect(screen.getByRole('button', { name: '订阅' })).toBeInTheDocument())
expect(screen.queryByRole('button', { name: '关注' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '取消关注' })).not.toBeInTheDocument()
})
it('shows visible feedback when the followed-user list request fails', async () => {
server.use(followSubscribersSettingHandler([], 500))
await renderDialog(createSubscribeShare())
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('请求失败,请稍后重试'))
})
it('keeps the follow action and shows feedback when the follow request fails', async () => {
const media = createSubscribeShare({ share_uid: 'failed-follow-user' })
server.use(followSubscribersSettingHandler([]), followSubscriberHandler({ success: true }, 500))
const user = userEvent.setup()
await renderDialog(media)
await user.click(await screen.findByRole('button', { name: '关注' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('请求失败,请稍后重试'))
expect(screen.getByRole('button', { name: '关注' })).toBeInTheDocument()
})
it('keeps the unfollow action and shows feedback when the unfollow request fails', async () => {
const media = createSubscribeShare({ share_uid: 'failed-unfollow-user' })
server.use(
followSubscribersSettingHandler(['failed-unfollow-user']),
unfollowSubscriberHandler({ success: true }, 500),
)
const user = userEvent.setup()
await renderDialog(media)
await user.click(await screen.findByRole('button', { name: '取消关注' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('请求失败,请稍后重试'))
expect(screen.getByRole('button', { name: '取消关注' })).toBeInTheDocument()
})
})
describe('ForkSubscribeDialog fork, delete, and navigation behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.spyOn(console, 'log').mockImplementation(() => {})
})
it('keeps the fork button pending and emits the created subscription ID on success', async () => {
const media = createSubscribeShare({ id: 6101, share_title: '待复用分享' })
const deferred = createDeferred()
const forkPayload = vi.fn(() => deferred.promise)
server.use(
followSubscribersSettingHandler([]),
forkSubscribeHandler({ data: { id: 7101 }, success: true }, 200, forkPayload),
)
const user = userEvent.setup()
const { events } = await renderDialog(media)
const forkButton = screen.getByRole('button', { name: '订阅' })
await user.click(forkButton)
await waitFor(() => expect(forkPayload).toHaveBeenCalledOnce())
expect(forkButton).toBeDisabled()
expect(events.fork).not.toHaveBeenCalled()
deferred.resolve()
await waitFor(() => expect(events.fork).toHaveBeenCalledWith(7101))
expect(forkPayload).toHaveBeenCalledWith(media)
expect(mocks.toastSuccess).toHaveBeenCalledWith('添加待复用分享成功!')
})
it('reports a fork business failure and does not emit', async () => {
server.use(
followSubscribersSettingHandler([]),
forkSubscribeHandler({ message: '订阅已存在', success: false }),
)
const user = userEvent.setup()
const { events } = await renderDialog(createSubscribeShare({ share_title: '冲突分享' }))
await user.click(screen.getByRole('button', { name: '订阅' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('添加冲突分享失败:订阅已存在!'))
expect(events.fork).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: '订阅' })).not.toBeDisabled()
})
it('reports an HTTP fork failure, restores the action, and does not emit', async () => {
server.use(followSubscribersSettingHandler([]), forkSubscribeHandler({ success: true }, 500))
const user = userEvent.setup()
const { events } = await renderDialog(createSubscribeShare())
await user.click(screen.getByRole('button', { name: '订阅' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('请求失败')))
expect(events.fork).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: '订阅' })).not.toBeDisabled()
})
it.each([
['the owner', 'owned-share', false, true],
['a share manager', 'other-user', true, true],
['another ordinary user', 'other-user', false, false],
])('shows delete permission for %s', async (_case, shareUid, canManage, visible) => {
server.use(followSubscribersSettingHandler([]))
await renderDialog(createSubscribeShare({ share_uid: shareUid }), {
SUBSCRIBE_SHARE_MANAGE: canManage,
USER_UNIQUE_ID: 'owned-share',
})
await waitFor(() => expect(screen.getByRole('button', { name: '订阅' })).toBeInTheDocument())
if (visible) expect(screen.getByRole('button', { name: '取消分享' })).toBeInTheDocument()
else expect(screen.queryByRole('button', { name: '取消分享' })).not.toBeInTheDocument()
})
it('deletes the exact share and emits success without relying on response data.id', async () => {
const media = createSubscribeShare({ id: 6201, share_uid: 'owned-share' })
const deferred = createDeferred()
const deleteRequest = vi.fn((_url: URL) => deferred.promise)
server.use(
followSubscribersSettingHandler([]),
deleteSubscribeShareHandler(6201, { success: true }, 200, deleteRequest),
)
const user = userEvent.setup()
const { events } = await renderDialog(media, { USER_UNIQUE_ID: 'owned-share' })
const deleteButton = screen.getByRole('button', { name: '取消分享' })
await user.click(deleteButton)
await waitFor(() => expect(deleteRequest).toHaveBeenCalledOnce())
expect(deleteButton).toBeDisabled()
expect(deleteRequest.mock.calls[0][0].pathname).toBe('/api/v1/subscribe/share/6201')
deferred.resolve()
await waitFor(() => expect(events.delete).toHaveBeenCalledOnce())
expect(events.delete).toHaveBeenCalledWith()
expect(mocks.toastSuccess).toHaveBeenCalledWith('已取消订阅!')
})
it('reports a delete business failure and does not emit', async () => {
const media = createSubscribeShare({ id: 6202, share_uid: 'owned-share' })
server.use(
followSubscribersSettingHandler([]),
deleteSubscribeShareHandler(6202, { message: '没有删除权限', success: false }),
)
const user = userEvent.setup()
const { events } = await renderDialog(media, { USER_UNIQUE_ID: 'owned-share' })
await user.click(screen.getByRole('button', { name: '取消分享' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('取消订阅失败:没有删除权限!'))
expect(events.delete).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: '取消分享' })).not.toBeDisabled()
})
it('reports an HTTP delete failure, restores the action, and does not emit', async () => {
const media = createSubscribeShare({ id: 6203, share_uid: 'owned-share' })
server.use(followSubscribersSettingHandler([]), deleteSubscribeShareHandler(6203, { success: true }, 500))
const user = userEvent.setup()
const { events } = await renderDialog(media, { USER_UNIQUE_ID: 'owned-share' })
await user.click(screen.getByRole('button', { name: '取消分享' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('请求失败')))
expect(events.delete).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: '取消分享' })).not.toBeDisabled()
})
it('emits close from the dialog close control', async () => {
server.use(followSubscribersSettingHandler([]))
const user = userEvent.setup()
const { events } = await renderDialog(createSubscribeShare())
await user.click(screen.getByRole('button', { name: '关闭' }))
expect(events.close).toHaveBeenCalledOnce()
})
it.each(mediaDetailCases)('routes %s shares to their media details', async (_source, identifiers, expectedMediaId) => {
const media: SubscribeShare = {
...createSubscribeShare({
doubanid: identifiers.doubanid,
tmdbid: identifiers.tmdbid,
}),
bangumiid: identifiers.bangumiid,
}
server.use(followSubscribersSettingHandler([]))
const user = userEvent.setup()
await renderDialog(media)
await user.click(screen.getByRole('button', { name: '查看媒体详情' }))
expect(mocks.routerPush).toHaveBeenCalledWith({
path: '/media',
query: {
mediaid: expectedMediaId,
title: media.name,
type: media.type,
year: media.year,
},
})
})
})

View File

@@ -0,0 +1,199 @@
import SiteAddEditDialog from '@/components/dialog/SiteAddEditDialog.vue'
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createSite, createSiteDownloader } from '@tests/support/factories/site'
import {
addSiteHandler,
siteDetailsHandler,
siteDownloadersHandler,
updateSiteHandler,
} from '@tests/support/msw/handlers/site'
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
done: vi.fn(),
start: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
}))
vi.mock('vue-toastification', () => ({
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
}))
vi.mock('@/api/nprogress', () => ({
configureNProgress: vi.fn(),
doneNProgress: mocks.done,
startNProgress: mocks.start,
}))
async function renderDialog(oper: 'add' | 'edit', siteid?: number) {
const events = { close: vi.fn(), save: vi.fn() }
const result = await renderWithProviders(SiteAddEditDialog, {
props: {
modelValue: true,
oper,
siteid,
onClose: events.close,
onSave: events.save,
},
global: { components: { VDialogCloseBtn: DialogCloseBtn } },
})
return { ...result, events }
}
describe('SiteAddEditDialog', () => {
beforeEach(() => {
server.use(siteDownloadersHandler())
})
it('creates a site with the entered form values and loaded downloader', async () => {
const saved = vi.fn()
server.use(
siteDownloadersHandler([createSiteDownloader({ name: '下载器 A' })]),
addSiteHandler({ success: true }, 200, saved),
)
const user = userEvent.setup()
const { events } = await renderDialog('add')
await user.type(screen.getByLabelText('站点地址'), 'https://new.example.com/')
await fireEvent.update(screen.getByLabelText('RSS地址'), 'https://new.example.com/rss')
await fireEvent.update(screen.getByLabelText('超时时间(秒)'), '30')
await fireEvent.update(screen.getByLabelText('站点Cookie'), 'session=test')
await fireEvent.update(screen.getByLabelText('站点User-Agent'), 'Goal5C-UA')
await user.click(screen.getByRole('tab', { name: 'API' }))
await fireEvent.update(screen.getByLabelText('请求头Authorization'), 'Bearer goal5c')
await fireEvent.update(screen.getByLabelText('令牌API Key'), 'goal5c-key')
await user.click(screen.getByLabelText('下载器'))
await user.click(await screen.findByText('下载器 A'))
await user.click(screen.getByLabelText('限制站点访问频率'))
await fireEvent.update(screen.getByLabelText('单位周期(秒)'), '60')
await fireEvent.update(screen.getByLabelText('周期内访问次数'), '5')
await fireEvent.update(screen.getByLabelText('访问间隔(秒)'), '2')
await user.click(screen.getByLabelText('使用代理访问'))
await user.click(screen.getByLabelText('浏览器仿真'))
await user.click(screen.getByRole('button', { name: '新增站点' }))
await waitFor(() => expect(saved).toHaveBeenCalledOnce())
expect(saved.mock.calls[0][0]).toMatchObject({
apikey: 'goal5c-key',
cookie: 'session=test',
downloader: '下载器 A',
limit_count: '5',
limit_interval: '60',
limit_seconds: '2',
proxy: true,
render: true,
rss: 'https://new.example.com/rss',
timeout: '30',
token: 'Bearer goal5c',
ua: 'Goal5C-UA',
url: 'https://new.example.com/',
})
expect(events.save).toHaveBeenCalledOnce()
expect(mocks.toastSuccess).toHaveBeenCalledWith('新增站点成功')
expect(mocks.start).toHaveBeenCalledOnce()
expect(mocks.done).toHaveBeenCalledOnce()
})
it('does not submit an add request without a URL', async () => {
const saved = vi.fn()
server.use(addSiteHandler({ success: true }, 200, saved))
const { events } = await renderDialog('add')
await fireEvent.click(screen.getByRole('button', { name: '新增站点' }))
expect(saved).not.toHaveBeenCalled()
expect(events.save).not.toHaveBeenCalled()
expect(mocks.start).not.toHaveBeenCalled()
})
it('shows a business failure when creation is rejected', async () => {
server.use(addSiteHandler({ message: '站点已存在', success: false }))
const user = userEvent.setup()
const { events } = await renderDialog('add')
await user.type(screen.getByLabelText('站点地址'), 'https://duplicate.example.com/')
await user.click(screen.getByRole('button', { name: '新增站点' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('新增站点失败:站点已存在'))
expect(events.save).not.toHaveBeenCalled()
expect(mocks.done).toHaveBeenCalledOnce()
})
it('restores progress and keeps the dialog open after an HTTP creation failure', async () => {
server.use(addSiteHandler({ message: '服务异常', success: false }, 500))
const user = userEvent.setup()
const { events } = await renderDialog('add')
await user.type(screen.getByLabelText('站点地址'), 'https://failed.example.com/')
await user.click(screen.getByRole('button', { name: '新增站点' }))
await waitFor(() => expect(mocks.done).toHaveBeenCalledOnce())
expect(events.save).not.toHaveBeenCalled()
expect(mocks.toastSuccess).not.toHaveBeenCalled()
})
it('restores numeric flags and API mode when editing a rate-limited site', async () => {
const site = createSite({
apikey: 'api-key',
downloader: '下载器 A',
limit_count: 4,
limit_interval: 60,
limit_seconds: 2,
proxy: 1,
render: 0,
token: 'token',
})
server.use(siteDetailsHandler(site.id, site), siteDownloadersHandler([createSiteDownloader({ name: '下载器 A' })]))
await renderDialog('edit', site.id)
expect(await screen.findByText(site.name)).toBeInTheDocument()
expect(screen.getByLabelText('使用代理访问')).toBeChecked()
expect(screen.getByLabelText('浏览器仿真')).not.toBeChecked()
expect(screen.getByLabelText('限制站点访问频率')).toBeChecked()
expect(screen.getByDisplayValue('api-key')).toBeInTheDocument()
})
it('clears rate limits when disabled and emits save after an update', async () => {
const site = createSite({ limit_count: 4, limit_interval: 60, limit_seconds: 2, name: '限流站点' })
const saved = vi.fn()
server.use(siteDetailsHandler(site.id, site), updateSiteHandler({ success: true }, 200, saved))
const user = userEvent.setup()
const { events } = await renderDialog('edit', site.id)
await user.click(await screen.findByLabelText('限制站点访问频率'))
await user.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(saved).toHaveBeenCalledOnce())
expect(saved.mock.calls[0][0]).toMatchObject({ limit_count: 0, limit_interval: 0, limit_seconds: 0 })
expect(events.save).toHaveBeenCalledOnce()
expect(mocks.toastSuccess).toHaveBeenCalledWith('限流站点 更新成功')
})
it.each([
['business', 200, { message: '不允许更新', success: false }, '限流站点 更新失败:不允许更新'],
['HTTP', 500, { message: '服务异常', success: false }, '限流站点 更新失败!'],
])('keeps the dialog open on %s update failure', async (_case, status, response, message) => {
const site = createSite({ name: '限流站点' })
server.use(siteDetailsHandler(site.id, site), updateSiteHandler(response, status))
const { events } = await renderDialog('edit', site.id)
await fireEvent.click(await screen.findByRole('button', { name: '保存' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(message))
expect(events.save).not.toHaveBeenCalled()
expect(mocks.done).toHaveBeenCalledOnce()
})
it('emits close from the dialog close button', async () => {
const { events } = await renderDialog('add')
await fireEvent.click(document.querySelector('.absolute.right-3.top-3')!)
expect(events.close).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,128 @@
import SiteCookieUpdateDialog from '@/components/dialog/SiteCookieUpdateDialog.vue'
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createSite } from '@tests/support/factories/site'
import { updateSiteCookieHandler } from '@tests/support/msw/handlers/site'
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { HttpResponse, http } from 'msw'
import { describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({ toastError: vi.fn(), toastSuccess: vi.fn() }))
vi.mock('vue-toastification', () => ({
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
}))
async function renderDialog() {
const site = createSite({ id: 701, name: 'Cookie 站点' })
const events = { close: vi.fn(), done: vi.fn() }
const result = await renderWithProviders(SiteCookieUpdateDialog, {
props: { modelValue: true, site, onClose: events.close, onDone: events.done },
global: {
components: { VDialogCloseBtn: DialogCloseBtn },
stubs: { ProgressDialog: { props: ['text'], template: '<div role="status">{{ text }}</div>' } },
},
})
return { ...result, events, site }
}
async function fillCredentials(user: ReturnType<typeof userEvent.setup>) {
await user.type(screen.getByLabelText('用户名'), 'tester')
await user.type(screen.getByLabelText('密码'), 'secret')
await user.type(screen.getByLabelText('验证码'), '654321')
}
describe('SiteCookieUpdateDialog', () => {
it('skips requests until username and password are both provided', async () => {
const requested = vi.fn()
server.use(updateSiteCookieHandler(701, { success: true }, 200, requested))
const { events } = await renderDialog()
await fireEvent.click(screen.getByRole('button', { name: '开始更新' }))
expect(requested).not.toHaveBeenCalled()
expect(events.done).not.toHaveBeenCalled()
})
it('submits username, password, and OTP and emits done on success', async () => {
const requested = vi.fn()
server.use(updateSiteCookieHandler(701, { success: true }, 200, requested))
const user = userEvent.setup()
const { events } = await renderDialog()
await fillCredentials(user)
await user.click(screen.getByRole('button', { name: '开始更新' }))
await waitFor(() =>
expect(requested).toHaveBeenCalledWith({ code: '654321', password: 'secret', username: 'tester' }),
)
expect(events.done).toHaveBeenCalledOnce()
expect(mocks.toastSuccess).toHaveBeenCalledWith('Cookie 站点 更新Cookie & UA成功')
expect(screen.getByRole('button', { name: '开始更新' })).toBeEnabled()
})
it('toggles password visibility', async () => {
const user = userEvent.setup()
await renderDialog()
const password = screen.getByLabelText('密码')
const toggle = password.closest('.v-input')!.querySelector('.v-field__append-inner [role="button"]')!
expect(password).toHaveAttribute('type', 'password')
await user.click(toggle)
expect(password).toHaveAttribute('type', 'text')
})
it('shows the backend message for a business failure', async () => {
server.use(updateSiteCookieHandler(701, { message: '验证码错误', success: false }))
const user = userEvent.setup()
const { events } = await renderDialog()
await fillCredentials(user)
await user.click(screen.getByRole('button', { name: '开始更新' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('Cookie 站点 更新失败:验证码错误'))
expect(events.done).not.toHaveBeenCalled()
})
it.each([
['detail', { detail: '站点不存在' }, '站点不存在'],
['message', { message: '认证服务异常' }, '认证服务异常'],
])('shows an HTTP %s response and restores pending state', async (_case, body, message) => {
server.use(http.post('http://localhost/api/v1/site/cookie/701', () => HttpResponse.json(body, { status: 500 })))
const user = userEvent.setup()
await renderDialog()
await fillCredentials(user)
await user.click(screen.getByRole('button', { name: '开始更新' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(`Cookie 站点 更新失败:${message}`))
expect(screen.getByRole('button', { name: '开始更新' })).toBeEnabled()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
it('disables the update action while the request is pending and restores it afterward', async () => {
let resolveRequest!: () => void
const pending = new Promise<void>(resolve => {
resolveRequest = resolve
})
server.use(updateSiteCookieHandler(701, { success: true }, 200, () => pending))
const user = userEvent.setup()
await renderDialog()
await fillCredentials(user)
await user.click(screen.getByRole('button', { name: '开始更新' }))
expect(await screen.findByRole('status')).toHaveTextContent('正在更新 Cookie 站点 Cookie & UA...')
expect(screen.getByRole('button', { name: '开始更新' })).toBeDisabled()
resolveRequest()
await waitFor(() => expect(screen.getByRole('button', { name: '开始更新' })).toBeEnabled())
})
it('emits close from the dialog close button', async () => {
const { events } = await renderDialog()
await fireEvent.click(document.querySelector('.absolute.right-3.top-3')!)
expect(events.close).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,217 @@
import SiteImportDialog from '@/components/dialog/SiteImportDialog.vue'
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createSite } from '@tests/support/factories/site'
import { addSiteHandler, siteApiUrls } from '@tests/support/msw/handlers/site'
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { HttpResponse, http } from 'msw'
import { describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
done: vi.fn(),
start: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
toastWarning: vi.fn(),
}))
vi.mock('vue-toastification', () => ({
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess, warning: mocks.toastWarning }),
}))
vi.mock('@/api/nprogress', () => ({
configureNProgress: vi.fn(),
doneNProgress: mocks.done,
startNProgress: mocks.start,
}))
async function renderDialog() {
const events = { importSuccess: vi.fn(), update: vi.fn() }
const result = await renderWithProviders(SiteImportDialog, {
props: {
modelValue: true,
'onImport-success': events.importSuccess,
'onUpdate:modelValue': events.update,
},
global: { components: { VDialogCloseBtn: DialogCloseBtn } },
})
return { ...result, events }
}
function jsonFile(data: unknown, name = 'sites.json') {
const file = new File([JSON.stringify(data)], name, { type: 'application/json' })
Object.defineProperty(file, 'text', { value: async () => JSON.stringify(data) })
return file
}
function brokenJsonFile() {
const file = new File(['{broken'], 'broken.json', { type: 'application/json' })
Object.defineProperty(file, 'text', { value: async () => '{broken' })
return file
}
async function chooseFile(file: File) {
await userEvent.setup().upload(screen.getByLabelText('选择文件'), file)
}
describe('SiteImportDialog', () => {
it('previews only the first five sites and marks invalid rows', async () => {
const sites = Array.from({ length: 6 }, (_, index) =>
createSite(index === 1 ? { name: '' } : { name: `导入站点 ${index + 1}` }),
)
await renderDialog()
await chooseFile(jsonFile(sites))
expect(await screen.findByText('预览数据 (6 个站点)')).toBeInTheDocument()
expect(screen.getByText('导入站点 1')).toBeInTheDocument()
expect(screen.getByText('未命名站点')).toBeInTheDocument()
expect(screen.getByText('导入站点 5')).toBeInTheDocument()
expect(screen.queryByText('导入站点 6')).not.toBeInTheDocument()
expect(screen.getByText('数据无效')).toBeInTheDocument()
})
it('rejects a non-JSON drop before parsing', async () => {
await renderDialog()
const file = new File(['text'], 'sites.txt', { type: 'text/plain' })
await fireEvent.drop(document.querySelector('.upload-zone')!, { dataTransfer: { files: [file] } })
expect(mocks.toastError).toHaveBeenCalledWith('不支持的文件类型请选择JSON文件')
expect(screen.getByLabelText('选择文件')).toBeInTheDocument()
})
it.each([
['invalid JSON', brokenJsonFile(), '文件解析失败,请检查文件格式'],
['non-array JSON', jsonFile({ name: 'single' }), '文件格式无效,请检查文件内容'],
])('rejects %s', async (_case, file, message) => {
await renderDialog()
await chooseFile(file)
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(message))
expect(screen.getByLabelText('选择文件')).toBeInTheDocument()
})
it('tracks drag state and can reset a selected batch', async () => {
await renderDialog()
const zone = document.querySelector('.upload-zone')!
await fireEvent.dragOver(zone)
expect(zone).toHaveClass('dragging')
await fireEvent.dragLeave(zone)
expect(zone).not.toHaveClass('dragging')
await fireEvent.drop(zone, { dataTransfer: { files: [jsonFile([createSite({ name: '拖入站点' })])] } })
expect(await screen.findByText('拖入站点')).toBeInTheDocument()
await fireEvent.click(screen.getByRole('button', { name: '重置' }))
expect(screen.getByLabelText('选择文件')).toBeInTheDocument()
})
it('imports valid sites sequentially without their ids and closes on complete success', async () => {
const sites = [createSite({ id: 801, name: '第一站' }), createSite({ id: 802, name: '第二站' })]
const order: Array<Record<string, unknown>> = []
server.use(
addSiteHandler({ success: true }, 200, payload => {
order.push(payload as unknown as Record<string, unknown>)
}),
)
const user = userEvent.setup()
const { events } = await renderDialog()
await chooseFile(jsonFile(sites))
await user.click(await screen.findByRole('button', { name: '开始导入' }))
await waitFor(() => expect(order).toHaveLength(2))
expect(order.map(site => site.name)).toEqual(['第一站', '第二站'])
expect(order.every(site => !('id' in site))).toBe(true)
expect(mocks.toastSuccess).toHaveBeenCalledWith('成功导入 2 个站点')
expect(events.importSuccess).toHaveBeenCalledOnce()
expect(events.update).toHaveBeenCalledWith(false)
expect(mocks.start).toHaveBeenCalledOnce()
expect(mocks.done).toHaveBeenCalledOnce()
})
it('warns about invalid records and imports only valid records', async () => {
const valid = createSite({ name: '有效站点' })
const invalid = createSite({ domain: '', name: '无效站点' })
const requested = vi.fn()
server.use(addSiteHandler({ success: true }, 200, requested))
await renderDialog()
await chooseFile(jsonFile([valid, invalid]))
await fireEvent.click(await screen.findByRole('button', { name: '开始导入' }))
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
expect(mocks.toastWarning).toHaveBeenCalledWith('部分数据无效,有效数据 1/2 个')
})
it('keeps all business failures visible with backend details', async () => {
const sites = [createSite({ name: '重复站点' }), createSite({ name: '未支持站点' })]
let requestIndex = 0
server.use(
addSiteHandler({ success: false }, 200, () => {
requestIndex += 1
}),
)
await renderDialog()
await chooseFile(jsonFile(sites))
await fireEvent.click(await screen.findByRole('button', { name: '开始导入' }))
expect(await screen.findByText('导入过程中出现 2 个错误')).toBeInTheDocument()
expect(requestIndex).toBe(2)
expect(mocks.toastError).toHaveBeenCalledWith('导入失败2 个站点全部导入失败')
expect(screen.getByText('重复站点 - 错误详情')).toBeInTheDocument()
expect(screen.getByText('未支持站点 - 错误详情')).toBeInTheDocument()
})
it('reports partial success and preserves the HTTP error message', async () => {
const sites = [createSite({ name: '成功站点' }), createSite({ name: '失败站点' })]
let requestIndex = 0
server.use(
http.post(siteApiUrls.list, () => {
requestIndex += 1
if (requestIndex === 1) return HttpResponse.json({ success: true })
return HttpResponse.json({ message: '第二站请求失败', success: false }, { status: 500 })
}),
)
await renderDialog()
await chooseFile(jsonFile(sites))
await fireEvent.click(await screen.findByRole('button', { name: '开始导入' }))
expect(await screen.findByText('导入过程中出现 1 个错误')).toBeInTheDocument()
expect(screen.getByText('成功导入 1 个站点')).toBeInTheDocument()
expect(mocks.toastError).toHaveBeenCalledWith('导入完成,成功 1 个,失败 1 个')
await fireEvent.click(screen.getByText('失败站点 - 错误详情'))
expect(await screen.findByText('Request failed with status code 500')).toBeInTheDocument()
})
it('does not start a request when every record is invalid', async () => {
const requested = vi.fn()
server.use(addSiteHandler({ success: true }, 200, requested))
await renderDialog()
await chooseFile(jsonFile([createSite({ domain: '', name: '', url: '' })]))
await fireEvent.click(await screen.findByRole('button', { name: '开始导入' }))
expect(mocks.toastError).toHaveBeenCalledWith('没有有效的数据')
expect(requested).not.toHaveBeenCalled()
expect(mocks.start).not.toHaveBeenCalled()
})
it('closes a failed result without a success event', async () => {
server.use(addSiteHandler({ success: false, message: '已存在' }))
const { events } = await renderDialog()
await chooseFile(jsonFile([createSite({ name: '失败站点' })]))
await fireEvent.click(await screen.findByRole('button', { name: '开始导入' }))
expect(await screen.findByText('导入过程中出现 1 个错误')).toBeInTheDocument()
await fireEvent.click(screen.getByRole('button', { name: '关闭' }))
expect(events.update).toHaveBeenCalledWith(false)
expect(events.importSuccess).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,463 @@
import type { TorrentInfo } from '@/api/types'
import SiteResourceDialog from '@/components/dialog/SiteResourceDialog.vue'
import i18n from '@/plugins/i18n'
import { getActiveRequestsCount } from '@/utils/requestOptimizer'
import { screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createSite, createSiteCategory, createTorrentInfo } from '@tests/support/factories/site'
import { siteApiUrls, siteCategoriesHandler, siteResourcesHandler } from '@tests/support/msw/handlers/site'
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { HttpResponse, http } from 'msw'
import { defineComponent, h, type Component, type PropType } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const AddDownloadDialogStub = defineComponent({
name: 'AddDownloadDialog',
props: {
torrent: Object,
},
emits: ['close', 'done', 'error', 'update:modelValue'],
template: `
<section data-testid="add-download-dialog">
{{ torrent?.title }}
<button type="button" @click="$emit('done', 'url')">done</button>
<button type="button" @click="$emit('error', 'error')">error</button>
<button type="button" @click="$emit('close')">close-download</button>
</section>
`,
})
const DialogStub = defineComponent({
name: 'VDialog',
template: '<div role="dialog"><slot /></div>',
})
const ProgressiveCardGridStub = defineComponent({
name: 'ProgressiveCardGrid',
props: {
getItemKey: { type: Function as PropType<(item: TorrentInfo, index: number) => string>, required: true },
items: { type: Array as PropType<TorrentInfo[]>, required: true },
},
setup(props, { slots }) {
return () =>
h(
'section',
{ 'data-testid': 'progressive-grid' },
props.items.map((item, index) =>
h('article', { 'data-resource-key': props.getItemKey(item, index) }, slots.default?.({ item })),
),
)
},
})
const DataTableStub = defineComponent({
name: 'VDataTable',
props: {
items: { type: Array as PropType<TorrentInfo[]>, required: true },
itemsPerPage: { type: Number, required: true },
loading: Boolean,
page: { type: Number, required: true },
},
emits: ['update:itemsPerPage', 'update:page'],
setup(props, { emit, slots }) {
return () =>
h('section', { 'data-loading': String(props.loading), 'data-testid': 'resource-table' }, [
h('span', { 'data-testid': 'resource-page' }, String(props.page)),
h('button', { onClick: () => emit('update:page', 4), type: 'button' }, 'page-4'),
h('button', { onClick: () => emit('update:itemsPerPage', 100), type: 'button' }, 'per-page-100'),
...props.items.flatMap(item => slots['item.title']?.({ item }) ?? []),
props.items.length === 0 ? slots['no-data']?.({}) : null,
])
},
})
function createDeferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>(done => {
resolve = done
})
return { promise, resolve }
}
function setViewport(width: number) {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: width, writable: true })
window.dispatchEvent(new Event('resize'))
}
async function renderDialog(
site = createSite({ id: 501, name: '资源测试站' }),
stubs: Record<string, boolean | Component> = {},
) {
const close = vi.fn()
const result = await renderWithProviders(SiteResourceDialog, {
props: { site, onClose: close },
global: {
stubs: {
AddDownloadDialog: AddDownloadDialogStub,
ProgressiveCardGrid: ProgressiveCardGridStub,
VDialog: DialogStub,
...stubs,
},
},
})
return { ...result, close, site }
}
describe('SiteResourceDialog', () => {
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.spyOn(console, 'warn').mockImplementation(() => {})
setViewport(1280)
})
it.each([
['success', 200],
['HTTP failure', 500],
] as const)('keeps the newest mobile search state when an older request ends with %s', async (_case, oldStatus) => {
setViewport(390)
const oldResponse = createDeferred<void>()
const latestResponse = createDeferred<TorrentInfo[]>()
const oldRequested = vi.fn()
const latestRequested = vi.fn()
const latestResource = createTorrentInfo({ title: '新条件结果' })
server.use(
siteCategoriesHandler(501, [createSiteCategory()]),
http.get(siteApiUrls.resources(501), async ({ request }) => {
const keyword = new URL(request.url).searchParams.get('keyword')
if (keyword) {
latestRequested()
return HttpResponse.json(await latestResponse.promise)
}
oldRequested()
await oldResponse.promise
return oldStatus === 200
? HttpResponse.json([createTorrentInfo({ title: '旧条件结果' })])
: HttpResponse.json({ detail: 'stale failure' }, { status: oldStatus })
}),
)
const user = userEvent.setup()
const { container } = await renderDialog()
await waitFor(() => expect(oldRequested).toHaveBeenCalledOnce())
await user.click(container.querySelector('.site-resource-mobile-search__toggle') as HTMLElement)
const keywordFields = screen.getAllByLabelText('搜索关键字')
const keyword = keywordFields[keywordFields.length - 1]
await user.type(keyword, 'new')
const searchButtons = screen.getAllByRole('button', { name: /搜索/ })
await user.click(searchButtons[searchButtons.length - 1])
await waitFor(() => expect(latestRequested).toHaveBeenCalledOnce())
await waitFor(() => expect(getActiveRequestsCount()).toBe(2))
oldResponse.resolve()
await waitFor(() => expect(getActiveRequestsCount()).toBe(1))
expect(screen.getByText('加载中...')).toBeInTheDocument()
expect(container.querySelector('.site-resource-filter-panel')).toBeInTheDocument()
expect(screen.queryByText('资源加载失败,请重试')).not.toBeInTheDocument()
if (oldStatus === 500) expect(console.error).not.toHaveBeenCalled()
latestResponse.resolve([latestResource])
expect(await screen.findByText('新条件结果')).toBeInTheDocument()
await waitFor(() => expect(getActiveRequestsCount()).toBe(0))
expect(screen.queryByText('旧条件结果')).not.toBeInTheDocument()
expect(screen.queryByText('加载中...')).not.toBeInTheDocument()
expect(screen.queryByLabelText('搜索关键字')).not.toBeInTheDocument()
expect(screen.queryByText('资源加载失败,请重试')).not.toBeInTheDocument()
})
it('shows a retry action after resource loading fails and recovers on the same query', async () => {
let attempts = 0
server.use(
siteCategoriesHandler(501, []),
http.get(siteApiUrls.resources(501), () => {
attempts += 1
if (attempts === 1) return HttpResponse.json({ detail: 'temporary failure' }, { status: 500 })
return HttpResponse.json([createTorrentInfo({ title: '重试恢复结果' })])
}),
)
const user = userEvent.setup()
await renderDialog()
await user.click(await screen.findByRole('button', { name: '重试' }))
expect(await screen.findByText('重试恢复结果')).toBeInTheDocument()
expect(attempts).toBe(2)
})
it.each([
['desktop', 1280],
['mobile', 390],
] as const)('does not show the empty state with an initial resource error on %s', async (_layout, width) => {
setViewport(width)
server.use(siteCategoriesHandler(501, []), siteResourcesHandler(501, [], 500))
await renderDialog(undefined, { VDataTable: DataTableStub })
expect(await screen.findByText('资源加载失败,请重试')).toBeInTheDocument()
expect(screen.queryByText('没有数据')).not.toBeInTheDocument()
})
it('keeps existing resources visible when a repeated search fails', async () => {
let attempts = 0
server.use(
siteCategoriesHandler(501, []),
http.get(siteApiUrls.resources(501), () => {
attempts += 1
if (attempts === 1) return HttpResponse.json([createTorrentInfo({ title: '已有资源' })])
return HttpResponse.json({ detail: 'temporary failure' }, { status: 500 })
}),
)
const user = userEvent.setup()
await renderDialog()
expect(await screen.findByText('已有资源')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /搜索/ }))
expect(await screen.findByText('资源加载失败,请重试')).toBeInTheDocument()
expect(screen.getByText('已有资源')).toBeInTheDocument()
expect(screen.queryByText('没有数据')).not.toBeInTheDocument()
})
it('loads categories and resources from their direct-array endpoints', async () => {
const categoryRequests: URL[] = []
const resourceRequests: URL[] = []
server.use(
siteCategoriesHandler(501, [createSiteCategory({ desc: '高清电影', id: 11 })], 200, url =>
categoryRequests.push(url),
),
siteResourcesHandler(501, [createTorrentInfo({ title: '首载资源' })], 200, url => resourceRequests.push(url)),
)
await renderDialog()
expect(await screen.findByText('首载资源')).toBeInTheDocument()
expect(categoryRequests).toHaveLength(1)
expect(resourceRequests).toHaveLength(1)
expect(resourceRequests[0].searchParams.get('keyword')).toBeNull()
expect(resourceRequests[0].searchParams.get('cat')).toBe('')
})
it('sends the exact keyword and selected category ids on every search', async () => {
const requests: URL[] = []
server.use(
siteCategoriesHandler(501, [
createSiteCategory({ desc: '电影', id: 11 }),
createSiteCategory({ cat: 'tv', desc: '剧集', id: 22 }),
]),
siteResourcesHandler(501, [], 200, url => requests.push(url)),
)
const user = userEvent.setup()
await renderDialog()
await waitFor(() => expect(requests).toHaveLength(1))
await user.type(screen.getByLabelText('搜索关键字'), '2160p')
await user.click(screen.getByLabelText('资源分类'))
await user.click(await screen.findByRole('option', { name: '电影' }))
await user.click(await screen.findByRole('option', { name: '剧集' }))
await user.click(screen.getByRole('button', { name: /搜索/ }))
await waitFor(() => expect(requests).toHaveLength(2))
expect(requests[1].searchParams.get('keyword')).toBe('2160p')
expect(requests[1].searchParams.get('cat')).toBe('11,22')
})
it('distinguishes a valid empty resource list from a category request failure', async () => {
server.use(siteCategoriesHandler(501, [], 500), siteResourcesHandler(501, []))
await renderDialog()
expect(await screen.findByText('没有数据')).toBeInTheDocument()
expect(screen.queryByText('资源加载失败,请重试')).not.toBeInTheDocument()
await waitFor(() => expect(console.error).toHaveBeenCalledOnce())
})
it('exposes loading during a repeated search and clears it when the latest request completes', async () => {
const nextResponse = createDeferred<TorrentInfo[]>()
let requestCount = 0
server.use(
siteCategoriesHandler(501, []),
http.get(siteApiUrls.resources(501), async () => {
requestCount += 1
if (requestCount === 1) return HttpResponse.json([])
return HttpResponse.json(await nextResponse.promise)
}),
)
const user = userEvent.setup()
await renderDialog(undefined, { VDataTable: DataTableStub })
await waitFor(() => expect(screen.getByTestId('resource-table')).toHaveAttribute('data-loading', 'false'))
await user.click(screen.getByRole('button', { name: /搜索/ }))
await waitFor(() => expect(screen.getByTestId('resource-table')).toHaveAttribute('data-loading', 'true'))
nextResponse.resolve([createTorrentInfo({ title: '重复搜索结果' })])
expect(await screen.findByText('重复搜索结果')).toBeInTheDocument()
expect(screen.getByTestId('resource-table')).toHaveAttribute('data-loading', 'false')
})
it('renders resource metadata and every promotion style branch', async () => {
server.use(
siteCategoriesHandler(501, []),
siteResourcesHandler(501, [
createTorrentInfo({
date_elapsed: '2 小时前',
description: '完整资源说明',
downloadvolumefactor: 0,
freedate_diff: '剩余 1 天',
hit_and_run: true,
labels: ['原盘'],
pubdate: '2026-07-19',
title: '免费资源',
volume_factor: 'FREE',
}),
createTorrentInfo({ downloadvolumefactor: 0.5, title: '半价资源', volume_factor: '50%' }),
createTorrentInfo({ title: '双倍上传', uploadvolumefactor: 2, volume_factor: '2X' }),
createTorrentInfo({ title: '普通资源' }),
]),
)
await renderDialog()
expect(await screen.findByText('免费资源')).toBeInTheDocument()
expect(screen.getByText('完整资源说明')).toBeInTheDocument()
expect(screen.getByText('H&R')).toBeInTheDocument()
expect(screen.getByText('剩余 1 天')).toBeInTheDocument()
expect(screen.getByText('原盘')).toBeInTheDocument()
expect(screen.getByText('FREE').closest('.v-chip')).toHaveClass('bg-lime-500')
expect(screen.getByText('50%').closest('.v-chip')).toHaveClass('bg-green-500')
expect(screen.getByText('2X').closest('.v-chip')).toHaveClass('bg-sky-500')
expect(screen.queryByText('1x')).not.toBeInTheDocument()
})
it('opens and closes the add-download boundary through all child outcomes', async () => {
server.use(siteCategoriesHandler(501, []), siteResourcesHandler(501, [createTorrentInfo({ title: '待下载资源' })]))
const user = userEvent.setup()
await renderDialog()
const title = await screen.findByRole('button', { name: /待下载资源/ })
await user.click(title)
expect(screen.getByTestId('add-download-dialog')).toHaveTextContent('待下载资源')
await user.click(screen.getByRole('button', { name: 'done' }))
expect(screen.queryByTestId('add-download-dialog')).not.toBeInTheDocument()
await user.click(title)
await user.click(screen.getByRole('button', { name: 'error' }))
expect(screen.queryByTestId('add-download-dialog')).not.toBeInTheDocument()
await user.click(title)
await user.click(screen.getByRole('button', { name: 'close-download' }))
expect(screen.queryByTestId('add-download-dialog')).not.toBeInTheDocument()
})
it('corrects an out-of-range desktop page after the page size changes', async () => {
server.use(
siteCategoriesHandler(501, []),
siteResourcesHandler(
501,
Array.from({ length: 30 }, (_, index) => createTorrentInfo({ title: `分页资源 ${index + 1}` })),
),
)
const user = userEvent.setup()
await renderDialog(undefined, { VDataTable: DataTableStub })
await screen.findByText('分页资源 1')
await user.click(screen.getByRole('button', { name: 'page-4' }))
expect(screen.getByTestId('resource-page')).toHaveTextContent('4')
await user.click(screen.getByRole('button', { name: 'per-page-100' }))
await waitFor(() => expect(screen.getByTestId('resource-page')).toHaveTextContent('1'))
})
it('uses stable mobile keys and guards detail and torrent external links', async () => {
setViewport(390)
const open = vi.spyOn(window, 'open').mockImplementation(() => null)
server.use(
siteCategoriesHandler(501, []),
siteResourcesHandler(501, [
createTorrentInfo({
description: '移动端资源说明',
downloadvolumefactor: 0,
enclosure: 'https://tracker.example.com/download/one',
freedate_diff: '剩余 2 小时',
hit_and_run: true,
labels: ['移动标签'],
page_url: 'https://tracker.example.com/details/one',
title: '详情资源',
volume_factor: 'FREE',
}),
createTorrentInfo({ enclosure: 'magnet:?xt=urn:btih:test', page_url: '', title: '无外链资源' }),
createTorrentInfo({ enclosure: '', page_url: '', pubdate: '2026-07-19', title: '回退键资源' }),
]),
)
const user = userEvent.setup()
await renderDialog()
expect(await screen.findByText('详情资源')).toBeInTheDocument()
expect(screen.getByText('移动端资源说明')).toBeInTheDocument()
expect(screen.getByText('移动标签')).toBeInTheDocument()
const cards = screen.getByTestId('progressive-grid').querySelectorAll('[data-resource-key]')
expect(Array.from(cards, card => card.getAttribute('data-resource-key'))).toEqual([
'https://tracker.example.com/details/one',
'magnet:?xt=urn:btih:test',
'回退键资源-2026-07-19-2',
])
await user.click(screen.getByRole('button', { name: /详情资源/ }))
await user.click(screen.getByRole('button', { name: 'close-download' }))
await user.click(screen.getAllByRole('button', { name: '添加下载' })[0])
await user.click(screen.getByRole('button', { name: 'close-download' }))
const detailButtons = screen.getAllByLabelText('查看详情')
await user.click(detailButtons[0])
await user.click(detailButtons[1])
await user.click(screen.getAllByLabelText('下载种子文件')[0])
expect(open).toHaveBeenNthCalledWith(1, 'https://tracker.example.com/details/one', '_blank')
expect(open).toHaveBeenNthCalledWith(2, 'https://tracker.example.com/download/one', '_blank')
expect(open).toHaveBeenCalledTimes(2)
expect(screen.getAllByLabelText('下载种子文件')[1]).toBeDisabled()
})
it('expands and closes mobile search without retaining it after a desktop switch', async () => {
setViewport(390)
server.use(siteCategoriesHandler(501, []), siteResourcesHandler(501, []))
const user = userEvent.setup()
const { container } = await renderDialog()
await screen.findByText('没有数据')
const toggle = container.querySelector('.site-resource-mobile-search__toggle')
expect(toggle).not.toBeNull()
await user.click(toggle as HTMLElement)
await user.type(screen.getByLabelText('搜索关键字'), 'mobile')
await user.click(screen.getByRole('button', { name: '取消' }))
expect(screen.queryByLabelText('搜索关键字')).not.toBeInTheDocument()
await user.click(toggle as HTMLElement)
setViewport(1280)
await waitFor(() => expect(container.querySelector('.site-resource-mobile-search')).toBeNull())
setViewport(390)
await waitFor(() => expect(screen.queryByLabelText('搜索关键字')).not.toBeInTheDocument())
})
it('emits close and formats the result summary in English', async () => {
server.use(
siteCategoriesHandler(501, []),
siteResourcesHandler(501, [createTorrentInfo({ title: 'Language resource' })]),
)
const user = userEvent.setup()
const { close, container } = await renderDialog()
await screen.findByText('Language resource')
i18n.global.locale.value = 'en-US'
expect(await screen.findByText('1 results')).toBeInTheDocument()
const closeButton = container.querySelector('.v-toolbar-items .v-btn')
expect(closeButton).not.toBeNull()
await user.click(closeButton as HTMLElement)
expect(close).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,396 @@
import SiteUserDataDialog from '@/components/dialog/SiteUserDataDialog.vue'
import type { ApiResponse, SiteUserData } from '@/api/types'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import { createSite, createSiteUserData } from '@tests/support/factories/site'
import { refreshSiteUserDataHandler, siteUserDataHandler } from '@tests/support/msw/handlers/site'
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { getActiveRequestsCount } from '@/utils/requestOptimizer'
import { flushPromises } from '@vue/test-utils'
import { defineComponent, h, type PropType } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
type ChartInput = {
options: Record<string, unknown>
series: { data?: unknown[]; name?: string }[]
type: string
}
const chartInputs: ChartInput[] = []
const ApexChartStub = defineComponent({
name: 'VApexChart',
props: {
options: { type: Object as PropType<Record<string, unknown>>, required: true },
series: { type: Array as PropType<ChartInput['series']>, required: true },
type: { type: String, required: true },
},
setup(props) {
chartInputs.push(props)
return () => h('div', { 'data-testid': `chart-${props.type}` })
},
})
const ProgressDialogStub = defineComponent({
name: 'ProgressDialog',
props: { text: String },
setup: props => () => h('div', { 'data-testid': 'refresh-progress' }, props.text),
})
const DialogCloseButtonStub = defineComponent({
name: 'VDialogCloseBtn',
setup:
(_, { attrs }) =>
() =>
h('button', { ...attrs, 'aria-label': '关闭' }),
})
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>(resolvePromise => {
resolve = resolvePromise
})
return { promise, resolve }
}
async function renderDialog(
initialResult: Pick<ApiResponse<SiteUserData[]>, 'data' | 'message' | 'success'> = { data: [], success: false },
status = 200,
onRequest: () => void | Promise<void> = () => {},
) {
const site = createSite()
server.use(siteUserDataHandler(site.id, initialResult, status, onRequest))
const result = await renderWithProviders(SiteUserDataDialog, {
global: {
stubs: {
ProgressDialog: ProgressDialogStub,
VApexChart: ApexChartStub,
VDialogCloseBtn: DialogCloseButtonStub,
},
},
props: { site },
})
return { ...result, site }
}
function getChart(type: string) {
const chart = chartInputs.find(input => input.type === type)
if (!chart) throw new Error(`Missing ${type} chart input`)
return chart
}
function getRefreshButton() {
const button = document.querySelector<HTMLButtonElement>('.v-card-title button')
if (!button) throw new Error('Missing refresh button')
return button
}
function getRetryButton() {
const button = Array.from(document.querySelectorAll<HTMLButtonElement>('.v-alert button')).find(element =>
element.textContent?.includes('重试'),
)
if (!button) throw new Error('Missing retry button')
return button
}
describe('SiteUserDataDialog projections', () => {
beforeEach(() => {
chartInputs.length = 0
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.spyOn(console, 'log').mockImplementation(() => {})
})
it('sorts history, renders the latest record, and projects positive, negative, and zero deltas', async () => {
const older = createSiteUserData({
bonus: 100,
download: 4 * 1024 ** 3,
ratio: 1.5,
seeding: 2,
seeding_size: 3 * 1024 ** 3,
updated_day: '2026-07-17',
upload: 1024 ** 3,
user_level: 'Old',
})
const latest = createSiteUserData({
bonus: 150,
download: 2 * 1024 ** 3,
ratio: 1.25,
seeding: 2,
seeding_info: [
[10, 2 * 1024 ** 3],
[0, 0],
],
seeding_size: 4 * 1024 ** 3,
updated_day: '2026-07-19',
upload: 3 * 1024 ** 3,
user_level: 'Elite',
})
await renderDialog({ data: [latest, older], success: true })
await screen.findByText('Elite')
expect(screen.getByText('150')).toBeInTheDocument()
expect(screen.getByText('(+50)')).toHaveClass('text-success')
expect(screen.getByText('(-0.25)')).toHaveClass('text-error')
expect(screen.getByText('(+0)')).not.toHaveClass('text-success', 'text-error')
expect(screen.getByText('3.00 GB')).toBeInTheDocument()
expect(screen.getByText('(+2.00 GB)')).toHaveClass('text-success')
expect(screen.getByText('2.00 GB')).toBeInTheDocument()
expect(screen.getByText('(-2.00 GB)')).toHaveClass('text-error')
expect(document.body).toHaveTextContent('2024-01-02')
const history = getChart('line')
expect(history.series).toEqual([
{ data: [1, 3], name: '上传量' },
{ data: [4, 2], name: '下载量' },
])
expect((history.options.xaxis as { categories: string[] }).categories).toEqual(['2026-07-17', '2026-07-19'])
expect((history.options.theme as { mode: string }).mode).toBe('light')
expect((history.options.chart as { background: string; foreColor: string }).background).toBeTruthy()
expect(
(history.options.xaxis as { labels: { formatter: (value: string) => string } }).labels.formatter('2026-07-19'),
).toBe(new Date('2026-07-19').toLocaleDateString('zh-CN'))
expect((history.options.yaxis as { labels: { formatter: (value: number) => string } }).labels.formatter(1234)).toBe(
(1234).toLocaleString(),
)
const seeding = getChart('scatter')
expect(seeding.series).toEqual([
{
data: [
[10, 2],
[0, 0],
],
name: '体积',
},
])
expect((seeding.options.theme as { mode: string }).mode).toBe('light')
expect((seeding.options.tooltip as { x: { formatter: (value: number) => string } }).x.formatter(1234)).toBe(
`数量:${(1234).toLocaleString()}`,
)
expect((seeding.options.xaxis as { labels: { formatter: (value: number) => string } }).labels.formatter(1.6)).toBe(
'2',
)
expect((seeding.options.yaxis as { labels: { formatter: (value: number) => string } }).labels.formatter(2048)).toBe(
`${(2048).toLocaleString()} GB`,
)
})
it('uses the only record as both latest data and the baseline delta', async () => {
const only = createSiteUserData({ bonus: 42, ratio: 0, seeding: 5, updated_day: '2026-07-18' })
await renderDialog({ data: [only], success: true })
await screen.findByText('42')
expect(screen.getByText('(+42)')).toHaveClass('text-success')
expect(screen.getByText('(+0)')).not.toHaveClass('text-success', 'text-error')
expect(screen.getByText('(+5)')).toHaveClass('text-success')
})
it('normalizes missing historical counters and seeding tuple values to zero', async () => {
const sparseRecord = () =>
createSiteUserData({
bonus: undefined,
download: undefined,
ratio: undefined,
seeding: undefined,
seeding_info: [[undefined, undefined]],
seeding_size: undefined,
updated_day: undefined,
upload: undefined,
})
await renderDialog({ data: [sparseRecord(), sparseRecord()], success: true })
await waitFor(() =>
expect(getChart('line').series).toEqual([
{ data: [0, 0], name: '上传量' },
{ data: [0, 0], name: '下载量' },
]),
)
expect(getChart('scatter').series).toEqual([{ data: [[0, 0]], name: '体积' }])
})
it('keeps a legal empty response renderable without fabricated values', async () => {
const requested = vi.fn()
await renderDialog({ data: [], success: false }, 200, requested)
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
await waitFor(() => expect(getActiveRequestsCount()).toBe(0))
expect(getChart('line').series).toEqual([
{ data: [], name: '上传量' },
{ data: [], name: '下载量' },
])
expect(screen.getByText('无')).toBeInTheDocument()
expect(getChart('scatter').series).toEqual([{ data: [], name: '体积' }])
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
})
describe('SiteUserDataDialog refresh and recovery', () => {
beforeEach(() => {
chartInputs.length = 0
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.spyOn(console, 'log').mockImplementation(() => {})
})
it('reloads history after a successful refresh and keeps progress until both requests finish', async () => {
const initial = createSiteUserData({ bonus: 1 })
const refreshed = createSiteUserData({ bonus: 222, updated_day: '2026-07-19' })
const refreshRequest = deferred<void>()
let loadCount = 0
const { site } = await renderDialog({ data: [initial], success: true }, 200, () => {
loadCount += 1
})
server.use(refreshSiteUserDataHandler(site.id, { data: {}, success: true }, 200, () => refreshRequest.promise))
await screen.findByText('1')
await fireEvent.click(getRefreshButton())
expect(screen.getByTestId('refresh-progress')).toHaveTextContent('正在刷新站点数据...')
server.use(
siteUserDataHandler(site.id, { data: [refreshed], success: true }, 200, () => {
loadCount += 1
}),
)
refreshRequest.resolve()
await screen.findByText('222')
await waitFor(() => expect(screen.queryByTestId('refresh-progress')).not.toBeInTheDocument())
expect(loadCount).toBe(2)
})
it.each([
['late success', 200, { data: [createSiteUserData({ bonus: 1 })] as SiteUserData[], success: true }],
['late HTTP failure', 500, { data: [] as SiteUserData[], message: 'old request failed', success: false }],
] as const)('ignores an initial request that settles after refreshed data on %s', async (_case, status, response) => {
const initialRequest = deferred<void>()
const initialRequested = vi.fn()
const initialReleased = vi.fn()
const refreshed = createSiteUserData({ bonus: 222, updated_day: '2026-07-19' })
const { site } = await renderDialog(response, status, async () => {
initialRequested()
await initialRequest.promise
initialReleased()
})
await waitFor(() => expect(initialRequested).toHaveBeenCalledOnce())
server.use(
refreshSiteUserDataHandler(site.id, { data: {}, success: true }),
siteUserDataHandler(site.id, { data: [refreshed], success: true }),
)
await fireEvent.click(getRefreshButton())
await screen.findByText('222')
initialRequest.resolve()
await waitFor(() => expect(initialReleased).toHaveBeenCalledOnce())
await waitFor(() => expect(getActiveRequestsCount()).toBe(0))
await flushPromises()
expect(screen.getByText('222')).toBeInTheDocument()
expect(screen.queryByText('1')).not.toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('keeps a refresh failure visible when the initial request settles afterwards', async () => {
const initialRequest = deferred<void>()
const initialRequested = vi.fn()
const initialReleased = vi.fn()
const refreshRequested = vi.fn()
const { site } = await renderDialog({ data: [createSiteUserData({ bonus: 1 })], success: true }, 200, async () => {
initialRequested()
await initialRequest.promise
initialReleased()
})
await waitFor(() => expect(initialRequested).toHaveBeenCalledOnce())
server.use(
refreshSiteUserDataHandler(
site.id,
{ data: {}, message: '站点不支持刷新', success: false },
200,
refreshRequested,
),
)
await fireEvent.click(getRefreshButton())
expect(await screen.findByText(/刷新站点数据失败/)).toBeInTheDocument()
initialRequest.resolve()
await waitFor(() => expect(initialReleased).toHaveBeenCalledOnce())
await waitFor(() => expect(getActiveRequestsCount()).toBe(0))
await flushPromises()
expect(screen.getByText(/刷新站点数据失败/)).toBeInTheDocument()
expect(refreshRequested).toHaveBeenCalledOnce()
server.use(refreshSiteUserDataHandler(site.id, { data: {}, success: true }, 200, refreshRequested))
await fireEvent.click(getRetryButton())
await waitFor(() => expect(refreshRequested).toHaveBeenCalledTimes(2))
await waitFor(() => expect(screen.queryByText(/刷新站点数据失败/)).not.toBeInTheDocument())
})
it.each([
['business failure', 200, { data: {}, message: '站点不支持刷新', success: false }],
['HTTP failure', 500, { data: {}, message: 'server down', success: false }],
] as const)('ends progress and exposes a retry after refresh %s', async (_case, status, response) => {
const refreshRequested = vi.fn()
const { site } = await renderDialog({ data: [createSiteUserData()], success: true })
server.use(refreshSiteUserDataHandler(site.id, response, status, refreshRequested))
await screen.findByText('Elite')
await fireEvent.click(getRefreshButton())
await waitFor(() => expect(refreshRequested).toHaveBeenCalledOnce())
await waitFor(() => expect(screen.queryByTestId('refresh-progress')).not.toBeInTheDocument())
expect(await screen.findByText(/刷新站点数据失败/)).toBeInTheDocument()
expect(getRetryButton()).toBeInTheDocument()
server.use(refreshSiteUserDataHandler(site.id, { data: {}, success: true }, 200, refreshRequested))
await fireEvent.click(getRetryButton())
await waitFor(() => expect(refreshRequested).toHaveBeenCalledTimes(2))
await waitFor(() => expect(screen.queryByText(/刷新站点数据失败/)).not.toBeInTheDocument())
})
it('distinguishes initial HTTP failure from empty data and retries the same site', async () => {
let attempt = 0
const { site } = await renderDialog({ data: [], success: false }, 500, () => {
attempt += 1
})
expect(await screen.findByText(/加载站点数据失败/)).toBeInTheDocument()
server.use(
siteUserDataHandler(site.id, { data: [createSiteUserData({ bonus: 88 })], success: true }, 200, () => {
attempt += 1
}),
)
await fireEvent.click(getRetryButton())
await screen.findByText('88')
expect(attempt).toBe(2)
expect(screen.queryByText(/加载站点数据失败/)).not.toBeInTheDocument()
})
it('clears an initial HTTP error when retry returns the legal empty state', async () => {
const { site } = await renderDialog({ data: [], success: false }, 500)
expect(await screen.findByText(/加载站点数据失败/)).toBeInTheDocument()
server.use(siteUserDataHandler(site.id, { data: [], success: false }))
await fireEvent.click(getRetryButton())
await waitFor(() => expect(getActiveRequestsCount()).toBe(0))
await waitFor(() => expect(screen.queryByText(/加载站点数据失败/)).not.toBeInTheDocument())
expect(screen.getByText('无')).toBeInTheDocument()
})
it('emits close without mutating loaded user data', async () => {
const { emitted } = await renderDialog({ data: [createSiteUserData()], success: true })
await screen.findByText('Elite')
const closeButton = document.querySelector<HTMLButtonElement>('button[aria-label="关闭"]')
if (!closeButton) throw new Error('Missing close button')
await fireEvent.click(closeButton)
expect(emitted('close')).toHaveLength(1)
expect(screen.getByText('Elite')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,512 @@
import type { Subscribe, SubscrbieInfo } from '@/api/types'
import SubscribeFilesDialog from '@/components/dialog/SubscribeFilesDialog.vue'
import { screen, waitFor, within } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { server } from '@tests/support/msw/server'
import { subscribeApiUrls, subscribeFilesHandler } from '@tests/support/msw/handlers/subscribe'
import { renderWithProviders } from '@tests/support/render'
import { HttpResponse, http, type JsonBodyType } from 'msw'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
copyToClipboard: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
}))
vi.mock('@/@core/utils/navigator', () => ({
copyToClipboard: (...args: unknown[]) => mocks.copyToClipboard(...args),
}))
vi.mock('vue-toastification', () => ({
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
}))
function createSubscribe(overrides: Partial<Subscribe> = {}): Subscribe {
return {
best_version: 0,
current_priority: 0,
date: '2026-07-17 10:00:00',
id: 3101,
last_update: '2026-07-17 10:00:00',
name: '文件测试剧',
page_open: false,
show_edit_dialog: false,
sites: [],
state: 'R',
tmdbid: 31010,
type: '电视剧',
username: 'tester',
year: '2026',
...overrides,
}
}
function createFilesInfo(overrides: Partial<SubscrbieInfo> = {}): SubscrbieInfo {
return {
episodes: {
1: {
download: [],
library: [],
title: '第一集',
},
},
subscribe: createSubscribe({ season: 1, total_episode: 1 }),
...overrides,
}
}
function createTvFilesInfo(): SubscrbieInfo {
return createFilesInfo({
episodes: {
10: {
description: '第十集简介',
download: [],
library: [],
title: '第十集',
},
2: {
description: '第二集简介',
download: [
{
downloader: 'Transmission',
file_path: '/downloads/show.S01E02.1080p.mkv',
hash: 'hash-episode-2',
site_name: '站点二',
torrent_title: 'Show.S01E02.1080p.WEB-DL',
},
],
library: [
{
file_path: '/media/show.S01E02.1080p.mkv',
storage: 'local-disk',
},
],
title: '第二集',
},
1: {
backdrop: 'https://image.example.com/t/p/w500/episode-1.jpg',
description: '第一集简介',
download: [
{
downloader: 'qBittorrent',
file_path: '/downloads/show.S01E01.2160p.mkv',
hash: 'hash-episode-1',
site_name: '站点一',
torrent_title: 'Show.S01E01.2160p.WEB-DL',
},
],
library: [],
title: '第一集',
},
},
subscribe: createSubscribe({
backdrop: 'https://image.example.com/t/p/w780/show.jpg',
description: '整剧简介',
poster: 'https://image.example.com/poster.jpg',
season: 1,
total_episode: 4,
}),
})
}
function createDeferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>(done => {
resolve = done
})
return { promise, resolve }
}
function setViewport(width: number) {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: width, writable: true })
window.dispatchEvent(new Event('resize'))
}
function useFilesResponse(
id: number,
response: JsonBodyType,
status = 200,
onRequest: (url: URL) => void = () => {},
) {
server.use(subscribeFilesHandler(id, response, status, onRequest))
}
async function renderDialog(subid: number) {
const close = vi.fn()
const result = await renderWithProviders(SubscribeFilesDialog, {
initialState: {
globalSettings: {
data: { GLOBAL_IMAGE_CACHE: false },
},
},
props: {
modelValue: true,
subid,
onClose: close,
},
})
return { ...result, close }
}
describe('SubscribeFilesDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(console, 'log').mockImplementation(() => {})
setViewport(1280)
})
it('sorts TV episodes numerically, selects the first one, and applies status priority and statistics', async () => {
const requested = vi.fn()
useFilesResponse(3110, createTvFilesInfo() as unknown as JsonBodyType, 200, requested)
await renderDialog(3110)
expect(await screen.findByRole('heading', { name: '文件测试剧' })).toBeInTheDocument()
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
expect(requested.mock.calls[0][0].pathname).toBe('/api/v1/subscribe/files/3110')
const rail = document.querySelector('.subscribe-files-episode-rail')
expect(rail).not.toBeNull()
const episodeButtons = within(rail as HTMLElement).getAllByRole('button')
expect(episodeButtons.map(button => button.textContent)).toEqual([
expect.stringMatching(/E01.*.*/s),
expect.stringMatching(/E02.*.*/s),
expect.stringMatching(/E10.*.*/s),
])
expect(episodeButtons[0]).toHaveClass('subscribe-files-episode-item--active')
const detailTitle = document.querySelector('.subscribe-files-detail__title')
expect(detailTitle).not.toBeNull()
expect(detailTitle).toHaveTextContent('E01')
expect(detailTitle).toHaveTextContent('第一集')
const statCards = document.querySelectorAll('.subscribe-files-stat-card')
expect(statCards).toHaveLength(2)
expect(statCards[0]).toHaveTextContent('下载')
expect(statCards[0]).toHaveTextContent('2/4')
expect(statCards[1]).toHaveTextContent('入库')
expect(statCards[1]).toHaveTextContent('1/4')
expect(screen.getByText('缺失 3')).toBeInTheDocument()
})
it('counts a non-TMDB custom episode range instead of treating its ending episode as the total', async () => {
const episodes = Object.fromEntries(
[44, 45, 46, 47, 48].map(episode => [
episode,
{
download: [],
library: [],
title: `${episode}`,
},
]),
)
const info = createFilesInfo({
episodes,
subscribe: createSubscribe({
doubanid: 'douban-range-44-48',
start_episode: 44,
tmdbid: undefined,
total_episode: 48,
}),
})
useFilesResponse(3120, info as unknown as JsonBodyType)
await renderDialog(3120)
expect(await screen.findByRole('heading', { name: '文件测试剧' })).toBeInTheDocument()
expect(screen.getAllByText('0/5')).toHaveLength(2)
expect(screen.getByText('缺失 5')).toBeInTheDocument()
})
it('uses the returned TMDB episode keys when they exceed the configured target range', async () => {
const episodes = Object.fromEntries(
[1, 2, 3, 4].map(episode => [
episode,
{
download: [],
library: [],
title: `${episode}`,
},
]),
)
const info = createFilesInfo({
episodes,
subscribe: createSubscribe({ start_episode: 3, tmdbid: 31210, total_episode: 4 }),
})
useFilesResponse(3121, info as unknown as JsonBodyType)
await renderDialog(3121)
expect(await screen.findByRole('heading', { name: '文件测试剧' })).toBeInTheDocument()
expect(screen.getAllByText('0/4')).toHaveLength(2)
expect(screen.getByText('缺失 4')).toBeInTheDocument()
})
it('renders download details, switches desktop episodes, and shows both tab empty states', async () => {
useFilesResponse(3111, createTvFilesInfo() as unknown as JsonBodyType)
const user = userEvent.setup()
await renderDialog(3111)
const firstTorrent = await screen.findByRole('heading', { name: 'Show.S01E01.2160p.WEB-DL' })
const firstFileCard = firstTorrent.closest('.subscribe-files-file-card')
expect(firstFileCard).not.toBeNull()
expect(within(firstFileCard as HTMLElement).getByText('2160P')).toBeInTheDocument()
expect(within(firstFileCard as HTMLElement).getByText('站点一')).toBeInTheDocument()
expect(within(firstFileCard as HTMLElement).getByText('下载器qBittorrent')).toBeInTheDocument()
expect(within(firstFileCard as HTMLElement).getByText('Hashhash-episode-1')).toBeInTheDocument()
expect(within(firstFileCard as HTMLElement).getByText('/downloads/show.S01E01.2160p.mkv')).toBeInTheDocument()
const rail = document.querySelector('.subscribe-files-episode-rail') as HTMLElement
const episodeButtons = within(rail).getAllByRole('button')
await user.click(episodeButtons[1])
expect(await screen.findByRole('heading', { name: 'Show.S01E02.1080p.WEB-DL' })).toBeInTheDocument()
expect(screen.queryByRole('heading', { name: 'Show.S01E01.2160p.WEB-DL' })).not.toBeInTheDocument()
await user.click(episodeButtons[2])
expect(await screen.findByText('暂无下载文件')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '媒体库文件' }))
expect(await screen.findByText('暂无媒体库文件')).toBeInTheDocument()
})
it('renders local library paths and safe HTTP(S) media-server links', async () => {
const httpUrl = 'http://media.example.com/items/emby-1'
const httpsUrl = 'https://media.example.com/items/jellyfin-2'
const info = createFilesInfo({
episodes: {
1: {
download: [],
library: [
{ file_path: '/media/show.S01E01.1080p.mkv', storage: '本地存储' },
{ file_path: httpUrl, itemid: 'emby-1', server: '家庭 Emby', server_type: 'emby' },
{ file_path: httpsUrl, itemid: 'jellyfin-2', server: '家庭 Jellyfin', server_type: 'jellyfin' },
{},
],
title: '媒体库详情集',
},
},
subscribe: createSubscribe({ season: 1, total_episode: 1 }),
})
useFilesResponse(3112, info as unknown as JsonBodyType)
const user = userEvent.setup()
await renderDialog(3112)
expect(await screen.findByRole('heading', { name: '文件测试剧' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '媒体库文件' }))
const localPath = await screen.findByText('/media/show.S01E01.1080p.mkv')
expect(localPath.closest('a')).toBeNull()
expect(screen.getByText('1080P')).toBeInTheDocument()
expect(screen.getByText('本地存储')).toBeInTheDocument()
expect(screen.getByText('local')).toBeInTheDocument()
expect(screen.getByText('家庭 Emby')).toBeInTheDocument()
expect(screen.getByText('家庭 Jellyfin')).toBeInTheDocument()
for (const url of [httpUrl, httpsUrl]) {
const link = screen.getByRole('link', { name: url })
expect(link).toHaveAttribute('href', url)
expect(link).toHaveAttribute('target', '_blank')
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
}
expect(screen.getByText('暂无路径')).toBeInTheDocument()
const copyButtons = screen.getAllByRole('button', { name: '复制路径' })
expect(copyButtons.at(-1)).toBeDisabled()
})
it('reports clipboard success, false results, and exceptions while disabling empty paths', async () => {
const info = createFilesInfo({
episodes: {
1: {
download: [
{ file_path: '/downloads/one.mkv', torrent_title: '文件一' },
{ file_path: '/downloads/two.mkv', torrent_title: '文件二' },
{ file_path: '/downloads/three.mkv', torrent_title: '文件三' },
{ torrent_title: '无路径文件' },
],
library: [],
title: '复制测试集',
},
},
subscribe: createSubscribe({ total_episode: 1 }),
})
useFilesResponse(3113, info as unknown as JsonBodyType)
mocks.copyToClipboard
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(false)
.mockRejectedValueOnce(new Error('clipboard denied'))
const user = userEvent.setup()
await renderDialog(3113)
expect(await screen.findByRole('heading', { name: '文件一' })).toBeInTheDocument()
const copyButtons = screen.getAllByRole('button', { name: '复制路径' })
expect(copyButtons).toHaveLength(4)
expect(copyButtons[3]).toBeDisabled()
await user.click(copyButtons[0])
await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('路径已复制'))
await user.click(copyButtons[1])
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledTimes(1))
await user.click(copyButtons[2])
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledTimes(2))
await user.click(copyButtons[3])
expect(mocks.copyToClipboard.mock.calls).toEqual([
['/downloads/one.mkv'],
['/downloads/two.mkv'],
['/downloads/three.mkv'],
])
expect(mocks.toastError).toHaveBeenNthCalledWith(1, '复制失败')
expect(mocks.toastError).toHaveBeenNthCalledWith(2, '复制失败')
})
it('renders every episode and its active-tab files on mobile', async () => {
setViewport(480)
const info = createFilesInfo({
episodes: {
2: {
download: [{ file_path: '/downloads/mobile-2.mkv', torrent_title: '移动第二集' }],
library: [],
title: '移动第二集',
},
1: {
download: [{ file_path: '/downloads/mobile-1.mkv', torrent_title: '移动第一集' }],
library: [{ file_path: '/media/mobile-1.mkv', storage: 'mobile-storage' }],
title: '移动第一集',
},
},
subscribe: createSubscribe({ total_episode: 2 }),
})
useFilesResponse(3114, info as unknown as JsonBodyType)
const user = userEvent.setup()
await renderDialog(3114)
expect(await screen.findByRole('heading', { name: '文件测试剧' })).toBeInTheDocument()
await waitFor(() => expect(document.querySelector('.subscribe-files-mobile-list')).not.toBeNull())
expect(document.querySelector('.subscribe-files-episode-rail')).toBeNull()
const mobileCards = document.querySelectorAll('.subscribe-files-mobile-card')
expect(mobileCards).toHaveLength(2)
expect(mobileCards[0]).toHaveTextContent('E01')
expect(mobileCards[0]).toHaveTextContent('/downloads/mobile-1.mkv')
expect(mobileCards[1]).toHaveTextContent('E02')
expect(mobileCards[1]).toHaveTextContent('/downloads/mobile-2.mkv')
await user.click(screen.getByRole('button', { name: '媒体库文件' }))
expect(await screen.findByText('/media/mobile-1.mkv')).toBeInTheDocument()
expect(screen.getByText('暂无媒体库文件')).toBeInTheDocument()
})
it('uses the movie model for episode zero and keeps its statistics meaningful', async () => {
const info = createFilesInfo({
episodes: {
0: {
download: [{ file_path: '/downloads/movie.4k.mkv', torrent_title: 'Movie.4K' }],
library: [],
title: '电影正片',
},
},
subscribe: createSubscribe({ season: undefined, total_episode: 0, type: '电影' }),
})
useFilesResponse(3115, info as unknown as JsonBodyType)
await renderDialog(3115)
expect(await screen.findByRole('heading', { name: '文件测试剧' })).toBeInTheDocument()
const rail = document.querySelector('.subscribe-files-episode-rail') as HTMLElement
expect(within(rail).getByRole('button')).toHaveTextContent('电影')
expect(within(rail).getByRole('button')).toHaveTextContent('电影正片')
expect(document.querySelector('.subscribe-files-detail__title')).toHaveTextContent('电影')
expect(screen.getAllByText('1/1')).toHaveLength(1)
expect(screen.getByText('4K')).toBeInTheDocument()
})
it('shows season zero as a valid season label', async () => {
const info = createFilesInfo({
subscribe: createSubscribe({ season: 0, total_episode: 1 }),
})
useFilesResponse(3101, info as unknown as JsonBodyType)
await renderDialog(3101)
expect(await screen.findByRole('heading', { name: '文件测试剧' })).toBeInTheDocument()
expect(screen.getByText('第 0 季')).toBeInTheDocument()
})
it('distinguishes an HTTP failure from an empty response and offers retry', async () => {
const info = createFilesInfo({ subscribe: createSubscribe({ name: '重试恢复剧', total_episode: 1 }) })
let requestCount = 0
server.use(http.get(subscribeApiUrls.filesById(3102), () => {
requestCount += 1
if (requestCount === 1) return HttpResponse.json({}, { status: 500 })
return HttpResponse.json(info as unknown as JsonBodyType)
}))
const user = userEvent.setup()
await renderDialog(3102)
expect(await screen.findByText('服务器错误,请稍后重试。')).toBeInTheDocument()
expect(screen.queryByText('没有数据')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '重试' }))
expect(await screen.findByRole('heading', { name: '重试恢复剧' })).toBeInTheDocument()
expect(screen.queryByText('服务器错误,请稍后重试。')).not.toBeInTheDocument()
expect(requestCount).toBe(2)
})
it('shows loading until the request resolves', async () => {
const deferred = createDeferred<JsonBodyType>()
server.use(http.get(subscribeApiUrls.filesById(3116), async () => {
return HttpResponse.json(await deferred.promise)
}))
await renderDialog(3116)
expect(document.querySelector('.initial-loading-container')).not.toBeNull()
deferred.resolve(createFilesInfo() as unknown as JsonBodyType)
expect(await screen.findByRole('heading', { name: '文件测试剧' })).toBeInTheDocument()
expect(document.querySelector('.initial-loading-container')).toBeNull()
})
it('keeps a successful empty response as the no-data state without retry', async () => {
useFilesResponse(3117, { episodes: {}, subscribe: null })
await renderDialog(3117)
expect(await screen.findByText('没有数据')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '重试' })).not.toBeInTheDocument()
})
it.each([
['R', '订阅中'],
['P', '待处理'],
['S', '已暂停'],
['N', '新订阅'],
['unexpected', '未知'],
])('renders subscription state %s as %s', async (state, label) => {
const info = createFilesInfo({
episodes: {},
subscribe: createSubscribe({ state, total_episode: 0 }),
})
useFilesResponse(3118, info as unknown as JsonBodyType)
await renderDialog(3118)
expect(await screen.findByRole('heading', { name: '文件测试剧' })).toBeInTheDocument()
const chips = document.querySelector('.subscribe-files-hero__chips') as HTMLElement
expect(within(chips).getByText(label)).toBeInTheDocument()
expect(screen.getAllByText('0/0')).toHaveLength(2)
expect(screen.getByText('缺失 0')).toBeInTheDocument()
})
it('emits close from the dialog close button', async () => {
useFilesResponse(3119, createFilesInfo() as unknown as JsonBodyType)
const user = userEvent.setup()
const { close } = await renderDialog(3119)
expect(await screen.findByRole('heading', { name: '文件测试剧' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '关闭' }))
expect(close).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,429 @@
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
import { mediaTypeDict } from '@/api/constants'
import type { Subscribe } from '@/api/types'
import SubscribeHistoryDialog from '@/components/dialog/SubscribeHistoryDialog.vue'
import { fireEvent, screen, waitFor, within } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import {
createSubscribeHandler,
deleteSubscribeHistoryHandler,
subscribeApiUrls,
subscribeHistoryHandler,
} from '@tests/support/msw/handlers/subscribe'
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { HttpResponse, http, type JsonBodyType } from 'msw'
import { defineComponent, h, onMounted, ref, type PropType } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
toastError: vi.fn(),
}))
vi.mock('vue-toastification', () => ({
useToast: () => ({ error: mocks.toastError }),
}))
type InfiniteScrollStatus = 'empty' | 'error' | 'ok'
const InfiniteScrollStub = defineComponent({
name: 'VInfiniteScroll',
props: {
items: {
type: Array as PropType<unknown[]>,
default: () => [],
},
},
emits: ['load'],
setup(_props, { emit, slots }) {
const status = ref<'empty' | 'error' | 'idle' | 'loading'>('idle')
function load() {
status.value = 'loading'
emit('load', {
done(nextStatus: InfiniteScrollStatus) {
status.value = nextStatus === 'ok' ? 'idle' : nextStatus
},
})
}
onMounted(load)
return () =>
h('div', { 'data-testid': 'history-infinite-scroll' }, [
status.value === 'loading' ? slots.loading?.({}) : null,
status.value === 'error'
? slots.error?.({
side: 'end',
props: { color: undefined, onClick: load },
})
: null,
status.value === 'empty' ? slots.empty?.({}) : null,
slots.default?.(),
h(
'button',
{
'aria-label': '加载更多历史',
type: 'button',
onClick: load,
},
'加载更多历史',
),
])
},
})
const VirtualScrollStub = defineComponent({
name: 'VVirtualScroll',
props: {
items: {
type: Array as PropType<Subscribe[]>,
default: () => [],
},
},
setup(props, { slots }) {
const itemRef = () => {}
return () => h('div', props.items.map(item => slots.default?.({ item, itemRef })))
},
})
const MenuStub = defineComponent({
name: 'VMenu',
setup(_props, { slots }) {
return () => h('div', slots.default?.())
},
})
const ProgressDialogStub = defineComponent({
name: 'ProgressDialog',
props: {
text: String,
},
setup(props) {
return () => h('div', { role: 'status' }, props.text)
},
})
interface Deferred<T> {
promise: Promise<T>
resolve: (value: T) => void
}
function createDeferred<T>(): Deferred<T> {
let resolve!: (value: T) => void
const promise = new Promise<T>(done => {
resolve = done
})
return { promise, resolve }
}
let historySeed = 4000
function createHistory(overrides: Partial<Subscribe> = {}): Subscribe {
historySeed += 1
return {
best_version: 0,
current_priority: 0,
date: '2026-07-17 12:00:00',
description: `历史说明 ${historySeed}`,
id: historySeed,
last_update: '2026-07-17 12:00:00',
name: `历史媒体 ${historySeed}`,
poster: `/images/history-${historySeed}.jpg`,
show_edit_dialog: false,
sites: [],
state: 'R',
tmdbid: historySeed,
type: '电影',
username: 'tester',
year: '2026',
...overrides,
}
}
async function renderDialog(type: '电影' | '电视剧' = '电影') {
const events = {
close: vi.fn(),
save: vi.fn(),
}
const result = await renderWithProviders(SubscribeHistoryDialog, {
props: {
modelValue: true,
type,
onClose: events.close,
onSave: events.save,
},
global: {
components: {
VDialogCloseBtn: DialogCloseBtn,
},
stubs: {
ProgressDialog: ProgressDialogStub,
VInfiniteScroll: InfiniteScrollStub,
VMenu: MenuStub,
VVirtualScroll: VirtualScrollStub,
},
},
})
return { ...result, events }
}
function historyRow(item: Subscribe) {
const description = screen.getByText(item.description!)
const row = description.closest('.v-list-item')
if (!row) throw new Error(`History row ${item.id} was not rendered`)
return row as HTMLElement
}
describe('SubscribeHistoryDialog', () => {
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.spyOn(console, 'warn').mockImplementation(() => {})
})
it('loads movie history with the exact type, page and count request', async () => {
const movie = createHistory({ name: '首载电影' })
const requests: URL[] = []
server.use(
subscribeHistoryHandler('电影', [movie], 200, url => {
requests.push(url)
}),
)
await renderDialog('电影')
expect(await screen.findByText('首载电影')).toBeInTheDocument()
expect(screen.getByText(`${mediaTypeDict['电影']}订阅历史`)).toBeInTheDocument()
expect(screen.queryByText(/第 \d+ 季/)).not.toBeInTheDocument()
expect(requests).toHaveLength(1)
expect(decodeURIComponent(requests[0].pathname).endsWith('/subscribe/history/电影')).toBe(true)
expect(requests[0].searchParams.get('page')).toBe('1')
expect(requests[0].searchParams.get('count')).toBe('30')
})
it('loads TV history and renders the season copy', async () => {
const show = createHistory({ name: '首载剧集', season: 3, type: '电视剧' })
const requests: URL[] = []
server.use(
subscribeHistoryHandler('电视剧', [show], 200, url => {
requests.push(url)
}),
)
await renderDialog('电视剧')
expect(await screen.findByText('首载剧集')).toBeInTheDocument()
expect(screen.getByText('第 3 季')).toBeInTheDocument()
expect(screen.getByText(`${mediaTypeDict['电视剧']}订阅历史`)).toBeInTheDocument()
expect(decodeURIComponent(requests[0].pathname).endsWith('/subscribe/history/电视剧')).toBe(true)
expect(requests[0].searchParams.get('page')).toBe('1')
expect(requests[0].searchParams.get('count')).toBe('30')
})
it('appends later pages and keeps existing rows when the next page is empty', async () => {
const first = createHistory({ name: '第一页电影' })
const second = createHistory({ name: '第二页电影' })
const requestedPages: string[] = []
server.use(
http.get(subscribeApiUrls.historyByType('电影'), ({ request }) => {
const url = new URL(request.url)
const page = url.searchParams.get('page') ?? ''
requestedPages.push(page)
if (page === '1') return HttpResponse.json([first])
if (page === '2') return HttpResponse.json([second])
return HttpResponse.json([])
}),
)
const user = userEvent.setup()
await renderDialog()
expect(await screen.findByText('第一页电影')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '加载更多历史' }))
expect(await screen.findByText('第二页电影')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '加载更多历史' }))
await waitFor(() => expect(requestedPages).toEqual(['1', '2', '3']))
expect(screen.getByText('第一页电影')).toBeInTheDocument()
expect(screen.getByText('第二页电影')).toBeInTheDocument()
expect(screen.queryByText('没有已完成的订阅')).not.toBeInTheDocument()
})
it('renders the empty state after an empty first page', async () => {
server.use(subscribeHistoryHandler('电影'))
await renderDialog()
expect(await screen.findByText('没有已完成的订阅')).toBeInTheDocument()
expect(screen.getByText('完成的订阅会显示在这里')).toBeInTheDocument()
})
it('shows a visible list error and retries the same page', async () => {
const recovered = createHistory({ name: '重试恢复电影' })
const requestedPages: string[] = []
server.use(
http.get(subscribeApiUrls.historyByType('电影'), ({ request }) => {
const url = new URL(request.url)
requestedPages.push(url.searchParams.get('page') ?? '')
if (requestedPages.length === 1) return HttpResponse.json({ detail: 'failed' }, { status: 500 })
return HttpResponse.json([recovered])
}),
)
const user = userEvent.setup()
await renderDialog()
expect(await screen.findByRole('alert')).toHaveTextContent('请求失败,请稍后重试')
await user.click(screen.getByRole('button', { name: '重试' }))
expect(await screen.findByText('重试恢复电影')).toBeInTheDocument()
expect(requestedPages).toEqual(['1', '1'])
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('does not issue another request when the load event reenters while pending', async () => {
const pending = createDeferred<Subscribe[]>()
const requested = vi.fn()
const movie = createHistory({ name: '重入保护电影' })
server.use(
http.get(subscribeApiUrls.historyByType('电影'), async () => {
requested()
return HttpResponse.json(await pending.promise)
}),
)
await renderDialog()
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
await fireEvent.click(screen.getByRole('button', { name: '加载更多历史' }))
expect(requested).toHaveBeenCalledOnce()
pending.resolve([movie])
expect(await screen.findByText('重入保护电影')).toBeInTheDocument()
expect(requested).toHaveBeenCalledOnce()
})
it.each([
['电影', createHistory({ name: '重新订阅电影' }), '正在重新订阅 重新订阅电影...'],
[
'电视剧',
createHistory({ name: '重新订阅剧集', season: 2, type: '电视剧' }),
'正在重新订阅 重新订阅剧集 第 2 季...',
],
] as const)('shows the %s pending copy and emits save only after success', async (type, item, progressText) => {
const pending = createDeferred<{ success: boolean }>()
let payload: JsonBodyType | undefined
server.use(
subscribeHistoryHandler(type, [item]),
http.post(subscribeApiUrls.create, async ({ request }) => {
payload = (await request.json()) as JsonBodyType
return HttpResponse.json(await pending.promise)
}),
)
const user = userEvent.setup()
const { events } = await renderDialog(type)
expect(await screen.findByText(item.name)).toBeInTheDocument()
await user.click(within(historyRow(item)).getByText('重新订阅'))
expect(await screen.findByRole('status')).toHaveTextContent(progressText)
expect(events.save).not.toHaveBeenCalled()
pending.resolve({ success: true })
await waitFor(() => expect(events.save).toHaveBeenCalledOnce())
expect(payload).toEqual(item)
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(events.close).not.toHaveBeenCalled()
expect(mocks.toastError).not.toHaveBeenCalled()
})
it('toasts a business failure when resubscribing and does not emit save', async () => {
const movie = createHistory({ name: '业务失败电影' })
server.use(
subscribeHistoryHandler('电影', [movie]),
createSubscribeHandler({ success: false }),
)
const user = userEvent.setup()
const { events } = await renderDialog()
expect(await screen.findByText(movie.name)).toBeInTheDocument()
await user.click(within(historyRow(movie)).getByText('重新订阅'))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('请求失败,请稍后重试'))
expect(events.save).not.toHaveBeenCalled()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(screen.getByText(movie.name)).toBeInTheDocument()
})
it('toasts an HTTP failure when resubscribing and does not emit save', async () => {
const movie = createHistory({ name: '网络失败电影' })
server.use(
subscribeHistoryHandler('电影', [movie]),
http.post(subscribeApiUrls.create, () => HttpResponse.json({ detail: 'failed' }, { status: 500 })),
)
const user = userEvent.setup()
const { events } = await renderDialog()
expect(await screen.findByText(movie.name)).toBeInTheDocument()
await user.click(within(historyRow(movie)).getByText('重新订阅'))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('请求失败,请稍后重试'))
expect(events.save).not.toHaveBeenCalled()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(screen.getByText(movie.name)).toBeInTheDocument()
})
it('removes only the target row when the history endpoint returns success true', async () => {
const first = createHistory({ name: '待删除电影' })
const second = createHistory({ name: '保留电影' })
const deleteRequested = vi.fn()
server.use(
subscribeHistoryHandler('电影', [first, second]),
deleteSubscribeHistoryHandler(first.id, { success: true }, 200, deleteRequested),
)
const user = userEvent.setup()
const { events } = await renderDialog()
expect(await screen.findByText(first.name)).toBeInTheDocument()
await user.click(within(historyRow(first)).getByText('删除'))
await waitFor(() => expect(screen.queryByText(first.name)).not.toBeInTheDocument())
expect(screen.getByText(second.name)).toBeInTheDocument()
expect(deleteRequested).toHaveBeenCalledOnce()
expect(events.save).not.toHaveBeenCalled()
expect(events.close).not.toHaveBeenCalled()
expect(mocks.toastError).not.toHaveBeenCalled()
})
it('keeps the row and toasts when history deletion fails over HTTP', async () => {
const movie = createHistory({ name: '删除失败电影' })
server.use(
subscribeHistoryHandler('电影', [movie]),
http.delete(subscribeApiUrls.historyById(movie.id), () =>
HttpResponse.json({ detail: 'failed' }, { status: 500 }),
),
)
const user = userEvent.setup()
await renderDialog()
expect(await screen.findByText(movie.name)).toBeInTheDocument()
await user.click(within(historyRow(movie)).getByText('删除'))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('请求失败,请稍后重试'))
expect(screen.getByText(movie.name)).toBeInTheDocument()
})
it('emits close from the dialog close button', async () => {
server.use(subscribeHistoryHandler('电影'))
const user = userEvent.setup()
const { events } = await renderDialog()
expect(await screen.findByText('没有已完成的订阅')).toBeInTheDocument()
const closeButton = document.querySelector('.absolute.right-3.top-3')
if (!(closeButton instanceof HTMLButtonElement)) throw new Error('Dialog close button was not rendered')
await user.click(closeButton)
expect(events.close).toHaveBeenCalledOnce()
expect(events.save).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,410 @@
import type { MediaInfo, MediaSeason, NotExistMediaInfo } from '@/api/types'
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
import SubscribeSeasonDialog from '@/components/dialog/SubscribeSeasonDialog.vue'
import type { SubscribeMode } from '@/composables/useMediaSubscribe'
import { fireEvent, screen, waitFor, within } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import {
createMediaInfo,
createMediaSeason,
createNotExistMediaInfo,
} from '@tests/support/factories/media'
import {
mediaEpisodeGroupsHandler,
mediaGroupSeasonsHandler,
mediaNotExistsHandler,
mediaSeasonsHandler,
} from '@tests/support/msw/handlers/media'
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { flushPromises } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
interface SeasonDialogProps {
defaultSubscribeMode?: SubscribeMode
initialEpisodeGroup?: string
media?: MediaInfo
modelValue?: boolean
selectedSeason?: number
subscribedSeasonModes?: Record<number, SubscribeMode>
subscribedSeasons?: number[]
}
function createDeferred() {
let resolve!: () => void
const promise = new Promise<void>(done => {
resolve = done
})
return { promise, resolve }
}
function createTvMedia(overrides: Partial<MediaInfo> = {}) {
return createMediaInfo({
poster_path: '/images/fallback-poster.jpg',
season: 1,
title: '季订阅测试剧',
tmdb_id: 7301,
type: '电视剧',
year: '2026',
...overrides,
})
}
async function renderDialog(props: SeasonDialogProps = {}) {
const events = {
close: vi.fn(),
subscribe: vi.fn(),
}
const result = await renderWithProviders(SubscribeSeasonDialog, {
global: {
components: {
VDialogCloseBtn: DialogCloseBtn,
},
},
initialState: {
globalSettings: {
data: {
GLOBAL_IMAGE_CACHE: false,
TMDB_IMAGE_DOMAIN: 'image.tmdb.org',
},
},
},
props: {
media: createTvMedia(),
modelValue: true,
...props,
onClose: events.close,
onSubscribe: events.subscribe,
},
})
return { ...result, events }
}
function seasonRow(number: number) {
const title = screen.getByText(`${number}`)
const row = title.closest('.v-list-item')
if (!row) throw new Error(`Season ${number} row was not rendered`)
return row as HTMLElement
}
async function settleRequests() {
await flushPromises()
await flushPromises()
}
describe('SubscribeSeasonDialog', () => {
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.spyOn(console, 'warn').mockImplementation(() => {})
})
it('loads the default TMDB seasons once with exact requests and renders season states', async () => {
const media = createTvMedia({ season: 0, tmdb_id: 7302 })
const seasons = [
createMediaSeason({ air_date: '2020-01-02', episode_count: 3, poster_path: '', season_number: 0 }),
createMediaSeason({ air_date: '2021-02-03', episode_count: 4, season_number: 1 }),
createMediaSeason({ air_date: '2022-03-04', episode_count: 5, season_number: 2 }),
]
const states = [
createNotExistMediaInfo({ episodes: [1, 2, 3], season: 0, total_episode: 3 }),
createNotExistMediaInfo({ episodes: [1, 2], season: 1, total_episode: 4 }),
createNotExistMediaInfo({ episodes: [], season: 2, total_episode: 5 }),
]
const seasonRequests: URL[] = []
const missingPayloads: Record<string, unknown>[] = []
const groupRequests = vi.fn()
server.use(
mediaSeasonsHandler(seasons, 200, url => {
seasonRequests.push(url)
}),
mediaNotExistsHandler(states, 200, payload => {
missingPayloads.push(payload)
}),
mediaEpisodeGroupsHandler(media.tmdb_id!, [], 200, groupRequests),
)
await renderDialog({ media })
expect(await screen.findByText('第 0 季')).toBeInTheDocument()
await waitFor(() => expect(screen.getByText('部分缺失')).toBeInTheDocument())
expect(screen.getByText('缺失')).toBeInTheDocument()
expect(screen.getByText('首播于 2020年1月2日')).toBeInTheDocument()
expect(seasonRow(0)).toHaveTextContent('3 集')
await settleRequests()
expect(seasonRequests).toHaveLength(1)
expect(missingPayloads).toHaveLength(1)
expect(groupRequests).toHaveBeenCalledOnce()
expect(seasonRequests[0].searchParams.get('mediaid')).toBe(`tmdb:${media.tmdb_id}`)
expect(seasonRequests[0].searchParams.get('title')).toBe(media.title)
expect(seasonRequests[0].searchParams.get('year')).toBe(media.year)
expect(seasonRequests[0].searchParams.get('season')).toBe('0')
expect(missingPayloads[0]).toMatchObject({ episode_group: '', season: 0, tmdb_id: media.tmdb_id })
})
it.each([
['Douban', { douban_id: 'db-7303', tmdb_id: undefined }, 'douban:db-7303'],
['Bangumi', { bangumi_id: 'bgm-7304', douban_id: undefined, tmdb_id: undefined }, 'bangumi:bgm-7304'],
[
'custom source',
{ bangumi_id: undefined, douban_id: undefined, media_id: 'custom-7305', mediaid_prefix: 'custom', tmdb_id: undefined },
'custom:custom-7305',
],
] as const)('uses the %s media identifier without requesting TMDB groups', async (_label, overrides, mediaId) => {
const media = createTvMedia(overrides)
const requested = vi.fn()
server.use(
mediaSeasonsHandler([createMediaSeason({ season_number: 1 })], 200, requested),
mediaNotExistsHandler([]),
)
await renderDialog({ media })
expect(await screen.findByText('第 1 季')).toBeInTheDocument()
await settleRequests()
expect(requested).toHaveBeenCalledOnce()
expect(requested.mock.calls[0][0].searchParams.get('mediaid')).toBe(mediaId)
})
it('synchronizes visible selections and modes, then emits the five-argument subscription payload', async () => {
const media = createTvMedia({ tmdb_id: 7306 })
const seasons = [
createMediaSeason({ season_number: 0 }),
createMediaSeason({ season_number: 1 }),
createMediaSeason({ season_number: 2 }),
]
server.use(
mediaSeasonsHandler(seasons),
mediaNotExistsHandler([
createNotExistMediaInfo({ episodes: [1], season: 0, total_episode: 1 }),
createNotExistMediaInfo({ episodes: [], season: 1, total_episode: 12 }),
createNotExistMediaInfo({ episodes: [1], season: 2, total_episode: 12 }),
]),
mediaEpisodeGroupsHandler(media.tmdb_id!, []),
)
const user = userEvent.setup()
const { events } = await renderDialog({
defaultSubscribeMode: 'normal',
media,
selectedSeason: 0,
subscribedSeasonModes: { 1: 'best_version' },
subscribedSeasons: [1, 99],
})
expect(await screen.findByText('第 2 季')).toBeInTheDocument()
await settleRequests()
expect(within(seasonRow(0)).getByRole('button', { name: '全集洗版' })).toHaveClass('v-btn--active')
expect(within(seasonRow(1)).getByRole('button', { name: '分集洗版' })).toHaveClass('v-btn--active')
await user.click(within(seasonRow(0)).getByRole('button', { name: '普通订阅' }))
await user.click(seasonRow(2))
await user.click(within(seasonRow(2)).getByRole('button', { name: '分集洗版' }))
await user.click(screen.getByRole('button', { name: '提交订阅' }))
expect(events.subscribe).toHaveBeenCalledOnce()
const [selected, states, episodeGroup, modes, visible] = events.subscribe.mock.calls[0]
expect(selected).toEqual(seasons)
expect(states).toEqual({ 0: 0, 1: 2, 2: 1 })
expect(episodeGroup).toBe('')
expect(modes).toMatchObject({ 0: 'normal', 1: 'best_version', 2: 'best_version' })
expect(visible).toEqual([0, 1, 2])
})
it('keeps the submit action disabled when subscribed selections and modes are unchanged', async () => {
const media = createTvMedia({ tmdb_id: 7307 })
server.use(
mediaSeasonsHandler([createMediaSeason({ season_number: 1 })]),
mediaNotExistsHandler([createNotExistMediaInfo({ episodes: [], season: 1 })]),
mediaEpisodeGroupsHandler(media.tmdb_id!, []),
)
await renderDialog({
media,
subscribedSeasonModes: { 1: 'best_version' },
subscribedSeasons: [1],
})
expect(await screen.findByRole('button', { name: '提交订阅' })).toBeDisabled()
expect(await screen.findByText('缺失')).toBeInTheDocument()
expect(screen.getByText('已订阅')).toBeInTheDocument()
})
it('preserves a manually chosen mode when delayed missing-state data arrives', async () => {
const media = createTvMedia({ tmdb_id: 7308 })
const missingGate = createDeferred()
server.use(
mediaSeasonsHandler([createMediaSeason({ season_number: 1 })]),
mediaNotExistsHandler(
[createNotExistMediaInfo({ episodes: [], season: 1 })],
200,
async () => missingGate.promise,
),
mediaEpisodeGroupsHandler(media.tmdb_id!, []),
)
const user = userEvent.setup()
const { events } = await renderDialog({
defaultSubscribeMode: 'best_version',
media,
selectedSeason: 1,
})
expect(await screen.findByText('第 1 季')).toBeInTheDocument()
await user.click(within(seasonRow(1)).getByRole('button', { name: '普通订阅' }))
missingGate.resolve()
await waitFor(() => expect(screen.getByText('缺失')).toBeInTheDocument())
await user.click(screen.getByRole('button', { name: '提交订阅' }))
const modes = events.subscribe.mock.calls[0][3]
expect(modes[1]).toBe('normal')
})
it('shows Loading instead of an error empty state while switching from a custom group to default seasons', async () => {
const media = createTvMedia({ tmdb_id: 7309 })
const defaultRequestStarted = createDeferred()
const defaultResponseGate = createDeferred()
server.use(
mediaEpisodeGroupsHandler(media.tmdb_id!, [
{ episode_count: 8, group_count: 1, id: 'group-a', name: '自定义排序 A' },
]),
mediaGroupSeasonsHandler('group-a', [createMediaSeason({ season_number: 5 })]),
mediaSeasonsHandler(
[createMediaSeason({ season_number: 1 })],
200,
async () => {
defaultRequestStarted.resolve()
await defaultResponseGate.promise
},
),
mediaNotExistsHandler([]),
)
const user = userEvent.setup()
await renderDialog({ initialEpisodeGroup: 'group-a', media })
expect(await screen.findByText('第 5 季')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /默认/ }))
await defaultRequestStarted.promise
const showedLoading = document.querySelector('.initial-loading-container') !== null
const showedErrorEmptyState = screen.queryByText(`${media.title} 未查询到季集信息`) !== null
defaultResponseGate.resolve()
expect(await screen.findByText('第 1 季')).toBeInTheDocument()
expect(showedLoading).toBe(true)
expect(showedErrorEmptyState).toBe(false)
})
it('exits Loading after the default season request fails', async () => {
const media = createTvMedia({ tmdb_id: 7310 })
const requested = vi.fn()
server.use(
mediaSeasonsHandler([], 500, requested),
mediaNotExistsHandler([]),
mediaEpisodeGroupsHandler(media.tmdb_id!, []),
)
await renderDialog({ media })
await waitFor(() => expect(requested).toHaveBeenCalled())
await settleRequests()
expect(document.querySelector('.initial-loading-container')).not.toBeInTheDocument()
expect(screen.getByText(`${media.title} 未查询到季集信息`)).toBeInTheDocument()
})
it('keeps season selection usable when the missing-state request fails', async () => {
const media = createTvMedia({ tmdb_id: 7313 })
const missingRequested = vi.fn()
server.use(
mediaSeasonsHandler([createMediaSeason({ season_number: 1 })]),
mediaNotExistsHandler([], 500, missingRequested),
mediaEpisodeGroupsHandler(media.tmdb_id!, []),
)
const user = userEvent.setup()
const { events } = await renderDialog({ media })
expect(await screen.findByText('第 1 季')).toBeInTheDocument()
await waitFor(() => expect(missingRequested).toHaveBeenCalledOnce())
await settleRequests()
await user.click(seasonRow(1))
await user.click(screen.getByRole('button', { name: '提交订阅' }))
expect(events.subscribe).toHaveBeenCalledOnce()
expect(events.subscribe.mock.calls[0][1]).toEqual({})
})
it('keeps season selection usable when optional episode groups fail to load', async () => {
const media = createTvMedia({ tmdb_id: 7314 })
const groupsRequested = vi.fn()
server.use(
mediaSeasonsHandler([createMediaSeason({ season_number: 1 })]),
mediaNotExistsHandler([createNotExistMediaInfo({ episodes: [], season: 1 })]),
mediaEpisodeGroupsHandler(media.tmdb_id!, [], 500, groupsRequested),
)
const user = userEvent.setup()
const { events } = await renderDialog({ media })
expect(await screen.findByText('第 1 季')).toBeInTheDocument()
await waitFor(() => expect(groupsRequested).toHaveBeenCalledOnce())
await settleRequests()
await user.click(seasonRow(1))
await user.click(screen.getByRole('button', { name: '提交订阅' }))
expect(events.subscribe).toHaveBeenCalledOnce()
expect(screen.getByRole('button', { name: /^默认/ })).toBeInTheDocument()
})
it('renders the successful empty state and emits close without submitting', async () => {
const media = createTvMedia({ tmdb_id: 7311 })
server.use(
mediaSeasonsHandler([]),
mediaNotExistsHandler([]),
mediaEpisodeGroupsHandler(media.tmdb_id!, []),
)
const { events } = await renderDialog({ media })
expect(await screen.findByText(`${media.title} 未查询到季集信息`)).toBeInTheDocument()
await settleRequests()
const closeButton = document.querySelector('.absolute.right-3')
expect(closeButton).not.toBeNull()
await fireEvent.click(closeButton!)
expect(events.close).toHaveBeenCalledOnce()
expect(events.subscribe).not.toHaveBeenCalled()
})
it('updates episode-group rail navigation from its real scroll position', async () => {
const media = createTvMedia({ tmdb_id: 7312 })
server.use(
mediaSeasonsHandler([createMediaSeason({ season_number: 1 })]),
mediaNotExistsHandler([]),
mediaEpisodeGroupsHandler(media.tmdb_id!, [
{ episode_count: 8, group_count: 1, id: 'group-a', name: '排序 A' },
{ episode_count: 8, group_count: 1, id: 'group-b', name: '排序 B' },
]),
mediaGroupSeasonsHandler('group-a', [createMediaSeason({ season_number: 1 })]),
mediaGroupSeasonsHandler('group-b', [createMediaSeason({ season_number: 2 })]),
)
const user = userEvent.setup()
await renderDialog({ media })
await screen.findByText('排序 B')
const rail = document.querySelector('.subscribe-season-group-options') as HTMLElement & {
scrollBy: (options: ScrollToOptions) => void
}
Object.defineProperties(rail, {
clientWidth: { configurable: true, value: 400 },
scrollLeft: { configurable: true, value: 0, writable: true },
scrollWidth: { configurable: true, value: 1000 },
})
rail.scrollBy = vi.fn()
await fireEvent.scroll(rail)
await user.click(screen.getByRole('button', { name: '查看更多剧集组' }))
expect(rail.scrollBy).toHaveBeenCalledWith({ behavior: 'smooth', left: 288 })
rail.scrollLeft = 300
await fireEvent.scroll(rail)
await user.click(screen.getByRole('button', { name: '查看上一组剧集组' }))
expect(rail.scrollBy).toHaveBeenCalledWith({ behavior: 'smooth', left: -288 })
})
})

View File

@@ -0,0 +1,170 @@
import SubscribeShareDialog from '@/components/dialog/SubscribeShareDialog.vue'
import { screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createSubscribe } from '@tests/support/factories/subscribe'
import { shareSubscribeHandler } from '@tests/support/msw/handlers/subscribe'
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { flushPromises } from '@vue/test-utils'
import { defineComponent, h } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
toastError: vi.fn(),
toastSuccess: vi.fn(),
}))
vi.mock('vue-toastification', () => ({
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
}))
const DialogCloseButtonStub = defineComponent({
name: 'VDialogCloseBtn',
emits: ['click'],
setup(_props, { emit }) {
return () => h('button', { type: 'button', onClick: () => emit('click') }, '关闭')
},
})
function createDeferred() {
let resolve!: () => void
const promise = new Promise<void>(done => {
resolve = done
})
return { promise, resolve }
}
async function renderDialog(season = 2, name = '分享创建测试剧') {
const close = vi.fn()
const sub = createSubscribe({
id: 5201,
name,
season,
tmdbid: 52010,
type: '电视剧',
})
const result = await renderWithProviders(SubscribeShareDialog, {
props: {
modelValue: true,
onClose: close,
sub,
},
global: {
components: {
VDialogCloseBtn: DialogCloseButtonStub,
},
},
})
return { ...result, close, sub }
}
async function fillRequiredFields(comment = '覆盖精确订阅规则', shareUser = '测试分享人') {
const user = userEvent.setup()
await user.type(screen.getByLabelText('说明'), comment)
await user.type(screen.getByLabelText('分享用户'), shareUser)
return user
}
describe('SubscribeShareDialog', () => {
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {})
})
it.each([
['normal season', 2, 'S02', '第 2 季'],
['special season zero', 0, 'S00', '第 0 季'],
])('renders the readonly default title and subtitle for %s', async (_case, season, seasonLabel, subtitle) => {
const { sub } = await renderDialog(season)
expect(screen.getByLabelText('标题')).toHaveValue(`${sub.name} ${seasonLabel}`)
expect(document.querySelector('.v-card-subtitle')).toHaveTextContent(`${sub.name} ${subtitle}`)
})
it.each([
['description', '', '测试分享人'],
['sharing user', '覆盖精确订阅规则', ''],
])('blocks submission when the %s is missing', async (_case, comment, shareUser) => {
const requested = vi.fn()
server.use(shareSubscribeHandler({ success: true }, 200, requested))
await renderDialog()
const user = userEvent.setup()
if (comment) await user.type(screen.getByLabelText('说明'), comment)
if (shareUser) await user.type(screen.getByLabelText('分享用户'), shareUser)
await user.click(screen.getByRole('button', { name: '确认分享' }))
await flushPromises()
expect(requested).not.toHaveBeenCalled()
expect(mocks.toastSuccess).not.toHaveBeenCalled()
expect(mocks.toastError).not.toHaveBeenCalled()
})
it('submits the exact payload once, stays pending, then closes after success', async () => {
const deferred = createDeferred()
const payloads: unknown[] = []
server.use(
shareSubscribeHandler({ success: true }, 200, async payload => {
payloads.push(payload)
await deferred.promise
}),
)
const { close, sub } = await renderDialog()
const user = await fillRequiredFields('只保留精确字段', '分享者甲')
const submit = screen.getByRole('button', { name: '确认分享' })
await user.click(submit)
await waitFor(() => expect(payloads).toHaveLength(1))
expect(payloads[0]).toEqual({
share_comment: '只保留精确字段',
share_title: `${sub.name} S02`,
share_user: '分享者甲',
subscribe_id: sub.id,
})
expect(submit).toBeDisabled()
expect(close).not.toHaveBeenCalled()
deferred.resolve()
await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith(`${sub.name} 分享成功!`))
expect(close).toHaveBeenCalledOnce()
expect(submit).not.toBeDisabled()
})
it('keeps the dialog open and reports a business failure', async () => {
server.use(shareSubscribeHandler({ message: '远端拒绝', success: false }))
const { close, sub } = await renderDialog()
const user = await fillRequiredFields()
const submit = screen.getByRole('button', { name: '确认分享' })
await user.click(submit)
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(`${sub.name} 分享失败:远端拒绝!`))
expect(close).not.toHaveBeenCalled()
expect(submit).not.toBeDisabled()
})
it('recovers from an HTTP failure, keeps the dialog open, and reports the failure', async () => {
server.use(shareSubscribeHandler({ message: '服务异常', success: false }, 500))
const { close, sub } = await renderDialog(2, 'HTTP失败剧')
const user = await fillRequiredFields()
const submit = screen.getByRole('button', { name: '确认分享' })
await user.click(submit)
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining(`${sub.name} 分享失败`)))
expect(close).not.toHaveBeenCalled()
expect(submit).not.toBeDisabled()
})
it('emits close from the dialog close control', async () => {
const user = userEvent.setup()
const { close } = await renderDialog()
await user.click(screen.getByRole('button', { name: '关闭' }))
expect(close).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,174 @@
import SubscribeShareStatisticsDialog from '@/components/dialog/SubscribeShareStatisticsDialog.vue'
import { screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createSubscribeShareStatistics } from '@tests/support/factories/subscribe'
import { subscribeShareStatisticsHandler } from '@tests/support/msw/handlers/subscribe'
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { defineComponent, h } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const DialogCloseButtonStub = defineComponent({
name: 'VDialogCloseBtn',
emits: ['click'],
setup(_props, { emit }) {
return () => h('button', { type: 'button', onClick: () => emit('click') }, '关闭')
},
})
const LoadingBannerStub = defineComponent({
name: 'LoadingBanner',
setup() {
return () => h('div', { role: 'status' }, '加载中')
},
})
function createDeferred() {
let resolve!: () => void
const promise = new Promise<void>(done => {
resolve = done
})
return { promise, resolve }
}
async function renderDialog() {
const close = vi.fn()
const result = await renderWithProviders(SubscribeShareStatisticsDialog, {
global: {
components: {
VDialogCloseBtn: DialogCloseButtonStub,
},
stubs: {
LoadingBanner: LoadingBannerStub,
},
},
props: {
modelValue: true,
onClose: close,
},
})
return { ...result, close }
}
describe('SubscribeShareStatisticsDialog', () => {
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {})
})
it('shows loading until statistics arrive', async () => {
const deferred = createDeferred()
const requested = vi.fn(() => deferred.promise)
server.use(
subscribeShareStatisticsHandler(
[createSubscribeShareStatistics({ share_user: '延迟统计用户' })],
200,
requested,
),
)
renderDialog()
expect(await screen.findByRole('status')).toHaveTextContent('加载中')
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
deferred.resolve()
expect((await screen.findAllByText('延迟统计用户')).length).toBeGreaterThan(0)
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
it('sorts by reuse count and preserves source order for ties', async () => {
server.use(
subscribeShareStatisticsHandler([
createSubscribeShareStatistics({ share_user: '并列第一甲', total_reuse_count: 50 }),
createSubscribeShareStatistics({ share_user: '并列第一乙', total_reuse_count: 50 }),
createSubscribeShareStatistics({ share_user: '第三名', total_reuse_count: 30 }),
createSubscribeShareStatistics({ share_user: '并列末位甲', total_reuse_count: 10 }),
createSubscribeShareStatistics({ share_user: '并列末位乙', total_reuse_count: 10 }),
]),
)
await renderDialog()
expect(await screen.findByText('#4')).toBeInTheDocument()
const firstPlace = document.body.querySelector('.first-place')?.parentElement
expect(firstPlace).toHaveTextContent('并列第一甲')
expect(firstPlace).not.toHaveTextContent('并列第一乙')
const renderedText = document.body.textContent || ''
expect(renderedText.indexOf('并列末位甲')).toBeLessThan(renderedText.indexOf('并列末位乙'))
expect(screen.getByText('#5')).toBeInTheDocument()
})
it.each([1, 2, 3, 4])('renders a complete ranking with %i participant(s)', async count => {
const statistics = Array.from({ length: count }, (_, index) =>
createSubscribeShareStatistics({
share_user: `人数场景用户 ${count}-${index + 1}`,
total_reuse_count: count - index,
}),
)
server.use(subscribeShareStatisticsHandler(statistics))
await renderDialog()
for (const item of statistics) {
expect((await screen.findAllByText(item.share_user!)).length).toBeGreaterThan(0)
}
expect(screen.queryByText('暂无分享统计数据')).not.toBeInTheDocument()
if (count === 4) expect(screen.getByText('#4')).toBeInTheDocument()
})
it('renders missing aggregate fields across the podium and remaining rankings', async () => {
const statistics = Array.from({ length: 4 }, () =>
createSubscribeShareStatistics({
share_count: undefined,
share_user: undefined,
total_reuse_count: undefined,
}),
)
server.use(subscribeShareStatisticsHandler(statistics))
await renderDialog()
expect((await screen.findAllByText('未知')).length).toBeGreaterThan(0)
expect(screen.getAllByText('0').length).toBeGreaterThanOrEqual(2)
expect(screen.getByText('#4')).toBeInTheDocument()
})
it('treats an empty statistics list as valid empty data', async () => {
server.use(subscribeShareStatisticsHandler([]))
await renderDialog()
expect(await screen.findByText('暂无分享统计数据')).toBeInTheDocument()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
it('shows an HTTP error separately and retries the same request on demand', async () => {
const failedRequest = vi.fn()
server.use(subscribeShareStatisticsHandler([], 500, failedRequest))
const user = userEvent.setup()
await renderDialog()
expect(await screen.findByText('请求失败,请稍后重试')).toBeInTheDocument()
expect(failedRequest).toHaveBeenCalledOnce()
server.use(
subscribeShareStatisticsHandler([
createSubscribeShareStatistics({ share_user: '重试恢复用户', total_reuse_count: 20 }),
]),
)
await user.click(screen.getByRole('button', { name: '重试' }))
expect((await screen.findAllByText('重试恢复用户')).length).toBeGreaterThan(0)
expect(screen.queryByText('请求失败,请稍后重试')).not.toBeInTheDocument()
})
it('emits close from the dialog close control', async () => {
server.use(subscribeShareStatisticsHandler([]))
const user = userEvent.setup()
const { close } = await renderDialog()
await user.click(screen.getByRole('button', { name: '关闭' }))
expect(close).toHaveBeenCalledOnce()
})
})

View File

@@ -5,9 +5,14 @@ import { DashboardItem } from '@/api/types'
import DashboardRender from '@/components/render/DashboardRender.vue'
import { isNullOrEmptyObject } from '@/@core/utils'
import { loadRemoteComponent } from '@/utils/federationLoader'
import { useToast } from 'vue-toastification'
type DashboardComponentLoader = () => Promise<any>
// 仪表板联邦组件复用主应用 Toast 实例。
const $toast = useToast()
provide('moviepilot:toast', $toast)
const DashboardSkeleton = {
setup() {
const SkeletonLoader = resolveComponent('VSkeletonLoader')

View File

@@ -0,0 +1,167 @@
import VirtualSlideView from '@/components/slide/VirtualSlideView.vue'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import { renderWithProviders } from '@tests/support/render'
import { defineComponent, nextTick, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('vuetify', async importOriginal => {
const actual = await importOriginal<typeof import('vuetify')>()
return {
...actual,
useDisplay: () => ({ mobile: { value: false } }),
}
})
let resizeCallbacks: ResizeObserverCallback[] = []
let resizeObservers: ResizeObserverMock[] = []
class ResizeObserverMock implements ResizeObserver {
readonly disconnect = vi.fn()
readonly observe = vi.fn()
readonly unobserve = vi.fn()
constructor(callback: ResizeObserverCallback) {
resizeCallbacks.push(callback)
resizeObservers.push(this)
}
}
const items = Array.from({ length: 20 }, (_, index) => ({ id: index, label: `项目 ${index}` }))
async function renderSlide(overrides: Record<string, unknown> = {}) {
return renderWithProviders(VirtualSlideView, {
props: {
getItemKey: (item: { id: number }) => item.id,
itemGap: 10,
itemWidth: 100,
items,
overscanItems: 1,
...overrides,
},
slots: {
empty: '<p>没有内容</p>',
item: '<template #item="{ item, index }"><article :data-index="index">{{ item.label }}</article></template>',
loading: '<p>正在加载</p>',
title: '<h2>测试轨道</h2>',
},
})
}
function configureScroller(container: Element, clientWidth = 220, scrollWidth = 2190) {
const scroller = container.querySelector<HTMLElement>('.slider-content')
expect(scroller).not.toBeNull()
Object.defineProperties(scroller, {
clientWidth: { configurable: true, value: clientWidth },
scrollWidth: { configurable: true, value: scrollWidth },
})
return scroller as HTMLElement
}
describe('VirtualSlideView', () => {
beforeEach(() => {
resizeCallbacks = []
resizeObservers = []
vi.stubGlobal('ResizeObserver', ResizeObserverMock)
vi.stubGlobal(
'requestAnimationFrame',
vi.fn((callback: FrameRequestCallback) => {
callback(0)
return 1
}),
)
})
it('renders loading and empty slots without virtual content', async () => {
const loading = await renderSlide({ loading: true })
expect(screen.getByText('正在加载')).toBeInTheDocument()
expect(loading.container.querySelector('.virtual-track')).not.toBeInTheDocument()
loading.unmount()
const empty = await renderSlide({ items: [] })
expect(screen.getByText('没有内容')).toBeInTheDocument()
expect(empty.container.querySelector('.virtual-track')).not.toBeInTheDocument()
})
it('keeps total width while moving the rendered range with scroll position', async () => {
const { container } = await renderSlide()
const scroller = configureScroller(container)
resizeCallbacks[0]?.([], resizeObservers[0])
await waitFor(() => expect(container.querySelectorAll('.virtual-slide-item')).toHaveLength(3))
expect(container.querySelector<HTMLElement>('.virtual-track')).toHaveStyle({ width: '2190px' })
expect(screen.getByText('项目 0')).toHaveAttribute('data-index', '0')
expect(screen.getByText('项目 2')).toHaveAttribute('data-index', '2')
scroller.scrollLeft = 550
await fireEvent.scroll(scroller)
await waitFor(() => expect(screen.getByText('项目 4')).toHaveAttribute('data-index', '4'))
expect(screen.getByText('项目 7')).toHaveAttribute('data-index', '7')
expect(container.querySelectorAll('.virtual-spacer')).toHaveLength(2)
expect(container.querySelector('.nav-button-left')).toBeVisible()
})
it('scrolls one viewport smoothly and updates navigation state', async () => {
vi.useFakeTimers()
const { container } = await renderSlide()
const scroller = configureScroller(container)
const scrollTo = vi.fn(({ left }: ScrollToOptions) => {
scroller.scrollLeft = left ?? 0
})
scroller.scrollTo = scrollTo as typeof scroller.scrollTo
resizeCallbacks[0]?.([], resizeObservers[0])
await fireEvent.click(container.querySelector('.nav-button-right') as HTMLElement)
expect(scrollTo).toHaveBeenCalledWith({ behavior: 'smooth', left: 220, top: 0 })
expect(container.querySelector('.slider-container')).toHaveClass('is-scrolling')
await fireEvent.scroll(scroller)
await nextTick()
scroller.scrollLeft = 1970
await fireEvent.scroll(scroller)
await nextTick()
expect(container.querySelector('.nav-button-right')).not.toBeVisible()
await fireEvent.click(container.querySelector('.nav-button-left') as HTMLElement)
expect(scrollTo).toHaveBeenLastCalledWith({ behavior: 'smooth', left: 1760, top: 0 })
vi.advanceTimersByTime(1500)
await Promise.resolve()
expect(container.querySelector('.slider-container')).not.toHaveClass('is-scrolling')
})
it('restores the saved offset after KeepAlive activation and releases resources on unmount', async () => {
const Harness = defineComponent({
components: { VirtualSlideView },
setup() {
const active = ref(true)
return { active, items }
},
template: `
<button type="button" @click="active = false">停用轨道</button>
<button type="button" @click="active = true">启用轨道</button>
<KeepAlive>
<VirtualSlideView v-if="active" :items="items" :item-width="100" :item-gap="10" :overscan-items="1">
<template #title><h2>测试轨道</h2></template>
<template #item="{ item }"><article>{{ item.label }}</article></template>
</VirtualSlideView>
</KeepAlive>
`,
})
const removeListener = vi.spyOn(window, 'removeEventListener')
const { container, unmount } = await renderWithProviders(Harness)
let scroller = configureScroller(container)
resizeCallbacks[0]?.([], resizeObservers[0])
scroller.scrollLeft = 440
await fireEvent.scroll(scroller)
await fireEvent.click(screen.getByRole('button', { name: '停用轨道' }))
scroller.scrollLeft = 0
await fireEvent.click(screen.getByRole('button', { name: '启用轨道' }))
scroller = configureScroller(container)
await waitFor(() => expect(scroller.scrollLeft).toBe(440))
unmount()
expect(resizeObservers[0]?.disconnect).toHaveBeenCalledOnce()
expect(removeListener).toHaveBeenCalledWith('resize', expect.any(Function))
})
})

View File

@@ -422,6 +422,12 @@ function handlePluginClick() {
showPluginQuickAccess.value = false
}
// 组件卸载时清理监听
onBeforeUnmount(() => {
window.removeEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange)
window.removeEventListener(THEME_CUSTOMIZER_OPEN_EVENT, handleThemeCustomizerOpen)
})
/** 将插件侧边栏菜单合并到对应的导航分组中。 */
function appendPluginSidebarMenus() {
for (const { navMenu, section } of filterPluginSidebarNavEntries(
@@ -464,12 +470,6 @@ onMounted(async () => {
await pluginSidebarNavStore.ensureSidebarNav()
appendPluginSidebarMenus()
// 组件卸载时清理监听
onBeforeUnmount(() => {
window.removeEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange)
window.removeEventListener(THEME_CUSTOMIZER_OPEN_EVENT, handleThemeCustomizerOpen)
})
})
</script>

View File

@@ -0,0 +1,125 @@
import DefaultLayout from '@/layouts/default/components/DefaultLayout.vue'
import {
THEME_CUSTOMIZER_CHANGE_EVENT,
THEME_CUSTOMIZER_OPEN_EVENT,
} from '@/composables/useThemeCustomizer'
import { flushPromises, shallowMount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
emptyComponent: { template: '<div><slot /></div>' },
ensureSidebarNav: vi.fn(),
}))
vi.mock('@layouts/components/VerticalNavLayout.vue', () => ({ default: mocks.emptyComponent }))
vi.mock('@layouts/components/VerticalNavLink.vue', () => ({ default: mocks.emptyComponent }))
vi.mock('@layouts/components/VerticalNavSectionTitle.vue', () => ({ default: mocks.emptyComponent }))
vi.mock('@/components/agent/AgentAssistantWidget.vue', () => ({ default: mocks.emptyComponent }))
vi.mock('@/components/misc/ThemeLogoMark.vue', () => ({ default: mocks.emptyComponent }))
vi.mock('@/components/theme/ThemeCustomizer.vue', () => ({ default: mocks.emptyComponent }))
vi.mock('@/layouts/default/components/Footer.vue', () => ({ default: mocks.emptyComponent }))
vi.mock('@/layouts/default/components/HeaderTab.vue', () => ({ default: mocks.emptyComponent }))
vi.mock('@/layouts/default/components/OfflinePage.vue', () => ({ default: mocks.emptyComponent }))
vi.mock('@/layouts/default/components/QuickAccess.vue', () => ({ default: mocks.emptyComponent }))
vi.mock('@/layouts/default/components/SearchBar.vue', () => ({ default: mocks.emptyComponent }))
vi.mock('@/layouts/default/components/ShortcutBar.vue', () => ({ default: mocks.emptyComponent }))
vi.mock('@/layouts/default/components/UserNotification.vue', () => ({ default: mocks.emptyComponent }))
vi.mock('@/layouts/default/components/UserProfile.vue', () => ({ default: mocks.emptyComponent }))
vi.mock('@/stores', () => ({
useGlobalSettingsStore: () => ({ get: vi.fn(() => false) }),
usePluginSidebarNavStore: () => ({
ensureSidebarNav: mocks.ensureSidebarNav,
items: [],
}),
useUserStore: () => ({ permissions: [], superUser: true }),
}))
vi.mock('@/router/i18n-menu', () => ({ getNavMenus: () => [] }))
vi.mock('@/utils/pluginSidebarNav', () => ({ filterPluginSidebarNavEntries: () => [] }))
vi.mock('@/utils/permission', async importOriginal => ({
...(await importOriginal<typeof import('@/utils/permission')>()),
buildUserPermissionContext: () => ({}),
filterItemsByPermission: <T>(items: T[]) => items,
filterMenusByPermission: () => [],
hasItemPermission: () => true,
hasPermission: () => true,
}))
vi.mock('@/composables/useOfflineStatus', () => ({
useGlobalOfflineStatus: () => ({ isOffline: { value: false } }),
}))
vi.mock('@/composables/usePullDownGesture', () => ({
usePullDownGesture: () => ({
config: {
MAX_PULL_DISTANCE: 220,
SHOW_INDICATOR: 80,
TRIGGER_THRESHOLD: 140,
},
contentTransform: { value: '' },
contentTransition: { value: '' },
indicatorOpacity: { value: 0 },
indicatorRotation: { value: 0 },
indicatorTransform: { value: '' },
pullDistance: { value: 0 },
showPullIndicator: { value: false },
}),
}))
vi.mock('@/composables/usePWA', () => ({ usePWA: () => ({ appMode: { value: false } }) }))
vi.mock('vue-i18n', async importOriginal => ({
...(await importOriginal<typeof import('vue-i18n')>()),
useI18n: () => ({ t: (key: string) => key }),
}))
vi.mock('vue-router', async importOriginal => ({
...(await importOriginal<typeof import('vue-router')>()),
useRoute: () => ({ path: '/' }),
useRouter: () => ({ push: vi.fn() }),
}))
vi.mock('vuetify', async importOriginal => ({
...(await importOriginal<typeof import('vuetify')>()),
useDisplay: () => ({ mdAndDown: { value: false } }),
}))
describe('DefaultLayout', () => {
beforeEach(() => {
mocks.ensureSidebarNav.mockReset()
})
it('removes theme customizer listeners while sidebar loading is still pending', async () => {
let resolveSidebarNav!: () => void
mocks.ensureSidebarNav.mockImplementation(
() =>
new Promise<void>(resolve => {
resolveSidebarNav = resolve
}),
)
const addEventListener = vi.spyOn(window, 'addEventListener')
const removeEventListener = vi.spyOn(window, 'removeEventListener')
const wrapper = shallowMount(DefaultLayout, {
global: {
stubs: {
IconBtn: mocks.emptyComponent,
RouterLink: mocks.emptyComponent,
},
},
})
await flushPromises()
const changeHandler = addEventListener.mock.calls.find(
([event]) => String(event) === THEME_CUSTOMIZER_CHANGE_EVENT,
)?.[1]
const openHandler = addEventListener.mock.calls.find(
([event]) => String(event) === THEME_CUSTOMIZER_OPEN_EVENT,
)?.[1]
expect(changeHandler).toBeTypeOf('function')
expect(openHandler).toBeTypeOf('function')
wrapper.unmount()
expect(removeEventListener).toHaveBeenCalledWith(THEME_CUSTOMIZER_CHANGE_EVENT, changeHandler)
expect(removeEventListener).toHaveBeenCalledWith(THEME_CUSTOMIZER_OPEN_EVENT, openHandler)
resolveSidebarNav()
await flushPromises()
})
})

View File

@@ -2215,6 +2215,8 @@ export default {
entryCount: '{count} entries',
ruleCount: '{count} rules',
listLabel: 'Word list content (one rule per line)',
lineNumbers: 'Line numbers',
syntaxHighlighting: 'Syntax highlighting',
saved: 'Saved',
unsaved: 'Unsaved changes',
saveChanges: 'Save changes',
@@ -3252,6 +3254,8 @@ export default {
countTitle: 'Count:',
noData: 'None',
refreshing: 'Refreshing site data...',
loadFailed: 'Failed to load site data',
refreshFailed: 'Failed to refresh site data',
close: 'Close',
},
siteResource: {
@@ -3262,6 +3266,7 @@ export default {
itemsPerPage: 'Items Per Page',
noData: 'No Data',
loading: 'Loading...',
loadFailed: 'Failed to load resources. Please retry.',
titleColumn: 'Title',
timeColumn: 'Time',
sizeColumn: 'Size',

View File

@@ -2177,6 +2177,8 @@ export default {
entryCount: '{count} 条',
ruleCount: '共 {count} 条规则',
listLabel: '词表内容(每行一个规则)',
lineNumbers: '行号',
syntaxHighlighting: '语法高亮',
saved: '已保存',
unsaved: '有未保存修改',
saveChanges: '保存更改',
@@ -3200,6 +3202,8 @@ export default {
countTitle: '数量:',
noData: '无',
refreshing: '正在刷新站点数据...',
loadFailed: '加载站点数据失败',
refreshFailed: '刷新站点数据失败',
close: '关闭',
},
siteResource: {
@@ -3210,6 +3214,7 @@ export default {
itemsPerPage: '每页条数',
noData: '没有数据',
loading: '加载中...',
loadFailed: '资源加载失败,请重试',
titleColumn: '标题',
timeColumn: '时间',
sizeColumn: '大小',

View File

@@ -2176,6 +2176,8 @@ export default {
entryCount: '{count} 條',
ruleCount: '共 {count} 條規則',
listLabel: '詞表內容(每行一個規則)',
lineNumbers: '行號',
syntaxHighlighting: '語法高亮',
saved: '已保存',
unsaved: '有未保存修改',
saveChanges: '保存更改',
@@ -3199,6 +3201,8 @@ export default {
countTitle: '數量:',
noData: '無',
refreshing: '正在刷新站點數據...',
loadFailed: '載入站點數據失敗',
refreshFailed: '刷新站點數據失敗',
close: '關閉',
},
siteResource: {
@@ -3210,6 +3214,7 @@ export default {
pageText: '{0}-{1} 共 {2} 條',
noData: '沒有數據',
loading: '加載中...',
loadFailed: '資源加載失敗,請重試',
titleColumn: '標題',
timeColumn: '時間',
sizeColumn: '大小',

View File

@@ -0,0 +1,101 @@
import BrowsePage from '@/pages/browse.vue'
import { screen } from '@testing-library/vue'
import { renderWithProviders } from '@tests/support/render'
import { defineComponent, h, type PropType } from 'vue'
import { describe, expect, it } from 'vitest'
const MediaCardListViewStub = defineComponent({
name: 'MediaCardListView',
props: {
apipath: String,
params: Object as PropType<Record<string, unknown>>,
},
setup(props) {
return () =>
h('section', { 'aria-label': '媒体 browse 列表', 'data-api-path': props.apipath }, [
h('output', { 'aria-label': '媒体 browse 查询' }, JSON.stringify(props.params)),
])
},
})
const PersonCardListViewStub = defineComponent({
name: 'PersonCardListView',
props: {
apipath: String,
params: Object as PropType<Record<string, unknown>>,
},
setup(props) {
return () =>
h('section', { 'aria-label': '人物 browse 列表', 'data-api-path': props.apipath }, [
h('output', { 'aria-label': '人物 browse 查询' }, JSON.stringify(props.params)),
])
},
})
const PageContentTitleStub = defineComponent({
name: 'VPageContentTitle',
props: { title: String },
setup(props) {
return () => h('h1', props.title)
},
})
async function renderBrowse(paths: string[], query: Record<string, string | string[] | null>) {
return renderWithProviders(BrowsePage, {
initialRoute: { path: '/browse', query },
props: { paths },
global: {
stubs: {
MediaCardListView: MediaCardListViewStub,
PersonCardListView: PersonCardListViewStub,
VPageContentTitle: PageContentTitleStub,
VScrollToTopBtn: true,
},
},
})
}
function projectedQuery(label: string) {
return JSON.parse(screen.getByRole('status', { name: label }).textContent || '{}') as Record<string, unknown>
}
describe('browse page', () => {
it('joins array paths and projects the complete route query to the media list', async () => {
const query = {
genre: ['动作', '科幻'],
page: '7',
sort: 'vote',
title: '相似媒体',
type: 'movie',
}
await renderBrowse(['tmdb', 'recommend', '123', 'movie'], query)
expect(screen.getByRole('heading', { name: '相似媒体' })).toBeInTheDocument()
expect(screen.getByRole('region', { name: '媒体 browse 列表' })).toHaveAttribute(
'data-api-path',
'tmdb/recommend/123/movie',
)
expect(projectedQuery('媒体 browse 查询')).toEqual(query)
expect(screen.queryByRole('region', { name: '人物 browse 列表' })).not.toBeInTheDocument()
})
it('routes person queries to the person list and prefixes the title', async () => {
const query = {
page: '3',
source: 'themoviedb',
title: '张三',
type: 'person',
}
await renderBrowse(['person', 'search'], query)
expect(screen.getByRole('heading', { name: '演员: 张三' })).toBeInTheDocument()
expect(screen.getByRole('region', { name: '人物 browse 列表' })).toHaveAttribute(
'data-api-path',
'person/search',
)
expect(projectedQuery('人物 browse 查询')).toEqual(query)
expect(screen.queryByRole('region', { name: '媒体 browse 列表' })).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,425 @@
import DiscoverPage from '@/pages/discover.vue'
import type { DiscoverSource } from '@/api/types'
import { fireEvent, waitFor } from '@testing-library/vue'
import { renderWithProviders } from '@tests/support/render'
import {
discoverApiUrls,
discoverOrderConfigHandler,
discoverSourcesHandler,
saveDiscoverOrderHandler,
type DiscoverOrderItem,
} from '@tests/support/msw/handlers/discover'
import { server } from '@tests/support/msw/server'
import { HttpResponse, http } from 'msw'
import { defineComponent, h, ref, unref, type ComputedRef, type Ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
interface HeaderTabItem {
tab: string
title: string
}
interface HeaderTabConfig {
appendButtons: Array<{ action: () => void }>
items: ComputedRef<HeaderTabItem[]> | Ref<HeaderTabItem[]> | HeaderTabItem[]
modelValue: Ref<string>
}
interface SharedDialogEvents {
close: () => void
save: (tabs: DiscoverSource[]) => Promise<void>
'update:modelValue': (value: boolean) => void
}
interface SharedDialogController {
close: ReturnType<typeof vi.fn>
id: number
updateProps: ReturnType<typeof vi.fn>
}
const mocks = vi.hoisted(() => ({
controllers: [] as SharedDialogController[],
openSharedDialog: vi.fn(),
registerHeaderTab: vi.fn(),
}))
vi.mock('@/composables/useDynamicHeaderTab', () => ({
useDynamicHeaderTab: () => ({ registerHeaderTab: mocks.registerHeaderTab }),
}))
vi.mock('@/composables/useSharedDialog', () => ({
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
}))
const BuiltInViewStub = defineComponent({
name: 'BuiltInViewStub',
setup: () => () => h('section', '内置发现内容'),
})
const ExtraSourceViewStub = defineComponent({
name: 'ExtraSourceView',
props: {
source: { type: Object, required: true },
},
setup(props) {
return () => h('section', { 'data-testid': 'extra-source' }, (props.source as DiscoverSource).name)
},
})
function createSource(name: string, prefix: string, apiPath = `discover/${prefix}`): DiscoverSource {
return {
api_path: apiPath,
filter_params: { type: prefix },
filter_ui: [],
mediaid_prefix: prefix,
name,
}
}
function keepAliveHarness() {
return defineComponent({
components: { DiscoverPage },
setup() {
const active = ref(true)
return { active }
},
template: `
<button type="button" @click="active = false">停用发现页</button>
<button type="button" @click="active = true">启用发现页</button>
<KeepAlive><DiscoverPage v-if="active" /></KeepAlive>
`,
})
}
async function renderDiscover() {
const componentError = vi.fn()
const result = await renderWithProviders(keepAliveHarness(), {
initialRoute: '/discover',
global: {
config: {
errorHandler: componentError,
},
stubs: {
BangumiView: BuiltInViewStub,
DoubanView: BuiltInViewStub,
ExtraSourceView: ExtraSourceViewStub,
TheMovieDbView: BuiltInViewStub,
VScrollToTopBtn: true,
},
},
})
return { ...result, componentError }
}
function getHeaderConfig() {
const config = mocks.registerHeaderTab.mock.calls[0]?.[0] as HeaderTabConfig | undefined
if (!config) throw new Error('发现页未注册动态标签')
return config
}
function getHeaderItems() {
return unref(getHeaderConfig().items)
}
function getDialogCall(index = 0) {
const call = mocks.openSharedDialog.mock.calls[index]
if (!call) throw new Error(`未找到第 ${index + 1} 个排序弹窗`)
return {
events: call[2] as SharedDialogEvents,
tabs: (call[1] as { tabs: DiscoverSource[] }).tabs,
}
}
async function reactivateDiscover() {
await fireEvent.click(document.querySelector('button') as HTMLButtonElement)
const buttons = Array.from(document.querySelectorAll('button'))
const activate = buttons.find(button => button.textContent === '启用发现页')
if (!activate) throw new Error('未找到启用发现页按钮')
await fireEvent.click(activate)
}
describe('discover page', () => {
beforeEach(() => {
mocks.controllers.length = 0
mocks.openSharedDialog.mockImplementation(() => {
const controller: SharedDialogController = {
close: vi.fn(),
id: mocks.controllers.length + 1,
updateProps: vi.fn(),
}
mocks.controllers.push(controller)
return controller
})
})
it('uses local order, merges sources by prefix, and keeps unconfigured tabs stable', async () => {
const configRequested = vi.fn()
localStorage.setItem(
'MP_DISCOVER_TAB_ORDER',
JSON.stringify([{ name: '豆瓣' }, { name: '自定义来源' }]),
)
server.use(
discoverOrderConfigHandler([], 200, configRequested),
discoverSourcesHandler([
createSource('自定义来源', 'custom'),
createSource('重复自定义来源', 'custom'),
createSource('伪豆瓣', 'douban'),
]),
)
await renderDiscover()
await waitFor(() =>
expect(getHeaderItems().map(item => item.title)).toEqual(['豆瓣', '自定义来源', 'TheMovieDb', 'Bangumi']),
)
expect(configRequested).not.toHaveBeenCalled()
expect(getHeaderItems().map(item => item.tab)).toEqual(['douban', 'custom', 'themoviedb', 'bangumi'])
})
it('loads remote order when local order is absent and backfills localStorage', async () => {
const configRequested = vi.fn()
const remoteOrder = [{ name: 'Bangumi' }, { name: 'TheMovieDb' }]
server.use(
discoverOrderConfigHandler(remoteOrder, 200, configRequested),
discoverSourcesHandler([createSource('自定义来源', 'custom')]),
)
await renderDiscover()
await waitFor(() => expect(configRequested).toHaveBeenCalledOnce())
await waitFor(() =>
expect(getHeaderItems().map(item => item.title)).toEqual(['Bangumi', 'TheMovieDb', '豆瓣', '自定义来源']),
)
expect(localStorage.getItem('MP_DISCOVER_TAB_ORDER')).toBe(JSON.stringify(remoteOrder))
})
it('falls back to remote order when local JSON is malformed', async () => {
const configRequested = vi.fn()
const remoteOrder = [{ name: 'Bangumi' }, { name: 'TheMovieDb' }]
localStorage.setItem('MP_DISCOVER_TAB_ORDER', '{malformed')
server.use(discoverOrderConfigHandler(remoteOrder, 200, configRequested), discoverSourcesHandler([]))
const { componentError } = await renderDiscover()
await waitFor(() => expect(configRequested).toHaveBeenCalledOnce())
await waitFor(() => expect(getHeaderItems().map(item => item.title)).toEqual(['Bangumi', 'TheMovieDb', '豆瓣']))
expect(localStorage.getItem('MP_DISCOVER_TAB_ORDER')).toBe(JSON.stringify(remoteOrder))
expect(componentError).not.toHaveBeenCalled()
})
it('keeps built-in and extra sources usable when the order config request fails', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.spyOn(console, 'log').mockImplementation(() => {})
server.use(
discoverOrderConfigHandler(null, 500),
discoverSourcesHandler([createSource('可用扩展源', 'available')]),
)
await renderDiscover()
await waitFor(() => expect(getHeaderItems().map(item => item.title)).toContain('可用扩展源'))
expect(getHeaderItems().map(item => item.title)).toEqual(['TheMovieDb', '豆瓣', 'Bangumi', '可用扩展源'])
expect(getHeaderConfig().modelValue.value).toBe('themoviedb')
})
it('reuses the source request during initial KeepAlive activation', async () => {
const requested = vi.fn()
let releaseResponse = () => {}
const responseGate = new Promise<void>(resolve => {
releaseResponse = resolve
})
localStorage.setItem('MP_DISCOVER_TAB_ORDER', JSON.stringify([]))
server.use(
discoverSourcesHandler([createSource('延迟来源', 'delayed')], 200, async () => {
requested()
await responseGate
}),
)
await renderDiscover()
await waitFor(() => expect(requested).toHaveBeenCalled())
await new Promise(resolve => window.setTimeout(resolve, 25))
const requestCount = requested.mock.calls.length
releaseResponse()
await waitFor(() => expect(getHeaderItems().map(item => item.title)).toContain('延迟来源'))
expect(requestCount).toBe(1)
})
it('does not refetch sources when the initial response settles before activation', async () => {
const requested = vi.fn()
localStorage.setItem('MP_DISCOVER_TAB_ORDER', JSON.stringify([]))
server.use(discoverSourcesHandler([createSource('快速来源', 'fast')], 200, requested))
await renderDiscover()
await waitFor(() => expect(getHeaderItems().map(item => item.title)).toContain('快速来源'))
await new Promise(resolve => window.setTimeout(resolve, 25))
expect(requested).toHaveBeenCalledOnce()
})
it('removes a withdrawn source and falls back to the first sorted tab after reactivation', async () => {
let sources = [createSource('已撤销来源', 'withdrawn')]
const requested = vi.fn()
localStorage.setItem(
'MP_DISCOVER_TAB_ORDER',
JSON.stringify([{ name: '已撤销来源' }, { name: 'TheMovieDb' }]),
)
server.use(
http.get(discoverApiUrls.sources, () => {
requested()
return HttpResponse.json(sources)
}),
)
await renderDiscover()
await waitFor(() => expect(getHeaderItems().map(item => item.title)).toContain('已撤销来源'))
await waitFor(() => expect(getHeaderConfig().modelValue.value).toBe('withdrawn'))
const requestsBeforeReactivation = requested.mock.calls.length
sources = []
await reactivateDiscover()
await waitFor(() => expect(requested).toHaveBeenCalledTimes(requestsBeforeReactivation + 1))
await waitFor(() => expect(getHeaderItems().map(item => item.title)).not.toContain('已撤销来源'))
await waitFor(() => expect(getHeaderConfig().modelValue.value).toBe('themoviedb'))
})
it('keeps the last successful source snapshot when reactivation fails', async () => {
let status = 200
const requested = vi.fn()
localStorage.setItem('MP_DISCOVER_TAB_ORDER', JSON.stringify([]))
vi.spyOn(console, 'log').mockImplementation(() => {})
server.use(
http.get(discoverApiUrls.sources, () => {
requested()
return HttpResponse.json(status === 200 ? [createSource('缓存来源', 'cached')] : [], { status })
}),
)
await renderDiscover()
await waitFor(() => expect(getHeaderItems().map(item => item.title)).toContain('缓存来源'))
const requestsBeforeReactivation = requested.mock.calls.length
status = 500
await reactivateDiscover()
await waitFor(() => expect(requested).toHaveBeenCalledTimes(requestsBeforeReactivation + 1))
expect(getHeaderItems().map(item => item.title)).toContain('缓存来源')
expect(getHeaderItems().map(item => item.title)).toEqual(['TheMovieDb', '豆瓣', 'Bangumi', '缓存来源'])
})
it('replaces the header metadata when a source with the same prefix changes', async () => {
let sources = [createSource('旧来源名称', 'mutable', 'discover/old')]
const requested = vi.fn()
localStorage.setItem('MP_DISCOVER_TAB_ORDER', JSON.stringify([]))
server.use(
http.get(discoverApiUrls.sources, () => {
requested()
return HttpResponse.json(sources)
}),
)
await renderDiscover()
await waitFor(() => expect(getHeaderItems().map(item => item.title)).toContain('旧来源名称'))
const requestsBeforeReactivation = requested.mock.calls.length
sources = [createSource('新来源名称', 'mutable', 'discover/new')]
await reactivateDiscover()
await waitFor(() => expect(requested).toHaveBeenCalledTimes(requestsBeforeReactivation + 1))
await waitFor(() => expect(getHeaderItems().map(item => item.title)).toContain('新来源名称'))
expect(getHeaderItems().map(item => item.title)).not.toContain('旧来源名称')
})
it('saves the exact visible order through the shared dialog boundary', async () => {
const savedOrders: DiscoverOrderItem[][] = []
localStorage.setItem('MP_DISCOVER_TAB_ORDER', JSON.stringify([]))
server.use(
discoverSourcesHandler([createSource('自定义来源', 'custom')]),
saveDiscoverOrderHandler(order => {
savedOrders.push(order)
}),
)
await renderDiscover()
await waitFor(() => expect(getHeaderItems()).toHaveLength(4))
getHeaderConfig().appendButtons[0].action()
const { events, tabs } = getDialogCall()
const reorderedTabs = [tabs[3], tabs[1], tabs[0], tabs[2]]
await events.save(reorderedTabs)
const expectedOrder = reorderedTabs.map(item => ({ name: item.name }))
expect(savedOrders).toEqual([expectedOrder])
expect(localStorage.getItem('MP_DISCOVER_TAB_ORDER')).toBe(JSON.stringify(expectedOrder))
expect(getHeaderItems().map(item => item.title)).toEqual(reorderedTabs.map(item => item.name))
expect(mocks.controllers[0].close).toHaveBeenCalledOnce()
})
it('closes the previous controller before opening another order dialog', async () => {
localStorage.setItem('MP_DISCOVER_TAB_ORDER', JSON.stringify([]))
server.use(discoverSourcesHandler([createSource('弹窗就绪来源', 'dialog-ready')]))
await renderDiscover()
await waitFor(() => expect(getHeaderItems().map(item => item.title)).toContain('弹窗就绪来源'))
const action = getHeaderConfig().appendButtons[0].action
action()
action()
expect(mocks.openSharedDialog).toHaveBeenCalledTimes(2)
expect(mocks.controllers[0].close).toHaveBeenCalledOnce()
expect(mocks.controllers[1].close).not.toHaveBeenCalled()
})
it('releases only the controller named by close and model update events', async () => {
localStorage.setItem('MP_DISCOVER_TAB_ORDER', JSON.stringify([]))
server.use(discoverSourcesHandler([createSource('弹窗就绪来源', 'dialog-ready')]))
await renderDiscover()
await waitFor(() => expect(getHeaderItems().map(item => item.title)).toContain('弹窗就绪来源'))
const action = getHeaderConfig().appendButtons[0].action
action()
getDialogCall(0).events.close()
action()
expect(mocks.controllers[0].close).not.toHaveBeenCalled()
getDialogCall(1).events['update:modelValue'](true)
action()
expect(mocks.controllers[1].close).toHaveBeenCalledOnce()
getDialogCall(2).events['update:modelValue'](false)
action()
expect(mocks.controllers[2].close).not.toHaveBeenCalled()
})
it('does not let a pending save from an old dialog close a newer controller', async () => {
let releaseSave = () => {}
const saveGate = new Promise<void>(resolve => {
releaseSave = resolve
})
const saveStarted = vi.fn()
localStorage.setItem('MP_DISCOVER_TAB_ORDER', JSON.stringify([]))
server.use(
discoverSourcesHandler([createSource('弹窗就绪来源', 'dialog-ready')]),
saveDiscoverOrderHandler(async () => {
saveStarted()
await saveGate
}),
)
await renderDiscover()
await waitFor(() => expect(getHeaderItems().map(item => item.title)).toContain('弹窗就绪来源'))
const action = getHeaderConfig().appendButtons[0].action
action()
const firstDialog = getDialogCall(0)
const pendingSave = firstDialog.events.save(firstDialog.tabs)
await waitFor(() => expect(saveStarted).toHaveBeenCalledOnce())
firstDialog.events.close()
action()
expect(mocks.controllers).toHaveLength(2)
releaseSave()
await pendingSave
expect(mocks.controllers[1].close).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,57 @@
import MediaPage from '@/pages/media.vue'
import { screen } from '@testing-library/vue'
import { renderWithProviders } from '@tests/support/render'
import { defineComponent, h } from 'vue'
import { describe, expect, it } from 'vitest'
const MediaDetailViewStub = defineComponent({
name: 'MediaDetailView',
props: {
mediaid: String,
title: String,
type: String,
year: String,
},
setup(props) {
return () => h('output', { 'aria-label': '媒体详情参数' }, JSON.stringify(props))
},
})
async function renderPage(query: Record<string, string | string[] | null>) {
return renderWithProviders(MediaPage, {
initialRoute: { path: '/media', query },
global: {
stubs: {
MediaDetailView: MediaDetailViewStub,
},
},
})
}
function projectedProps() {
return JSON.parse(screen.getByRole('status', { name: '媒体详情参数' }).textContent || '{}') as Record<string, unknown>
}
describe('media page', () => {
it('projects route query values as strings to the detail view', async () => {
await renderPage({
mediaid: ['tmdb:101', 'ignored'],
title: '测试电影',
type: '电影',
year: '2026',
})
expect(projectedProps()).toEqual({
mediaid: 'tmdb:101,ignored',
title: '测试电影',
type: '电影',
year: '2026',
})
})
it('keeps missing query values undefined', async () => {
await renderPage({})
expect(projectedProps()).toEqual({})
})
})

View File

@@ -1,15 +1,18 @@
import RecommendPage from '@/pages/recommend.vue'
import type { RecommendSource } from '@/api/types'
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 {
recommendApiUrls,
recommendConfigHandler,
recommendSourcesHandler,
saveRecommendConfigHandler,
} from '@tests/support/msw/handlers/recommend'
import { server } from '@tests/support/msw/server'
import { defineComponent } from 'vue'
import { HttpResponse, http } from 'msw'
import { defineComponent, nextTick, ref, type Ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
@@ -72,6 +75,46 @@ async function renderRecommend(options: { superUser?: boolean; discovery?: boole
})
}
function keepAliveHarness() {
return defineComponent({
components: { RecommendPage },
setup() {
const active = ref(true)
return { active }
},
template: `
<button type="button" @click="active = false">停用推荐页</button>
<button type="button" @click="active = true">启用推荐页</button>
<KeepAlive><RecommendPage v-if="active" /></KeepAlive>
`,
})
}
async function renderKeptAliveRecommend() {
return renderWithProviders(keepAliveHarness(), {
initialRoute: '/recommend',
initialState: {
user: {
permissions: { ...DEFAULT_PERMISSIONS, discovery: true },
superUser: false,
},
},
global: {
stubs: {
MediaCardSlideView: MediaCardSlideViewStub,
VScrollToTopBtn: true,
},
},
})
}
function dynamicRecommendSources(getSources: () => RecommendSource[], onRequest = vi.fn()) {
return http.get(recommendApiUrls.sources, () => {
onRequest()
return HttpResponse.json(getSources())
})
}
describe('recommend page', () => {
beforeEach(() => {
mocks.openSharedDialog.mockReturnValue({
@@ -101,6 +144,73 @@ describe('recommend page', () => {
expect(remoteConfigRequests).toBe(0)
})
it('filters by the registered category and waits until ready before showing its empty state', async () => {
const setTimeout = vi.spyOn(window, 'setTimeout')
const sourcesRequested = vi.fn()
localStorage.setItem('MP_RECOMMEND', JSON.stringify({ '流行趋势': true }))
server.use(recommendSourcesHandler([], 200, sourcesRequested))
await renderRecommend()
await waitFor(() => expect(sourcesRequested).toHaveBeenCalledOnce())
const headerTab = mocks.registerHeaderTab.mock.calls[0][0] as { modelValue: Ref<string> }
headerTab.modelValue.value = '电视剧'
await nextTick()
expect(screen.queryByText('当前分类下没有可显示的内容')).not.toBeInTheDocument()
const readyTimer = setTimeout.mock.calls.find(([, delay]) => delay === 400)?.[0]
expect(typeof readyTimer).toBe('function')
;(readyTimer as () => void)()
await nextTick()
expect(screen.getByText('当前分类下没有可显示的内容')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '设置显示内容' })).toBeInTheDocument()
})
it('loads extra sources only once during the initial KeepAlive activation', async () => {
const requested = vi.fn()
let releaseResponse = () => {}
const responseGate = new Promise<void>(resolve => {
releaseResponse = resolve
})
localStorage.setItem('MP_RECOMMEND', JSON.stringify({ '流行趋势': true, '请求完成来源': true }))
server.use(
http.get(recommendApiUrls.sources, async () => {
requested()
await responseGate
return HttpResponse.json([{ api_path: 'recommend/completed', name: '请求完成来源', type: '扩展' }])
}),
)
await renderKeptAliveRecommend()
await waitFor(() => expect(requested).toHaveBeenCalled())
await new Promise(resolve => window.setTimeout(resolve, 25))
try {
expect(requested).toHaveBeenCalledOnce()
} finally {
releaseResponse()
}
expect(await screen.findByText('请求完成来源')).toBeInTheDocument()
})
it('removes an extra source withdrawn while the kept-alive page is inactive', async () => {
let sources: RecommendSource[] = [{ api_path: 'recommend/custom', name: '已撤销来源', type: '扩展' }]
const requested = vi.fn()
localStorage.setItem('MP_RECOMMEND', JSON.stringify({ '流行趋势': true, '已撤销来源': true }))
server.use(dynamicRecommendSources(() => sources, requested))
await renderKeptAliveRecommend()
expect(await screen.findByText('已撤销来源')).toBeInTheDocument()
const requestsBeforeReactivation = requested.mock.calls.length
sources = []
await fireEvent.click(screen.getByRole('button', { name: '停用推荐页' }))
await fireEvent.click(screen.getByRole('button', { name: '启用推荐页' }))
await waitFor(() => expect(requested).toHaveBeenCalledTimes(requestsBeforeReactivation + 1))
await waitFor(() => expect(screen.queryByText('已撤销来源')).not.toBeInTheDocument())
})
it('loads remote configuration when local configuration is absent', async () => {
const remoteConfig = { '流行趋势': false, '正在热映': true }
const configRequested = vi.fn()

View File

@@ -0,0 +1,23 @@
import SitePage from '@/pages/site.vue'
import { screen } from '@testing-library/vue'
import { renderWithProviders } from '@tests/support/render'
import { defineComponent, h } from 'vue'
import { describe, expect, it } from 'vitest'
const SiteCardListViewStub = defineComponent({
name: 'SiteCardListView',
setup: () => () => h('section', { 'aria-label': '站点列表视图' }, '站点列表'),
})
describe('site page', () => {
it('renders the site list view as the page content', async () => {
await renderWithProviders(SitePage, {
initialRoute: '/site',
global: {
stubs: { SiteCardListView: SiteCardListViewStub },
},
})
expect(screen.getByRole('region', { name: '站点列表视图' })).toBeInTheDocument()
})
})

View File

@@ -3,37 +3,33 @@ import MediaCardListView from '@/views/discover/MediaCardListView.vue'
import PersonCardListView from '@/views/discover/PersonCardListView.vue'
import { useI18n } from 'vue-i18n'
// 国际化
const { t } = useI18n()
// 输入参数
const props = defineProps({
// API路径
paths: Array as PropType<string[]> | PropType<string>,
// `/browse/:paths+` 捕获的 API 路径段。
paths: {
type: Array as PropType<string[]>,
required: true,
},
})
// 路由参数
const route = useRoute()
// 标题
let title = route.query?.title?.toString()
// 类型
const type = route.query?.type?.toString()
if (type === 'person') title = t('browse.actor') + ': ' + title
// 计算API路径
function getApiPath(paths: string[] | string) {
if (Array.isArray(paths)) return paths.join('/')
else return paths
function getApiPath(paths: string[]) {
return paths.join('/')
}
</script>
<template>
<div>
<VPageContentTitle :title="title" />
<PersonCardListView v-if="type === 'person'" :apipath="getApiPath(props.paths || '')" :params="route.query" />
<MediaCardListView v-else :apipath="getApiPath(props.paths || '')" :params="route.query" />
<PersonCardListView v-if="type === 'person'" :apipath="getApiPath(props.paths)" :params="route.query" />
<MediaCardListView v-else :apipath="getApiPath(props.paths)" :params="route.query" />
<Teleport to="body">
<VScrollToTopBtn />
</Teleport>

View File

@@ -41,61 +41,82 @@ const discoverTabItems = computed(() => {
const extraDiscoverSources = ref<DiscoverSource[]>([])
let orderDialogController: ReturnType<typeof openSharedDialog> | null = null
let extraSourcesRequest: Promise<void> | null = null
let initialLoadPromise: Promise<void> | null = null
// 打开发现页标签排序共享弹窗。
function openOrderConfigDialog() {
orderDialogController?.close()
orderDialogController = openSharedDialog(
const releaseController = () => {
if (orderDialogController === controller) orderDialogController = null
}
const controller = openSharedDialog(
DiscoverTabOrderDialog,
{
tabs: discoverTabs.value,
},
{
close: () => {
orderDialogController = null
},
save: saveTabOrder,
close: releaseController,
save: tabs => saveTabOrder(tabs, controller),
'update:modelValue': (value: boolean) => {
if (!value) orderDialogController = null
if (!value) releaseController()
},
},
{ closeOn: ['close', 'update:modelValue'] },
)
orderDialogController = controller
}
// 关闭发现页标签排序共享弹窗。
function closeOrderConfigDialog() {
orderDialogController?.close()
function closeOrderConfigDialog(controller = orderDialogController) {
if (!controller || orderDialogController !== controller) return
controller.close()
orderDialogController = null
}
// 初始化发现标签
function initDiscoverTabs() {
// 构造内置发现标签,扩展来源刷新时以此为稳定基线。
function createBuiltInDiscoverTabs() {
const tabs = getDiscoverTabs(t)
for (const tab of tabs) {
discoverTabs.value.push({
return tabs.map(tab => {
return {
name: tab.title,
mediaid_prefix: tab.tab,
api_path: '',
filter_params: {},
filter_ui: [],
})
}
}
})
}
// 加载额外的发现数据源
async function loadExtraDiscoverSources() {
// 用最近一次成功快照替换扩展来源,并保持 mediaid_prefix 唯一。
function replaceExtraDiscoverSources(sources: DiscoverSource[]) {
const builtInTabs = createBuiltInDiscoverTabs()
const seenPrefixes = new Set(builtInTabs.map(tab => tab.mediaid_prefix))
const nextExtraSources = sources.filter(source => {
if (seenPrefixes.has(source.mediaid_prefix)) return false
seenPrefixes.add(source.mediaid_prefix)
return true
})
extraDiscoverSources.value = nextExtraSources
discoverTabs.value = [...builtInTabs, ...nextExtraSources]
}
// 加载额外的发现数据源;并发生命周期钩子共享同一次请求。
function loadExtraDiscoverSources() {
if (extraSourcesRequest) return extraSourcesRequest
extraSourcesRequest = refreshExtraDiscoverSources().finally(() => {
extraSourcesRequest = null
})
return extraSourcesRequest
}
async function refreshExtraDiscoverSources() {
try {
extraDiscoverSources.value = await api.get('discover/source')
if (extraDiscoverSources.value.length === 0) {
return
}
for (const source of extraDiscoverSources.value) {
if (discoverTabs.value.find(tab => tab.mediaid_prefix === source.mediaid_prefix)) {
continue
}
discoverTabs.value.push(source)
}
const sources: DiscoverSource[] = await api.get('discover/source')
replaceExtraDiscoverSources(sources)
} catch (error) {
console.log(error)
}
@@ -121,18 +142,23 @@ async function loadOrderConfig() {
// 顺序配置
const local_order = localStorage.getItem(localOrderKey)
if (local_order) {
orderConfig.value = JSON.parse(local_order)
} else {
const response = await api.get(`/user/config/${localOrderKey}`)
if (response && response.data && response.data.value) {
orderConfig.value = response.data.value
localStorage.setItem(localOrderKey, JSON.stringify(orderConfig.value))
try {
orderConfig.value = JSON.parse(local_order)
return
} catch {
localStorage.removeItem(localOrderKey)
}
}
const response = await api.get(`/user/config/${localOrderKey}`)
if (response && response.data && response.data.value) {
orderConfig.value = response.data.value
localStorage.setItem(localOrderKey, JSON.stringify(orderConfig.value))
}
}
// 保存顺序设置
async function saveTabOrder(tabs = discoverTabs.value) {
async function saveTabOrder(tabs = discoverTabs.value, controller = orderDialogController) {
discoverTabs.value = [...tabs]
// 顺序配置
const orderObj = discoverTabs.value.map(item => ({ name: item.name }))
@@ -146,7 +172,7 @@ async function saveTabOrder(tabs = discoverTabs.value) {
} catch (error) {
console.error(error)
}
closeOrderConfigDialog()
closeOrderConfigDialog(controller)
}
// 使用动态标签页
@@ -168,18 +194,33 @@ registerHeaderTab({
],
})
onBeforeMount(async () => {
initDiscoverTabs()
await loadOrderConfig()
async function initializeDiscover() {
discoverTabs.value = createBuiltInDiscoverTabs()
try {
await loadOrderConfig()
} catch (error) {
console.log(error)
}
await loadExtraDiscoverSources()
sortSubscribeOrder()
// 选中第一个标签页
if (discoverTabs.value.length > 0) {
activeTab.value = discoverTabs.value[0].mediaid_prefix
}
}
onBeforeMount(() => {
initialLoadPromise = initializeDiscover().finally(() => {
initialLoadPromise = null
})
})
onActivated(async () => {
if (initialLoadPromise) {
await initialLoadPromise
return
}
await loadExtraDiscoverSources()
sortSubscribeOrder()
// 如果当前没有选中任何标签页,或者当前选中的标签页不存在,则选中第一个标签页

View File

@@ -1,9 +1,5 @@
<script setup lang="ts">
import MediaDetailView from '@/views/discover/MediaDetailView.vue'
import { useI18n } from 'vue-i18n'
// 国际化
const { t } = useI18n()
// 路由参数
const route = useRoute()

View File

@@ -2,6 +2,7 @@
import type { Component } from 'vue'
import api from '@/api'
import { loadRemoteAppPageComponent } from '@/utils/federationLoader'
import { useToast } from 'vue-toastification'
const route = useRoute()
@@ -11,6 +12,10 @@ const navKey = computed(() => (route.params.navKey as string) || 'main')
const RemoteView = shallowRef<Component | null>(null)
const loadError = ref(false)
// 侧栏联邦页面复用主应用 Toast 实例。
const $toast = useToast()
provide('moviepilot:toast', $toast)
watch(
[pluginId, navKey],
async ([pid, nk]) => {

View File

@@ -68,7 +68,8 @@ function openRecommendSettings() {
)
}
const viewList = reactive<RecommendViewSource[]>(createBuiltInRecommendSources(t))
const builtInRecommendSources = createBuiltInRecommendSources(t)
const viewList = reactive<RecommendViewSource[]>([...builtInRecommendSources])
// 计算当前分类下显示的视图
const filteredViews = computed(() => {
@@ -96,6 +97,7 @@ function initializeColors() {
// 额外的数据源
const extraRecommendSources = ref<RecommendSource[]>([])
let extraSourcesRequest: Promise<void> | null = null
/** 只接受以标题为键、布尔值为开关的推荐配置。 */
function normalizeEnableConfig(value: unknown): Record<string, boolean> | null {
@@ -107,14 +109,24 @@ function normalizeEnableConfig(value: unknown): Record<string, boolean> | null {
return Object.fromEntries(entries)
}
// 加载额外的发现数据源
async function loadExtraRecommendSources() {
try {
extraRecommendSources.value = await api.get('recommend/source')
mergeExtraRecommendSources(viewList, extraRecommendSources.value)
} catch (error) {
console.log(error)
}
/** 刷新扩展推荐源;并发生命周期入口共享请求,成功响应按当前服务端快照替换列表。 */
function loadExtraRecommendSources() {
if (extraSourcesRequest) return extraSourcesRequest
extraSourcesRequest = (async () => {
try {
extraRecommendSources.value = await api.get('recommend/source')
const nextViewList = [...builtInRecommendSources]
mergeExtraRecommendSources(nextViewList, extraRecommendSources.value)
viewList.splice(0, viewList.length, ...nextViewList)
} catch (error) {
console.log(error)
}
})().finally(() => {
extraSourcesRequest = null
})
return extraSourcesRequest
}
// 加载面板配置

View File

@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import type { authState } from '@/stores/types'
import { usePluginSidebarNavStore } from '@/stores/pluginSidebarNav'
import { clearCachedMediaSubscribeStatuses } from '@/utils/mediaStatusCache'
export const useAuthStore = defineStore('auth', {
state: (): authState => ({
@@ -32,6 +33,7 @@ export const useAuthStore = defineStore('auth', {
logout() {
this.clearToken()
this.setOriginalPath(null)
clearCachedMediaSubscribeStatuses()
usePluginSidebarNavStore().reset()
},
},

View File

@@ -0,0 +1,47 @@
import { resolveFederationRemoteUrl } from '@/utils/federationUrl'
import { describe, expect, it } from 'vitest'
describe('resolveFederationRemoteUrl', () => {
const remotePath = '/plugin/file/example/frontend/dist/assets/remoteEntry.js'
it.each([
{
name: 'Docker 根路径 HTTP 入口',
apiBaseUrl: 'api/v1/',
documentBaseUri: 'http://moviepilot.local/',
expected: 'http://moviepilot.local/api/v1/plugin/file/example/frontend/dist/assets/remoteEntry.js',
},
{
name: 'Docker 或 local_setup 的无尾斜杠 fallback 入口',
apiBaseUrl: 'api/v1/',
documentBaseUri: 'http://moviepilot.local/dashboard',
expected: 'http://moviepilot.local/api/v1/plugin/file/example/frontend/dist/assets/remoteEntry.js',
},
{
name: 'HTTPS 反向代理入口',
apiBaseUrl: 'api/v1/',
documentBaseUri: 'https://moviepilot.example.com/dashboard',
expected: 'https://moviepilot.example.com/api/v1/plugin/file/example/frontend/dist/assets/remoteEntry.js',
},
{
name: '反向代理子路径入口',
apiBaseUrl: 'api/v1/',
documentBaseUri: 'https://moviepilot.example.com/mp/',
expected: 'https://moviepilot.example.com/mp/api/v1/plugin/file/example/frontend/dist/assets/remoteEntry.js',
},
{
name: '根路径 API 配置',
apiBaseUrl: '/api/v1/',
documentBaseUri: 'https://moviepilot.example.com/mp/',
expected: 'https://moviepilot.example.com/api/v1/plugin/file/example/frontend/dist/assets/remoteEntry.js',
},
{
name: '完整跨域 API 配置',
apiBaseUrl: 'https://api.example.com/api/v1',
documentBaseUri: 'http://moviepilot.local/dashboard',
expected: 'https://api.example.com/api/v1/plugin/file/example/frontend/dist/assets/remoteEntry.js',
},
])('$name', ({ apiBaseUrl, documentBaseUri, expected }) => {
expect(resolveFederationRemoteUrl(remotePath, apiBaseUrl, documentBaseUri)).toBe(expected)
})
})

View File

@@ -0,0 +1,162 @@
import { useAuthStore } from '@/stores/auth'
import {
getCachedMediaExistsStatus,
getCachedMediaSubscribeStatus,
setCachedMediaExistsStatus,
setCachedMediaSubscribeStatus,
} from '@/utils/mediaStatusCache'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
let keySequence = 0
function nextKey(label: string) {
keySequence += 1
return `${label}:${keySequence}`
}
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, reject, resolve }
}
describe('media status cache', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-07-18T00:00:00Z'))
})
it('isolates exists and subscribe values by cache and key within the TTL', async () => {
const sharedKey = nextKey('isolated')
const otherKey = nextKey('other')
const existsLoader = vi.fn().mockResolvedValue(true)
const subscribeLoader = vi.fn().mockResolvedValue(false)
const otherLoader = vi.fn().mockResolvedValue(true)
await expect(getCachedMediaExistsStatus(sharedKey, existsLoader)).resolves.toBe(true)
await expect(getCachedMediaSubscribeStatus(sharedKey, subscribeLoader)).resolves.toBe(false)
await expect(getCachedMediaSubscribeStatus(otherKey, otherLoader)).resolves.toBe(true)
await expect(getCachedMediaExistsStatus(sharedKey, vi.fn().mockResolvedValue(false))).resolves.toBe(true)
await expect(getCachedMediaSubscribeStatus(sharedKey, vi.fn().mockResolvedValue(true))).resolves.toBe(false)
expect(existsLoader).toHaveBeenCalledOnce()
expect(subscribeLoader).toHaveBeenCalledOnce()
expect(otherLoader).toHaveBeenCalledOnce()
})
it('coalesces concurrent requests and retries after a rejected loader', async () => {
const key = nextKey('concurrent')
const first = deferred<boolean>()
const loader = vi.fn().mockReturnValue(first.promise)
const requestA = getCachedMediaExistsStatus(key, loader)
const requestB = getCachedMediaExistsStatus(key, loader)
expect(loader).toHaveBeenCalledOnce()
first.reject(new Error('temporary failure'))
await expect(requestA).rejects.toThrow('temporary failure')
await expect(requestB).rejects.toThrow('temporary failure')
const retryLoader = vi.fn().mockResolvedValue(true)
await expect(getCachedMediaExistsStatus(key, retryLoader)).resolves.toBe(true)
expect(retryLoader).toHaveBeenCalledOnce()
})
it('reloads expired values and lets explicit values replace cached values', async () => {
const existsKey = nextKey('expired')
const subscribeKey = nextKey('explicit')
await expect(getCachedMediaExistsStatus(existsKey, vi.fn().mockResolvedValue(false))).resolves.toBe(false)
vi.advanceTimersByTime(3 * 60 * 1000)
const expiredLoader = vi.fn().mockResolvedValue(true)
await expect(getCachedMediaExistsStatus(existsKey, expiredLoader)).resolves.toBe(true)
setCachedMediaExistsStatus(existsKey, false)
await expect(getCachedMediaExistsStatus(existsKey, vi.fn().mockResolvedValue(true))).resolves.toBe(false)
setCachedMediaSubscribeStatus(subscribeKey, true)
await expect(getCachedMediaSubscribeStatus(subscribeKey, vi.fn().mockResolvedValue(false))).resolves.toBe(true)
})
it('keeps an explicit mutation result when an older status request resolves later', async () => {
const key = nextKey('mutation-race')
const staleRequest = deferred<boolean>()
const pendingStatus = getCachedMediaSubscribeStatus(key, () => staleRequest.promise)
setCachedMediaSubscribeStatus(key, true)
staleRequest.resolve(false)
await expect(pendingStatus).resolves.toBe(true)
await expect(getCachedMediaSubscribeStatus(key, vi.fn().mockResolvedValue(false))).resolves.toBe(true)
})
it('keeps an explicit mutation result after its cache TTL when an older request resolves', async () => {
const key = nextKey('expired-mutation-race')
const staleRequest = deferred<boolean>()
const pendingStatus = getCachedMediaSubscribeStatus(key, () => staleRequest.promise)
setCachedMediaSubscribeStatus(key, false)
vi.advanceTimersByTime(3 * 60 * 1000)
staleRequest.resolve(true)
await expect(pendingStatus).resolves.toBe(false)
await expect(getCachedMediaSubscribeStatus(key, vi.fn().mockResolvedValue(true))).resolves.toBe(true)
})
it('reloads a completed subscription value after logout and login', async () => {
const key = nextKey('completed-session')
const authStore = useAuthStore()
authStore.login({ token: 'account-a', remember: false })
await expect(getCachedMediaSubscribeStatus(key, vi.fn().mockResolvedValue(true))).resolves.toBe(true)
authStore.logout()
authStore.login({ token: 'account-b', remember: false })
const accountBLoader = vi.fn().mockResolvedValue(false)
await expect(getCachedMediaSubscribeStatus(key, accountBLoader)).resolves.toBe(false)
expect(accountBLoader).toHaveBeenCalledOnce()
await expect(getCachedMediaSubscribeStatus(key, vi.fn().mockResolvedValue(true))).resolves.toBe(false)
})
it('does not reuse a previous account subscription request or value after logout and login', async () => {
const key = nextKey('session')
const oldAccountRequest = deferred<boolean>()
const newAccountRequest = deferred<boolean>()
const authStore = useAuthStore()
authStore.login({ token: 'account-a', remember: false })
const accountAStatus = getCachedMediaSubscribeStatus(key, () => oldAccountRequest.promise)
authStore.logout()
authStore.login({ token: 'account-b', remember: false })
const accountBLoader = vi.fn().mockReturnValue(newAccountRequest.promise)
const accountBStatus = getCachedMediaSubscribeStatus(key, accountBLoader)
let accountASettled = false
void accountAStatus.then(() => {
accountASettled = true
})
oldAccountRequest.resolve(true)
await Promise.resolve()
await Promise.resolve()
expect(accountASettled).toBe(false)
const repeatedAccountBStatus = getCachedMediaSubscribeStatus(key, accountBLoader)
expect(accountBLoader).toHaveBeenCalledOnce()
newAccountRequest.resolve(false)
await expect(accountAStatus).resolves.toBe(false)
await expect(accountBStatus).resolves.toBe(false)
await expect(repeatedAccountBStatus).resolves.toBe(false)
expect(accountBLoader).toHaveBeenCalledOnce()
await expect(getCachedMediaSubscribeStatus(key, vi.fn().mockResolvedValue(true))).resolves.toBe(false)
})
})

View File

@@ -0,0 +1,77 @@
import { getCachedSiteIcon } from '@/utils/siteIconCache'
import { beforeEach, describe, expect, it, vi } from 'vitest'
let keySeed = 0
function nextKey() {
keySeed += 1
return `site-icon-${keySeed}`
}
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, reject, resolve }
}
describe('site icon cache', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-07-19T00:00:00Z'))
})
it('isolates site ids and reuses each value within the TTL', async () => {
const firstLoader = vi.fn().mockResolvedValue('first-icon')
const secondLoader = vi.fn().mockResolvedValue('second-icon')
const firstKey = nextKey()
const secondKey = nextKey()
await expect(getCachedSiteIcon(firstKey, firstLoader)).resolves.toBe('first-icon')
await expect(getCachedSiteIcon(secondKey, secondLoader)).resolves.toBe('second-icon')
await expect(getCachedSiteIcon(firstKey, vi.fn().mockResolvedValue('stale'))).resolves.toBe('first-icon')
expect(firstLoader).toHaveBeenCalledOnce()
expect(secondLoader).toHaveBeenCalledOnce()
})
it('coalesces concurrent requests for the same site', async () => {
const pending = deferred<string>()
const loader = vi.fn().mockReturnValue(pending.promise)
const key = nextKey()
const firstRequest = getCachedSiteIcon(key, loader)
const secondRequest = getCachedSiteIcon(key, loader)
expect(loader).toHaveBeenCalledOnce()
pending.resolve('shared-icon')
await expect(firstRequest).resolves.toBe('shared-icon')
await expect(secondRequest).resolves.toBe('shared-icon')
})
it('reloads an expired value after ten minutes', async () => {
const key = nextKey()
await expect(getCachedSiteIcon(key, vi.fn().mockResolvedValue('old-icon'))).resolves.toBe('old-icon')
vi.advanceTimersByTime(10 * 60 * 1000)
const refreshedLoader = vi.fn().mockResolvedValue('fresh-icon')
await expect(getCachedSiteIcon(key, refreshedLoader)).resolves.toBe('fresh-icon')
expect(refreshedLoader).toHaveBeenCalledOnce()
})
it('allows a retry after the loader rejects', async () => {
const key = nextKey()
await expect(getCachedSiteIcon(key, vi.fn().mockRejectedValue(new Error('temporary failure')))).rejects.toThrow(
'temporary failure',
)
const retryLoader = vi.fn().mockResolvedValue('recovered-icon')
await expect(getCachedSiteIcon(key, retryLoader)).resolves.toBe('recovered-icon')
expect(retryLoader).toHaveBeenCalledOnce()
})
})

View File

@@ -1,4 +1,5 @@
import api from '@/api'
import { resolveFederationRemoteUrl } from '@/utils/federationUrl'
import {
__federation_method_setRemote,
__federation_method_getRemote,
@@ -144,18 +145,7 @@ async function fetchRemoteModules(): Promise<RemoteModule[]> {
* @param modules 远程模块列表
*/
export function injectRemoteModule(module: RemoteModule): void {
// 与 API 请求一致:使用 origin + pathname 作为前缀,子路径代理时 pathname 含 /mp 等
const baseUrl = new URL(window.location.href)
const pathBase = baseUrl.pathname.replace(/\/$/, '') || ''
let apiBase = import.meta.env.VITE_API_BASE_URL
if (apiBase.startsWith('/')) {
apiBase = apiBase.slice(1)
}
if (apiBase.endsWith('/')) {
apiBase = apiBase.slice(0, -1)
}
const pathWithoutLeadingSlash = module.url.startsWith('/') ? module.url.slice(1) : module.url
const remoteEntryUrl = `${baseUrl.origin}${pathBase}/${apiBase}/${pathWithoutLeadingSlash}`
const remoteEntryUrl = resolveFederationRemoteUrl(module.url, import.meta.env.VITE_API_BASE_URL, document.baseURI)
__federation_method_setRemote(module.id, {
url: () => Promise.resolve(remoteEntryUrl),
format: 'esm',

View File

@@ -0,0 +1,11 @@
/**
* 按页面文档基址解析插件联邦入口,使其与相对 API 请求遵循相同的部署路径规则。
* 相对 API 基址保留反向代理子路径,根路径或完整 URL 配置则按其显式语义解析。
*/
export function resolveFederationRemoteUrl(remotePath: string, apiBaseUrl: string, documentBaseUri: string): string {
const normalizedApiBase = apiBaseUrl.endsWith('/') ? apiBaseUrl : `${apiBaseUrl}/`
const apiUrl = new URL(normalizedApiBase, documentBaseUri)
const normalizedRemotePath = remotePath.replace(/^\/+/, '')
return new URL(normalizedRemotePath, apiUrl).href
}

View File

@@ -3,75 +3,132 @@ type StatusCacheEntry = {
value: boolean
}
type StatusCacheState = {
entries: Map<string, StatusCacheEntry>
explicitValues: Map<string, boolean>
generation: number
requests: Map<string, Promise<boolean>>
versions: Map<string, number>
}
const STATUS_CACHE_TTL = 3 * 60 * 1000
const existsStatusCache = new Map<string, StatusCacheEntry>()
const existsStatusRequests = new Map<string, Promise<boolean>>()
const subscribeStatusCache = new Map<string, StatusCacheEntry>()
const subscribeStatusRequests = new Map<string, Promise<boolean>>()
const existsStatusState: StatusCacheState = {
entries: new Map(),
explicitValues: new Map(),
generation: 0,
requests: new Map(),
versions: new Map(),
}
function getCachedValue(cache: Map<string, StatusCacheEntry>, key: string): boolean | undefined {
const entry = cache.get(key)
const subscribeStatusState: StatusCacheState = {
entries: new Map(),
explicitValues: new Map(),
generation: 0,
requests: new Map(),
versions: new Map(),
}
function getCachedValue(state: StatusCacheState, key: string): boolean | undefined {
const entry = state.entries.get(key)
if (!entry) {
return undefined
}
if (entry.expiresAt <= Date.now()) {
cache.delete(key)
state.entries.delete(key)
return undefined
}
return entry.value
}
function setCachedValue(cache: Map<string, StatusCacheEntry>, key: string, value: boolean) {
cache.set(key, {
function writeCachedValue(state: StatusCacheState, key: string, value: boolean) {
state.entries.set(key, {
expiresAt: Date.now() + STATUS_CACHE_TTL,
value,
})
}
function setCachedValue(state: StatusCacheState, key: string, value: boolean) {
if (state.requests.has(key)) {
state.versions.set(key, (state.versions.get(key) ?? 0) + 1)
state.explicitValues.set(key, value)
}
writeCachedValue(state, key, value)
}
async function resolveCachedStatus(
cache: Map<string, StatusCacheEntry>,
requests: Map<string, Promise<boolean>>,
state: StatusCacheState,
key: string,
loader: () => Promise<boolean>,
): Promise<boolean> {
const cachedValue = getCachedValue(cache, key)
const cachedValue = getCachedValue(state, key)
if (cachedValue !== undefined) {
return cachedValue
}
const currentRequest = requests.get(key)
const currentRequest = state.requests.get(key)
if (currentRequest) {
return currentRequest
}
const requestGeneration = state.generation
const requestVersion = state.versions.get(key) ?? 0
const requestRef: { current?: Promise<boolean> } = {}
const request = loader()
.then(value => {
setCachedValue(cache, key, value)
// 显式状态写入或会话切换发生后,旧请求只能返回当前状态,不能回写过期结果。
if (state.generation !== requestGeneration) {
const currentRequest = state.requests.get(key)
if (currentRequest && currentRequest !== requestRef.current) {
return currentRequest
}
return getCachedValue(state, key) ?? false
}
if ((state.versions.get(key) ?? 0) !== requestVersion) {
return state.explicitValues.get(key) ?? value
}
writeCachedValue(state, key, value)
return value
})
.finally(() => {
requests.delete(key)
if (state.requests.get(key) === requestRef.current) {
state.requests.delete(key)
state.versions.delete(key)
state.explicitValues.delete(key)
}
})
requests.set(key, request)
requestRef.current = request
state.requests.set(key, request)
return request
}
export function getCachedMediaExistsStatus(key: string, loader: () => Promise<boolean>) {
return resolveCachedStatus(existsStatusCache, existsStatusRequests, key, loader)
return resolveCachedStatus(existsStatusState, key, loader)
}
export function setCachedMediaExistsStatus(key: string, value: boolean) {
setCachedValue(existsStatusCache, key, value)
setCachedValue(existsStatusState, key, value)
}
export function getCachedMediaSubscribeStatus(key: string, loader: () => Promise<boolean>) {
return resolveCachedStatus(subscribeStatusCache, subscribeStatusRequests, key, loader)
return resolveCachedStatus(subscribeStatusState, key, loader)
}
export function setCachedMediaSubscribeStatus(key: string, value: boolean) {
setCachedValue(subscribeStatusCache, key, value)
setCachedValue(subscribeStatusState, key, value)
}
/** 清理当前登录会话拥有的订阅状态,并隔离仍在执行的旧会话请求。 */
export function clearCachedMediaSubscribeStatuses() {
subscribeStatusState.generation += 1
subscribeStatusState.entries.clear()
subscribeStatusState.explicitValues.clear()
subscribeStatusState.requests.clear()
subscribeStatusState.versions.clear()
}

View File

@@ -2,7 +2,7 @@
import { DiscoverSource } from '@/api/types'
import MediaCardListView from '@/views/discover/MediaCardListView.vue'
import FormRender from '@/components/render/FormRender.vue'
import { cloneDeep } from 'lodash-es'
import { cloneDeep, isEqual } from 'lodash-es'
// 输入参数
const props = defineProps<{
@@ -10,10 +10,10 @@ const props = defineProps<{
}>()
// 默认输入参数
const default_params = cloneDeep(props.source.filter_params)
let defaultParams = cloneDeep(props.source.filter_params)
// 过滤参数
const filterParams = reactive(props.source.filter_params)
const filterParams = reactive(cloneDeep(props.source.filter_params))
// 前一次的过滤参数
let previousParams = cloneDeep(props.source.filter_params)
@@ -21,13 +21,27 @@ let previousParams = cloneDeep(props.source.filter_params)
// 当前Key
const currentKey = ref(0)
// 来源筛选契约变化时重建模型;语义相同的来源快照保留用户当前选择。
watch(
() => props.source.filter_params,
newDefaultParams => {
if (isEqual(newDefaultParams, defaultParams)) return
defaultParams = cloneDeep(newDefaultParams)
for (const key of Object.keys(filterParams)) delete filterParams[key]
Object.assign(filterParams, cloneDeep(newDefaultParams))
previousParams = cloneDeep(newDefaultParams)
},
{ deep: true },
)
// 类型和过滤参数变化后重新刷新列表
watch(filterParams, newParams => {
// 检查每个值
for (const key in newParams) {
// 如果没有值但有默认值时,设置为默认值
if (!newParams[key] && default_params[key]) {
filterParams[key] = default_params[key]
if (!newParams[key] && defaultParams[key]) {
filterParams[key] = defaultParams[key]
}
// 检查依赖关系
const depends = props.source?.depends
@@ -53,7 +67,7 @@ watch(filterParams, newParams => {
<FormRender v-for="(element, index) in source.filter_ui" :key="index" :config="element" :model="filterParams" />
</div>
<div>
<MediaCardListView :key="currentKey" :apipath="source.api_path" :params="filterParams" />
<MediaCardListView :key="`${source.api_path}:${currentKey}`" :apipath="source.api_path" :params="filterParams" />
</div>
</template>

View File

@@ -8,10 +8,12 @@ import { useI18n } from 'vue-i18n'
const { t } = useI18n()
// 输入参数
const props = defineProps({
apipath: String,
params: Object as PropType<{ [key: string]: any }>,
apipath: {
type: String,
required: true,
},
params: Object as PropType<Record<string, unknown>>,
})
// 判断是否有滚动条
@@ -25,6 +27,9 @@ const page = ref(1)
// 是否加载中
const loading = ref(false)
// 首次成功响应前,请求失败只展示错误和重试入口。
const loadFailed = ref(false)
// 是否加载完成
const isRefreshed = ref(false)
@@ -34,14 +39,15 @@ const dataList = shallowRef<MediaInfo[]>([])
// 用于保存已处理过的 key
const seenKeys = new Set<string>()
// 保存本次列表生命周期内已经处理过的原始页签名。
const seenPageSignatures = new Set<string>()
// 拼装参数
function getParams() {
let params = {
return {
...props.params,
page: page.value,
}
if (props.params) params = { ...params, ...props.params }
return params
}
// MediaInfo 去重的字段
@@ -58,9 +64,14 @@ const dedupFields = [
'media_id',
] as const
// 去重、分页终止和渲染必须共用同一媒体身份,避免状态与 DOM key 分叉。
function getMediaIdentity(item: MediaInfo) {
return JSON.stringify(dedupFields.map(field => item[field] ?? null))
}
function deduplicate(items: MediaInfo[]): MediaInfo[] {
return items.filter(item => {
const key = dedupFields.map(field => String(item[field])).join('~')
const key = getMediaIdentity(item)
if (seenKeys.has(key)) {
return false
}
@@ -74,92 +85,87 @@ function appendData(items: MediaInfo[]) {
}
async function loadPageData() {
const rawData: MediaInfo[] = await api.get(props.apipath!, {
const rawData: MediaInfo[] = await api.get(props.apipath, {
params: getParams(),
})
const pageSignature = [...new Set(rawData.map(getMediaIdentity))].sort().join('\n')
const isTerminal = rawData.length === 0 || seenPageSignatures.has(pageSignature)
if (!isTerminal) seenPageSignatures.add(pageSignature)
return {
rawCount: rawData.length,
uniqueData: deduplicate(rawData),
isTerminal,
uniqueData: isTerminal ? [] : deduplicate(rawData),
}
}
// 获取列表数据
async function fetchData({ done }: { done: any }) {
async function fetchData({
done,
}: {
done: (status: 'empty' | 'error' | 'loading' | 'ok') => void
}) {
if (loading.value) {
done('ok')
return
}
loading.value = true
loadFailed.value = false
try {
if (!props.apipath) return
// 如果正在加载中,直接返回
if (loading.value) {
done('ok')
return
}
// 加载到满屏或者加载出错
// 未形成滚动区域时连续填页;已有滚动区域时每次只消费一页。
if (!hasScroll()) {
// 加载多次
while (!hasScroll()) {
// 设置加载中
loading.value = true
// 请求API
const { rawCount, uniqueData } = await loadPageData()
// 取消加载中
loading.value = false
// 标计为已请求完成
const { isTerminal, uniqueData } = await loadPageData()
isRefreshed.value = true
if (rawCount === 0) {
// 如果没有数据,跳出
if (isTerminal) {
done('empty')
return
}
// 合并数据
appendData(uniqueData)
// 页码+1
page.value++
// 返回加载成功
done('ok')
await nextTick()
}
} else {
// 加载一次
// 设置加载中
loading.value = true
// 请求API
const { rawCount, uniqueData } = await loadPageData()
// 标计为已请求完成
const { isTerminal, uniqueData } = await loadPageData()
isRefreshed.value = true
if (rawCount === 0) {
// 如果没有数据,跳出
if (isTerminal) {
done('empty')
return
} else {
// 合并数据
appendData(uniqueData)
// 页码+1
page.value++
// 返回加载成功
done('ok')
}
}
// 取消加载中
loading.value = false
done('ok')
} catch (error) {
console.error(error)
// 返回加载失败
loadFailed.value = true
done('error')
} finally {
loading.value = false
}
}
</script>
<template>
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
<LoadingBanner v-if="!isRefreshed && !loadFailed" class="mt-12" />
<VInfiniteScroll mode="intersect" side="end" :items="dataList" class="overflow-visible pt-3 px-2" @load="fetchData">
<template #loading />
<template #empty />
<template #error="{ props: retryProps }">
<div class="d-flex flex-column align-center ga-2 py-4">
<span class="text-body-2 text-medium-emphasis">{{ t('error.networkError') }}</span>
<VBtn v-bind="retryProps" prepend-icon="mdi-refresh" size="small" variant="tonal">
{{ t('common.retry') }}
</VBtn>
</div>
</template>
<ProgressiveCardGrid
v-if="dataList.length > 0"
:items="dataList"
:item-aspect-ratio="1.5"
:get-item-key="item => item.tmdb_id || item.douban_id || item.bangumi_id || item.media_id || item.title"
:get-item-key="getMediaIdentity"
tabindex="0"
>
<template #default="{ item }">
@@ -170,7 +176,6 @@ async function fetchData({ done }: { done: any }) {
v-if="dataList.length === 0 && isRefreshed"
error-code="404"
:error-title="t('common.noData')"
:error-description="t('error.networkError')"
/>
</VInfiniteScroll>
</template>

View File

@@ -3,7 +3,16 @@ import { useToast } from 'vue-toastification'
import PersonCardSlideView from './PersonCardSlideView.vue'
import MediaCardSlideView from './MediaCardSlideView.vue'
import api from '@/api'
import type { MediaInfo, MediaRelease, MediaSeason, NotExistMediaInfo, Site, Subscribe, TmdbEpisode } from '@/api/types'
import type {
ApiResponse,
MediaInfo,
MediaRelease,
MediaSeason,
NotExistMediaInfo,
Site,
Subscribe,
TmdbEpisode,
} from '@/api/types'
import NoDataFound from '@/components/states/NoDataFound.vue'
import { formatSeasonLabel } from '@/@core/utils/season'
import router from '@/router'
@@ -64,6 +73,9 @@ const isSubscribed = ref(false)
// 是否已加载完成
const isRefreshed = ref(false)
// 主详情请求失败时与合法空媒体分开展示,并允许用户重试。
const detailLoadFailed = ref(false)
// 存储每一季的集信息
const seasonEpisodesInfo = ref({} as { [key: number]: TmdbEpisode[] })
@@ -136,6 +148,7 @@ const canScrollEpisodeGroupsForward = ref(false)
let episodeGroupSeasonRequestId = 0
let seasonNotExistsRequestId = 0
let episodeExistsRequestId = 0
let seasonEpisodesRequestGeneration = 0
// 计算主题是否为透明
const isTransparentTheme = computed(() => {
@@ -172,7 +185,7 @@ async function querySites() {
// 查询用户选中的站点
async function querySelectedSites() {
try {
const result: { [key: string]: any } = await api.get('system/setting/public/IndexerSites')
const result: ApiResponse<{ value?: number[] }> = await api.get('system/setting/public/IndexerSites')
selectedSites.value = result.data?.value ?? []
} catch (error) {
@@ -193,28 +206,36 @@ function getSubscribeStatusKey(season: number | null = mediaDetail.value?.season
// 调用API查询详情
async function getMediaDetail() {
if (mediaProps.mediaid && mediaProps.type) {
mediaDetail.value = await api.get(`media/${mediaProps.mediaid}`, {
params: {
title: mediaProps.title,
year: mediaProps.year,
type_name: mediaProps.type,
},
})
isRefreshed.value = true
if (!mediaDetail.value.tmdb_id && !mediaDetail.value.douban_id && !mediaDetail.value.bangumi_id) return
detailLoadFailed.value = false
isRefreshed.value = false
try {
mediaDetail.value = await api.get(`media/${mediaProps.mediaid}`, {
params: {
title: mediaProps.title,
year: mediaProps.year,
type_name: mediaProps.type,
},
})
if (!mediaDetail.value.tmdb_id && !mediaDetail.value.douban_id && !mediaDetail.value.bangumi_id) return
selectedEpisodeGroup.value = mediaDetail.value.episode_group || ''
if (mediaDetail.value.type === '电视剧' && mediaDetail.value.tmdb_id) {
getEpisodeGroups()
if (selectedEpisodeGroup.value) loadEpisodeGroupSeasons(selectedEpisodeGroup.value)
selectedEpisodeGroup.value = mediaDetail.value.episode_group || ''
if (mediaDetail.value.type === '电视剧' && mediaDetail.value.tmdb_id) {
getEpisodeGroups()
if (selectedEpisodeGroup.value) loadEpisodeGroupSeasons(selectedEpisodeGroup.value)
}
// 检查存在状态
checkExists()
if (mediaDetail.value.type === '电视剧') checkSeasonsNotExists()
// 检查订阅状态
if (mediaDetail.value.type === '电影') checkMovieSubscribed()
else checkSeasonsSubscribed()
} catch (error) {
console.error(error)
detailLoadFailed.value = true
} finally {
isRefreshed.value = true
}
// 检查存在状态
checkExists()
if (mediaDetail.value.type === '电视剧') checkSeasonsNotExists()
// 检查订阅状态
if (mediaDetail.value.type === '电影') checkMovieSubscribed()
else checkSeasonsSubscribed()
}
}
@@ -224,10 +245,14 @@ async function loadSeasonEpisodes(season: number) {
loadEpisodeExists()
// 加载季集信息
if (seasonEpisodesInfo.value[season]) return
const requestGeneration = seasonEpisodesRequestGeneration
try {
const params = selectedEpisodeGroup.value ? { episode_group: selectedEpisodeGroup.value } : undefined
const result: TmdbEpisode[] = await api.get(`tmdb/${mediaDetail.value.tmdb_id}/${season}`, params ? { params } : undefined)
seasonEpisodesInfo.value[season] = result || []
const result: TmdbEpisode[] = await api.get(
`tmdb/${mediaDetail.value.tmdb_id}/${season}`,
params ? { params } : undefined,
)
if (requestGeneration === seasonEpisodesRequestGeneration) seasonEpisodesInfo.value[season] = result || []
} catch (error) {
console.error(error)
}
@@ -253,7 +278,7 @@ async function loadEpisodeExists() {
// 查询当前媒体是否已入库(数据库)
async function checkExists() {
try {
const result: { [key: string]: any } = await api.get('mediaserver/exists', {
const result: ApiResponse<{ item: { id: string } }> = await api.get('mediaserver/exists', {
params: {
tmdbid: mediaDetail.value.tmdb_id,
title: mediaDetail.value.title,
@@ -286,9 +311,7 @@ function isSameSubscribeMedia(subscribe: Subscribe) {
if (mediaDetail.value?.douban_id && subscribe.doubanid) return mediaDetail.value.douban_id === subscribe.doubanid
if (mediaDetail.value?.bangumi_id && subscribe.bangumiid) return mediaDetail.value.bangumi_id === subscribe.bangumiid
const mediaId = mediaDetail.value?.media_id
? `${mediaDetail.value.mediaid_prefix}:${mediaDetail.value.media_id}`
: ''
const mediaId = mediaDetail.value?.media_id ? `${mediaDetail.value.mediaid_prefix}:${mediaDetail.value.media_id}` : ''
return Boolean(mediaId && subscribe.mediaid === mediaId)
}
@@ -345,7 +368,8 @@ const episodeGroupOptions = computed<EpisodeGroupOption[]>(() => [
// 当前选中的剧集组选项
const selectedEpisodeGroupOption = computed(
() => episodeGroupOptions.value.find(group => group.id === selectedEpisodeGroup.value) ?? episodeGroupOptions.value[0]!,
() =>
episodeGroupOptions.value.find(group => group.id === selectedEpisodeGroup.value) ?? episodeGroupOptions.value[0]!,
)
// 季列表第0季排在最后
@@ -407,6 +431,7 @@ async function setEpisodeGroup(groupId: string) {
episodeGroupSeasons.value = []
episodeGroupSeasonRequestId += 1
episodeExistsRequestId += 1
seasonEpisodesRequestGeneration += 1
await Promise.all([loadEpisodeGroupSeasons(groupId), checkSeasonsNotExists()])
}
@@ -473,15 +498,17 @@ const subscribedSeasonNumbers = computed(() =>
.sort((a, b) => a - b),
)
// 默认季结构中的可订阅季总数
const subscribeSeasonTotal = computed(() => mediaDetail.value?.season_info?.length ?? 0)
// 默认季结构中的季号集合
const defaultSubscribeSeasonNumbers = computed(() =>
(mediaDetail.value?.season_info ?? []).map(season => season.season_number ?? 0),
)
// 当前媒体是否已订阅默认季结构中的全部季
const isAllSeasonsSubscribed = computed(
() =>
mediaDetail.value.type === '电视剧' &&
subscribeSeasonTotal.value > 0 &&
subscribedSeasonNumbers.value.length >= subscribeSeasonTotal.value,
defaultSubscribeSeasonNumbers.value.length > 0 &&
defaultSubscribeSeasonNumbers.value.every(season => seasonsSubscribed.value[season]),
)
// 订阅按钮响应;单季入口同时传递详情页当前选择的剧集组。
@@ -490,8 +517,8 @@ function handleSubscribe(season: number | null = null, episodeGroup = '') {
}
// 从genres中获取name使用、分隔
function getGenresName(genres: any[]) {
return genres.map(genre => genre.name).join('、')
function getGenresName(genres: Array<string | { name: string }>) {
return genres.map(genre => (typeof genre === 'string' ? genre : genre.name)).join('、')
}
// 拼装TheMovieDb地址
@@ -500,11 +527,6 @@ function getTheMovieDbLink() {
return `https://www.themoviedb.org/${mtype}/${mediaDetail.value.tmdb_id}`
}
// 拼装豆瓣地址
function getDoubanLink() {
return `https://movie.douban.com/subject/${mediaDetail.value.douban_id}`
}
// 处理豆瓣链接点击
async function handleDoubanClick() {
if (mediaDetail.value.douban_id) {
@@ -570,8 +592,7 @@ const getProductionCompanies = computed(() => {
// 获取指定类型的最早发行日期
function getEarliestReleaseDateByType(type: number): MediaRelease | null {
const filteredDates = mediaDetail.value.release_dates?.filter(date => date.type === type)
if (!filteredDates || filteredDates.length === 0)
return null
if (!filteredDates || filteredDates.length === 0) return null
return filteredDates.reduce((earliest, current) =>
new Date(current.date) < new Date(earliest.date) ? current : earliest,
@@ -611,7 +632,8 @@ function isEpisodeExists(season: number, episode: number) {
// 计算订阅图标
const getSubscribeIcon = computed(() => {
if (mediaDetail.value.type === '电视剧') return subscribedSeasonNumbers.value.length > 0 ? 'mdi-heart' : 'mdi-heart-outline'
if (mediaDetail.value.type === '电视剧')
return subscribedSeasonNumbers.value.length > 0 ? 'mdi-heart' : 'mdi-heart-outline'
if (isSubscribed.value) return 'mdi-heart'
else return 'mdi-heart-outline'
})
@@ -669,7 +691,9 @@ function handleSearch(resultType: 'torrent' | 'subtitle' = 'torrent', options: M
async function handlePlay() {
// 获取播放链接地址
try {
const result: { [key: string]: any } = await api.get(`mediaserver/play/${existsItemId.value}`)
const result: ApiResponse<{ item_id: string; server_id: string; server_type: string; url: string }> = await api.get(
`mediaserver/play/${existsItemId.value}`,
)
if (result?.success) {
// 使用深度链接工具优先跳转到APP失败后跳转到网页
await openMediaServerItem({
@@ -683,6 +707,7 @@ async function handlePlay() {
}
} catch (error) {
console.error(error)
$toast.error('获取播放链接失败!')
}
}
@@ -706,7 +731,11 @@ const subscribeActions = useMediaSubscribe({
})
// 搜索前弹出站点选择框,确认后执行资源或字幕搜索。
async function clickSearch(type: string, resultType: 'torrent' | 'subtitle' = 'torrent', options: MediaSearchOptions = {}) {
async function clickSearch(
type: string,
resultType: 'torrent' | 'subtitle' = 'torrent',
options: MediaSearchOptions = {},
) {
searchType.value = type
pendingSearchResultType.value = resultType
pendingSearchOptions.value = options
@@ -803,10 +832,7 @@ onUnmounted(() => {
</div>
<div class="media-actions">
<VBtn
v-if="
(mediaDetail.tmdb_id || mediaDetail.douban_id || mediaDetail.bangumi_id) &&
canSearch
"
v-if="(mediaDetail.tmdb_id || mediaDetail.douban_id || mediaDetail.bangumi_id) && canSearch"
variant="tonal"
color="primary"
class="media-action-button"
@@ -827,10 +853,7 @@ onUnmounted(() => {
</VMenu>
</VBtn>
<VBtn
v-if="
(mediaDetail.tmdb_id || mediaDetail.douban_id || mediaDetail.bangumi_id) &&
canSearch
"
v-if="(mediaDetail.tmdb_id || mediaDetail.douban_id || mediaDetail.bangumi_id) && canSearch"
variant="tonal"
color="info"
class="media-action-button"
@@ -842,7 +865,10 @@ onUnmounted(() => {
{{ t('media.actions.searchSubtitle') }}
</VBtn>
<VBtn
v-if="canSubscribe && (mediaDetail.type === '电影' || mediaDetail.tmdb_id || mediaDetail.douban_id || mediaDetail.bangumi_id)"
v-if="
canSubscribe &&
(mediaDetail.type === '电影' || mediaDetail.tmdb_id || mediaDetail.douban_id || mediaDetail.bangumi_id)
"
class="media-action-button"
:color="getSubscribeColor"
variant="tonal"
@@ -950,7 +976,11 @@ onUnmounted(() => {
>
<VIcon icon="mdi-chevron-left" />
</button>
<div ref="episodeGroupRail" class="episode-group-rail" @scroll.passive="updateEpisodeGroupScrollState">
<div
ref="episodeGroupRail"
class="episode-group-rail"
@scroll.passive="updateEpisodeGroupScrollState"
>
<button
v-for="group in episodeGroupOptions"
:key="group.id || 'default'"
@@ -1009,7 +1039,7 @@ onUnmounted(() => {
season.season_number === 0
? season.name || formatSeasonLabel(0, t('media.specials'))
: t('media.seasonNumber', { number: season.season_number })
}}</span>
}}</span>
<VChip size="small" class="ms-1">
{{ t('media.episodeCount', { count: season.episode_count }) }}
</VChip>
@@ -1120,7 +1150,9 @@ onUnmounted(() => {
<span>{{ t('media.info.digitalRelease') }}</span>
<span class="media-fact-value">
<span class="flex items-center justify-end">
<span class="inline-flex items-center justify-center h-4 w-4 text-[0.6rem] font-bold text-current border border-current leading-none">
<span
class="inline-flex items-center justify-center h-4 w-4 text-[0.6rem] font-bold text-current border border-current leading-none"
>
{{ getEarliestDigitalReleaseDate.iso_code }}
</span>
<span class="ml-1.5">{{ getEarliestDigitalReleaseDate.date.slice(0, 10) }}</span>
@@ -1131,7 +1163,9 @@ onUnmounted(() => {
<span>{{ t('media.info.physicalRelease') }}</span>
<span class="media-fact-value">
<span class="flex items-center justify-end">
<span class="inline-flex items-center justify-center h-4 w-4 text-[0.6rem] font-bold text-current border border-current leading-none">
<span
class="inline-flex items-center justify-center h-4 w-4 text-[0.6rem] font-bold text-current border border-current leading-none"
>
{{ getEarliestPhysicalReleaseDate.iso_code }}
</span>
<span class="ml-1.5">{{ getEarliestPhysicalReleaseDate.date.slice(0, 10) }}</span>
@@ -1280,7 +1314,17 @@ onUnmounted(() => {
</div>
</div>
<NoDataFound
v-if="!mediaDetail.tmdb_id && !mediaDetail.douban_id && !mediaDetail.bangumi_id && isRefreshed"
v-if="detailLoadFailed"
error-code="500"
:error-title="t('media.error.title')"
:error-description="t('error.networkError')"
>
<template #button>
<VBtn prepend-icon="mdi-refresh" variant="tonal" @click="getMediaDetail">{{ t('common.retry') }}</VBtn>
</template>
</NoDataFound>
<NoDataFound
v-else-if="!mediaDetail.tmdb_id && !mediaDetail.douban_id && !mediaDetail.bangumi_id && isRefreshed"
error-code="500"
:error-title="t('media.error.title')"
:error-description="t('media.error.noMediaInfo')"
@@ -1293,7 +1337,8 @@ onUnmounted(() => {
z-index: 0;
pointer-events: none;
background-image: linear-gradient(
background-image:
linear-gradient(
180deg,
rgba(var(--v-theme-background), 0) 50%,
rgba(var(--v-theme-background), var(--media-backdrop-edge-opacity)) 100%
@@ -1326,10 +1371,12 @@ onUnmounted(() => {
.media-detail-transparent .vue-media-back-image {
opacity: 0.78;
mask-image: linear-gradient(to bottom, transparent 0%, #000 16%, #000 58%, transparent 100%),
mask-image:
linear-gradient(to bottom, transparent 0%, #000 16%, #000 58%, transparent 100%),
linear-gradient(to right, transparent 0%, #000 10%, #000 90%, transparent 100%);
mask-composite: intersect;
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 16%, #000 58%, transparent 100%),
-webkit-mask-image:
linear-gradient(to bottom, transparent 0%, #000 16%, #000 58%, transparent 100%),
linear-gradient(to right, transparent 0%, #000 10%, #000 90%, transparent 100%);
-webkit-mask-composite: source-in;
}

View File

@@ -0,0 +1,92 @@
import BangumiView from '@/views/discover/BangumiView.vue'
import { screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { renderWithProviders } from '@tests/support/render'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createMediaListHarness, latestMediaListRequest } from './sourceViewTestUtils'
describe('BangumiView', () => {
let mediaList: ReturnType<typeof createMediaListHarness>
beforeEach(() => {
mediaList = createMediaListHarness()
})
async function renderView() {
return renderWithProviders(BangumiView, {
global: {
stubs: {
MediaCardListView: mediaList.stub,
},
},
})
}
it('starts with number, string, and null values and builds ten years from a fixed clock', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-07-18T12:00:00+08:00'))
await renderView()
expect(latestMediaListRequest(mediaList)).toEqual({
apipath: 'discover/bangumi',
params: {
type: 2,
cat: null,
sort: 'rank',
year: null,
},
})
for (const year of ['2026', '2025', '2024', '2023', '2022', '2021', '2020', '2019', '2018', '2017']) {
expect(screen.getByText(year)).toBeInTheDocument()
}
expect(screen.queryByText('2016')).not.toBeInTheDocument()
})
it('passes object keys as strings while keeping the fixed Bangumi type numeric', async () => {
const user = userEvent.setup()
const currentYear = String(new Date().getFullYear())
await renderView()
await user.click(screen.getByText('Movie'))
await user.click(screen.getByText('日期'))
await user.click(screen.getByText(currentYear))
await waitFor(() => {
expect(latestMediaListRequest(mediaList)).toEqual({
apipath: 'discover/bangumi',
params: {
type: 2,
cat: '3',
sort: 'date',
year: currentYear,
},
})
})
})
it('leaves deselected optional chip values undefined instead of normalizing them', async () => {
const user = userEvent.setup()
const currentYear = String(new Date().getFullYear())
await renderView()
await user.click(screen.getByText('Movie'))
await user.click(screen.getByText('日期'))
await user.click(screen.getByText(currentYear))
await user.click(screen.getByText('Movie'))
await user.click(screen.getByText('日期'))
await user.click(screen.getByText(currentYear))
await waitFor(() => {
expect(latestMediaListRequest(mediaList)).toEqual({
apipath: 'discover/bangumi',
params: {
type: 2,
cat: undefined,
sort: undefined,
year: undefined,
},
})
})
})
})

View File

@@ -0,0 +1,106 @@
import DoubanView from '@/views/discover/DoubanView.vue'
import { screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { renderWithProviders } from '@tests/support/render'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createMediaListHarness, latestMediaListRequest } from './sourceViewTestUtils'
describe('DoubanView', () => {
let mediaList: ReturnType<typeof createMediaListHarness>
beforeEach(() => {
mediaList = createMediaListHarness()
})
async function renderView() {
return renderWithProviders(DoubanView, {
global: {
stubs: {
MediaCardListView: mediaList.stub,
},
},
})
}
it('starts with the frontend sort default and builds recent years from a fixed clock', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-07-18T12:00:00+08:00'))
await renderView()
expect(latestMediaListRequest(mediaList)).toEqual({
apipath: 'discover/douban_movies',
params: {
sort: 'U',
tags: '',
},
})
for (const year of ['2026', '2025', '2024', '2023', '2022', '2021']) {
expect(screen.getByText(year)).toBeInTheDocument()
}
expect(screen.queryByText('2020')).not.toBeInTheDocument()
expect(screen.getByText('2020年代')).toBeInTheDocument()
})
it('keeps tags in genre-zone-year order across selection, replacement, and clearing', async () => {
const user = userEvent.setup()
await renderView()
await user.click(screen.getByText('2020年代'))
await user.click(screen.getByText('日本'))
await user.click(screen.getByText('喜剧'))
await waitFor(() => {
expect(latestMediaListRequest(mediaList)).toEqual({
apipath: 'discover/douban_movies',
params: {
sort: 'U',
tags: '喜剧,日本,2020年代',
},
})
})
await user.click(screen.getByText('动作'))
await user.click(screen.getByText('日本'))
await user.click(screen.getByText('2020年代'))
await waitFor(() => {
expect(latestMediaListRequest(mediaList).params.tags).toBe('动作')
})
await user.click(screen.getByText('动作'))
await waitFor(() => expect(latestMediaListRequest(mediaList).params.tags).toBe(''))
})
it('preserves filters across type changes and restores deselected type and sort defaults', async () => {
const user = userEvent.setup()
await renderView()
await user.click(screen.getByText('高分优先'))
await user.click(screen.getByText('喜剧'))
await user.click(screen.getByText('电视剧'))
await waitFor(() => {
expect(latestMediaListRequest(mediaList)).toEqual({
apipath: 'discover/douban_tvs',
params: {
sort: 'S',
tags: '喜剧',
},
})
})
await user.click(screen.getByText('电视剧'))
await user.click(screen.getByText('高分优先'))
await waitFor(() => {
expect(latestMediaListRequest(mediaList)).toEqual({
apipath: 'discover/douban_movies',
params: {
sort: 'U',
tags: '喜剧',
},
})
})
})
})

View File

@@ -0,0 +1,230 @@
import type { DiscoverSource, RenderProps } from '@/api/types'
import ExtraSourceView from '@/views/discover/ExtraSourceView.vue'
import { screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { renderWithProviders } from '@tests/support/render'
import { defineComponent, h, type PropType, ref } from 'vue'
import { beforeEach, describe, expect, it } from 'vitest'
import { createMediaListHarness, latestMediaListRequest } from './sourceViewTestUtils'
function filterControl(label: string, model: string, value: unknown): RenderProps {
return {
component: 'VBtn',
text: label,
props: {
model,
testValue: value,
},
}
}
function createSource(overrides: Partial<DiscoverSource> = {}): DiscoverSource {
return {
name: '扩展测试源',
mediaid_prefix: 'fixture',
api_path: 'plugin/fixture/discover',
filter_params: {
mtype: 'movie',
genre: null,
keyword: 'featured',
},
filter_ui: [
filterControl('选择动作类型', 'genre', 'action'),
filterControl('切换媒体类型', 'mtype', 'series'),
filterControl('清空媒体类型', 'mtype', null),
],
depends: {
genre: ['mtype'],
},
...overrides,
}
}
const FormRenderStub = defineComponent({
name: 'FormRender',
props: {
config: {
type: Object as PropType<RenderProps>,
required: true,
},
model: {
type: Object as PropType<Record<string, unknown>>,
required: true,
},
},
setup(props) {
return () => {
const field = String(props.config.props?.model)
return h(
'button',
{
onClick: () => {
props.model[field] = props.config.props?.testValue
},
type: 'button',
},
props.config.text,
)
}
},
})
describe('ExtraSourceView', () => {
let mediaList: ReturnType<typeof createMediaListHarness>
beforeEach(() => {
mediaList = createMediaListHarness()
})
async function renderSource(source: DiscoverSource = createSource()) {
return renderWithProviders(ExtraSourceView, {
props: { source },
global: {
stubs: {
FormRender: FormRenderStub,
MediaCardListView: mediaList.stub,
},
},
})
}
function sourceUpdateHarness(initial: DiscoverSource, next: () => DiscoverSource) {
return defineComponent({
setup() {
const source = ref(initial)
return () => [
h(
'button',
{
onClick: () => {
source.value = next()
},
type: 'button',
},
'刷新扩展源契约',
),
h(ExtraSourceView, { source: source.value }),
]
},
})
}
async function renderSourceUpdateHarness(initial: DiscoverSource, next: () => DiscoverSource) {
return renderWithProviders(sourceUpdateHarness(initial, next), {
global: {
stubs: {
FormRender: FormRenderStub,
MediaCardListView: mediaList.stub,
},
},
})
}
it('initializes the form and list from the source contract', async () => {
await renderSource()
expect(latestMediaListRequest(mediaList)).toEqual({
apipath: 'plugin/fixture/discover',
params: {
mtype: 'movie',
genre: null,
keyword: 'featured',
},
})
expect(screen.getByRole('button', { name: '选择动作类型' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '切换媒体类型' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '清空媒体类型' })).toBeInTheDocument()
})
it('restores a truthy root default and clears only fields that declare the changed dependency', async () => {
const user = userEvent.setup()
await renderSource()
await user.click(screen.getByRole('button', { name: '选择动作类型' }))
await waitFor(() => expect(latestMediaListRequest(mediaList).params.genre).toBe('action'))
await user.click(screen.getByRole('button', { name: '切换媒体类型' }))
await waitFor(() => {
expect(latestMediaListRequest(mediaList).params).toEqual({
mtype: 'series',
genre: null,
keyword: 'featured',
})
})
await user.click(screen.getByRole('button', { name: '清空媒体类型' }))
await waitFor(() => {
expect(latestMediaListRequest(mediaList).params).toEqual({
mtype: 'movie',
genre: null,
keyword: 'featured',
})
})
})
it('preserves user filters when a same-prefix source object keeps the same contract', async () => {
const user = userEvent.setup()
await renderSourceUpdateHarness(createSource(), () => createSource())
await user.click(screen.getByRole('button', { name: '切换媒体类型' }))
await waitFor(() => expect(latestMediaListRequest(mediaList).params.mtype).toBe('series'))
const requestsBeforeRefresh = mediaList.requests.length
await user.click(screen.getByRole('button', { name: '刷新扩展源契约' }))
await waitFor(() => {
expect(screen.getByLabelText('媒体列表请求')).toHaveAttribute('data-apipath', 'plugin/fixture/discover')
expect(screen.getByLabelText('媒体列表请求')).toHaveTextContent(
JSON.stringify({ mtype: 'series', genre: null, keyword: 'featured' }),
)
})
expect(mediaList.requests).toHaveLength(requestsBeforeRefresh)
})
it('preserves user filters but restarts the list when only the API path changes', async () => {
const user = userEvent.setup()
await renderSourceUpdateHarness(createSource(), () =>
createSource({ api_path: 'plugin/fixture/v2/discover' }),
)
await user.click(screen.getByRole('button', { name: '切换媒体类型' }))
await waitFor(() => expect(latestMediaListRequest(mediaList).params.mtype).toBe('series'))
const requestsBeforeRefresh = mediaList.requests.length
await user.click(screen.getByRole('button', { name: '刷新扩展源契约' }))
await waitFor(() => {
expect(latestMediaListRequest(mediaList)).toEqual({
apipath: 'plugin/fixture/v2/discover',
params: {
mtype: 'series',
genre: null,
keyword: 'featured',
},
})
})
expect(mediaList.requests).toHaveLength(requestsBeforeRefresh + 1)
})
it('switches to the new model when a same-prefix source changes its filter contract', async () => {
const user = userEvent.setup()
const nextParams = {
category: 'series',
year: null,
}
await renderSourceUpdateHarness(createSource(), () =>
createSource({
api_path: 'plugin/fixture/v2/discover',
filter_params: { ...nextParams },
filter_ui: [filterControl('选择新版分类', 'category', 'movie')],
depends: {},
}),
)
await user.click(screen.getByRole('button', { name: '刷新扩展源契约' }))
await waitFor(() => {
expect(screen.getByRole('button', { name: '选择新版分类' })).toBeInTheDocument()
expect(screen.getByLabelText('媒体列表请求')).toHaveAttribute('data-apipath', 'plugin/fixture/v2/discover')
expect(screen.getByLabelText('媒体列表请求')).toHaveTextContent(JSON.stringify(nextParams))
})
})
})

View File

@@ -0,0 +1,429 @@
import type { MediaInfo } from '@/api/types'
import MediaCardListView from '@/views/discover/MediaCardListView.vue'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createMediaInfo } from '@tests/support/factories/media'
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { HttpResponse, http, type JsonBodyType } from 'msw'
import { defineComponent, h, onMounted, ref, type PropType } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const API_BASE_URL = 'http://localhost/api/v1/'
const LIST_PATH = 'test/discover/list'
const LIST_URL = new URL(LIST_PATH, API_BASE_URL).href
type InfiniteScrollStatus = 'empty' | 'error' | 'ok'
const InfiniteScrollStub = defineComponent({
name: 'VInfiniteScroll',
props: {
items: {
type: Array as PropType<MediaInfo[]>,
default: () => [],
},
},
emits: ['load'],
setup(_props, { emit, slots }) {
const status = ref<'empty' | 'error' | 'idle' | 'loading'>('idle')
function load() {
status.value = 'loading'
emit('load', {
done(nextStatus: InfiniteScrollStatus) {
status.value = nextStatus === 'ok' ? 'idle' : nextStatus
},
})
}
onMounted(load)
return () =>
h('section', { 'aria-label': '媒体无限列表' }, [
h('output', { 'aria-label': '媒体无限列表状态' }, status.value),
status.value === 'error'
? slots.error?.({
side: 'end',
props: { color: undefined, onClick: load },
})
: null,
slots.default?.(),
h(
'button',
{
'aria-label': '触发媒体列表加载',
onClick: load,
type: 'button',
},
'加载更多',
),
])
},
})
const ProgressiveCardGridStub = defineComponent({
name: 'ProgressiveCardGrid',
props: {
getItemKey: {
type: Function as PropType<(item: MediaInfo, index: number) => string | number>,
required: true,
},
items: {
type: Array as PropType<MediaInfo[]>,
required: true,
},
},
setup(props, { slots }) {
return () =>
h('section', { 'aria-label': '媒体渐进网格' }, [
h(
'output',
{ 'aria-label': '媒体渐进网格键' },
JSON.stringify(props.items.map((item, index) => props.getItemKey(item, index))),
),
...props.items.flatMap(item => slots.default?.({ item }) ?? []),
])
},
})
const MediaCardStub = defineComponent({
name: 'MediaCard',
props: {
media: {
type: Object as PropType<MediaInfo>,
required: true,
},
},
setup(props) {
return () => h('article', { 'aria-label': `媒体卡片 ${props.media.title}` }, props.media.title)
},
})
const LoadingBannerStub = defineComponent({
name: 'LoadingBanner',
template: '<div role="status">正在加载媒体列表</div>',
})
const NoDataFoundStub = defineComponent({
name: 'NoDataFound',
props: {
errorDescription: String,
errorTitle: String,
},
template: '<section aria-label="媒体列表空态">{{ errorTitle }} {{ errorDescription }}</section>',
})
interface Deferred {
promise: Promise<void>
resolve: () => void
}
function createDeferred(): Deferred {
let resolve!: () => void
const promise = new Promise<void>(done => {
resolve = done
})
return { promise, resolve }
}
function setScrollHeight(getHeight: () => number) {
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(600)
vi.spyOn(document.body, 'scrollHeight', 'get').mockImplementation(getHeight)
}
async function renderList(
options: {
apipath?: string
params?: Record<string, unknown>
} = {},
) {
return renderWithProviders(MediaCardListView, {
props: {
apipath: Object.prototype.hasOwnProperty.call(options, 'apipath') ? options.apipath : LIST_PATH,
params: options.params,
},
global: {
stubs: {
LoadingBanner: LoadingBannerStub,
MediaCard: MediaCardStub,
NoDataFound: NoDataFoundStub,
ProgressiveCardGrid: ProgressiveCardGridStub,
VInfiniteScroll: InfiniteScrollStub,
},
},
})
}
function gridKeys() {
return JSON.parse(screen.getByRole('status', { name: '媒体渐进网格键' }).textContent || '[]') as Array<
string | number
>
}
describe('MediaCardListView', () => {
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {})
})
it('keeps the component-owned page ahead of a page value in browse query params', async () => {
setScrollHeight(() => 900)
const requests: URL[] = []
server.use(
http.get(LIST_URL, ({ request }) => {
requests.push(new URL(request.url))
return HttpResponse.json([createMediaInfo({ title: '内部页码结果' })])
}),
)
await renderList({ params: { genre: '科幻', page: '99' } })
expect(await screen.findByRole('article', { name: '媒体卡片 内部页码结果' })).toBeInTheDocument()
expect(requests).toHaveLength(1)
expect(requests[0].searchParams.get('page')).toBe('1')
expect(requests[0].searchParams.get('genre')).toBe('科幻')
})
it('loads only one page when the viewport is already scrollable', async () => {
setScrollHeight(() => 900)
const requestedPages: string[] = []
server.use(
http.get(LIST_URL, ({ request }) => {
const page = new URL(request.url).searchParams.get('page') ?? ''
requestedPages.push(page)
return HttpResponse.json([createMediaInfo({ title: '单页媒体' })])
}),
)
await renderList()
expect(await screen.findByRole('article', { name: '媒体卡片 单页媒体' })).toBeInTheDocument()
expect(requestedPages).toEqual(['1'])
})
it('loads consecutive pages until an underfilled viewport becomes scrollable', async () => {
const requestedPages: string[] = []
setScrollHeight(() => (requestedPages.length >= 2 ? 900 : 500))
server.use(
http.get(LIST_URL, ({ request }) => {
const page = new URL(request.url).searchParams.get('page') ?? ''
requestedPages.push(page)
return HttpResponse.json([
createMediaInfo({ title: page === '1' ? '未满屏第一页' : '未满屏第二页' }),
])
}),
)
await renderList()
expect(await screen.findByRole('article', { name: '媒体卡片 未满屏第一页' })).toBeInTheDocument()
expect(await screen.findByRole('article', { name: '媒体卡片 未满屏第二页' })).toBeInTheDocument()
expect(requestedPages).toEqual(['1', '2'])
})
it('deduplicates the complete composite identity and uses it for stable render keys', async () => {
setScrollHeight(() => 900)
const base = createMediaInfo({
bangumi_id: 'bangumi-42',
douban_id: 'douban-42',
imdb_id: 'tt0000042',
media_id: 'media-42',
mediaid_prefix: 'fixture',
season: 1,
source: 'themoviedb',
title: '复合标识基准',
tmdb_id: 42,
tvdb_id: 'tvdb-42',
type: '电视剧',
})
const variants: Array<Partial<MediaInfo>> = [
{ source: 'douban', title: '不同 source' },
{ type: '电影', title: '不同 type' },
{ season: 2, title: '不同 season' },
{ tmdb_id: 43, title: '不同 tmdb_id' },
{ imdb_id: 'tt0000043', title: '不同 imdb_id' },
{ tvdb_id: 'tvdb-43', title: '不同 tvdb_id' },
{ douban_id: 'douban-43', title: '不同 douban_id' },
{ bangumi_id: 'bangumi-43', title: '不同 bangumi_id' },
{ mediaid_prefix: 'fixture-v2', title: '不同 mediaid_prefix' },
{ media_id: 'media-43', title: '不同 media_id' },
]
const response = [
base,
{ ...base, title: '完全重复项' },
...variants.map(variant => ({ ...base, ...variant })),
]
server.use(http.get(LIST_URL, () => HttpResponse.json(response as unknown as JsonBodyType)))
await renderList()
expect(await screen.findByRole('article', { name: '媒体卡片 复合标识基准' })).toBeInTheDocument()
expect(screen.queryByRole('article', { name: '媒体卡片 完全重复项' })).not.toBeInTheDocument()
expect(screen.getAllByRole('article')).toHaveLength(variants.length + 1)
const identityFields = [
'source',
'type',
'season',
'tmdb_id',
'imdb_id',
'tvdb_id',
'douban_id',
'bangumi_id',
'mediaid_prefix',
'media_id',
] as const
const expectedItems = [base, ...variants.map(variant => ({ ...base, ...variant }))]
expect(gridKeys()).toEqual(
expectedItems.map(item => JSON.stringify(identityFields.map(field => item[field] ?? null))),
)
})
it('continues after a first-seen page signature whose items were all seen globally', async () => {
const requestedPages: string[] = []
setScrollHeight(() => (requestedPages.length >= 3 ? 900 : 500))
const first = createMediaInfo({ title: '签名第一页 A', tmdb_id: 101 })
const second = createMediaInfo({ title: '签名第一页 B', tmdb_id: 102 })
const later = createMediaInfo({ title: '签名第三页新媒体', tmdb_id: 103 })
server.use(
http.get(LIST_URL, ({ request }) => {
const page = new URL(request.url).searchParams.get('page') ?? ''
requestedPages.push(page)
if (page === '1') return HttpResponse.json([first, second] as unknown as JsonBodyType)
if (page === '2') return HttpResponse.json([{ ...first, title: '第二页全重复' }] as unknown as JsonBodyType)
return HttpResponse.json([later] as unknown as JsonBodyType)
}),
)
await renderList()
expect(await screen.findByRole('article', { name: '媒体卡片 签名第三页新媒体' })).toBeInTheDocument()
expect(screen.queryByRole('article', { name: '媒体卡片 第二页全重复' })).not.toBeInTheDocument()
expect(requestedPages).toEqual(['1', '2', '3'])
})
it('stops when a raw page repeats any historical identity set regardless of order and duplicate count', async () => {
const requestedPages: string[] = []
setScrollHeight(() => (requestedPages.length >= 3 ? 900 : 500))
const first = createMediaInfo({ title: '历史签名 A', tmdb_id: 201 })
const second = createMediaInfo({ title: '历史签名 B', tmdb_id: 202 })
const between = createMediaInfo({ title: '中间签名 C', tmdb_id: 203 })
server.use(
http.get(LIST_URL, ({ request }) => {
const page = new URL(request.url).searchParams.get('page') ?? ''
requestedPages.push(page)
if (page === '1') {
return HttpResponse.json([first, { ...first, title: '页内重复 A' }, second] as unknown as JsonBodyType)
}
if (page === '2') return HttpResponse.json([between] as unknown as JsonBodyType)
return HttpResponse.json([{ ...second }, { ...first }] as unknown as JsonBodyType)
}),
)
await renderList()
expect(await screen.findByRole('article', { name: '媒体卡片 中间签名 C' })).toBeInTheDocument()
await waitFor(() =>
expect(screen.getByRole('status', { name: '媒体无限列表状态' })).toHaveTextContent('empty'),
)
expect(requestedPages).toEqual(['1', '2', '3'])
expect(screen.getAllByRole('article')).toHaveLength(3)
})
it('marks an empty first page as the pagination terminal state', async () => {
setScrollHeight(() => 900)
server.use(http.get(LIST_URL, () => HttpResponse.json([])))
await renderList()
await waitFor(() =>
expect(screen.getByRole('status', { name: '媒体无限列表状态' })).toHaveTextContent('empty'),
)
expect(screen.queryByText('正在加载媒体列表')).not.toBeInTheDocument()
})
it('renders HTTP 200 plus an empty array as ordinary no-data rather than a network error', async () => {
setScrollHeight(() => 900)
server.use(http.get(LIST_URL, () => HttpResponse.json([])))
await renderList()
const emptyState = await screen.findByRole('region', { name: '媒体列表空态' })
expect(emptyState).toHaveTextContent('暂无数据')
expect(emptyState).not.toHaveTextContent('无法获取到媒体信息,请检查网络连接。')
})
it('retries the same first page after an initial HTTP failure', async () => {
setScrollHeight(() => 900)
const requestedPages: string[] = []
server.use(
http.get(LIST_URL, ({ request }) => {
const page = new URL(request.url).searchParams.get('page') ?? ''
requestedPages.push(page)
if (requestedPages.length === 1) return HttpResponse.json({ detail: 'failed' }, { status: 500 })
return HttpResponse.json([createMediaInfo({ title: '首载重试成功' })])
}),
)
const user = userEvent.setup()
await renderList()
const retryButton = await screen.findByRole('button', { name: '重试' })
expect(screen.queryByText('正在加载媒体列表')).not.toBeInTheDocument()
expect(screen.queryByRole('region', { name: '媒体列表空态' })).not.toBeInTheDocument()
await user.click(retryButton)
expect(await screen.findByRole('article', { name: '媒体卡片 首载重试成功' })).toBeInTheDocument()
expect(requestedPages).toEqual(['1', '1'])
})
it('keeps existing cards and retries the same later page after an HTTP failure', async () => {
setScrollHeight(() => 900)
const requestedPages: string[] = []
let pageTwoAttempts = 0
server.use(
http.get(LIST_URL, ({ request }) => {
const page = new URL(request.url).searchParams.get('page') ?? ''
requestedPages.push(page)
if (page === '1') return HttpResponse.json([createMediaInfo({ title: '保留第一页' })])
pageTwoAttempts += 1
if (pageTwoAttempts === 1) return HttpResponse.json({ detail: 'failed' }, { status: 500 })
return HttpResponse.json([createMediaInfo({ title: '第二页重试成功' })])
}),
)
const user = userEvent.setup()
await renderList()
expect(await screen.findByRole('article', { name: '媒体卡片 保留第一页' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '触发媒体列表加载' }))
await user.click(await screen.findByRole('button', { name: '重试' }))
expect(await screen.findByRole('article', { name: '媒体卡片 第二页重试成功' })).toBeInTheDocument()
expect(screen.getByRole('article', { name: '媒体卡片 保留第一页' })).toBeInTheDocument()
expect(requestedPages).toEqual(['1', '2', '2'])
})
it('deduplicates load events while the current page request is still in flight', async () => {
setScrollHeight(() => 900)
const gate = createDeferred()
const requests: URL[] = []
server.use(
http.get(LIST_URL, async ({ request }) => {
requests.push(new URL(request.url))
await gate.promise
return HttpResponse.json([createMediaInfo({ title: '在途请求结果' })])
}),
)
await renderList()
await waitFor(() => expect(requests).toHaveLength(1))
try {
await fireEvent.click(screen.getByRole('button', { name: '触发媒体列表加载' }))
await fireEvent.click(screen.getByRole('button', { name: '触发媒体列表加载' }))
expect(requests).toHaveLength(1)
} finally {
gate.resolve()
}
expect(await screen.findByRole('article', { name: '媒体卡片 在途请求结果' })).toBeInTheDocument()
expect(requests[0].searchParams.get('page')).toBe('1')
})
})

View File

@@ -0,0 +1,342 @@
import type { MediaInfo } from '@/api/types'
import MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import { createMediaInfo } from '@tests/support/factories/media'
import { recommendMediaHandler } from '@tests/support/msw/handlers/recommend'
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { defineComponent, h, type PropType, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const SOURCE_PATH = 'recommend/tmdb_trending'
let animationFrameCallbacks: FrameRequestCallback[] = []
let fallbackCallbacks: Array<() => void> = []
let intersectionCallbacks: IntersectionObserverCallback[] = []
class IntersectionObserverMock implements IntersectionObserver {
readonly root = null
readonly rootMargin = '300px'
readonly thresholds = [0]
constructor(callback: IntersectionObserverCallback) {
intersectionCallbacks.push(callback)
}
disconnect() {}
observe() {}
takeRecords(): IntersectionObserverEntry[] {
return []
}
unobserve() {}
}
const VirtualSlideViewStub = defineComponent({
name: 'VirtualSlideView',
props: {
getItemKey: {
type: Function as PropType<(item: MediaInfo, index: number) => string | number>,
required: true,
},
items: {
type: Array as PropType<MediaInfo[]>,
required: true,
},
loading: {
type: Boolean,
required: true,
},
},
setup(props, { slots }) {
return () =>
h(
'section',
{
'aria-label': '媒体横向列表',
'data-item-count': String(props.items.length),
'data-loading': String(props.loading),
},
[
h(
'output',
{ 'aria-label': '媒体横向列表键' },
props.items.map((item, index) => String(props.getItemKey(item, index))).join('|'),
),
props.loading
? h('span', { role: 'status' }, '正在加载媒体')
: props.items.flatMap(item => slots.item?.({ item }) ?? []),
],
)
},
})
const MediaCardStub = defineComponent({
name: 'MediaCard',
props: {
media: {
type: Object as PropType<MediaInfo>,
required: true,
},
width: {
type: String,
required: true,
},
},
setup(props) {
return () =>
h(
'article',
{
'aria-label': `媒体卡片 ${props.media.title}`,
'data-width': props.width,
},
props.media.title,
)
},
})
function installLoadTriggerControls() {
const nativeSetTimeout = window.setTimeout.bind(window)
type TimeoutHandle = ReturnType<typeof window.setTimeout>
vi.stubGlobal('IntersectionObserver', IntersectionObserverMock)
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
bottom: 200,
height: 100,
left: 0,
right: 100,
toJSON: () => ({}),
top: 100,
width: 100,
x: 0,
y: 100,
})
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
animationFrameCallbacks.push(callback)
return animationFrameCallbacks.length
})
vi.spyOn(window, 'setTimeout').mockImplementation(
((handler: TimerHandler, timeout?: number, ...args: unknown[]): TimeoutHandle => {
if (timeout === 600) {
fallbackCallbacks.push(() => {
if (typeof handler === 'function') handler(...args)
})
return fallbackCallbacks.length as unknown as TimeoutHandle
}
return nativeSetTimeout(handler, timeout, ...args) as unknown as TimeoutHandle
}) as unknown as typeof window.setTimeout,
)
}
async function renderSlide(props: { ready?: boolean } = {}) {
return renderWithProviders(MediaCardSlideView, {
props: {
apipath: SOURCE_PATH,
linkurl: '/browse/recommend/tmdb_trending',
ready: props.ready ?? true,
title: '流行趋势',
},
global: {
stubs: {
MediaCard: MediaCardStub,
VirtualSlideView: VirtualSlideViewStub,
},
},
})
}
function triggerIntersection(isIntersecting = true) {
const callback = intersectionCallbacks.at(-1)
const boundingClientRect = document.body.getBoundingClientRect()
expect(callback).toBeTypeOf('function')
callback?.(
[
{
boundingClientRect,
intersectionRatio: isIntersecting ? 1 : 0,
intersectionRect: boundingClientRect,
isIntersecting,
rootBounds: null,
target: document.body,
time: 0,
},
],
{} as IntersectionObserver,
)
}
function triggerAnimationFrame() {
const callback = animationFrameCallbacks.shift()
expect(callback).toBeTypeOf('function')
callback?.(0)
}
function triggerFallback() {
const callback = fallbackCallbacks.shift()
expect(callback).toBeTypeOf('function')
callback?.()
}
function keepAliveHarness() {
return defineComponent({
components: { MediaCardSlideView },
setup() {
const active = ref(true)
return { active }
},
template: `
<button type="button" @click="active = false">停用横向列表</button>
<button type="button" @click="active = true">启用横向列表</button>
<KeepAlive>
<MediaCardSlideView
v-if="active"
apipath="recommend/tmdb_trending"
linkurl="/browse/recommend/tmdb_trending"
title="流行趋势"
/>
</KeepAlive>
`,
})
}
async function renderKeptAliveSlide() {
return renderWithProviders(keepAliveHarness(), {
global: {
stubs: {
MediaCard: MediaCardStub,
VirtualSlideView: VirtualSlideViewStub,
},
},
})
}
describe('MediaCardSlideView', () => {
beforeEach(() => {
animationFrameCallbacks = []
fallbackCallbacks = []
intersectionCallbacks = []
installLoadTriggerControls()
vi.spyOn(console, 'error').mockImplementation(() => {})
})
it.each([
['IntersectionObserver', triggerIntersection],
['requestAnimationFrame', triggerAnimationFrame],
['600ms fallback', triggerFallback],
])('loads media through the %s entry', async (_entry, triggerLoad) => {
const media = createMediaInfo({ title: `${_entry} 媒体` })
const requested = vi.fn()
server.use(recommendMediaHandler(SOURCE_PATH, [media], 200, requested))
await renderSlide()
await waitFor(() => {
expect(animationFrameCallbacks).toHaveLength(1)
expect(fallbackCallbacks).toHaveLength(1)
expect(intersectionCallbacks).toHaveLength(1)
})
triggerLoad()
expect(await screen.findByRole('article', { name: `媒体卡片 ${media.title}` })).toBeInTheDocument()
expect(requested).toHaveBeenCalledOnce()
})
it('deduplicates competing observer, animation frame, and fallback loads', async () => {
const media = createMediaInfo({ title: '竞争入口媒体' })
const requested = vi.fn()
server.use(recommendMediaHandler(SOURCE_PATH, [media], 200, requested))
await renderSlide()
await waitFor(() => expect(intersectionCallbacks).toHaveLength(1))
triggerIntersection()
triggerAnimationFrame()
triggerFallback()
expect(await screen.findByRole('article', { name: '媒体卡片 竞争入口媒体' })).toBeInTheDocument()
expect(requested).toHaveBeenCalledOnce()
})
it('keeps non-empty data loading until ready and then projects cards', async () => {
const media = createMediaInfo({ title: '等待页面就绪' })
const requested = vi.fn()
server.use(recommendMediaHandler(SOURCE_PATH, [media], 200, requested))
const { rerender } = await renderSlide({ ready: false })
triggerAnimationFrame()
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
expect(screen.getByLabelText('媒体横向列表')).toHaveAttribute('data-loading', 'true')
expect(screen.queryByRole('article', { name: '媒体卡片 等待页面就绪' })).not.toBeInTheDocument()
await rerender({ ready: true })
expect(await screen.findByRole('article', { name: '媒体卡片 等待页面就绪' })).toBeInTheDocument()
expect(screen.getByLabelText('媒体横向列表')).toHaveAttribute('data-loading', 'false')
})
it('finishes an empty response without waiting for ready', async () => {
const requested = vi.fn()
server.use(recommendMediaHandler(SOURCE_PATH, [], 200, requested))
await renderSlide({ ready: false })
triggerAnimationFrame()
await waitFor(() => expect(screen.getByLabelText('媒体横向列表')).toHaveAttribute('data-loading', 'false'))
expect(screen.getByLabelText('媒体横向列表')).toHaveAttribute('data-item-count', '0')
expect(requested).toHaveBeenCalledOnce()
})
it.each([
['empty response', 200],
['HTTP failure', 500],
])('retries after an initial %s when the kept-alive view is activated', async (_case, firstStatus) => {
const requested = vi.fn()
const recovered = createMediaInfo({ title: `恢复于 ${_case}` })
server.use(recommendMediaHandler(SOURCE_PATH, [], firstStatus, requested))
await renderKeptAliveSlide()
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
await waitFor(() => expect(screen.getByLabelText('媒体横向列表')).toHaveAttribute('data-loading', 'false'))
server.use(recommendMediaHandler(SOURCE_PATH, [recovered], 200, requested))
await fireEvent.click(screen.getByRole('button', { name: '停用横向列表' }))
await fireEvent.click(screen.getByRole('button', { name: '启用横向列表' }))
expect(await screen.findByRole('article', { name: `媒体卡片 ${recovered.title}` })).toBeInTheDocument()
expect(requested).toHaveBeenCalledTimes(2)
})
it('projects items, stable key fallbacks, and fixed card width', async () => {
const media = [
createMediaInfo({ douban_id: 'unused-douban', title: 'TMDB 媒体', tmdb_id: 101 }),
createMediaInfo({ douban_id: 'douban-202', title: '豆瓣媒体', tmdb_id: undefined }),
createMediaInfo({ bangumi_id: 'bangumi-303', douban_id: undefined, title: 'Bangumi 媒体', tmdb_id: undefined }),
createMediaInfo({
bangumi_id: undefined,
douban_id: undefined,
media_id: 'custom-404',
title: '自定义媒体',
tmdb_id: undefined,
}),
createMediaInfo({
bangumi_id: undefined,
douban_id: undefined,
media_id: undefined,
title: '标题回退',
tmdb_id: undefined,
}),
]
server.use(recommendMediaHandler(SOURCE_PATH, media))
await renderSlide()
triggerAnimationFrame()
await waitFor(() => expect(screen.getByLabelText('媒体横向列表')).toHaveAttribute('data-loading', 'false'))
expect(screen.getByLabelText('媒体横向列表')).toHaveAttribute('data-item-count', '5')
expect(screen.getByLabelText('媒体横向列表键')).toHaveTextContent(
'101|douban-202|bangumi-303|custom-404|标题回退',
)
expect(screen.getAllByRole('article')).toHaveLength(5)
screen.getAllByRole('article').forEach(card => expect(card).toHaveAttribute('data-width', '9rem'))
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,186 @@
import type { Person } from '@/api/types'
import PersonCardSlideView from '@/views/discover/PersonCardSlideView.vue'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { HttpResponse, http } from 'msw'
import { defineComponent, h, type PropType, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const API_URL = 'http://localhost/api/v1/recommend/tmdb_person'
let intersectionCallbacks: IntersectionObserverCallback[] = []
class IntersectionObserverMock implements IntersectionObserver {
readonly root = null
readonly rootMargin = '300px'
readonly thresholds = [0]
constructor(callback: IntersectionObserverCallback) {
intersectionCallbacks.push(callback)
}
disconnect() {}
observe() {}
takeRecords(): IntersectionObserverEntry[] {
return []
}
unobserve() {}
}
const VirtualSlideViewStub = defineComponent({
name: 'VirtualSlideView',
props: {
getItemKey: {
type: Function as PropType<(item: Person) => string | number | undefined>,
required: true,
},
items: {
type: Array as PropType<Person[]>,
required: true,
},
loading: {
type: Boolean,
required: true,
},
},
setup(props, { slots }) {
return () =>
h('section', { 'aria-label': '人物横向列表', 'data-loading': String(props.loading) }, [
h('output', { 'aria-label': '人物键' }, props.items.map(item => String(props.getItemKey(item))).join('|')),
props.loading
? h('span', { role: 'status' }, '正在加载人物')
: props.items.flatMap(item => slots.item?.({ item }) ?? []),
])
},
})
const PersonCardStub = defineComponent({
name: 'PersonCard',
props: {
person: {
type: Object as PropType<Person>,
required: true,
},
width: {
type: String,
required: true,
},
},
setup(props) {
return () =>
h('article', { 'aria-label': `人物卡片 ${props.person.name}`, 'data-width': props.width }, props.person.name)
},
})
function personResponse(people: Person[], status = 200, onRequest: () => void = () => {}) {
return http.get(API_URL, () => {
onRequest()
return HttpResponse.json(people, { status })
})
}
function triggerIntersection(isIntersecting = true) {
const callback = intersectionCallbacks.at(-1)
expect(callback).toBeTypeOf('function')
callback?.(
[{ isIntersecting, target: document.body } as unknown as IntersectionObserverEntry],
{} as IntersectionObserver,
)
}
async function renderSlide() {
return renderWithProviders(PersonCardSlideView, {
props: {
apipath: 'recommend/tmdb_person',
linkurl: '/browse/recommend/tmdb_person',
title: '热门人物',
},
global: {
stubs: {
PersonCard: PersonCardStub,
VirtualSlideView: VirtualSlideViewStub,
},
},
})
}
function keepAliveHarness() {
return defineComponent({
components: { PersonCardSlideView },
setup() {
const active = ref(true)
return { active }
},
template: `
<button type="button" @click="active = false">停用人物列表</button>
<button type="button" @click="active = true">启用人物列表</button>
<KeepAlive>
<PersonCardSlideView
v-if="active"
apipath="recommend/tmdb_person"
linkurl="/browse/recommend/tmdb_person"
title="热门人物"
/>
</KeepAlive>
`,
})
}
describe('PersonCardSlideView', () => {
beforeEach(() => {
intersectionCallbacks = []
vi.stubGlobal('IntersectionObserver', IntersectionObserverMock)
vi.spyOn(console, 'error').mockImplementation(() => {})
})
it('loads once after intersection and projects stable person cards', async () => {
const requested = vi.fn()
server.use(
personResponse(
[
{ id: 101, name: '人物甲', source: 'themoviedb' },
{ id: 202, name: '人物乙', source: 'themoviedb' },
],
200,
requested,
),
)
await renderSlide()
expect(screen.getByLabelText('人物横向列表')).toHaveAttribute('data-loading', 'true')
expect(requested).not.toHaveBeenCalled()
triggerIntersection(false)
expect(requested).not.toHaveBeenCalled()
triggerIntersection()
expect(await screen.findByRole('article', { name: '人物卡片 人物甲' })).toHaveAttribute('data-width', '9rem')
expect(screen.getByRole('article', { name: '人物卡片 人物乙' })).toHaveAttribute('data-width', '9rem')
expect(screen.getByLabelText('人物键')).toHaveTextContent('101|202')
expect(screen.getByLabelText('人物横向列表')).toHaveAttribute('data-loading', 'false')
expect(requested).toHaveBeenCalledOnce()
})
it('retries an empty or failed load when the kept-alive view is activated', async () => {
const requested = vi.fn()
server.use(personResponse([], 500, requested))
await renderWithProviders(keepAliveHarness(), {
global: {
stubs: {
PersonCard: PersonCardStub,
VirtualSlideView: VirtualSlideViewStub,
},
},
})
triggerIntersection()
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
server.use(personResponse([{ id: 303, name: '重试人物', source: 'themoviedb' }], 200, requested))
await fireEvent.click(screen.getByRole('button', { name: '停用人物列表' }))
await fireEvent.click(screen.getByRole('button', { name: '启用人物列表' }))
expect(await screen.findByRole('article', { name: '人物卡片 重试人物' })).toBeInTheDocument()
expect(requested).toHaveBeenCalledTimes(2)
})
})

View File

@@ -0,0 +1,132 @@
import TheMovieDbView from '@/views/discover/TheMovieDbView.vue'
import { screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { renderWithProviders } from '@tests/support/render'
import { beforeEach, describe, expect, it } from 'vitest'
import { createMediaListHarness, latestMediaListRequest } from './sourceViewTestUtils'
const INITIAL_PARAMS = {
sort_by: 'popularity.desc',
with_genres: '',
with_original_language: '',
with_keywords: '',
with_watch_providers: '',
vote_average: 0,
vote_count: 10,
release_date: '',
}
describe('TheMovieDbView', () => {
let mediaList: ReturnType<typeof createMediaListHarness>
beforeEach(() => {
mediaList = createMediaListHarness()
})
async function renderView() {
return renderWithProviders(TheMovieDbView, {
global: {
stubs: {
MediaCardListView: mediaList.stub,
},
},
})
}
it('starts with the exact movie endpoint and filter contract', async () => {
await renderView()
expect(latestMediaListRequest(mediaList)).toEqual({
apipath: 'discover/tmdb_movies',
params: INITIAL_PARAMS,
})
})
it('repairs media-specific sort and genre values in both type directions', async () => {
const user = userEvent.setup()
await renderView()
await user.click(screen.getByText('上映日期降序'))
await user.click(screen.getByText('动作'))
await user.click(screen.getByText('电视剧'))
await waitFor(() => {
expect(latestMediaListRequest(mediaList)).toEqual({
apipath: 'discover/tmdb_tvs',
params: {
...INITIAL_PARAMS,
sort_by: 'popularity.desc',
with_genres: '',
},
})
})
await user.click(screen.getByText('首播日期降序'))
await user.click(screen.getByText('动作冒险'))
await user.click(screen.getByText('电影'))
await waitFor(() => {
expect(latestMediaListRequest(mediaList)).toEqual({
apipath: 'discover/tmdb_movies',
params: {
...INITIAL_PARAMS,
sort_by: 'popularity.desc',
with_genres: '',
},
})
})
})
it('preserves shared filters and the current numeric input types across type changes', async () => {
const user = userEvent.setup()
await renderView()
await user.click(screen.getByText('评分降序'))
await user.click(screen.getByText('动画'))
await user.click(screen.getByText('中文'))
screen.getByRole('slider').focus()
await user.keyboard('{ArrowRight}'.repeat(7))
const voteCount = screen.getByRole('spinbutton')
await user.clear(voteCount)
await user.type(voteCount, '25')
await user.click(screen.getByText('电视剧'))
await waitFor(() => {
expect(latestMediaListRequest(mediaList)).toEqual({
apipath: 'discover/tmdb_tvs',
params: {
...INITIAL_PARAMS,
sort_by: 'vote_average.desc',
with_genres: '16',
with_original_language: 'zh',
vote_average: 7,
vote_count: '25',
},
})
})
})
it('restores mandatory defaults while leaving optional chip filters unset', async () => {
const user = userEvent.setup()
await renderView()
await user.click(screen.getByText('电影'))
await user.click(screen.getByText('评分降序'))
await user.click(screen.getByText('评分降序'))
await user.click(screen.getByText('动画'))
await user.click(screen.getByText('动画'))
await user.click(screen.getByText('中文'))
await user.click(screen.getByText('中文'))
await waitFor(() => {
expect(latestMediaListRequest(mediaList)).toEqual({
apipath: 'discover/tmdb_movies',
params: {
...INITIAL_PARAMS,
with_genres: undefined,
with_original_language: undefined,
},
})
})
})
})

View File

@@ -0,0 +1,48 @@
import { defineComponent, h, type PropType } from 'vue'
export interface MediaListRequest {
apipath: string
params: Record<string, unknown>
}
export function createMediaListHarness() {
const requests: MediaListRequest[] = []
const stub = defineComponent({
name: 'MediaCardListView',
props: {
apipath: {
type: String,
default: '',
},
params: {
type: Object as PropType<Record<string, unknown>>,
default: () => ({}),
},
},
setup(props) {
requests.push({
apipath: props.apipath,
params: { ...props.params },
})
return () =>
h(
'output',
{
'aria-label': '媒体列表请求',
'data-apipath': props.apipath,
},
JSON.stringify(props.params, (_key, value) => (value === undefined ? '__undefined__' : value)),
)
},
})
return { requests, stub }
}
export function latestMediaListRequest(harness: ReturnType<typeof createMediaListHarness>): MediaListRequest {
const request = harness.requests.at(-1)
if (!request) throw new Error('MediaCardListView has not mounted')
return request
}

View File

@@ -19,6 +19,8 @@ import { useBackground } from '@/composables/useBackground'
import { useGlobalSettingsStore, useUserStore } from '@/stores'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
import { getDisplayImageUrl } from '@/utils/imageUtils'
import noImage from '@images/no-image.jpeg'
const TransferHistoryDeleteDialog = defineAsyncComponent(
() => import('@/components/dialog/TransferHistoryDeleteDialog.vue'),
@@ -640,6 +642,18 @@ function getIcon(type: string) {
else return 'mdi-help-circle'
}
// 计算移动端卡片海报地址,优先使用后端图片代理并兼顾全局缓存设置。
function getHistoryPosterUrl(item: TransferHistory) {
const image = item.image
if (!image) return noImage
if (!/^https?:\/\//i.test(image)) {
return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(image)}`
}
return getDisplayImageUrl(image, globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE)
}
// 删除历史记录
async function removeHistory(item: TransferHistory) {
currentHistory.value = item
@@ -1662,7 +1676,7 @@ onUnmounted(() => {
:items="mobileDataList"
:columns="1"
:gap="14"
:estimated-item-height="280"
:estimated-item-height="296"
:overscan-rows="5"
:get-item-key="getMobileHistoryItemKey"
>
@@ -1677,9 +1691,28 @@ onUnmounted(() => {
@click="handleMobileRecordClick(item)"
>
<header class="transfer-history-mobile-record__header">
<VAvatar class="transfer-history-mobile-record__avatar" size="40">
<VIcon :icon="getIcon(item.type || '')" />
</VAvatar>
<div class="transfer-history-mobile-record__poster-wrapper">
<VImg
class="transfer-history-mobile-record__poster"
:src="getHistoryPosterUrl(item)"
:alt="item.title"
cover
>
<template #placeholder>
<div class="transfer-history-mobile-record__poster-skeleton">
<VSkeletonLoader class="h-full" />
</div>
</template>
<template #error>
<VImg :src="noImage" cover :alt="item.title" class="transfer-history-mobile-record__poster-fallback" />
</template>
</VImg>
<VIcon
class="transfer-history-mobile-record__poster-type"
:icon="getIcon(item.type || '')"
size="14"
/>
</div>
<div class="transfer-history-mobile-record__heading">
<div class="transfer-history-mobile-record__title">
@@ -1728,16 +1761,16 @@ onUnmounted(() => {
</VList>
</VMenu>
</IconBtn>
</header>
<div class="transfer-history-mobile-record__meta">
<VChip class="transfer-history-mobile-record__mode" variant="outlined" color="primary" size="small">
{{ TransferDict[item?.mode ?? ''] || t('common.unknown') }}
</VChip>
<span>{{ formatFileSize(item?.src_fileitem?.size || 0) }}</span>
<span class="transfer-history-mobile-record__dot">·</span>
<span v-if="item?.date">{{ getHistoryDateText(item.date) }}</span>
</div>
<div class="transfer-history-mobile-record__meta">
<VChip class="transfer-history-mobile-record__mode" variant="outlined" color="primary" size="small">
{{ TransferDict[item?.mode ?? ''] || t('common.unknown') }}
</VChip>
<span>{{ formatFileSize(item?.src_fileitem?.size || 0) }}</span>
<span class="transfer-history-mobile-record__dot">·</span>
<span v-if="item?.date">{{ getHistoryDateText(item.date) }}</span>
</div>
</header>
<button
type="button"
@@ -1819,6 +1852,8 @@ onUnmounted(() => {
</template>
<style lang="scss">
/* stylelint-disable selector-pseudo-class-no-unknown */
.v-table th {
white-space: nowrap;
}
@@ -1931,21 +1966,61 @@ onUnmounted(() => {
display: grid;
align-items: start;
gap: 0.75rem;
grid-template-columns: 3rem minmax(0, 1fr) auto 2rem;
padding-block: 1rem 0.75rem;
grid-template-columns: 3.5rem minmax(0, 1fr) auto 2rem;
grid-template-rows: auto auto;
padding-block: 0.85rem 0.75rem;
padding-inline: 1rem 0.85rem;
}
.transfer-history-mobile-record__avatar {
background: transparent;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
.transfer-history-mobile-record__poster-wrapper {
position: relative;
grid-row: 1 / span 2;
inline-size: 3.5rem;
block-size: 5.25rem;
border-radius: 8px;
overflow: hidden;
background: var(--transfer-history-mobile-muted-bg);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.18);
}
.transfer-history-mobile-record__avatar :deep(.v-icon) {
font-size: 2rem;
.transfer-history-mobile-record__poster {
inline-size: 100%;
block-size: 100%;
border-radius: 8px;
}
.transfer-history-mobile-record__poster :deep(.v-img__img) {
transition: opacity 0.2s ease;
}
.transfer-history-mobile-record__poster-skeleton {
inline-size: 100%;
block-size: 100%;
}
.transfer-history-mobile-record__poster-skeleton :deep(.v-skeleton-loader) {
inline-size: 100%;
block-size: 100%;
}
.transfer-history-mobile-record__poster-fallback {
inline-size: 100%;
block-size: 100%;
}
.transfer-history-mobile-record__poster-type {
position: absolute;
inset-block-end: 4px;
inset-inline-start: 4px;
padding: 2px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.55);
color: rgb(255, 255, 255);
opacity: 0.92;
}
.transfer-history-mobile-record__heading {
align-self: center;
min-inline-size: 0;
}
@@ -1990,23 +2065,24 @@ onUnmounted(() => {
}
.transfer-history-mobile-record__meta {
grid-column: 2 / -1;
grid-row: 2;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.65rem;
overflow-x: auto;
padding-block: 0 0.85rem;
padding-inline: 1rem;
scrollbar-width: none;
margin-block-start: 0;
min-inline-size: 0;
overflow: hidden;
white-space: nowrap;
}
.transfer-history-mobile-record__meta::-webkit-scrollbar {
display: none;
}
.transfer-history-mobile-record__meta > span {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.875rem;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
}
.transfer-history-mobile-record__mode {

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup>
import api from '@/api'
import type { Site, SiteUserData } from '@/api/types'
import type { ApiResponse, Site, SiteStatistic, SiteUserData } from '@/api/types'
import SiteCard from '@/components/cards/SiteCard.vue'
import NoDataFound from '@/components/states/NoDataFound.vue'
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
@@ -38,11 +38,14 @@ const SiteImportDialog = defineAsyncComponent(() => import('@/components/dialog/
// 站点列表
const siteList = ref<Site[]>([])
// 排序失败时只能回退到最近一次由服务端确认的列表,不能保留未保存的拖拽结果。
const confirmedSiteList = ref<Site[]>([])
// 站点数据列表
const userDataList = ref<SiteUserData[]>([])
// 站点统计数据列表
const siteStatsList = ref<{ [domain: string]: any }>({})
const siteStatsList = ref<Record<string, SiteStatistic>>({})
// 是否刷新过
const isRefreshed = ref(false)
@@ -50,8 +53,14 @@ const isRefreshed = ref(false)
// 是否加载中
const loading = ref(false)
// 首次加载失败时保留独立错误态,避免把网络异常显示成空站点。
const loadError = ref(false)
const sortMode = ref(false)
// 排序保存期间锁定拖拽,确保一次请求只确认它实际提交的顺序快照。
const savingPriority = ref(false)
// 筛选相关
const filterMenu = ref(false)
const filterOption = ref('all') // all, active, inactive, connected, slow, failed, unknown
@@ -124,6 +133,7 @@ async function fetchData(context: KeepAliveRefreshContext = {}) {
try {
if (showLoading) {
loading.value = true
loadError.value = false
}
const [sites] = await Promise.all([
@@ -132,9 +142,14 @@ async function fetchData(context: KeepAliveRefreshContext = {}) {
fetchSiteStats(),
])
siteList.value = sites
confirmedSiteList.value = [...sites]
isRefreshed.value = true
loadError.value = false
} catch (error) {
console.error(error)
if (!isRefreshed.value) {
loadError.value = true
}
} finally {
if (showLoading) {
loading.value = false
@@ -155,18 +170,15 @@ async function fetchUserData() {
async function fetchSiteStats() {
try {
// 使用批量接口一次性获取所有站点统计数据
const response = await api.get('site/statistic')
const stats = response.data || response
const stats: SiteStatistic[] = await api.get('site/statistic')
// 将数组转换为以domain为键的对象
const statsMap: { [domain: string]: any } = {}
if (Array.isArray(stats)) {
stats.forEach((stat: any) => {
if (stat.domain) {
statsMap[stat.domain] = stat
}
})
}
const statsMap: Record<string, SiteStatistic> = {}
stats.forEach(stat => {
if (stat.domain) {
statsMap[stat.domain] = stat
}
})
siteStatsList.value = statsMap
} catch (error) {
console.error('Failed to fetch site statistics:', error)
@@ -193,19 +205,28 @@ function getConnectionStatus(domain: string) {
// 保存站点排序
async function savaSitesPriority() {
// 只在显示全部站点时允许排序
if (filterOption.value !== 'all') {
if (filterOption.value !== 'all' || savingPriority.value) {
return
}
// 重新排序
const priorities = draggableSiteList.value.map((site, index) => ({ id: site.id, pri: index + 1 }))
const submittedSiteList = [...draggableSiteList.value]
const priorities = submittedSiteList.map((site, index) => ({ id: site.id, pri: index + 1 }))
savingPriority.value = true
try {
const result: { [key: string]: any } = await api.post('site/priorities', priorities)
const result: ApiResponse<unknown> = await api.post('site/priorities', priorities)
if (!result.success) {
fetchData()
siteList.value = [...confirmedSiteList.value]
await fetchData()
} else {
siteList.value = [...submittedSiteList]
confirmedSiteList.value = [...submittedSiteList]
}
} catch (error) {
console.error(error)
siteList.value = [...confirmedSiteList.value]
await fetchData()
} finally {
savingPriority.value = false
}
}
@@ -214,7 +235,7 @@ async function handleRefreshStats(domain?: string) {
if (domain) {
// 刷新特定站点的统计数据
try {
const stats = await api.get(`site/statistic/${domain}`)
const stats = await api.get<SiteStatistic, SiteStatistic>(`site/statistic/${domain}`)
siteStatsList.value[domain] = stats
} catch (error) {
console.error(`Failed to refresh stats for ${domain}:`, error)
@@ -430,10 +451,11 @@ useDynamicButton({
</div>
</VAlert>
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
<LoadingBanner v-if="!isRefreshed && !loadError" class="mt-12" />
<Draggable
v-if="draggableSiteList.length > 0 && canDragSort"
v-model="draggableSiteList"
:disabled="savingPriority"
@end="savaSitesPriority"
item-key="id"
tag="div"
@@ -472,6 +494,18 @@ useDynamicButton({
</template>
</ProgressiveCardGrid>
</div>
<NoDataFound
v-if="loadError && !isRefreshed"
error-code="500"
:error-title="t('common.serverConnectionFailed')"
:error-description="t('common.troubleshooting')"
>
<template #button>
<VBtn color="primary" variant="tonal" @click="fetchData()">
{{ t('common.retry') }}
</VBtn>
</template>
</NoDataFound>
<NoDataFound
v-if="draggableSiteList.length === 0 && isRefreshed"
error-code="404"

View File

@@ -0,0 +1,621 @@
import type { DynamicButtonMenuItem } from '@/composables/useDynamicButton'
import type { Site, SiteStatistic, SiteUserData } from '@/api/types'
import SiteCardListView from '@/views/site/SiteCardListView.vue'
import { DEFAULT_PERMISSIONS } from '@/utils/permission'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createSite, createSiteStatistic, createSiteUserData } from '@tests/support/factories/site'
import {
saveSitePrioritiesHandler,
siteListHandler,
siteStatisticHandler,
siteStatisticsHandler,
siteUserDataLatestHandler,
} from '@tests/support/msw/handlers/site'
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { defineComponent, h, nextTick, ref, unref, type ComputedRef, type PropType, type Ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
appMode: false,
openSharedDialog: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
useDynamicButton: vi.fn(),
}))
vi.mock('vue-toastification', () => ({
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
}))
vi.mock('@/composables/useDynamicButton', () => ({
useDynamicButton: (options: unknown) => mocks.useDynamicButton(options),
}))
vi.mock('@/composables/usePWA', async () => {
const { computed } = await import('vue')
return { usePWA: () => ({ appMode: computed(() => mocks.appMode) }) }
})
vi.mock('@/composables/useSharedDialog', () => ({
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
}))
type MaybeRef<T> = T | Ref<T> | ComputedRef<T>
interface DynamicButtonConfig {
menuItems: MaybeRef<DynamicButtonMenuItem[]>
onClick: () => void
show: MaybeRef<boolean>
}
const SiteCardStub = defineComponent({
name: 'SiteCard',
props: {
data: Object as PropType<SiteUserData>,
site: { type: Object as PropType<Site>, required: true },
sortable: Boolean,
stats: Object as PropType<SiteStatistic>,
},
emits: ['refresh-stats', 'remove', 'update'],
setup(props, { emit }) {
return () =>
h('article', { 'data-testid': `site-card-${props.site.id}` }, [
h('span', props.site.name),
h('output', { 'aria-label': `site-domain-${props.site.id}` }, props.site.domain),
h('output', { 'aria-label': `site-data-${props.site.id}` }, props.data?.username ?? ''),
h('output', { 'aria-label': `site-state-${props.site.id}` }, JSON.stringify(props.stats ?? {})),
h('output', { 'aria-label': `site-sortable-${props.site.id}` }, String(props.sortable)),
h('button', { onClick: () => emit('remove'), type: 'button' }, `remove-${props.site.id}`),
h('button', { onClick: () => emit('update'), type: 'button' }, `update-${props.site.id}`),
h(
'button',
{ onClick: () => emit('refresh-stats', props.site.domain), type: 'button' },
`refresh-${props.site.id}`,
),
])
},
})
const ProgressiveCardGridStub = defineComponent({
name: 'ProgressiveCardGrid',
props: { items: { type: Array as PropType<Site[]>, required: true } },
setup(props, { slots }) {
return () =>
h(
'section',
{ 'data-testid': 'site-grid' },
props.items.flatMap(item => slots.default?.({ item }) ?? []),
)
},
})
const DraggableStub = defineComponent({
name: 'Draggable',
props: {
disabled: Boolean,
modelValue: { type: Array as PropType<Site[]>, required: true },
},
emits: ['end', 'update:modelValue'],
setup(props, { emit, slots }) {
async function reverseOrder() {
if (props.disabled) return
emit('update:modelValue', [...props.modelValue].reverse())
await nextTick()
emit('end')
}
return () =>
h('section', { 'data-testid': 'site-draggable' }, [
...props.modelValue.flatMap(element => slots.item?.({ element }) ?? []),
h('button', { disabled: props.disabled, onClick: reverseOrder, type: 'button' }, 'reverse-sites'),
h('button', { onClick: () => emit('end'), type: 'button' }, 'emit-drag-end'),
])
},
})
const LoadingBannerStub = defineComponent({
name: 'LoadingBanner',
template: '<div role="status">正在加载站点</div>',
})
const NoDataFoundStub = defineComponent({
name: 'NoDataFound',
props: { errorDescription: String, errorTitle: String },
template: '<section aria-label="站点空态">{{ errorTitle }} {{ errorDescription }}<slot name="button" /></section>',
})
const IconButtonStub = defineComponent({
name: 'IconBtn',
inheritAttrs: false,
setup(_props, { attrs, slots }) {
return () => h('button', { ...attrs, type: 'button' }, slots.default?.())
},
})
const IconStub = defineComponent({
name: 'VIcon',
props: { icon: String },
setup(props) {
return () => h('span', props.icon)
},
})
const KeepAliveHost = defineComponent({
name: 'SiteKeepAliveHost',
components: { SiteCardListView },
setup() {
const active = ref(true)
return { active }
},
template: `
<button type="button" @click="active = false">停用站点页</button>
<button type="button" @click="active = true">启用站点页</button>
<KeepAlive><SiteCardListView v-if="active" /></KeepAlive>
`,
})
interface RenderListOptions {
appMode?: boolean
initialRoute?: string
listStatus?: number | (() => number)
manage?: boolean
sites?: Site[]
statistics?: SiteStatistic[]
statisticsStatus?: number
onListRequest?: () => void | Promise<void>
onStatisticsRequest?: () => void | Promise<void>
onUserDataRequest?: () => void | Promise<void>
userData?: SiteUserData[]
useKeepAlive?: boolean
}
async function renderList(options: RenderListOptions = {}) {
mocks.appMode = options.appMode ?? false
server.use(
siteListHandler(options.sites ?? [], options.listStatus ?? 200, options.onListRequest),
siteStatisticsHandler(options.statistics ?? [], options.statisticsStatus ?? 200, options.onStatisticsRequest),
siteUserDataLatestHandler(options.userData ?? [], 200, options.onUserDataRequest),
)
return renderWithProviders(options.useKeepAlive ? KeepAliveHost : SiteCardListView, {
initialRoute: options.initialRoute ?? '/site',
initialState: {
user: {
permissions: { ...DEFAULT_PERMISSIONS, manage: options.manage ?? true },
superUser: false,
},
},
global: {
stubs: {
IconBtn: IconButtonStub,
LoadingBanner: LoadingBannerStub,
NoDataFound: NoDataFoundStub,
ProgressiveCardGrid: ProgressiveCardGridStub,
SiteCard: SiteCardStub,
VFab: IconButtonStub,
VIcon: IconStub,
VPageContentTitle: true,
draggable: DraggableStub,
},
},
})
}
function getDynamicButtonConfig() {
return mocks.useDynamicButton.mock.calls.at(-1)?.[0] as DynamicButtonConfig
}
function getMenuItem(titleKey: string) {
const item = unref(getDynamicButtonConfig().menuItems).find(candidate => candidate.titleKey === titleKey)
if (!item) throw new Error(`未注册动态操作 ${titleKey}`)
return item
}
async function selectFilter(label: string) {
await fireEvent.click(screen.getByText('mdi-filter-multiple-outline'))
await fireEvent.click(await screen.findByText(label))
}
function readBlob(blob: Blob) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.addEventListener('load', () => resolve(String(reader.result ?? '')))
reader.addEventListener('error', () => reject(reader.error))
reader.readAsText(blob)
})
}
describe('SiteCardListView', () => {
beforeEach(() => {
mocks.appMode = false
vi.spyOn(console, 'error').mockImplementation(() => {})
})
it('loads sites with statistics and maps latest user data by domain', async () => {
const fast = createSite({ id: 101, name: '快速站点', domain: 'fast.example.com' })
const failed = createSite({ id: 102, name: '失败站点', domain: 'failed.example.com' })
await renderList({
sites: [fast, failed],
statistics: [
createSiteStatistic({ domain: fast.domain, seconds: 1, lst_state: 0 }),
createSiteStatistic({ domain: failed.domain, lst_state: 1 }),
],
userData: [createSiteUserData({ domain: fast.domain, username: 'fast-user' })],
})
expect(await screen.findByText('快速站点')).toBeInTheDocument()
expect(screen.getByText('失败站点')).toBeInTheDocument()
expect(screen.getByLabelText('site-data-101')).toHaveTextContent('fast-user')
expect(screen.getByLabelText('site-data-102')).toBeEmptyDOMElement()
expect(screen.getByLabelText('site-state-101')).toHaveTextContent('"seconds":1')
expect(screen.getByLabelText('site-state-102')).toHaveTextContent('"lst_state":1')
})
it('keeps a successful site list when loading statistics fails', async () => {
const site = createSite({ id: 111, name: '无统计站点' })
await renderList({ sites: [site], statisticsStatus: 500 })
expect(await screen.findByText('无统计站点')).toBeInTheDocument()
expect(screen.getByLabelText('site-state-111')).toHaveTextContent('{}')
expect(screen.queryByRole('region', { name: '站点空态' })).not.toBeInTheDocument()
})
it.each([
['激活', '快速站点'],
['未激活', '停用站点'],
['连接正常', '快速站点'],
['连接缓慢', '缓慢站点'],
['连接失败', '失败站点'],
['连接未知', '未知站点'],
])('filters %s sites using active and statistic state', async (filterLabel, expectedSite) => {
const sites = [
createSite({ id: 201, name: '快速站点', domain: 'fast.example.com', is_active: true }),
createSite({ id: 202, name: '停用站点', domain: 'inactive.example.com', is_active: false }),
createSite({ id: 203, name: '缓慢站点', domain: 'slow.example.com', is_active: true }),
createSite({ id: 204, name: '失败站点', domain: 'failed.example.com', is_active: true }),
createSite({ id: 205, name: '未知站点', domain: 'unknown.example.com', is_active: true }),
]
await renderList({
sites,
statistics: [
createSiteStatistic({ domain: 'fast.example.com', lst_state: 0, seconds: 1 }),
createSiteStatistic({ domain: 'inactive.example.com', lst_state: 0, seconds: 1 }),
createSiteStatistic({ domain: 'slow.example.com', lst_state: 0, seconds: 5 }),
createSiteStatistic({ domain: 'failed.example.com', lst_state: 1 }),
],
})
expect(await screen.findByText('快速站点')).toBeInTheDocument()
await selectFilter(filterLabel)
expect(screen.getByText(expectedSite)).toBeInTheDocument()
const visibleCards = screen.getAllByTestId(/^site-card-/)
if (filterLabel === '激活') expect(visibleCards).toHaveLength(4)
else if (filterLabel === '连接正常') expect(visibleCards).toHaveLength(2)
else expect(visibleCards).toHaveLength(1)
})
it('reorders only the complete list and posts exact priorities', async () => {
const saved = vi.fn()
const first = createSite({ id: 301, name: '第一站' })
const second = createSite({ id: 302, name: '第二站' })
server.use(saveSitePrioritiesHandler(saved))
await renderList({ sites: [first, second] })
expect(await screen.findByText('第一站')).toBeInTheDocument()
await fireEvent.click(screen.getByText('mdi-sort-variant'))
expect(screen.getByText('已进入拖拽排序模式')).toBeInTheDocument()
await fireEvent.click(screen.getByText('reverse-sites'))
await waitFor(() =>
expect(saved).toHaveBeenCalledWith([
{ id: 302, pri: 1 },
{ id: 301, pri: 2 },
]),
)
expect(screen.getByLabelText('site-sortable-302')).toHaveTextContent('true')
await selectFilter('未激活')
expect(screen.queryByText('已进入拖拽排序模式')).not.toBeInTheDocument()
})
it('locks drag interactions until the submitted priority order is confirmed', async () => {
let resolveSave: (() => void) | undefined
const savePending = new Promise<void>(resolve => {
resolveSave = resolve
})
const saved = vi.fn(async () => savePending)
const first = createSite({ id: 306, name: '锁定第一站' })
const second = createSite({ id: 307, name: '锁定第二站' })
server.use(saveSitePrioritiesHandler(saved))
await renderList({ sites: [first, second] })
expect(await screen.findByText('锁定第一站')).toBeInTheDocument()
await fireEvent.click(screen.getByText('mdi-sort-variant'))
const reverseButton = screen.getByRole('button', { name: 'reverse-sites' })
await fireEvent.click(reverseButton)
await waitFor(() => expect(saved).toHaveBeenCalledOnce())
expect(reverseButton).toBeDisabled()
expect(screen.getAllByTestId(/^site-card-/).map(card => card.textContent)).toEqual([
expect.stringContaining('锁定第二站'),
expect.stringContaining('锁定第一站'),
])
await fireEvent.click(reverseButton)
await fireEvent.click(screen.getByRole('button', { name: 'emit-drag-end' }))
expect(saved).toHaveBeenCalledOnce()
expect(screen.getAllByTestId(/^site-card-/).map(card => card.textContent)).toEqual([
expect.stringContaining('锁定第二站'),
expect.stringContaining('锁定第一站'),
])
resolveSave?.()
await waitFor(() => expect(reverseButton).toBeEnabled())
expect(screen.getAllByTestId(/^site-card-/).map(card => card.textContent)).toEqual([
expect.stringContaining('锁定第二站'),
expect.stringContaining('锁定第一站'),
])
})
it('restores the server order when saving priorities fails with an HTTP error', async () => {
const first = createSite({ id: 311, name: '服务端第一站' })
const second = createSite({ id: 312, name: '服务端第二站' })
const listRequests = vi.fn()
server.use(saveSitePrioritiesHandler(() => {}, { status: 500 }))
await renderList({ onListRequest: listRequests, sites: [first, second] })
expect(await screen.findByText('服务端第一站')).toBeInTheDocument()
await fireEvent.click(screen.getByText('mdi-sort-variant'))
await fireEvent.click(screen.getByText('reverse-sites'))
await waitFor(() => expect(listRequests).toHaveBeenCalledTimes(2))
expect(screen.getAllByTestId(/^site-card-/).map(card => card.textContent)).toEqual([
expect.stringContaining('服务端第一站'),
expect.stringContaining('服务端第二站'),
])
})
it('restores the last confirmed order when both saving and reloading fail', async () => {
const first = createSite({ id: 316, name: '确认第一站' })
const second = createSite({ id: 317, name: '确认第二站' })
const listRequests = vi.fn()
const statisticsRequests = vi.fn()
let listResponse = 0
server.use(saveSitePrioritiesHandler(() => {}, { status: 500 }))
await renderList({
listStatus: () => (listResponse++ === 0 ? 200 : 500),
onListRequest: listRequests,
onStatisticsRequest: statisticsRequests,
sites: [first, second],
})
expect(await screen.findByText('确认第一站')).toBeInTheDocument()
await fireEvent.click(screen.getByText('mdi-sort-variant'))
await fireEvent.click(screen.getByText('reverse-sites'))
await waitFor(() => expect(listRequests).toHaveBeenCalledTimes(2))
await waitFor(() => expect(statisticsRequests).toHaveBeenCalledTimes(2))
await waitFor(() => expect(console.error).toHaveBeenCalledTimes(2))
await waitFor(() =>
expect(screen.getAllByTestId(/^site-card-/).map(card => card.textContent)).toEqual([
expect.stringContaining('确认第一站'),
expect.stringContaining('确认第二站'),
]),
)
})
it('restores the server order when saving priorities is rejected', async () => {
const first = createSite({ id: 321, name: '业务第一站' })
const second = createSite({ id: 322, name: '业务第二站' })
const listRequests = vi.fn()
server.use(saveSitePrioritiesHandler(() => {}, { success: false }))
await renderList({ onListRequest: listRequests, sites: [first, second] })
expect(await screen.findByText('业务第一站')).toBeInTheDocument()
await fireEvent.click(screen.getByText('mdi-sort-variant'))
await fireEvent.click(screen.getByText('reverse-sites'))
await waitFor(() => expect(listRequests).toHaveBeenCalledTimes(2))
expect(screen.getAllByTestId(/^site-card-/).map(card => card.textContent)).toEqual([
expect.stringContaining('业务第一站'),
expect.stringContaining('业务第二站'),
])
})
it('refreshes one site statistic without reloading the site list', async () => {
const site = createSite({ id: 331, name: '统计站点', domain: 'stats.example.com' })
const listRequests = vi.fn()
const statisticRequests = vi.fn()
server.use(
siteStatisticHandler(
site.domain,
createSiteStatistic({ domain: site.domain, seconds: 8, lst_state: 0 }),
200,
statisticRequests,
),
)
await renderList({
onListRequest: listRequests,
sites: [site],
statistics: [createSiteStatistic({ domain: site.domain, seconds: 1, lst_state: 0 })],
})
expect(await screen.findByLabelText('site-state-331')).toHaveTextContent('"seconds":1')
await fireEvent.click(screen.getByRole('button', { name: 'refresh-331' }))
await waitFor(() => expect(screen.getByLabelText('site-state-331')).toHaveTextContent('"seconds":8'))
expect(statisticRequests).toHaveBeenCalledOnce()
expect(listRequests).toHaveBeenCalledOnce()
})
it.each(['remove', 'update'])('reloads the site list after a card %s event', async eventName => {
const listRequests = vi.fn()
const site = createSite({ id: 341, name: '事件站点' })
await renderList({ onListRequest: listRequests, sites: [site] })
expect(await screen.findByText('事件站点')).toBeInTheDocument()
await fireEvent.click(screen.getByRole('button', { name: `${eventName}-341` }))
await waitFor(() => expect(listRequests).toHaveBeenCalledTimes(2))
})
it('opens add, import and statistics dialogs with their declared event contracts', async () => {
const site = createSite({ id: 401, name: '弹窗站点' })
const listRequests = vi.fn()
await renderList({ onListRequest: listRequests, sites: [site] })
expect(await screen.findByText('弹窗站点')).toBeInTheDocument()
getDynamicButtonConfig().onClick()
getMenuItem('site.actions.import').action()
getMenuItem('site.statistics').action()
expect(mocks.openSharedDialog).toHaveBeenCalledTimes(3)
expect(mocks.openSharedDialog.mock.calls[0]?.[1]).toEqual({ oper: 'add' })
expect(mocks.openSharedDialog.mock.calls[0]?.[3]).toEqual({ closeOn: ['close', 'save'] })
expect(mocks.openSharedDialog.mock.calls[1]?.[2]).toHaveProperty('import-success')
expect(mocks.openSharedDialog.mock.calls[2]?.[1]).toEqual({ sites: [site] })
const save = mocks.openSharedDialog.mock.calls[0]?.[2]?.save as () => void
save()
await waitFor(() => expect(listRequests).toHaveBeenCalledTimes(2))
})
it('shows management actions only on the site route for users with manage permission', async () => {
await renderList({ manage: false, sites: [createSite()] })
expect(await screen.findByTestId(/^site-card-/)).toBeInTheDocument()
expect(unref(getDynamicButtonConfig().show)).toBe(false)
expect(document.querySelector('.compact-fab-stack')).not.toBeInTheDocument()
})
it('registers the app-mode dynamic menu after the first successful load', async () => {
await renderList({ appMode: true, sites: [createSite()] })
expect(await screen.findByTestId(/^site-card-/)).toBeInTheDocument()
expect(unref(getDynamicButtonConfig().show)).toBe(true)
expect(unref(getDynamicButtonConfig().menuItems).map(item => item.titleKey)).toEqual([
'site.actions.add',
'site.actions.import',
'site.actions.export',
'site.statistics',
])
})
it('exports the current complete site fields and releases the object URL', async () => {
const site = createSite({
apikey: 'api-key',
cookie: 'cookie-value',
id: 501,
name: '导出站点',
token: 'token-value',
})
const createObjectURL = vi.fn<(blob: Blob) => string>(() => 'blob:site-export')
const revokeObjectURL = vi.fn()
class TestURL extends URL {}
TestURL.createObjectURL = createObjectURL
TestURL.revokeObjectURL = revokeObjectURL
vi.stubGlobal('URL', TestURL)
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
await renderList({ sites: [site] })
expect(await screen.findByText('导出站点')).toBeInTheDocument()
await getMenuItem('site.actions.export').action()
await waitFor(() => expect(createObjectURL).toHaveBeenCalledOnce())
const blob = createObjectURL.mock.calls[0]?.[0] as Blob
const body = JSON.parse(await readBlob(blob)) as Array<Record<string, unknown>>
expect(body).toEqual([
expect.objectContaining({
apikey: 'api-key',
cookie: 'cookie-value',
domain: site.domain,
name: '导出站点',
token: 'token-value',
url: site.url,
}),
])
expect(body[0]).not.toHaveProperty('id')
expect(revokeObjectURL).toHaveBeenCalledWith('blob:site-export')
expect(mocks.toastSuccess).toHaveBeenCalledWith('站点导出成功')
})
it('reports an export failure without creating a download', async () => {
const createObjectURL = vi.fn()
class TestURL extends URL {}
TestURL.createObjectURL = createObjectURL
vi.stubGlobal('URL', TestURL)
await renderList({ sites: [createSite({ name: '不可导出站点' })] })
expect(await screen.findByText('不可导出站点')).toBeInTheDocument()
server.use(siteListHandler([], 500))
await getMenuItem('site.actions.export').action()
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('站点导出失败'))
expect(createObjectURL).not.toHaveBeenCalled()
})
it('shows the empty state after a successful empty response', async () => {
await renderList()
expect(await screen.findByRole('region', { name: '站点空态' })).toHaveTextContent(
'没有站点 已添加并支持的站点将会在这里显示。',
)
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
it('shows a retryable error after the initial site request fails', async () => {
await renderList({ listStatus: 500 })
expect(await screen.findByRole('region', { name: '站点空态' })).toHaveTextContent('服务器连接失败')
expect(screen.queryByRole('status')).not.toBeInTheDocument()
server.use(siteListHandler([createSite({ name: '重试恢复站点' })]))
await fireEvent.click(screen.getByRole('button', { name: '重试' }))
expect(await screen.findByText('重试恢复站点')).toBeInTheDocument()
})
it('refreshes list, statistics and latest user data after KeepAlive reactivation', async () => {
const listRequests = vi.fn()
const statsRequests = vi.fn()
const userDataRequests = vi.fn()
await renderList({
onListRequest: listRequests,
onStatisticsRequest: statsRequests,
onUserDataRequest: userDataRequests,
sites: [createSite({ name: '缓存站点' })],
useKeepAlive: true,
})
expect(await screen.findByText('缓存站点')).toBeInTheDocument()
expect(listRequests).toHaveBeenCalledOnce()
await userEvent.click(screen.getByText('停用站点页'))
await userEvent.click(screen.getByText('启用站点页'))
await waitFor(() => expect(listRequests).toHaveBeenCalledTimes(2))
expect(statsRequests).toHaveBeenCalledTimes(2)
expect(userDataRequests).toHaveBeenCalledTimes(2)
})
it('keeps the existing list when a silent KeepAlive refresh fails', async () => {
const listRequests = vi.fn()
let listResponse = 0
await renderList({
listStatus: () => (listResponse++ === 0 ? 200 : 500),
onListRequest: listRequests,
sites: [createSite({ name: '静默刷新保留站点' })],
useKeepAlive: true,
})
expect(await screen.findByText('静默刷新保留站点')).toBeInTheDocument()
await userEvent.click(screen.getByText('停用站点页'))
await userEvent.click(screen.getByText('启用站点页'))
await waitFor(() => expect(listRequests).toHaveBeenCalledTimes(2))
expect(screen.getByText('静默刷新保留站点')).toBeInTheDocument()
expect(screen.queryByRole('region', { name: '站点空态' })).not.toBeInTheDocument()
})
})

View File

@@ -523,7 +523,6 @@ function getMobileDateSubtitle(date: Date) {
// 获取移动端单条事件主标题。
function getMobileEventMainTitle(event: CalendarEventInfo) {
if (event.episodeTitle) return event.episodeTitle
if (event.subtitle) return t('calendar.mobileEpisodeTitle', { number: event.subtitle })
return event.title
}
@@ -531,8 +530,10 @@ function getMobileEventMainTitle(event: CalendarEventInfo) {
// 获取移动端单条事件副标题。
function getMobileEventSubtitle(event: CalendarEventInfo) {
if (event.mediaType === '电影') return event.year || event.mediaType
// 主标题已是订阅名时,副标题不再重复订阅名。
if (event.episodeTitle) return event.title
return event.title
return ''
}
// 获取移动端集季标识。
@@ -543,13 +544,6 @@ function getMobileEventEpisodeTag(event: CalendarEventInfo) {
return formatSeasonEpisode(event.season, event.episodeNumbers)
}
// 获取移动端海报角标集号。
function getMobilePosterEpisodeTag(event: CalendarEventInfo) {
if (event.mediaType === '电影' || !event.episodeNumbers.length) return ''
return formatSeasonEpisode(undefined, event.episodeNumbers)
}
// 获取移动端时长标识。
function getMobileEventRuntimeTag(event: CalendarEventInfo) {
if (!event.runtime) return ''
@@ -557,11 +551,11 @@ function getMobileEventRuntimeTag(event: CalendarEventInfo) {
return t('calendar.runtimeMinutes', { minutes: event.runtime })
}
// 获取移动端卡片右侧日期标识
function getMobileEventDateBadge(event: CalendarEventInfo) {
if (isDateToday(event.start)) return t('calendar.today')
if (isDateAfterToday(event.start)) return t('calendar.upcoming')
if (isDateBeforeToday(event.start)) return t('calendar.expired')
// 获取移动端单日状态标识(今天已在日期标题中体现,不再重复展示)
function getMobileDayStatus(date: Date) {
if (isDateToday(date)) return ''
if (isDateAfterToday(date)) return t('calendar.upcoming')
if (isDateBeforeToday(date)) return t('calendar.expired')
return ''
}
@@ -817,7 +811,19 @@ onActivated(() => {
<span>{{ group.subtitle }}</span>
</div>
<span class="mobile-calendar-day-count">{{ t('calendar.episodeCount', { count: group.count }) }}</span>
<div class="mobile-calendar-day-meta">
<span
v-if="getMobileDayStatus(group.date)"
class="mobile-calendar-day-status"
:class="{
'mobile-calendar-day-status--upcoming': isDateAfterToday(group.date),
'mobile-calendar-day-status--expired': isDateBeforeToday(group.date),
}"
>
{{ getMobileDayStatus(group.date) }}
</span>
<span class="mobile-calendar-day-count">{{ t('calendar.episodeCount', { count: group.count }) }}</span>
</div>
</header>
<div class="mobile-calendar-event-list">
@@ -847,29 +853,11 @@ onActivated(() => {
</div>
</template>
</VImg>
<span v-if="getMobilePosterEpisodeTag(calendarEvent)" class="mobile-calendar-poster-episode">
{{ getMobilePosterEpisodeTag(calendarEvent) }}
</span>
</div>
<div class="mobile-calendar-event-content">
<div class="mobile-calendar-event-title-row">
<h3>{{ getMobileEventMainTitle(calendarEvent) }}</h3>
<span
v-if="getMobileEventDateBadge(calendarEvent)"
class="mobile-calendar-date-badge"
:class="{
'mobile-calendar-date-badge--today': isDateToday(calendarEvent.start),
'mobile-calendar-date-badge--upcoming': isDateAfterToday(calendarEvent.start),
'mobile-calendar-date-badge--expired': isDateBeforeToday(calendarEvent.start),
}"
>
{{ getMobileEventDateBadge(calendarEvent) }}
</span>
</div>
<p>{{ getMobileEventSubtitle(calendarEvent) }}</p>
<h3>{{ getMobileEventMainTitle(calendarEvent) }}</h3>
<p v-if="getMobileEventSubtitle(calendarEvent)">{{ getMobileEventSubtitle(calendarEvent) }}</p>
<div class="mobile-calendar-event-tags">
<span v-if="getMobileEventEpisodeTag(calendarEvent)" class="mobile-calendar-event-tag mobile-calendar-event-tag--primary">
@@ -1607,6 +1595,36 @@ onActivated(() => {
line-height: 1.25;
}
.mobile-calendar-day-meta {
display: flex;
align-items: center;
column-gap: 0.5rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.mobile-calendar-day-status {
border-radius: var(--app-control-radius);
background: rgba(var(--v-theme-primary), 0.14);
color: rgb(var(--v-theme-primary));
font-size: 0.74rem;
font-weight: 800;
line-height: 1;
padding-block: 0.32rem;
padding-inline: 0.5rem;
white-space: nowrap;
}
.mobile-calendar-day-status--upcoming {
background: rgba(var(--v-theme-warning), 0.16);
color: rgb(var(--v-theme-warning));
}
.mobile-calendar-day-status--expired {
background: rgba(var(--v-theme-error), 0.16);
color: rgb(var(--v-theme-error));
}
.mobile-calendar-day-count {
flex: 0 0 auto;
padding-block-end: 0.12rem;
@@ -1664,25 +1682,6 @@ onActivated(() => {
gap: 0.3rem;
}
.mobile-calendar-poster-episode {
position: absolute;
overflow: hidden;
border-end-end-radius: var(--app-control-radius);
border-end-start-radius: var(--app-control-radius);
background: rgba(0, 0, 0, 68%);
color: #fff;
font-size: 0.72rem;
font-weight: 900;
inset-block-end: 0;
inset-inline: 0;
line-height: 1.1;
padding-block: 0.28rem;
padding-inline: 0.35rem;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.mobile-calendar-event-content {
display: flex;
flex-direction: column;
@@ -1691,17 +1690,9 @@ onActivated(() => {
min-inline-size: 0;
}
.mobile-calendar-event-title-row {
display: flex;
align-items: flex-start;
gap: 0.55rem;
min-inline-size: 0;
}
.mobile-calendar-event-title-row h3 {
.mobile-calendar-event-content h3 {
display: -webkit-box;
overflow: hidden;
flex: 1 1 auto;
-webkit-box-orient: vertical;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
font-size: 1.05rem;
@@ -1713,34 +1704,6 @@ onActivated(() => {
overflow-wrap: anywhere;
}
.mobile-calendar-date-badge {
flex: 0 0 auto;
border-radius: var(--app-control-radius);
background: rgba(var(--v-theme-primary), 0.14);
color: rgb(var(--v-theme-primary));
font-size: 0.78rem;
font-weight: 900;
line-height: 1;
padding-block: 0.38rem;
padding-inline: 0.58rem;
white-space: nowrap;
}
.mobile-calendar-date-badge--today {
background: rgba(var(--v-theme-success), 0.16);
color: rgb(var(--v-theme-success));
}
.mobile-calendar-date-badge--upcoming {
background: rgba(var(--v-theme-warning), 0.16);
color: rgb(var(--v-theme-warning));
}
.mobile-calendar-date-badge--expired {
background: rgba(var(--v-theme-error), 0.16);
color: rgb(var(--v-theme-error));
}
.mobile-calendar-event-content p {
display: -webkit-box;
overflow: hidden;
@@ -1845,7 +1808,7 @@ html[data-theme='transparent'] .mobile-subscribe-calendar,
padding: 0.75rem;
}
.mobile-calendar-event-title-row h3 {
.mobile-calendar-event-content h3 {
font-size: 1rem;
}

View File

@@ -25,15 +25,18 @@ const apipath = 'subscribe/popular'
// 当前页码
const page = ref(1)
// 是否加载中
const loading = ref(false)
// 是否加载完成
const isRefreshed = ref(false)
// 当前列表请求是否失败;合法空数组仍使用空数据状态。
const loadError = ref(false)
// 数据列表
const dataList = ref<MediaInfo[]>([])
const currData = ref<MediaInfo[]>([])
// 筛选重置允许新旧请求短暂并行,只接纳当前代次的响应。
let requestGeneration = 0
const loadingGenerations = new Set<number>()
// 筛选参数
const filterParams = reactive({
@@ -48,9 +51,11 @@ const filterParams = reactive({
const currentKey = ref(0)
function resetData() {
requestGeneration++
dataList.value = []
page.value = 1
isRefreshed.value = false
loadError.value = false
currentKey.value++
}
@@ -140,69 +145,62 @@ function getParams() {
}
// 获取列表数据
async function fetchData({ done }: { done: any }) {
try {
// 如果正在加载中,直接返回
if (loading.value) {
done('ok')
return
}
async function fetchData({ done }: { done: (status: 'empty' | 'error' | 'ok') => void }) {
const generation = requestGeneration
// 加载到满屏或者加载出错
if (!hasScroll()) {
// 加载多次
while (!hasScroll()) {
// 设置加载中
loading.value = true
// 请求API
currData.value = await api.get(apipath, {
params: getParams(),
})
// 取消加载中
loading.value = false
// 标计为已请求完成
isRefreshed.value = true
if (currData.value.length === 0) {
// 如果没有数据,跳出
done('empty')
return
}
// 合并数据
dataList.value = [...dataList.value, ...currData.value]
// 页码+1
page.value++
// 返回加载成功
done('ok')
await nextTick()
}
} else {
// 设置加载中
loading.value = true
// 请求API
currData.value = await api.get(apipath, {
// 同一筛选条件只允许一个分页请求在途。
if (loadingGenerations.has(generation)) {
return
}
loadingGenerations.add(generation)
loadError.value = false
try {
while (generation === requestGeneration) {
const currentData: MediaInfo[] = await api.get(apipath, {
params: getParams(),
})
loading.value = false
// 标计为已请求完成
if (generation !== requestGeneration) return
isRefreshed.value = true
if (currData.value.length === 0) {
// 如果没有数据,跳出
if (currentData.length === 0) {
done('empty')
} else {
// 合并数据
dataList.value = [...dataList.value, ...currData.value]
// 页码+1
page.value++
// 返回加载成功
done('ok')
return
}
dataList.value = [...dataList.value, ...currentData]
page.value++
done('ok')
await nextTick()
if (hasScroll()) return
}
} catch (error) {
if (generation !== requestGeneration) return
console.error(error)
// 返回加载失败
isRefreshed.value = true
loadError.value = true
done('error')
} finally {
loadingGenerations.delete(generation)
}
}
/** 使用媒体来源、稳定 ID 与季号区分热门条目。 */
function getMediaItemKey(item: MediaInfo) {
const mediaId = item.tmdb_id
? `tmdb:${item.tmdb_id}`
: item.douban_id
? `douban:${item.douban_id}`
: item.bangumi_id
? `bangumi:${item.bangumi_id}`
: `${item.mediaid_prefix ?? 'media'}:${item.media_id ?? item.title ?? ''}`
return `${item.source ?? 'unknown'}:${mediaId}:season:${item.season ?? 'all'}`
}
</script>
<template>
@@ -278,11 +276,19 @@ async function fetchData({ done }: { done: any }) {
:key="currentKey"
>
<template #loading />
<template #error="{ props: retryProps }">
<div class="d-flex flex-column align-center ga-2 py-4" role="alert">
<span class="text-medium-emphasis">{{ t('subscribe.requestFailed') }}</span>
<VBtn v-bind="retryProps" prepend-icon="mdi-refresh" size="small" variant="tonal">
{{ t('common.retry') }}
</VBtn>
</div>
</template>
<template #empty />
<ProgressiveCardGrid
v-if="dataList.length > 0"
:items="dataList"
:get-item-key="item => item.tmdb_id || item.douban_id || item.bangumi_id || item.media_id || item.title"
:get-item-key="getMediaItemKey"
:min-item-width="144"
:estimated-item-height="320"
tabindex="0"
@@ -298,7 +304,7 @@ async function fetchData({ done }: { done: any }) {
</template>
</ProgressiveCardGrid>
<NoDataFound
v-if="dataList.length === 0 && isRefreshed"
v-if="dataList.length === 0 && isRefreshed && !loadError"
error-code="404"
:error-title="t('common.noData')"
:error-description="t('subscribe.noPopularData')"

View File

@@ -41,9 +41,11 @@ const filterParams = reactive({
const currentKey = ref(0)
function resetData() {
requestGeneration++
dataList.value = []
page.value = 1
isRefreshed.value = false
loadError.value = false
currentKey.value++
}
@@ -114,15 +116,18 @@ watch(
{ deep: true },
)
// 是否加载中
const loading = ref(false)
// 是否加载完成
const isRefreshed = ref(false)
// 当前列表请求是否失败;合法空数组仍使用空数据状态。
const loadError = ref(false)
// 数据列表
const dataList = ref<SubscribeShare[]>([])
const currData = ref<SubscribeShare[]>([])
// 搜索或筛选重置允许新旧请求短暂并行,只接纳当前代次的响应。
let requestGeneration = 0
const loadingGenerations = new Set<number>()
// 拼装参数
function getParams() {
@@ -150,67 +155,47 @@ function getParams() {
}
// 获取列表数据
async function fetchData({ done }: { done: any }) {
try {
// 如果正在加载中,直接返回
if (loading.value) {
done('ok')
return
}
async function fetchData({ done }: { done: (status: 'empty' | 'error' | 'ok') => void }) {
const generation = requestGeneration
// 加载到满屏或者加载出错
if (!hasScroll()) {
// 加载多次
while (!hasScroll()) {
// 设置加载中
loading.value = true
// 请求API
currData.value = await api.get(apipath, {
params: getParams(),
})
// 取消加载中
loading.value = false
// 标计为已请求完成
isRefreshed.value = true
if (currData.value.length === 0) {
// 如果没有数据,跳出
done('empty')
return
}
// 合并数据
dataList.value = [...dataList.value, ...currData.value]
// 页码+1
page.value++
// 返回加载成功
done('ok')
await nextTick()
}
} else {
// 设置加载中
loading.value = true
// 请求API
currData.value = await api.get(apipath, {
// 同一搜索条件只允许一个分页请求在途。
if (loadingGenerations.has(generation)) {
return
}
loadingGenerations.add(generation)
loadError.value = false
try {
while (generation === requestGeneration) {
const currentData: SubscribeShare[] = await api.get(apipath, {
params: getParams(),
})
loading.value = false
// 标计为已请求完成
if (generation !== requestGeneration) return
isRefreshed.value = true
if (currData.value.length === 0) {
// 如果没有数据,跳出
if (currentData.length === 0) {
done('empty')
} else {
// 合并数据
dataList.value = [...dataList.value, ...currData.value]
// 页码+1
page.value++
// 返回加载成功
done('ok')
return
}
dataList.value = [...dataList.value, ...currentData]
page.value++
done('ok')
await nextTick()
if (hasScroll()) return
}
} catch (error) {
if (generation !== requestGeneration) return
console.error(error)
// 返回加载失败
isRefreshed.value = true
loadError.value = true
done('error')
} finally {
loadingGenerations.delete(generation)
}
}
@@ -294,6 +279,14 @@ function removeData(id: number) {
:key="currentKey"
>
<template #loading />
<template #error="{ props: retryProps }">
<div class="d-flex flex-column align-center ga-2 py-4" role="alert">
<span class="text-medium-emphasis">{{ t('subscribe.requestFailed') }}</span>
<VBtn v-bind="retryProps" prepend-icon="mdi-refresh" size="small" variant="tonal">
{{ t('common.retry') }}
</VBtn>
</div>
</template>
<template #empty />
<ProgressiveCardGrid
v-if="dataList.length > 0"
@@ -308,7 +301,7 @@ function removeData(id: number) {
</template>
</ProgressiveCardGrid>
<NoDataFound
v-if="dataList.length === 0 && isRefreshed"
v-if="dataList.length === 0 && isRefreshed && !loadError"
error-code="404"
:error-title="t('common.noData')"
:error-description="keyword ? t('common.noContent') : t('subscribe.noShareData')"

View File

@@ -376,7 +376,7 @@ describe('FullCalendarView', () => {
const completeCard = (await screen.findByRole('heading', { name: '跨年首集' })).closest(
'.mobile-calendar-event-card',
)
const noneCard = screen.getByRole('heading', { name: '第 2 集' }).closest('.mobile-calendar-event-card')
const noneCard = screen.getByRole('heading', { name: '年度剧集' }).closest('.mobile-calendar-event-card')
const partialCard = screen
.getByRole('heading', { name: '第一集 / 第二集' })
.closest('.mobile-calendar-event-card')

View File

@@ -0,0 +1,401 @@
import type { MediaInfo } from '@/api/types'
import SubscribePopularView from '@/views/subscribe/SubscribePopularView.vue'
import { screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createSubscribeMovie, createSubscribeTv } from '@tests/support/factories/subscribe'
import { popularSubscribesHandler, subscribeApiUrls } from '@tests/support/msw/handlers/subscribe'
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { flushPromises } from '@vue/test-utils'
import { HttpResponse, http } from 'msw'
import { defineComponent, h, onMounted, ref, type PropType } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
type InfiniteScrollStatus = 'empty' | 'error' | 'ok'
const InfiniteScrollStub = defineComponent({
name: 'VInfiniteScroll',
emits: ['load'],
setup(_props, { emit, slots }) {
const status = ref<'empty' | 'error' | 'idle' | 'loading'>('idle')
function load() {
status.value = 'loading'
emit('load', {
done(nextStatus: InfiniteScrollStatus) {
status.value = nextStatus === 'ok' ? 'idle' : nextStatus
},
})
}
onMounted(load)
return () =>
h('section', { 'aria-label': '热门订阅无限列表' }, [
h('output', { 'aria-label': '热门订阅无限列表状态' }, status.value),
status.value === 'loading' ? slots.loading?.({}) : null,
status.value === 'error'
? slots.error?.({
side: 'end',
props: { color: undefined, onClick: load },
})
: null,
status.value === 'empty' ? slots.empty?.({}) : null,
slots.default?.(),
h(
'button',
{
'aria-label': '触发热门订阅加载',
onClick: load,
type: 'button',
},
'触发热门订阅加载',
),
])
},
})
const ProgressiveCardGridStub = defineComponent({
name: 'ProgressiveCardGrid',
props: {
getItemKey: {
type: Function as PropType<(item: MediaInfo, index: number) => string | number>,
required: true,
},
items: {
type: Array as PropType<MediaInfo[]>,
required: true,
},
},
setup(props, { slots }) {
return () =>
h('section', { 'aria-label': '热门订阅渐进网格' }, [
h(
'output',
{ 'aria-label': '热门订阅渐进网格键' },
props.items.map((item, index) => String(props.getItemKey(item, index))).join('|'),
),
...props.items.flatMap(item => slots.default?.({ item }) ?? []),
])
},
})
const MediaCardStub = defineComponent({
name: 'MediaCard',
props: {
media: {
type: Object as PropType<MediaInfo>,
required: true,
},
},
setup(props) {
return () => h('article', props.media.title)
},
})
const LoadingBannerStub = defineComponent({
name: 'LoadingBanner',
template: '<div role="status">正在加载热门订阅</div>',
})
const NoDataFoundStub = defineComponent({
name: 'NoDataFound',
props: {
errorDescription: String,
errorTitle: String,
},
template: '<section aria-label="热门订阅空态">{{ errorTitle }} {{ errorDescription }}</section>',
})
interface Deferred {
promise: Promise<void>
resolve: () => void
}
function createDeferred(): Deferred {
let resolve!: () => void
const promise = new Promise<void>(done => {
resolve = done
})
return { promise, resolve }
}
function setHasScroll(hasScroll: boolean) {
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(600)
return vi.spyOn(document.body, 'scrollHeight', 'get').mockReturnValue(hasScroll ? 900 : 500)
}
async function renderPopular(type: '电影' | '电视剧' = '电影') {
return renderWithProviders(SubscribePopularView, {
props: { type },
global: {
stubs: {
LoadingBanner: LoadingBannerStub,
MediaCard: MediaCardStub,
NoDataFound: NoDataFoundStub,
ProgressiveCardGrid: ProgressiveCardGridStub,
VInfiniteScroll: InfiniteScrollStub,
},
},
})
}
describe('SubscribePopularView', () => {
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {})
setHasScroll(true)
})
it.each([
['电影', () => createSubscribeMovie({ popularity: 18, title: '默认热门电影' })],
['电视剧', () => createSubscribeTv({ popularity: 27, title: '默认热门剧集' })],
] as const)('loads %s with the exact default query', async (type, createMedia) => {
const requests: URL[] = []
server.use(
popularSubscribesHandler([createMedia()], 200, url => {
requests.push(url)
}),
)
await renderPopular(type)
expect(await screen.findByText(type === '电影' ? '默认热门电影' : '默认热门剧集')).toBeInTheDocument()
expect(screen.getByText(type === '电影' ? '18' : '27')).toBeInTheDocument()
expect(requests).toHaveLength(1)
expect(requests[0].searchParams.get('stype')).toBe(type)
expect(requests[0].searchParams.get('page')).toBe('1')
expect(requests[0].searchParams.get('count')).toBe('30')
expect(requests[0].searchParams.get('sort_type')).toBe('count')
expect(requests[0].searchParams.get('genre_id')).toBeNull()
expect(requests[0].searchParams.get('min_rating')).toBeNull()
expect(requests[0].searchParams.get('max_rating')).toBeNull()
expect(requests[0].searchParams.get('min_sub')).toBeNull()
})
it('resets to page one with exact sort, genre and rating filters', async () => {
const requests: URL[] = []
server.use(
http.get(subscribeApiUrls.popular, ({ request }) => {
const url = new URL(request.url)
requests.push(url)
const title = url.searchParams.has('min_rating')
? '高分热门结果'
: url.searchParams.has('genre_id')
? '动作热门结果'
: url.searchParams.get('sort_type') === 'time'
? '最新热门结果'
: '默认热门结果'
return HttpResponse.json([createSubscribeMovie({ title })])
}),
)
const user = userEvent.setup()
await renderPopular()
expect(await screen.findByText('默认热门结果')).toBeInTheDocument()
await user.click(screen.getByText('最新'))
expect(await screen.findByText('最新热门结果')).toBeInTheDocument()
expect(screen.queryByText('默认热门结果')).not.toBeInTheDocument()
await user.click(screen.getByText('动作'))
expect(await screen.findByText('动作热门结果')).toBeInTheDocument()
expect(screen.queryByText('最新热门结果')).not.toBeInTheDocument()
screen.getByRole('slider').focus()
await user.keyboard('{ArrowRight}'.repeat(7))
expect(await screen.findByText('高分热门结果')).toBeInTheDocument()
expect(screen.queryByText('动作热门结果')).not.toBeInTheDocument()
expect(requests.length).toBeGreaterThanOrEqual(4)
expect(requests.slice(1).every(url => url.searchParams.get('page') === '1')).toBe(true)
expect(requests[1].searchParams.get('sort_type')).toBe('time')
expect(requests[2].searchParams.get('genre_id')).toBe('28')
expect(requests.at(-1)?.searchParams.get('min_rating')).toBe('7')
})
it('appends later pages and stops when a page is empty', async () => {
const first = createSubscribeMovie({ title: '热门第一页' })
const second = createSubscribeMovie({ title: '热门第二页' })
const requestedPages: string[] = []
server.use(
http.get(subscribeApiUrls.popular, ({ request }) => {
const page = new URL(request.url).searchParams.get('page') ?? ''
requestedPages.push(page)
if (page === '1') return HttpResponse.json([first])
if (page === '2') return HttpResponse.json([second])
return HttpResponse.json([])
}),
)
const user = userEvent.setup()
await renderPopular()
expect(await screen.findByText('热门第一页')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '触发热门订阅加载' }))
expect(await screen.findByText('热门第二页')).toBeInTheDocument()
expect(screen.getByText('热门第一页')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '触发热门订阅加载' }))
await waitFor(() => expect(requestedPages).toEqual(['1', '2', '3']))
expect(screen.getByText('热门第一页')).toBeInTheDocument()
expect(screen.getByText('热门第二页')).toBeInTheDocument()
})
it('loads consecutive pages until an underfilled viewport becomes scrollable', async () => {
const first = createSubscribeMovie({ title: '未满屏第一页' })
const second = createSubscribeMovie({ title: '未满屏第二页' })
const requestedPages: string[] = []
const scrollHeight = vi.spyOn(document.body, 'scrollHeight', 'get').mockImplementation(() =>
requestedPages.length >= 2 ? 900 : 500,
)
server.use(
http.get(subscribeApiUrls.popular, ({ request }) => {
const page = new URL(request.url).searchParams.get('page') ?? ''
requestedPages.push(page)
return HttpResponse.json(page === '1' ? [first] : [second])
}),
)
await renderPopular()
expect(await screen.findByText('未满屏第一页')).toBeInTheDocument()
expect(await screen.findByText('未满屏第二页')).toBeInTheDocument()
expect(requestedPages).toEqual(['1', '2'])
scrollHeight.mockRestore()
})
it('shows the no-data state for an empty first page', async () => {
server.use(popularSubscribesHandler([]))
await renderPopular()
expect(await screen.findByRole('region', { name: '热门订阅空态' })).toBeInTheDocument()
expect(screen.queryByText('正在加载热门订阅')).not.toBeInTheDocument()
})
it('deduplicates concurrent load events while the request is pending', async () => {
const gate = createDeferred()
const requests: URL[] = []
server.use(
popularSubscribesHandler([createSubscribeMovie({ title: '并发加载结果' })], 200, async url => {
requests.push(url)
await gate.promise
}),
)
const user = userEvent.setup()
await renderPopular()
await waitFor(() => expect(requests).toHaveLength(1))
await user.click(screen.getByRole('button', { name: '触发热门订阅加载' }))
await user.click(screen.getByRole('button', { name: '触发热门订阅加载' }))
expect(requests).toHaveLength(1)
expect(screen.getByRole('status', { name: '热门订阅无限列表状态' })).toHaveTextContent('loading')
gate.resolve()
expect(await screen.findByText('并发加载结果')).toBeInTheDocument()
})
it('recovers from an initial HTTP failure by retrying page one', async () => {
const requestedPages: string[] = []
server.use(
http.get(subscribeApiUrls.popular, ({ request }) => {
const page = new URL(request.url).searchParams.get('page') ?? ''
requestedPages.push(page)
if (requestedPages.length === 1) return HttpResponse.json({ detail: 'failed' }, { status: 500 })
return HttpResponse.json([createSubscribeMovie({ title: '热门首载重试成功' })])
}),
)
const user = userEvent.setup()
await renderPopular()
const retry = await screen.findByRole('button', { name: '重试' })
expect(screen.getByRole('alert')).toBeInTheDocument()
await user.click(retry)
expect(await screen.findByText('热门首载重试成功')).toBeInTheDocument()
expect(requestedPages).toEqual(['1', '1'])
})
it('keeps existing cards and retries the failed later page', async () => {
const first = createSubscribeMovie({ title: '热门保留第一页' })
const second = createSubscribeMovie({ title: '热门第二页重试成功' })
const requestedPages: string[] = []
let pageTwoAttempts = 0
server.use(
http.get(subscribeApiUrls.popular, ({ request }) => {
const page = new URL(request.url).searchParams.get('page') ?? ''
requestedPages.push(page)
if (page === '1') return HttpResponse.json([first])
pageTwoAttempts += 1
if (pageTwoAttempts === 1) return HttpResponse.json({ detail: 'failed' }, { status: 500 })
return HttpResponse.json([second])
}),
)
const user = userEvent.setup()
await renderPopular()
expect(await screen.findByText('热门保留第一页')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '触发热门订阅加载' }))
const retry = await screen.findByRole('button', { name: '重试' })
expect(screen.getByText('热门保留第一页')).toBeInTheDocument()
await user.click(retry)
expect(await screen.findByText('热门第二页重试成功')).toBeInTheDocument()
expect(screen.getByText('热门保留第一页')).toBeInTheDocument()
expect(requestedPages).toEqual(['1', '2', '2'])
})
it('ignores an obsolete response when filters reset a pending request', async () => {
const gate = createDeferred()
const requests: URL[] = []
const staleResponse = vi.fn()
server.use(
http.get(subscribeApiUrls.popular, async ({ request }) => {
const url = new URL(request.url)
requests.push(url)
if (!url.searchParams.has('genre_id')) {
await gate.promise
staleResponse()
return HttpResponse.json([createSubscribeMovie({ title: '过期热门结果' })])
}
return HttpResponse.json([createSubscribeMovie({ title: '新筛选热门结果' })])
}),
)
const user = userEvent.setup()
await renderPopular()
await waitFor(() => expect(requests).toHaveLength(1))
await user.click(screen.getByText('动作'))
expect(await screen.findByText('新筛选热门结果')).toBeInTheDocument()
expect(requests.filter(url => url.searchParams.get('genre_id') === '28')).toHaveLength(1)
expect(requests.at(-1)?.searchParams.get('page')).toBe('1')
gate.resolve()
await waitFor(() => expect(staleResponse).toHaveBeenCalledOnce())
await flushPromises()
await flushPromises()
expect(screen.getByText('新筛选热门结果')).toBeInTheDocument()
expect(screen.queryByText('过期热门结果')).not.toBeInTheDocument()
})
it('provides unique progressive-grid keys for different seasons of the same TMDB title', async () => {
server.use(
popularSubscribesHandler([
createSubscribeTv({ season: 1, source: undefined, title: '同剧第一季', tmdb_id: 880 }),
createSubscribeTv({ season: 2, source: undefined, title: '同剧第二季', tmdb_id: 880 }),
]),
)
await renderPopular('电视剧')
expect(await screen.findByText('同剧第一季')).toBeInTheDocument()
expect(screen.getByText('同剧第二季')).toBeInTheDocument()
const keys = screen.getByRole('status', { name: '热门订阅渐进网格键' }).textContent?.split('|') ?? []
expect(keys).toEqual(['unknown:tmdb:880:season:1', 'unknown:tmdb:880:season:2'])
})
})

View File

@@ -0,0 +1,441 @@
import type { SubscribeShare } from '@/api/types'
import SubscribeShareView from '@/views/subscribe/SubscribeShareView.vue'
import { screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createSubscribeShare } from '@tests/support/factories/subscribe'
import { subscribeApiUrls, subscribeSharesHandler } from '@tests/support/msw/handlers/subscribe'
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { flushPromises } from '@vue/test-utils'
import { HttpResponse, http } from 'msw'
import { defineComponent, h, onMounted, ref, type PropType } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
type InfiniteScrollStatus = 'empty' | 'error' | 'ok'
const InfiniteScrollStub = defineComponent({
name: 'VInfiniteScroll',
emits: ['load'],
setup(_props, { emit, slots }) {
const status = ref<'empty' | 'error' | 'idle' | 'loading'>('idle')
function load() {
status.value = 'loading'
emit('load', {
done(nextStatus: InfiniteScrollStatus) {
status.value = nextStatus === 'ok' ? 'idle' : nextStatus
},
})
}
onMounted(load)
return () =>
h('section', { 'aria-label': '订阅分享无限列表' }, [
h('output', { 'aria-label': '订阅分享无限列表状态' }, status.value),
status.value === 'loading' ? slots.loading?.({}) : null,
status.value === 'error'
? slots.error?.({
side: 'end',
props: { color: undefined, onClick: load },
})
: null,
status.value === 'empty' ? slots.empty?.({}) : null,
slots.default?.(),
h(
'button',
{
'aria-label': '触发订阅分享加载',
onClick: load,
type: 'button',
},
'触发订阅分享加载',
),
])
},
})
const ProgressiveCardGridStub = defineComponent({
name: 'ProgressiveCardGrid',
props: {
getItemKey: {
type: Function as PropType<(item: SubscribeShare, index: number) => string | number>,
required: true,
},
items: {
type: Array as PropType<SubscribeShare[]>,
required: true,
},
},
setup(props, { slots }) {
return () =>
h('section', { 'aria-label': '订阅分享渐进网格' }, [
h(
'output',
{ 'aria-label': '订阅分享渐进网格键' },
props.items.map((item, index) => String(props.getItemKey(item, index))).join('|'),
),
...props.items.flatMap(item => slots.default?.({ item }) ?? []),
])
},
})
const SubscribeShareCardStub = defineComponent({
name: 'SubscribeShareCard',
props: {
media: {
type: Object as PropType<SubscribeShare>,
required: true,
},
},
emits: ['delete'],
setup(props, { emit }) {
return () =>
h('article', [
h('span', props.media.share_title),
h(
'button',
{
'aria-label': `删除分享 ${props.media.id}`,
onClick: () => emit('delete'),
type: 'button',
},
'删除',
),
])
},
})
const LoadingBannerStub = defineComponent({
name: 'LoadingBanner',
template: '<div role="status">正在加载订阅分享</div>',
})
const NoDataFoundStub = defineComponent({
name: 'NoDataFound',
props: {
errorDescription: String,
errorTitle: String,
},
template: '<section aria-label="订阅分享空态">{{ errorTitle }} {{ errorDescription }}</section>',
})
const PageContentTitleStub = defineComponent({
name: 'VPageContentTitle',
props: { title: String },
template: '<h2>{{ title }}</h2>',
})
interface Deferred {
promise: Promise<void>
resolve: () => void
}
function createDeferred(): Deferred {
let resolve!: () => void
const promise = new Promise<void>(done => {
resolve = done
})
return { promise, resolve }
}
function setHasScroll(hasScroll: boolean) {
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(600)
return vi.spyOn(document.body, 'scrollHeight', 'get').mockReturnValue(hasScroll ? 900 : 500)
}
async function renderShare(keyword = '') {
return renderWithProviders(SubscribeShareView, {
props: { keyword },
global: {
stubs: {
LoadingBanner: LoadingBannerStub,
NoDataFound: NoDataFoundStub,
ProgressiveCardGrid: ProgressiveCardGridStub,
SubscribeShareCard: SubscribeShareCardStub,
VInfiniteScroll: InfiniteScrollStub,
VPageContentTitle: PageContentTitleStub,
},
},
})
}
describe('SubscribeShareView', () => {
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {})
setHasScroll(true)
})
it('loads the default list with the exact query and stable share IDs', async () => {
const share = createSubscribeShare({ id: 3101, share_title: '默认分享卡片' })
const requests: URL[] = []
server.use(
subscribeSharesHandler([share], 200, url => {
requests.push(url)
}),
)
await renderShare()
expect(await screen.findByText('默认分享卡片')).toBeInTheDocument()
expect(requests).toHaveLength(1)
expect(requests[0].searchParams.get('page')).toBe('1')
expect(requests[0].searchParams.get('count')).toBe('30')
expect(requests[0].searchParams.get('name') ?? '').toBe('')
expect(requests[0].searchParams.get('sort_type')).toBe('time')
expect(requests[0].searchParams.get('genre_id')).toBeNull()
expect(requests[0].searchParams.get('min_rating')).toBeNull()
expect(requests[0].searchParams.get('max_rating')).toBeNull()
expect(screen.getByRole('status', { name: '订阅分享渐进网格键' })).toHaveTextContent('3101')
})
it('resets to page one with exact sort, genre and rating filters', async () => {
const requests: URL[] = []
server.use(
http.get(subscribeApiUrls.shares, ({ request }) => {
const url = new URL(request.url)
requests.push(url)
const shareTitle = url.searchParams.has('min_rating')
? '高分分享结果'
: url.searchParams.has('genre_id')
? '动作分享结果'
: url.searchParams.get('sort_type') === 'count'
? '热门分享结果'
: '默认分享结果'
return HttpResponse.json([createSubscribeShare({ share_title: shareTitle })])
}),
)
const user = userEvent.setup()
await renderShare()
expect(await screen.findByText('默认分享结果')).toBeInTheDocument()
await user.click(screen.getByText('热门'))
expect(await screen.findByText('热门分享结果')).toBeInTheDocument()
expect(screen.queryByText('默认分享结果')).not.toBeInTheDocument()
await user.click(screen.getByText('动作'))
expect(await screen.findByText('动作分享结果')).toBeInTheDocument()
expect(screen.queryByText('热门分享结果')).not.toBeInTheDocument()
screen.getByRole('slider').focus()
await user.keyboard('{ArrowRight}'.repeat(6))
expect(await screen.findByText('高分分享结果')).toBeInTheDocument()
expect(screen.queryByText('动作分享结果')).not.toBeInTheDocument()
expect(requests.length).toBeGreaterThanOrEqual(4)
expect(requests.slice(1).every(url => url.searchParams.get('page') === '1')).toBe(true)
expect(requests[1].searchParams.get('sort_type')).toBe('count')
expect(requests[2].searchParams.get('genre_id')).toBe('28')
expect(requests.at(-1)?.searchParams.get('min_rating')).toBe('6')
})
it('appends later pages and stops when a page is empty', async () => {
const first = createSubscribeShare({ share_title: '分享第一页' })
const second = createSubscribeShare({ share_title: '分享第二页' })
const requestedPages: string[] = []
server.use(
http.get(subscribeApiUrls.shares, ({ request }) => {
const page = new URL(request.url).searchParams.get('page') ?? ''
requestedPages.push(page)
if (page === '1') return HttpResponse.json([first])
if (page === '2') return HttpResponse.json([second])
return HttpResponse.json([])
}),
)
const user = userEvent.setup()
await renderShare()
expect(await screen.findByText('分享第一页')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '触发订阅分享加载' }))
expect(await screen.findByText('分享第二页')).toBeInTheDocument()
expect(screen.getByText('分享第一页')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '触发订阅分享加载' }))
await waitFor(() => expect(requestedPages).toEqual(['1', '2', '3']))
expect(screen.getByText('分享第一页')).toBeInTheDocument()
expect(screen.getByText('分享第二页')).toBeInTheDocument()
})
it('loads consecutive pages until an underfilled viewport becomes scrollable', async () => {
const first = createSubscribeShare({ share_title: '未满屏分享第一页' })
const second = createSubscribeShare({ share_title: '未满屏分享第二页' })
const requestedPages: string[] = []
const scrollHeight = vi.spyOn(document.body, 'scrollHeight', 'get').mockImplementation(() =>
requestedPages.length >= 2 ? 900 : 500,
)
server.use(
http.get(subscribeApiUrls.shares, ({ request }) => {
const page = new URL(request.url).searchParams.get('page') ?? ''
requestedPages.push(page)
return HttpResponse.json(page === '1' ? [first] : [second])
}),
)
await renderShare()
expect(await screen.findByText('未满屏分享第一页')).toBeInTheDocument()
expect(await screen.findByText('未满屏分享第二页')).toBeInTheDocument()
expect(requestedPages).toEqual(['1', '2'])
scrollHeight.mockRestore()
})
it('shows the keyword-specific no-data state for an empty first page', async () => {
server.use(subscribeSharesHandler([]))
await renderShare('不存在的分享')
expect(await screen.findByRole('heading', { name: '搜索:不存在的分享' })).toBeInTheDocument()
expect(await screen.findByRole('region', { name: '订阅分享空态' })).toBeInTheDocument()
expect(screen.queryByText('正在加载订阅分享')).not.toBeInTheDocument()
})
it('deduplicates concurrent load events while the request is pending', async () => {
const gate = createDeferred()
const requests: URL[] = []
server.use(
subscribeSharesHandler([createSubscribeShare({ share_title: '并发分享结果' })], 200, async url => {
requests.push(url)
await gate.promise
}),
)
const user = userEvent.setup()
await renderShare()
await waitFor(() => expect(requests).toHaveLength(1))
await user.click(screen.getByRole('button', { name: '触发订阅分享加载' }))
await user.click(screen.getByRole('button', { name: '触发订阅分享加载' }))
expect(requests).toHaveLength(1)
expect(screen.getByRole('status', { name: '订阅分享无限列表状态' })).toHaveTextContent('loading')
gate.resolve()
expect(await screen.findByText('并发分享结果')).toBeInTheDocument()
})
it('reloads page one when the keyword prop changes', async () => {
const requests: URL[] = []
server.use(
http.get(subscribeApiUrls.shares, ({ request }) => {
const url = new URL(request.url)
requests.push(url)
const keyword = url.searchParams.get('name') ?? ''
return HttpResponse.json([
createSubscribeShare({ share_title: keyword === '新关键字' ? '新关键字分享' : '旧关键字分享' }),
])
}),
)
const view = await renderShare('旧关键字')
expect(await screen.findByText('旧关键字分享')).toBeInTheDocument()
await view.rerender({ keyword: '新关键字' })
expect(await screen.findByText('新关键字分享')).toBeInTheDocument()
expect(screen.queryByText('旧关键字分享')).not.toBeInTheDocument()
expect(screen.getByRole('heading', { name: '搜索:新关键字' })).toBeInTheDocument()
expect(requests.map(url => url.searchParams.get('name'))).toEqual(['旧关键字', '新关键字'])
expect(requests.every(url => url.searchParams.get('page') === '1')).toBe(true)
})
it('removes only the share whose card emitted delete', async () => {
const first = createSubscribeShare({ id: 4101, share_title: '待删除分享' })
const second = createSubscribeShare({ id: 4102, share_title: '保留分享' })
server.use(subscribeSharesHandler([first, second]))
const user = userEvent.setup()
await renderShare()
expect(await screen.findByText('待删除分享')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '删除分享 4101' }))
expect(screen.queryByText('待删除分享')).not.toBeInTheDocument()
expect(screen.getByText('保留分享')).toBeInTheDocument()
})
it('recovers from an initial HTTP failure by retrying page one', async () => {
const requestedPages: string[] = []
server.use(
http.get(subscribeApiUrls.shares, ({ request }) => {
const page = new URL(request.url).searchParams.get('page') ?? ''
requestedPages.push(page)
if (requestedPages.length === 1) return HttpResponse.json({ detail: 'failed' }, { status: 500 })
return HttpResponse.json([createSubscribeShare({ share_title: '分享首载重试成功' })])
}),
)
const user = userEvent.setup()
await renderShare()
const retry = await screen.findByRole('button', { name: '重试' })
expect(screen.getByRole('alert')).toBeInTheDocument()
await user.click(retry)
expect(await screen.findByText('分享首载重试成功')).toBeInTheDocument()
expect(requestedPages).toEqual(['1', '1'])
})
it('keeps existing shares and retries the failed later page', async () => {
const first = createSubscribeShare({ share_title: '分享保留第一页' })
const second = createSubscribeShare({ share_title: '分享第二页重试成功' })
const requestedPages: string[] = []
let pageTwoAttempts = 0
server.use(
http.get(subscribeApiUrls.shares, ({ request }) => {
const page = new URL(request.url).searchParams.get('page') ?? ''
requestedPages.push(page)
if (page === '1') return HttpResponse.json([first])
pageTwoAttempts += 1
if (pageTwoAttempts === 1) return HttpResponse.json({ detail: 'failed' }, { status: 500 })
return HttpResponse.json([second])
}),
)
const user = userEvent.setup()
await renderShare()
expect(await screen.findByText('分享保留第一页')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '触发订阅分享加载' }))
const retry = await screen.findByRole('button', { name: '重试' })
expect(screen.getByText('分享保留第一页')).toBeInTheDocument()
await user.click(retry)
expect(await screen.findByText('分享第二页重试成功')).toBeInTheDocument()
expect(screen.getByText('分享保留第一页')).toBeInTheDocument()
expect(requestedPages).toEqual(['1', '2', '2'])
})
it('ignores an obsolete response when keyword resets a pending request', async () => {
const gate = createDeferred()
const requests: URL[] = []
const staleResponse = vi.fn()
server.use(
http.get(subscribeApiUrls.shares, async ({ request }) => {
const url = new URL(request.url)
requests.push(url)
if (url.searchParams.get('name') === '旧关键字') {
await gate.promise
staleResponse()
return HttpResponse.json([createSubscribeShare({ share_title: '过期关键字分享' })])
}
return HttpResponse.json([createSubscribeShare({ share_title: '新关键字实时分享' })])
}),
)
const view = await renderShare('旧关键字')
await waitFor(() => expect(requests).toHaveLength(1))
await view.rerender({ keyword: '新关键字' })
expect(await screen.findByText('新关键字实时分享')).toBeInTheDocument()
expect(requests.filter(url => url.searchParams.get('name') === '新关键字')).toHaveLength(1)
expect(requests.at(-1)?.searchParams.get('page')).toBe('1')
gate.resolve()
await waitFor(() => expect(staleResponse).toHaveBeenCalledOnce())
await flushPromises()
await flushPromises()
expect(screen.getByText('新关键字实时分享')).toBeInTheDocument()
expect(screen.queryByText('过期关键字分享')).not.toBeInTheDocument()
})
})

View File

@@ -10,6 +10,9 @@ const { t } = useI18n()
const $toast = useToast()
const { global: globalTheme } = useTheme()
const WORDS_LINE_NUMBERS_STORAGE_KEY = 'MP_WORDS_SHOW_LINE_NUMBERS'
const WORDS_SYNTAX_HIGHLIGHTING_STORAGE_KEY = 'MP_WORDS_SYNTAX_HIGHLIGHTING'
type TextSectionKey = 'identifiers' | 'releaseGroups' | 'customization' | 'excludeWords'
type WordSectionKey = TextSectionKey | 'episodeRules'
@@ -45,16 +48,29 @@ const episodeFormatRules = ref<EpisodeFormatRule[]>([])
const activeSection = ref<WordSectionKey>('identifiers')
const expandedHelp = ref<string | null>(null)
const saving = ref(false)
const showLineNumbers = ref(localStorage.getItem(WORDS_LINE_NUMBERS_STORAGE_KEY) === 'true')
const showSyntaxHighlighting = ref(localStorage.getItem(WORDS_SYNTAX_HIGHLIGHTING_STORAGE_KEY) === 'true')
const textEditorLanguage = computed(() => (showSyntaxHighlighting.value ? 'word_list_syntax' : 'word_list'))
const textEditorTheme = computed(() => (globalTheme.current.value.dark ? 'github_dark' : 'github_light_default'))
const textEditorOptions = {
const textEditorOptions = computed(() => ({
fontSize: 13.6,
highlightActiveLine: false,
scrollPastEnd: 0,
showGutter: false,
showFoldWidgets: false,
showGutter: showLineNumbers.value,
showLineNumbers: showLineNumbers.value,
showPrintMargin: false,
tabSize: 2,
}
}))
watch(showLineNumbers, value => {
localStorage.setItem(WORDS_LINE_NUMBERS_STORAGE_KEY, String(value))
})
watch(showSyntaxHighlighting, value => {
localStorage.setItem(WORDS_SYNTAX_HIGHLIGHTING_STORAGE_KEY, String(value))
})
const savedTextValues = reactive<Record<TextSectionKey, string>>({
identifiers: '',
@@ -483,13 +499,33 @@ onMounted(() => {
<template v-if="isTextSection">
<div class="words-field-meta">
<strong>{{ t('setting.words.listLabel') }}</strong>
<span>{{ t('setting.words.entryCount', { count: activeSectionCount }) }}</span>
<div class="words-field-actions">
<span>{{ t('setting.words.entryCount', { count: activeSectionCount }) }}</span>
<VSwitch
v-if="activeSection === 'identifiers'"
v-model="showLineNumbers"
class="words-editor-switch"
color="primary"
density="compact"
hide-details
:label="t('setting.words.lineNumbers')"
/>
<VSwitch
v-if="activeSection === 'identifiers'"
v-model="showSyntaxHighlighting"
class="words-editor-switch"
color="primary"
density="compact"
hide-details
:label="t('setting.words.syntaxHighlighting')"
/>
</div>
</div>
<VAceEditor
v-if="activeSection === 'identifiers'"
v-model:value="activeTextValue"
lang="word_list"
:lang="textEditorLanguage"
:theme="textEditorTheme"
:options="textEditorOptions"
:placeholder="activeTextPlaceholder"
@@ -856,6 +892,23 @@ onMounted(() => {
font-weight: 600;
}
.words-field-actions {
display: flex;
flex: 0 0 auto;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.8rem;
}
.words-editor-switch {
flex: 0 0 auto;
}
.words-editor-switch :deep(.v-label) {
font-size: 0.78rem;
}
.words-textarea :deep(textarea) {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
font-size: 0.85rem;
@@ -867,6 +920,15 @@ onMounted(() => {
}
.words-text-editor {
--words-token-block: #af00db;
--words-token-replaced: #001080;
--words-token-replacement: #a31515;
--words-token-front: #267f99;
--words-token-back: #795e26;
--words-token-offset: #098658;
--words-token-comment: #008000;
--words-token-operator: #000;
overflow: hidden;
block-size: 15.8rem;
border: 1px solid rgba(var(--v-theme-on-surface), var(--v-border-opacity));
@@ -876,6 +938,17 @@ onMounted(() => {
transform: translateZ(0);
}
.words-text-editor.ace-github-dark {
--words-token-block: #c586c0;
--words-token-replaced: #9cdcfe;
--words-token-replacement: #ce9178;
--words-token-front: #4ec9b0;
--words-token-back: #dcdcaa;
--words-token-offset: #b5cea8;
--words-token-comment: #6a9955;
--words-token-operator: #d4d4d4;
}
.words-text-editor :deep(.ace_scroller),
.words-text-editor :deep(.ace_content),
.words-text-editor :deep(.ace_text-layer) {
@@ -884,10 +957,46 @@ onMounted(() => {
}
.words-text-editor :deep(.ace_comment) {
color: rgb(var(--v-theme-success)) !important;
color: var(--words-token-comment) !important;
font-style: normal;
}
.words-text-editor :deep(.ace_gutter-layer) {
text-align: start;
}
.words-text-editor :deep(.ace_gutter-cell) {
padding-inline: 0.35rem 0.25rem;
}
.words-text-editor :deep(.ace_word_list_block) {
color: var(--words-token-block);
}
.words-text-editor :deep(.ace_word_list_replaced) {
color: var(--words-token-replaced);
}
.words-text-editor :deep(.ace_word_list_replacement) {
color: var(--words-token-replacement);
}
.words-text-editor :deep(.ace_word_list_front) {
color: var(--words-token-front);
}
.words-text-editor :deep(.ace_word_list_back) {
color: var(--words-token-back);
}
.words-text-editor :deep(.ace_word_list_offset) {
color: var(--words-token-offset);
}
.words-text-editor :deep(.ace_keyword.ace_operator.ace_word-list) {
color: var(--words-token-operator);
}
.words-inline-hint {
display: flex;
align-items: flex-start;
@@ -1124,9 +1233,21 @@ onMounted(() => {
}
.words-field-meta {
align-items: flex-start;
flex-wrap: wrap;
gap: 0.75rem;
min-block-size: 2.75rem;
}
.words-field-meta strong {
min-inline-size: 0;
}
.words-field-actions {
gap: 0.5rem;
inline-size: 100%;
}
.words-textarea :deep(.v-field__input) {
min-block-size: 18rem;
}

View File

@@ -0,0 +1,179 @@
import WordsView from '@/views/system/WordsView.vue'
import { screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { renderWithProviders } from '@tests/support/render'
import { defineComponent } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
apiGet: vi.fn(),
apiPost: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
}))
vi.mock('@/api', () => ({
default: {
get: mocks.apiGet,
post: mocks.apiPost,
},
}))
vi.mock('vue-toastification', () => ({
useToast: () => ({
error: mocks.toastError,
success: mocks.toastSuccess,
}),
}))
const AceEditorStub = defineComponent({
name: 'VAceEditor',
props: {
lang: { type: String, default: 'text' },
options: { type: Object, default: () => ({}) },
value: { type: String, default: '' },
},
template: `
<div
data-testid="words-ace-editor"
:data-lang="lang"
:data-show-gutter="String(Boolean(options.showGutter))"
:data-show-line-numbers="String(Boolean(options.showLineNumbers))"
:data-value="value"
/>
`,
})
async function renderWordsView() {
return renderWithProviders(WordsView, {
global: {
stubs: {
VAceEditor: AceEditorStub,
},
},
})
}
describe('WordsView editor preferences', () => {
beforeEach(() => {
mocks.apiGet.mockImplementation((endpoint: string) => {
if (endpoint.includes('EpisodeFormatRuleTable')) return Promise.resolve({ data: { value: [] } })
return Promise.resolve({ data: { value: ['alpha', 'beta'] } })
})
mocks.apiPost.mockResolvedValue({ success: true })
})
it('keeps line numbers disabled when no preference is stored', async () => {
await renderWordsView()
const editor = await screen.findByTestId('words-ace-editor')
const switchControl = screen.getByRole('checkbox', { name: '行号' })
expect(switchControl).not.toBeChecked()
expect(editor).toHaveAttribute('data-show-gutter', 'false')
expect(editor).toHaveAttribute('data-show-line-numbers', 'false')
expect(editor).toHaveAttribute('data-lang', 'word_list')
expect(screen.getByRole('checkbox', { name: '语法高亮' })).not.toBeChecked()
expect(localStorage.getItem('MP_WORDS_SHOW_LINE_NUMBERS')).toBeNull()
expect(localStorage.getItem('MP_WORDS_SYNTAX_HIGHLIGHTING')).toBeNull()
})
it('restores the enabled preference from local storage', async () => {
localStorage.setItem('MP_WORDS_SHOW_LINE_NUMBERS', 'true')
await renderWordsView()
const editor = await screen.findByTestId('words-ace-editor')
expect(screen.getByRole('checkbox', { name: '行号' })).toBeChecked()
expect(editor).toHaveAttribute('data-show-gutter', 'true')
expect(editor).toHaveAttribute('data-show-line-numbers', 'true')
})
it('treats an unrecognized stored preference as disabled', async () => {
localStorage.setItem('MP_WORDS_SHOW_LINE_NUMBERS', 'enabled')
await renderWordsView()
const editor = await screen.findByTestId('words-ace-editor')
expect(screen.getByRole('checkbox', { name: '行号' })).not.toBeChecked()
expect(editor).toHaveAttribute('data-show-gutter', 'false')
expect(editor).toHaveAttribute('data-show-line-numbers', 'false')
})
it('restores the syntax highlighting preference from local storage', async () => {
localStorage.setItem('MP_WORDS_SYNTAX_HIGHLIGHTING', 'true')
await renderWordsView()
const editor = await screen.findByTestId('words-ace-editor')
expect(screen.getByRole('checkbox', { name: '语法高亮' })).toBeChecked()
expect(editor).toHaveAttribute('data-lang', 'word_list_syntax')
})
it('updates Ace options and persists the preference without changing content', async () => {
const user = userEvent.setup()
await renderWordsView()
const editor = await screen.findByTestId('words-ace-editor')
await waitFor(() => expect(editor).toHaveAttribute('data-value', 'alpha\nbeta'))
await user.click(screen.getByRole('checkbox', { name: '行号' }))
await waitFor(() => {
expect(editor).toHaveAttribute('data-show-gutter', 'true')
expect(editor).toHaveAttribute('data-show-line-numbers', 'true')
expect(localStorage.getItem('MP_WORDS_SHOW_LINE_NUMBERS')).toBe('true')
})
expect(editor).toHaveAttribute('data-value', 'alpha\nbeta')
expect(mocks.apiPost).not.toHaveBeenCalled()
await user.click(screen.getByRole('checkbox', { name: '行号' }))
await waitFor(() => {
expect(editor).toHaveAttribute('data-show-gutter', 'false')
expect(editor).toHaveAttribute('data-show-line-numbers', 'false')
expect(localStorage.getItem('MP_WORDS_SHOW_LINE_NUMBERS')).toBe('false')
})
})
it('switches the Ace mode and persists syntax highlighting without changing content', async () => {
const user = userEvent.setup()
await renderWordsView()
const editor = await screen.findByTestId('words-ace-editor')
await waitFor(() => expect(editor).toHaveAttribute('data-value', 'alpha\nbeta'))
await user.click(screen.getByRole('checkbox', { name: '语法高亮' }))
await waitFor(() => {
expect(editor).toHaveAttribute('data-lang', 'word_list_syntax')
expect(localStorage.getItem('MP_WORDS_SYNTAX_HIGHLIGHTING')).toBe('true')
})
expect(editor).toHaveAttribute('data-value', 'alpha\nbeta')
expect(mocks.apiPost).not.toHaveBeenCalled()
await user.click(screen.getByRole('checkbox', { name: '语法高亮' }))
await waitFor(() => {
expect(editor).toHaveAttribute('data-lang', 'word_list')
expect(localStorage.getItem('MP_WORDS_SYNTAX_HIGHLIGHTING')).toBe('false')
})
})
it('shows the switch only for custom identifiers', async () => {
const user = userEvent.setup()
await renderWordsView()
await screen.findByTestId('words-ace-editor')
const releaseGroupButtons = screen.getAllByRole('button', { name: /自定义制作组\/字幕组/ })
await user.click(releaseGroupButtons[0])
expect(screen.queryByRole('checkbox', { name: '行号' })).not.toBeInTheDocument()
expect(screen.queryByRole('checkbox', { name: '语法高亮' })).not.toBeInTheDocument()
expect(screen.queryByTestId('words-ace-editor')).not.toBeInTheDocument()
expect(mocks.apiPost).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,269 @@
import { ESLint } from 'eslint'
import globals from 'globals'
import { expect, it } from 'vitest'
const eslint = new ESLint()
const restrictedSyntaxRule = 'no-restricted-syntax'
const restrictedGlobalsRule = 'no-restricted-globals'
const unusedVariablesRule = '@typescript-eslint/no-unused-vars'
const browserGlobalNames = new Set(Object.keys(globals.browser))
const nodeOnlyGlobalNames = Object.keys(globals.node)
.filter(name => !browserGlobalNames.has(name))
.sort()
/** 使用真实 flat config 检查虚拟源码,避免项目边界和新增问题门禁在依赖升级时静默丢失。 */
async function lintRuleIds(code: string, filePath: string) {
const [result] = await eslint.lintText(code, { filePath })
return result.messages.map(message => message.ruleId)
}
const cases = [
{
name: '允许图片使用通用源码别名',
code: "import '@/assets/images/example.png'",
filePath: 'src/components/valid-image-import.ts',
ruleId: restrictedSyntaxRule,
shouldReport: false,
},
{
name: '允许动态样式使用通用源码别名',
code: "import('@/styles/example.scss')",
filePath: 'src/components/valid-dynamic-style-import.ts',
ruleId: restrictedSyntaxRule,
shouldReport: false,
},
{
name: '拒绝 layouts 反向依赖 core',
code: "import '@core/utils'",
filePath: 'src/@layouts/invalid-core-import.ts',
ruleId: restrictedSyntaxRule,
shouldReport: true,
},
{
name: '允许普通组件依赖 core',
code: "import '@core/utils'",
filePath: 'src/components/valid-core-import.ts',
ruleId: restrictedSyntaxRule,
shouldReport: false,
},
{
name: '拒绝 layouts 通过通用别名反向依赖 core',
code: "import '@/@core/utils'",
filePath: 'src/@layouts/invalid-generic-core-import.ts',
ruleId: restrictedSyntaxRule,
shouldReport: true,
},
{
name: '拒绝 layouts 通过相对路径反向依赖 core',
code: "import '../../@core/utils'",
filePath: 'src/@layouts/components/invalid-relative-core-import.ts',
ruleId: restrictedSyntaxRule,
shouldReport: true,
},
{
name: '拒绝 layouts 通过归一化相对路径反向依赖 core',
code: "import './../@core/utils'",
filePath: 'src/@layouts/invalid-normalized-relative-core-import.ts',
ruleId: restrictedSyntaxRule,
shouldReport: true,
},
{
name: '拒绝 layouts 通过折返路径反向依赖 core',
code: "import '../../@layouts/../@core/utils'",
filePath: 'src/@layouts/components/invalid-detour-core-import.ts',
ruleId: restrictedSyntaxRule,
shouldReport: true,
},
{
name: '拒绝 layouts 动态反向依赖 core',
code: "import('@/@core/utils')",
filePath: 'src/@layouts/invalid-dynamic-core-import.ts',
ruleId: restrictedSyntaxRule,
shouldReport: true,
},
{
name: '拒绝 layouts 使用无法静态验证边界的动态路径',
code: "const file = 'utils'; import(`../@core/${file}.ts`)",
filePath: 'src/@layouts/invalid-template-dynamic-import.ts',
ruleId: restrictedSyntaxRule,
shouldReport: true,
},
{
name: '允许 layouts 使用边界内的字面量动态路径',
code: "import('../utils')",
filePath: 'src/@layouts/valid-literal-dynamic-import.ts',
ruleId: restrictedSyntaxRule,
shouldReport: false,
},
{
name: '拒绝 layouts 通过 Vite glob 反向加载 core',
code: "import.meta.glob('../@core/**/*.ts')",
filePath: 'src/@layouts/invalid-core-glob.ts',
ruleId: restrictedSyntaxRule,
shouldReport: true,
},
{
name: '拒绝 layouts 通过 Vite glob 数组反向加载 core',
code: "import.meta.glob(['../utils/*.ts', '../../@core/**/*.ts'])",
filePath: 'src/@layouts/components/invalid-core-glob-array.ts',
ruleId: restrictedSyntaxRule,
shouldReport: true,
},
{
name: '拒绝 layouts 通过通用别名 Vite glob 反向加载 core',
code: "import.meta.glob('@/@core/**/*.ts')",
filePath: 'src/@layouts/invalid-alias-core-glob.ts',
ruleId: restrictedSyntaxRule,
shouldReport: true,
},
{
name: '拒绝 layouts 使用无法证明边界安全的 Vite glob',
code: "import.meta.glob('../utils/*.ts')",
filePath: 'src/@layouts/invalid-layout-glob.ts',
ruleId: restrictedSyntaxRule,
shouldReport: true,
},
{
name: '拒绝 layouts 使用可能跨越到 core 的通配 glob',
code: "import.meta.glob('../*/**/*.ts')",
filePath: 'src/@layouts/invalid-cross-boundary-glob.ts',
ruleId: restrictedSyntaxRule,
shouldReport: true,
},
{
name: '允许普通源码使用 Vite glob',
code: "import.meta.glob('../views/**/*.vue')",
filePath: 'src/components/valid-vite-glob.ts',
ruleId: restrictedSyntaxRule,
shouldReport: false,
},
{
name: '拒绝 layouts 通过通用别名折返到 core',
code: "export * from '@/@layouts/../@core/utils'",
filePath: 'src/@layouts/invalid-alias-detour-core-export.ts',
ruleId: restrictedSyntaxRule,
shouldReport: true,
},
{
name: '拒绝 baseline 之外的普通 lint 问题',
code: 'const newUnusedValue = 1',
filePath: 'src/components/new-lint-violation.ts',
ruleId: unusedVariablesRule,
shouldReport: true,
},
{
name: '拒绝 TypeScript 源码保留 debugger',
code: 'debugger',
filePath: 'src/components/invalid-debugger.ts',
ruleId: 'no-debugger',
shouldReport: true,
},
{
name: '拒绝浏览器 JavaScript 使用 Node 全局变量',
code: 'process.env.NODE_ENV',
filePath: 'src/components/invalid-node-global.js',
ruleId: 'no-undef',
shouldReport: true,
},
{
name: '拒绝浏览器 Vue 脚本使用 Node 全局变量',
code: '<script setup lang="ts">const bytes = Buffer.from("x")</script><template>{{ bytes }}</template>',
filePath: 'src/components/invalid-node-global.vue',
ruleId: restrictedGlobalsRule,
shouldReport: true,
},
{
name: '允许构建配置使用 Node 全局变量',
code: 'process.env.NODE_ENV',
filePath: 'example.config.js',
ruleId: 'no-undef',
shouldReport: false,
},
{
name: '允许 TypeScript 构建配置使用 Node 全局变量',
code: 'const runtime: string | undefined = process.env.NODE_ENV; console.log(runtime)',
filePath: 'example.config.mts',
ruleId: restrictedGlobalsRule,
shouldReport: false,
},
{
name: '允许浏览器目录中的 Node 配置使用 Node 全局变量',
code: 'const runtime: string | undefined = process.env.NODE_ENV; console.log(runtime)',
filePath: 'src/tool.config.ts',
ruleId: restrictedGlobalsRule,
shouldReport: false,
},
{
name: '拒绝 MTS 脚本保留 debugger',
code: 'const value: string = "x"; console.log(value); debugger',
filePath: 'scripts/invalid-debugger.mts',
ruleId: 'no-debugger',
shouldReport: true,
},
{
name: '拒绝 CTS 脚本保留 debugger',
code: 'const value: string = "x"; console.log(value); debugger',
filePath: 'scripts/invalid-debugger.cts',
ruleId: 'no-debugger',
shouldReport: true,
},
{
name: '允许 Vuetify 点号 slot 名称',
code: '<template><VDataTable><template #item.name /></VDataTable></template>',
filePath: 'src/components/valid-vuetify-slot.vue',
ruleId: 'vue/valid-v-slot',
shouldReport: false,
},
{
name: '允许界面效果使用伪随机数',
code: 'export const randomOffset = Math.random()',
filePath: 'src/components/valid-ui-random.ts',
ruleId: 'sonarjs/pseudo-random',
shouldReport: false,
},
{
name: '拒绝动态执行代码',
code: 'export const runCode = (source: string) => new Function(source)()',
filePath: 'src/components/invalid-dynamic-code.ts',
ruleId: 'sonarjs/code-eval',
shouldReport: true,
},
]
it.each(cases)('$name', async ({ code, filePath, ruleId, shouldReport }) => {
const ruleIds = await lintRuleIds(code, filePath)
expect(ruleIds.includes(ruleId)).toBe(shouldReport)
})
it.each(nodeOnlyGlobalNames)('拒绝浏览器 TypeScript 使用 Node 全局变量 %s', async globalName => {
const ruleIds = await lintRuleIds(`console.log(${globalName})`, `src/components/invalid-${globalName}.ts`)
expect(ruleIds).toContain(restrictedGlobalsRule)
})
it.each(['example.config.mts', 'example.config.cts', 'scripts/example.mts', 'scripts/example.cts'])(
'使用 TypeScript parser 解析 %s',
async filePath => {
const ruleIds = await lintRuleIds('const value: string = "x"; console.log(value)', filePath)
expect(ruleIds).not.toContain(null)
},
)
it('忽略 Vite 生成的临时配置模块', async () => {
await expect(eslint.isPathIgnored('vite.config.ts.timestamp-1784340502076-0129cef6d3bbd8.mjs')).resolves.toBe(true)
})
it('忽略 Vite PWA 开发构建产物', async () => {
await expect(eslint.isPathIgnored('dev-dist/registerSW.js')).resolves.toBe(true)
})
it('不忽略普通 MJS 源码', async () => {
await expect(eslint.isPathIgnored('scripts/example.mjs')).resolves.toBe(false)
})
it('不忽略名称包含 timestamp 的普通 MJS 源码', async () => {
await expect(eslint.isPathIgnored('scripts/feature.timestamp-parser.mjs')).resolves.toBe(false)
})

View File

@@ -0,0 +1,214 @@
import { execFileSync, spawnSync } from 'node:child_process'
import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { delimiter, dirname, resolve } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
const selectorScript = resolve(process.cwd(), 'scripts/format-changed.mjs')
const temporaryRepositories: string[] = []
function git(repository: string, ...args: string[]) {
return execFileSync('git', args, { cwd: repository, encoding: 'utf8' }).trim()
}
function write(repository: string, filePath: string, content: string) {
const absolutePath = resolve(repository, filePath)
mkdirSync(dirname(absolutePath), { recursive: true })
writeFileSync(absolutePath, content)
}
function createRepository() {
const repository = mkdtempSync(resolve(tmpdir(), 'moviepilot-format-changed-'))
temporaryRepositories.push(repository)
git(repository, 'init', '--initial-branch=v2')
git(repository, 'config', 'user.name', 'Format Test')
git(repository, 'config', 'user.email', 'format-test@example.invalid')
write(repository, '.gitignore', 'coverage/\n')
write(repository, '.prettierignore', 'generated/\n/vite.config.*.timestamp-*.mjs\n')
write(repository, 'src/modified.ts', "export const value = 'before'\n")
write(repository, 'src/rename old.ts', "export const renamed = 'before'\n")
write(repository, 'src/deleted.ts', 'export const deleted = true\n')
write(repository, 'coverage/tracked.ts', 'export const generated = true\n')
git(repository, 'add', '.')
git(repository, 'add', '--force', 'coverage/tracked.ts')
git(repository, 'commit', '-m', 'base')
return repository
}
function runSelector(repository: string, args: string[], env = process.env) {
const output = execFileSync(process.execPath, [selectorScript, '--list', ...args], {
cwd: repository,
encoding: 'utf8',
env,
maxBuffer: 128 * 1024 * 1024,
})
return JSON.parse(output) as string[]
}
function listSelected(repository: string, ...args: string[]) {
return runSelector(repository, args)
}
afterEach(() => {
for (const repository of temporaryRepositories.splice(0)) {
rmSync(repository, { recursive: true, force: true })
}
})
describe('Prettier 变更文件选择器', () => {
it('按 base 与 head 选择受支持文件并排除删除、忽略和符号链接', () => {
const repository = createRepository()
git(repository, 'switch', '-c', 'feature')
write(repository, 'src/modified.ts', "export const value='after'\n")
git(repository, 'mv', 'src/rename old.ts', 'src/rename [new].ts')
git(repository, 'rm', 'src/deleted.ts')
write(repository, 'src/new file.vue', '<template><div>new</div></template>\n')
write(repository, 'src/line\nbreak.ts', 'export const lineBreak=true\n')
symlinkSync('modified.ts', resolve(repository, 'src/linked.ts'))
write(repository, 'generated/ignored.ts', 'export const ignored=true\n')
write(repository, 'coverage/tracked.ts', 'export const generated=false\n')
write(repository, 'notes.bin', 'not prettier source\n')
git(repository, 'add', '-A')
git(repository, 'commit', '-m', 'feature')
expect(listSelected(repository, '--base', 'v2', '--head', 'HEAD')).toEqual([
'src/line\nbreak.ts',
'src/modified.ts',
'src/new file.vue',
'src/rename [new].ts',
])
})
it('本地模式合并分支、工作区和未跟踪文件,并排除删除、忽略和符号链接', () => {
const repository = createRepository()
git(repository, 'switch', '-c', 'feature')
write(repository, 'src/modified.ts', "export const value='working-tree'\n")
write(repository, 'src/untracked file.ts', 'export const untracked=true\n')
write(repository, 'scripts/feature.timestamp-parser.mjs', 'export const parser=true\n')
write(repository, 'vite.config.ts.timestamp-123-generated.mjs', 'export const temporary=true\n')
symlinkSync('modified.ts', resolve(repository, 'src/untracked-link.ts'))
write(repository, 'generated/untracked.ts', 'export const ignored=true\n')
git(repository, 'rm', 'src/deleted.ts')
expect(listSelected(repository)).toEqual([
'scripts/feature.timestamp-parser.mjs',
'src/modified.ts',
'src/untracked file.ts',
])
})
it('没有受支持的变更文件时正常通过', () => {
const repository = createRepository()
expect(listSelected(repository, '--base', 'HEAD', '--head', 'HEAD')).toEqual([])
expect(
spawnSync(process.execPath, [selectorScript, '--check', '--base', 'HEAD', '--head', 'HEAD'], {
cwd: repository,
}).status,
).toBe(0)
})
it('显式比较必须同时提供 base 与 head', () => {
const repository = createRepository()
const result = spawnSync(process.execPath, [selectorScript, '--check', '--base', 'HEAD'], {
cwd: repository,
encoding: 'utf8',
})
expect(result.status).toBe(1)
expect(result.stderr).toContain('--base 与 --head 必须一起使用。')
})
it('通过参数数组安全格式化特殊文件名', () => {
const repository = createRepository()
git(repository, 'switch', '-c', 'feature')
write(repository, '--special [file].ts', 'export const special=true\n')
execFileSync(process.execPath, [selectorScript, '--write'], { cwd: repository })
expect(readFileSync(resolve(repository, '--special [file].ts'), 'utf8')).toBe('export const special = true;\n')
})
it('本地基线使用完整分支引用,不受同名标签影响', () => {
const repository = createRepository()
git(repository, 'switch', '-c', 'feature')
write(repository, 'src/modified.ts', "export const value='feature'\n")
git(repository, 'add', 'src/modified.ts')
git(repository, 'commit', '-m', 'feature')
git(repository, 'tag', 'v2', 'HEAD')
expect(listSelected(repository)).toEqual(['src/modified.ts'])
})
it('首个候选没有 merge base 时回退到后续可比较分支', () => {
const repository = createRepository()
const baseCommit = git(repository, 'rev-parse', 'HEAD')
git(repository, 'switch', '-c', 'feature')
write(repository, 'src/modified.ts', "export const value='feature'\n")
git(repository, 'add', 'src/modified.ts')
git(repository, 'commit', '-m', 'feature')
const tree = git(repository, 'rev-parse', `${baseCommit}^{tree}`)
const unrelatedCommit = execFileSync('git', ['commit-tree', tree, '-m', 'unrelated'], {
cwd: repository,
encoding: 'utf8',
}).trim()
git(repository, 'update-ref', 'refs/remotes/upstream/v2', unrelatedCommit)
git(repository, 'update-ref', 'refs/remotes/origin/v2', baseCommit)
expect(listSelected(repository)).toEqual(['src/modified.ts'])
})
it('纳入转为普通文件的类型变更,并继续排除最终为符号链接的路径', () => {
const repository = createRepository()
symlinkSync('modified.ts', resolve(repository, 'src/to-regular.ts'))
write(repository, 'src/to-link.ts', 'export const link = false\n')
git(repository, 'add', 'src/to-regular.ts', 'src/to-link.ts')
git(repository, 'commit', '-m', 'type-change base')
git(repository, 'switch', '-c', 'feature')
rmSync(resolve(repository, 'src/to-regular.ts'))
write(repository, 'src/to-regular.ts', 'export const regular=true\n')
rmSync(resolve(repository, 'src/to-link.ts'))
symlinkSync('modified.ts', resolve(repository, 'src/to-link.ts'))
git(repository, 'add', '-A')
git(repository, 'commit', '-m', 'type changes')
expect(listSelected(repository, '--base', 'v2', '--head', 'HEAD')).toEqual(['src/to-regular.ts'])
})
it('Git 路径输出超过默认缓冲区时仍能完成选择', () => {
const repository = createRepository()
const wrapperDirectory = resolve(repository, '.test-bin')
const wrapperPath = resolve(wrapperDirectory, 'git')
write(
repository,
'.test-bin/git',
`#!/usr/bin/env node
const { spawnSync } = require('node:child_process')
const { writeSync } = require('node:fs')
const args = process.argv.slice(2)
if (args[0] === 'ls-files') {
const output = Buffer.from('src/modified.ts\\0'.repeat(300_000))
let offset = 0
while (offset < output.length) offset += writeSync(1, output, offset)
process.exit(0)
}
const result = spawnSync('git', args, {
env: { ...process.env, PATH: process.env.REAL_GIT_PATH },
stdio: 'inherit',
})
process.exit(result.status ?? 1)
`,
)
chmodSync(wrapperPath, 0o755)
expect(
runSelector(repository, [], {
...process.env,
PATH: `${wrapperDirectory}${delimiter}${process.env.PATH ?? ''}`,
REAL_GIT_PATH: process.env.PATH ?? '',
}),
).toEqual(['src/modified.ts'])
})
})

View File

@@ -0,0 +1,22 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const workflowPath = resolve(process.cwd(), '.github/workflows/test.yml')
describe('前端 Pull Request workflow', () => {
it('使用只读的变更文件格式检查,并显式比较事件 base/head SHA', () => {
const workflow = readFileSync(workflowPath, 'utf8')
const formatJob = workflow.match(/\n {2}format:\n(?<job>[\s\S]*?)(?=\n {2}[\w-]+:\n|$)/)?.groups?.job
expect(workflow).toContain('permissions:\n contents: read')
expect(formatJob).toBeDefined()
expect(formatJob).toContain('fetch-depth: 0')
expect(formatJob).toContain("node-version: '24'")
expect(formatJob).toContain('run: yarn --frozen-lockfile')
expect(formatJob).toContain('BASE_SHA: ${{ github.event.pull_request.base.sha }}')
expect(formatJob).toContain('HEAD_SHA: ${{ github.event.pull_request.head.sha }}')
expect(formatJob).toContain('run: yarn format:check --base "$BASE_SHA" --head "$HEAD_SHA"')
expect(formatJob).not.toContain('--write')
})
})

View File

@@ -1,7 +1,8 @@
import type { MediaInfo, TmdbEpisode } from '@/api/types'
import type { MediaInfo, MediaSeason, NotExistMediaInfo, TmdbEpisode } from '@/api/types'
let episodeSeed = 0
let mediaSeed = 0
let seasonSeed = 0
export function createTmdbEpisode(overrides: Partial<TmdbEpisode> = {}): TmdbEpisode {
episodeSeed += 1
@@ -32,3 +33,36 @@ export function createMediaInfo(overrides: Partial<MediaInfo> = {}): MediaInfo {
...overrides,
}
}
/** 构造后端无法识别媒体时返回的空详情。 */
export function createEmptyMediaInfo(): MediaInfo {
return {
episode_run_time: [],
origin_country: [],
}
}
/** 构造季选择弹窗使用的最小季信息。 */
export function createMediaSeason(overrides: Partial<MediaSeason> = {}): MediaSeason {
seasonSeed += 1
return {
air_date: `202${seasonSeed % 10}-01-01`,
episode_count: 12,
name: `${seasonSeed}`,
poster_path: `/images/season-${seasonSeed}.jpg`,
season_number: seasonSeed,
vote_average: 8,
...overrides,
}
}
/** 构造媒体服务器返回的单季缺失状态。 */
export function createNotExistMediaInfo(overrides: Partial<NotExistMediaInfo> = {}): NotExistMediaInfo {
return {
episodes: [],
season: 1,
start_episode: 1,
total_episode: 12,
...overrides,
}
}

View File

@@ -0,0 +1,92 @@
import type { DownloaderConf, Site, SiteCategory, SiteStatistic, SiteUserData, TorrentInfo } from '@/api/types'
let siteSeed = 0
let torrentSeed = 0
export function createSite(overrides: Partial<Site> = {}): Site {
siteSeed += 1
return {
domain: `site-${siteSeed}.example.com`,
downloader: '',
id: siteSeed,
is_active: true,
name: `测试站点 ${siteSeed}`,
pri: siteSeed,
url: `https://site-${siteSeed}.example.com/`,
...overrides,
}
}
export function createSiteStatistic(overrides: Partial<SiteStatistic> = {}): SiteStatistic {
return {
fail: 0,
lst_state: 0,
seconds: 1,
success: 10,
...overrides,
}
}
export function createSiteUserData(overrides: Partial<SiteUserData> = {}): SiteUserData {
return {
bonus: 100,
download: 1024,
join_at: '2024-01-02 10:00:00',
ratio: 2,
seeding: 3,
seeding_info: [],
seeding_size: 4 * 1024 ** 3,
updated_day: '2026-07-18',
upload: 2048,
user_level: 'Elite',
username: 'site-user',
...overrides,
}
}
/** 构造可用的下载器配置测试记录。 */
export function createSiteDownloader(overrides: Partial<DownloaderConf> = {}): DownloaderConf {
return {
config: {},
default: false,
enabled: true,
name: '测试下载器',
type: 'qbittorrent',
...overrides,
}
}
/** 构造站点资源分类测试记录。 */
export function createSiteCategory(overrides: Partial<SiteCategory> = {}): SiteCategory {
return {
cat: 'movie',
desc: '电影',
id: 1,
...overrides,
}
}
/** 构造满足资源列表展示与下载边界的种子记录。 */
export function createTorrentInfo(overrides: Partial<TorrentInfo> = {}): TorrentInfo {
torrentSeed += 1
return {
category: '电影',
downloadvolumefactor: 1,
freedate: '',
freedate_diff: '',
grabs: 10,
hit_and_run: false,
imdbid: '',
labels: [],
peers: 3,
pri_order: 0,
seeders: 12,
site_order: 0,
site_proxy: false,
size: 1_073_741_824,
title: `测试资源 ${torrentSeed}`,
uploadvolumefactor: 1,
volume_factor: '1x',
...overrides,
}
}

View File

@@ -4,11 +4,14 @@ import type {
MediaInfo,
Site,
Subscribe,
SubscribeShare,
SubscribeShareStatistics,
TransferDirectoryConf,
} from '@/api/types'
import { createMediaInfo } from './media'
let subscribeSeed = 1000
let subscribeShareSeed = 2000
let siteSeed = 100
/** 构造满足前端订阅契约的最小记录。 */
@@ -35,6 +38,42 @@ export function createSubscribe(overrides: Partial<Subscribe> = {}): Subscribe {
}
}
/** 构造订阅分享列表、卡片与复用弹窗共同使用的稳定记录。 */
export function createSubscribeShare(overrides: Partial<SubscribeShare> = {}): SubscribeShare {
subscribeShareSeed += 1
return {
backdrop: `https://images.example.com/share-backdrop-${subscribeShareSeed}.jpg`,
count: 3,
date: '2026-07-17 12:00:00',
id: subscribeShareSeed,
name: `分享媒体 ${subscribeShareSeed}`,
poster: `https://images.example.com/share-poster-${subscribeShareSeed}.jpg`,
share_comment: `分享说明 ${subscribeShareSeed}`,
share_title: `分享标题 ${subscribeShareSeed}`,
share_uid: `share-user-${subscribeShareSeed}`,
share_user: `分享用户 ${subscribeShareSeed}`,
subscribe_id: subscribeShareSeed + 1000,
tmdbid: subscribeShareSeed,
type: '电影',
vote: 8.2,
year: '2026',
...overrides,
}
}
/** 构造分享排行榜使用的稳定聚合记录。 */
export function createSubscribeShareStatistics(
overrides: Partial<SubscribeShareStatistics> = {},
): SubscribeShareStatistics {
subscribeShareSeed += 1
return {
share_count: 2,
share_user: `统计用户 ${subscribeShareSeed}`,
total_reuse_count: 5,
...overrides,
}
}
/** 构造电影媒体信息。 */
export function createSubscribeMovie(overrides: Partial<MediaInfo> = {}): MediaInfo {
return createMediaInfo({ type: '电影', ...overrides })

View File

@@ -0,0 +1,46 @@
import type { DiscoverSource } from '@/api/types'
import { HttpResponse, http, type JsonBodyType } from 'msw'
const API_BASE_URL = 'http://localhost/api/v1/'
export interface DiscoverOrderItem {
name: string
}
export const discoverApiUrls = {
orderConfig: new URL('user/config/MP_DISCOVER_TAB_ORDER', API_BASE_URL).href,
sources: new URL('discover/source', API_BASE_URL).href,
}
export function discoverSourcesHandler(
sources: DiscoverSource[],
status = 200,
onRequest: () => void | Promise<void> = () => {},
) {
return http.get(discoverApiUrls.sources, async () => {
await onRequest()
return HttpResponse.json(sources as unknown as JsonBodyType, { status })
})
}
export function discoverOrderConfigHandler(
order: DiscoverOrderItem[] | null,
status = 200,
onRequest: () => void | Promise<void> = () => {},
) {
return http.get(discoverApiUrls.orderConfig, async () => {
await onRequest()
return HttpResponse.json({ data: { value: order }, success: status < 400 }, { status })
})
}
export function saveDiscoverOrderHandler(
onSave: (order: DiscoverOrderItem[]) => void | Promise<void> = () => {},
status = 200,
) {
return http.post(discoverApiUrls.orderConfig, async ({ request }) => {
const order = (await request.json()) as DiscoverOrderItem[]
await onSave(order)
return HttpResponse.json({ success: status < 400 }, { status })
})
}

View File

@@ -1,20 +1,66 @@
import type { MediaInfo, TmdbEpisode } from '@/api/types'
import type { MediaInfo, MediaSeason, NotExistMediaInfo, TmdbEpisode } from '@/api/types'
import { HttpResponse, http, type JsonBodyType } from 'msw'
const API_BASE_URL = 'http://localhost/api/v1/'
export const mediaApiUrls = {
details: (mediaId: string) => new URL(`media/${mediaId}`, API_BASE_URL).href,
episodeGroups: (tmdbId: number) => new URL(`media/groups/${tmdbId}`, API_BASE_URL).href,
exists: new URL('mediaserver/exists', API_BASE_URL).href,
existsRemote: new URL('mediaserver/exists_remote', API_BASE_URL).href,
groupSeasons: (episodeGroup: string) => new URL(`media/group/seasons/${episodeGroup}`, API_BASE_URL).href,
notExists: new URL('mediaserver/notexists', API_BASE_URL).href,
play: (itemId: string) => new URL(`mediaserver/play/${itemId}`, API_BASE_URL).href,
seasons: new URL('media/seasons', API_BASE_URL).href,
}
export function mediaExistsHandler(
response: { data?: Record<string, unknown>; message?: string; success: boolean },
status = 200,
onRequest: (url: URL) => void | Promise<void> = () => {},
) {
return http.get(mediaApiUrls.exists, async ({ request }) => {
await onRequest(new URL(request.url))
return HttpResponse.json(response as JsonBodyType, { status })
})
}
export function mediaDetailsHandler(
tmdbId: number,
mediaId: number | string,
response: MediaInfo,
status = 200,
onRequest: (url: URL) => void = () => {},
) {
return http.get(new URL(`media/tmdb:${tmdbId}`, API_BASE_URL).href, ({ request }) => {
const normalizedMediaId = typeof mediaId === 'number' ? `tmdb:${mediaId}` : mediaId
return http.get(mediaApiUrls.details(normalizedMediaId), ({ request }) => {
onRequest(new URL(request.url))
return HttpResponse.json(response as unknown as JsonBodyType, { status })
})
}
export function mediaRemoteExistsHandler(
response: Record<number, number[]>,
status = 200,
onRequest: (payload: Record<string, unknown>) => void | Promise<void> = () => {},
) {
return http.post(mediaApiUrls.existsRemote, async ({ request }) => {
await onRequest((await request.json()) as Record<string, unknown>)
return HttpResponse.json(response as JsonBodyType, { status })
})
}
export function mediaPlayHandler(
itemId: string,
response: { data?: Record<string, unknown>; message?: string; success: boolean },
status = 200,
onRequest: () => void = () => {},
) {
return http.get(mediaApiUrls.play(itemId), () => {
onRequest()
return HttpResponse.json(response as JsonBodyType, { status })
})
}
export function tmdbSeasonEpisodesHandler(
tmdbId: number,
season: number,
@@ -27,3 +73,50 @@ export function tmdbSeasonEpisodesHandler(
return HttpResponse.json(response as unknown as JsonBodyType, { status })
})
}
export function mediaSeasonsHandler(
response: MediaSeason[],
status = 200,
onRequest: (url: URL) => void | Promise<void> = () => {},
) {
return http.get(mediaApiUrls.seasons, async ({ request }) => {
await onRequest(new URL(request.url))
return HttpResponse.json(response as unknown as JsonBodyType, { status })
})
}
export function mediaEpisodeGroupsHandler(
tmdbId: number,
response: Record<string, unknown>[],
status = 200,
onRequest: (url: URL) => void | Promise<void> = () => {},
) {
return http.get(mediaApiUrls.episodeGroups(tmdbId), async ({ request }) => {
await onRequest(new URL(request.url))
return HttpResponse.json(response as JsonBodyType, { status })
})
}
export function mediaGroupSeasonsHandler(
episodeGroup: string,
response: MediaSeason[],
status = 200,
onRequest: (url: URL) => void | Promise<void> = () => {},
) {
return http.get(mediaApiUrls.groupSeasons(episodeGroup), async ({ request }) => {
await onRequest(new URL(request.url))
return HttpResponse.json(response as unknown as JsonBodyType, { status })
})
}
export function mediaNotExistsHandler(
response: NotExistMediaInfo[],
status = 200,
onRequest: (payload: Record<string, unknown>, url: URL) => void | Promise<void> = () => {},
) {
return http.post(mediaApiUrls.notExists, async ({ request }) => {
const payload = (await request.json()) as Record<string, unknown>
await onRequest(payload, new URL(request.url))
return HttpResponse.json(response as unknown as JsonBodyType, { status })
})
}

View File

@@ -0,0 +1,241 @@
import type {
ApiResponse,
DownloaderConf,
Site,
SiteCategory,
SiteStatistic,
SiteUserData,
TorrentInfo,
} from '@/api/types'
import { HttpResponse, http, type JsonBodyType } from 'msw'
const API_BASE_URL = 'http://localhost/api/v1/'
interface SiteMutationResponse {
success: boolean
data?: Record<string, unknown>
message?: string
}
export const siteApiUrls = {
categories: (siteId: number) => new URL(`site/category/${siteId}`, API_BASE_URL).href,
cookie: (id: number) => new URL(`site/cookie/${id}`, API_BASE_URL).href,
delete: (id: number) => new URL(`site/${id}`, API_BASE_URL).href,
details: (id: number) => new URL(`site/${id}`, API_BASE_URL).href,
downloaders: new URL('download/clients', API_BASE_URL).href,
icon: (id: number) => new URL(`site/icon/${id}`, API_BASE_URL).href,
list: new URL('site/', API_BASE_URL).href,
priorities: new URL('site/priorities', API_BASE_URL).href,
resources: (siteId: number) => new URL(`site/resource/${siteId}`, API_BASE_URL).href,
statistic: (domain: string) => new URL(`site/statistic/${domain}`, API_BASE_URL).href,
statistics: new URL('site/statistic', API_BASE_URL).href,
test: (id: number) => new URL(`site/test/${id}`, API_BASE_URL).href,
userData: (id: number) => new URL(`site/userdata/${id}`, API_BASE_URL).href,
userDataLatest: new URL('site/userdata/latest', API_BASE_URL).href,
}
export function siteListHandler(
sites: Site[],
status: number | (() => number) = 200,
onRequest: () => void | Promise<void> = () => {},
) {
return http.get(siteApiUrls.list, async () => {
await onRequest()
return HttpResponse.json(sites as unknown as JsonBodyType, {
status: typeof status === 'function' ? status() : status,
})
})
}
export function siteStatisticsHandler(
statistics: SiteStatistic[],
status = 200,
onRequest: () => void | Promise<void> = () => {},
) {
return http.get(siteApiUrls.statistics, async () => {
await onRequest()
return HttpResponse.json(statistics as unknown as JsonBodyType, { status })
})
}
export function siteStatisticHandler(
domain: string,
statistic: SiteStatistic,
status = 200,
onRequest: () => void | Promise<void> = () => {},
) {
return http.get(siteApiUrls.statistic(domain), async () => {
await onRequest()
return HttpResponse.json(statistic as unknown as JsonBodyType, { status })
})
}
export function siteUserDataLatestHandler(
userData: SiteUserData[],
status = 200,
onRequest: () => void | Promise<void> = () => {},
) {
return http.get(siteApiUrls.userDataLatest, async () => {
await onRequest()
return HttpResponse.json(userData as unknown as JsonBodyType, { status })
})
}
export function siteUserDataHandler(
id: number,
result: Pick<ApiResponse<SiteUserData[]>, 'data' | 'message' | 'success'>,
status = 200,
onRequest: () => void | Promise<void> = () => {},
) {
return http.get(siteApiUrls.userData(id), async () => {
await onRequest()
return response(result as unknown as JsonBodyType, status)
})
}
export function refreshSiteUserDataHandler(
id: number,
result: Pick<ApiResponse<Record<string, unknown>>, 'data' | 'message' | 'success'>,
status = 200,
onRequest: () => void | Promise<void> = () => {},
) {
return http.post(siteApiUrls.userData(id), async () => {
await onRequest()
return response(result as unknown as JsonBodyType, status)
})
}
export function saveSitePrioritiesHandler(
onSave: (priorities: Array<{ id: number; pri: number }>) => void | Promise<void> = () => {},
options: { status?: number; success?: boolean } = {},
) {
return http.post(siteApiUrls.priorities, async ({ request }) => {
const priorities = (await request.json()) as Array<{ id: number; pri: number }>
await onSave(priorities)
return HttpResponse.json(
{ success: options.success ?? (options.status ?? 200) < 400 },
{ status: options.status ?? 200 },
)
})
}
function response(body: JsonBodyType, status: number) {
return HttpResponse.json(body, { status })
}
export function siteIconHandler(
id: number,
icon: string | null,
status = 200,
onRequest: () => void | Promise<void> = () => {},
) {
return http.get(siteApiUrls.icon(id), async () => {
await onRequest()
return response({ data: icon ? { icon } : {}, success: Boolean(icon) }, status)
})
}
export function testSiteConnectionHandler(
id: number,
result: Pick<ApiResponse<never>, 'message' | 'success'>,
status = 200,
onRequest: () => void | Promise<void> = () => {},
) {
return http.get(siteApiUrls.test(id), async () => {
await onRequest()
return response(result, status)
})
}
export function deleteSiteHandler(
id: number,
result: Pick<ApiResponse<never>, 'message' | 'success'> = { success: true },
status = 200,
onRequest: () => void | Promise<void> = () => {},
) {
return http.delete(siteApiUrls.delete(id), async () => {
await onRequest()
return response(result, status)
})
}
export function siteDownloadersHandler(
response: DownloaderConf[] = [],
status = 200,
onRequest: () => void | Promise<void> = () => {},
) {
return http.get(siteApiUrls.downloaders, async () => {
await onRequest()
return HttpResponse.json(response as unknown as JsonBodyType, { status })
})
}
export function siteDetailsHandler(
id: number,
response: Site,
status = 200,
onRequest: () => void | Promise<void> = () => {},
) {
return http.get(siteApiUrls.details(id), async () => {
await onRequest()
return HttpResponse.json(response as unknown as JsonBodyType, { status })
})
}
export function addSiteHandler(
response: SiteMutationResponse = { success: true },
status = 200,
onRequest: (payload: Site) => void | Promise<void> = () => {},
) {
return http.post(siteApiUrls.list, async ({ request }) => {
await onRequest((await request.json()) as Site)
return HttpResponse.json(response, { status })
})
}
export function updateSiteHandler(
response: SiteMutationResponse = { success: true },
status = 200,
onRequest: (payload: Site) => void | Promise<void> = () => {},
) {
return http.put(siteApiUrls.list, async ({ request }) => {
await onRequest((await request.json()) as Site)
return HttpResponse.json(response, { status })
})
}
export function updateSiteCookieHandler(
id: number,
response: SiteMutationResponse = { success: true },
status = 200,
onRequest: (payload: { code: string; password: string; username: string }) => void | Promise<void> = () => {},
) {
return http.post(siteApiUrls.cookie(id), async ({ request }) => {
await onRequest((await request.json()) as { code: string; password: string; username: string })
return HttpResponse.json(response, { status })
})
}
export function siteCategoriesHandler(
siteId: number,
response: SiteCategory[],
status = 200,
onRequest: (url: URL) => void = () => {},
) {
return http.get(siteApiUrls.categories(siteId), ({ request }) => {
onRequest(new URL(request.url))
return HttpResponse.json(response, { status })
})
}
export function siteResourcesHandler(
siteId: number,
response: TorrentInfo[],
status = 200,
onRequest: (url: URL) => void = () => {},
) {
return http.get(siteApiUrls.resources(siteId), ({ request }) => {
onRequest(new URL(request.url))
return HttpResponse.json(response, { status })
})
}

Some files were not shown because too many files have changed in this diff Show More