mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-26 02:31:05 +08:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4da856fb6d | ||
|
|
03126d32c1 | ||
|
|
ad555b3b36 | ||
|
|
7f8e6952df | ||
|
|
2542339733 | ||
|
|
0d094b01b5 | ||
|
|
47f007480f | ||
|
|
374a0866be | ||
|
|
126a204b2a | ||
|
|
c491195b19 | ||
|
|
395401e493 | ||
|
|
52479ebbd5 | ||
|
|
f6d8ab8aeb | ||
|
|
67e78d0eb1 | ||
|
|
ef7f346e7a | ||
|
|
707bfc87a1 | ||
|
|
3416c879de | ||
|
|
bbed5bf0ae | ||
|
|
985874506e | ||
|
|
8434b75af3 | ||
|
|
8a1c10bbf7 | ||
|
|
e4e5bce234 | ||
|
|
ae4f304cc2 | ||
|
|
9a838647fe | ||
|
|
435e9ecfdd | ||
|
|
2b310c9a97 | ||
|
|
7d2c98c5ae |
69
.eslintrc.js
69
.eslintrc.js
@@ -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: {},
|
||||
},
|
||||
},
|
||||
}
|
||||
45
.github/workflows/test.yml
vendored
45
.github/workflows/test.yml
vendored
@@ -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
1
.node-version
Normal file
@@ -0,0 +1 @@
|
||||
24
|
||||
@@ -1,2 +1,8 @@
|
||||
dist
|
||||
node_modules
|
||||
node_modules
|
||||
|
||||
# 自动生成文件的格式由各自生成器决定。
|
||||
auto-imports.d.ts
|
||||
components.d.ts
|
||||
eslint-suppressions.json
|
||||
/vite.config.*.timestamp-*.mjs
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"bracketSpacing": true,
|
||||
"htmlWhitespaceSensitivity": "css",
|
||||
"insertPragma": false,
|
||||
"jsxBracketSameLine": false,
|
||||
"bracketSameLine": false,
|
||||
"jsxSingleQuote": true,
|
||||
"printWidth": 120,
|
||||
"proseWrap": "preserve",
|
||||
|
||||
@@ -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
179
docs/code-quality.md
Normal 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.19;Node.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.19,Node 24 作为推荐和主 CI | 保持兼容 job,版本提升独立处理 |
|
||||
| 全仓 required check 配置过早 | 先强制变更文件,持续观察全仓剩余范围 | 保留变更文件门禁,暂缓全仓切换 |
|
||||
| ESLint 与 Prettier 循环改写 | ESLint 关闭格式规则,Prettier 单独负责格式 | 关闭冲突规则并增加回归样例 |
|
||||
|
||||
依赖迁移、规则迁移、存量代码修复、格式化和 GitHub required checks 应保持可独立评审、验证和回退,禁止合并成一个难以定位风险的大型变更。
|
||||
@@ -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。
|
||||
|
||||
@@ -67,7 +67,7 @@ tests/
|
||||
|
||||
## 新增测试
|
||||
|
||||
1. 在被测源码所在目录的 `__tests__/` 中创建同名 `*.spec.ts`。
|
||||
1. 业务测试在被测源码所在目录的 `__tests__/` 中创建同名 `*.spec.ts`;工具链配置契约测试放在 `tests/config/`。
|
||||
2. 纯函数、store 和无渲染模块直接使用 Vitest;Vue 组件使用标准渲染入口。
|
||||
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
1240
eslint-suppressions.json
Normal file
File diff suppressed because it is too large
Load Diff
195
eslint.config.js
Normal file
195
eslint.config.js
Normal 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,
|
||||
],
|
||||
},
|
||||
},
|
||||
])
|
||||
34
package.json
34
package.json
@@ -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
208
scripts/format-changed.mjs
Normal 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
|
||||
})
|
||||
}
|
||||
62
src/__tests__/ace-config.spec.ts
Normal file
62
src/__tests__/ace-config.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -114,6 +114,8 @@ export interface SubscribeShare {
|
||||
tmdbid?: number
|
||||
// 豆瓣ID
|
||||
doubanid?: string
|
||||
// Bangumi ID
|
||||
bangumiid?: number
|
||||
// 季号
|
||||
season?: number
|
||||
// 海报
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
539
src/components/cards/__tests__/MediaCard.spec.ts
Normal file
539
src/components/cards/__tests__/MediaCard.spec.ts
Normal 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' })))
|
||||
})
|
||||
})
|
||||
253
src/components/cards/__tests__/SiteCard.spec.ts
Normal file
253
src/components/cards/__tests__/SiteCard.spec.ts
Normal 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'))
|
||||
})
|
||||
})
|
||||
180
src/components/cards/__tests__/SubscribeShareCard.spec.ts
Normal file
180
src/components/cards/__tests__/SubscribeShareCard.spec.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -40,6 +40,9 @@ const progressText = ref('')
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 向联邦插件提供主应用 Toast,避免远程组件自行创建通知容器。
|
||||
provide('moviepilot:toast', $toast)
|
||||
|
||||
// 是否刷新
|
||||
const isRefreshed = ref(false)
|
||||
|
||||
|
||||
@@ -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)
|
||||
// 组件是否已加载成功
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }">
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
137
src/components/dialog/__tests__/DiscoverTabOrderDialog.spec.ts
Normal file
137
src/components/dialog/__tests__/DiscoverTabOrderDialog.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
395
src/components/dialog/__tests__/ForkSubscribeDialog.spec.ts
Normal file
395
src/components/dialog/__tests__/ForkSubscribeDialog.spec.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
199
src/components/dialog/__tests__/SiteAddEditDialog.spec.ts
Normal file
199
src/components/dialog/__tests__/SiteAddEditDialog.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
128
src/components/dialog/__tests__/SiteCookieUpdateDialog.spec.ts
Normal file
128
src/components/dialog/__tests__/SiteCookieUpdateDialog.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
217
src/components/dialog/__tests__/SiteImportDialog.spec.ts
Normal file
217
src/components/dialog/__tests__/SiteImportDialog.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
463
src/components/dialog/__tests__/SiteResourceDialog.spec.ts
Normal file
463
src/components/dialog/__tests__/SiteResourceDialog.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
396
src/components/dialog/__tests__/SiteUserDataDialog.spec.ts
Normal file
396
src/components/dialog/__tests__/SiteUserDataDialog.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
512
src/components/dialog/__tests__/SubscribeFilesDialog.spec.ts
Normal file
512
src/components/dialog/__tests__/SubscribeFilesDialog.spec.ts
Normal 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('Hash:hash-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()
|
||||
})
|
||||
})
|
||||
429
src/components/dialog/__tests__/SubscribeHistoryDialog.spec.ts
Normal file
429
src/components/dialog/__tests__/SubscribeHistoryDialog.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
410
src/components/dialog/__tests__/SubscribeSeasonDialog.spec.ts
Normal file
410
src/components/dialog/__tests__/SubscribeSeasonDialog.spec.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
170
src/components/dialog/__tests__/SubscribeShareDialog.spec.ts
Normal file
170
src/components/dialog/__tests__/SubscribeShareDialog.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
167
src/components/slide/__tests__/VirtualSlideView.spec.ts
Normal file
167
src/components/slide/__tests__/VirtualSlideView.spec.ts
Normal 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))
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
|
||||
125
src/layouts/default/components/__tests__/DefaultLayout.spec.ts
Normal file
125
src/layouts/default/components/__tests__/DefaultLayout.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '大小',
|
||||
|
||||
@@ -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: '大小',
|
||||
|
||||
101
src/pages/__tests__/browse.spec.ts
Normal file
101
src/pages/__tests__/browse.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
425
src/pages/__tests__/discover.spec.ts
Normal file
425
src/pages/__tests__/discover.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
57
src/pages/__tests__/media.spec.ts
Normal file
57
src/pages/__tests__/media.spec.ts
Normal 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({})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
23
src/pages/__tests__/site.spec.ts
Normal file
23
src/pages/__tests__/site.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
// 如果当前没有选中任何标签页,或者当前选中的标签页不存在,则选中第一个标签页
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]) => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// 加载面板配置
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
},
|
||||
|
||||
47
src/utils/__tests__/federationUrl.spec.ts
Normal file
47
src/utils/__tests__/federationUrl.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
162
src/utils/__tests__/mediaStatusCache.spec.ts
Normal file
162
src/utils/__tests__/mediaStatusCache.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
77
src/utils/__tests__/siteIconCache.spec.ts
Normal file
77
src/utils/__tests__/siteIconCache.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
11
src/utils/federationUrl.ts
Normal file
11
src/utils/federationUrl.ts
Normal 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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
92
src/views/discover/__tests__/BangumiView.spec.ts
Normal file
92
src/views/discover/__tests__/BangumiView.spec.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
106
src/views/discover/__tests__/DoubanView.spec.ts
Normal file
106
src/views/discover/__tests__/DoubanView.spec.ts
Normal 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: '喜剧',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
230
src/views/discover/__tests__/ExtraSourceView.spec.ts
Normal file
230
src/views/discover/__tests__/ExtraSourceView.spec.ts
Normal 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))
|
||||
})
|
||||
})
|
||||
})
|
||||
429
src/views/discover/__tests__/MediaCardListView.spec.ts
Normal file
429
src/views/discover/__tests__/MediaCardListView.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
342
src/views/discover/__tests__/MediaCardSlideView.spec.ts
Normal file
342
src/views/discover/__tests__/MediaCardSlideView.spec.ts
Normal 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'))
|
||||
})
|
||||
})
|
||||
1085
src/views/discover/__tests__/MediaDetailView.spec.ts
Normal file
1085
src/views/discover/__tests__/MediaDetailView.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
186
src/views/discover/__tests__/PersonCardSlideView.spec.ts
Normal file
186
src/views/discover/__tests__/PersonCardSlideView.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
132
src/views/discover/__tests__/TheMovieDbView.spec.ts
Normal file
132
src/views/discover/__tests__/TheMovieDbView.spec.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
48
src/views/discover/__tests__/sourceViewTestUtils.ts
Normal file
48
src/views/discover/__tests__/sourceViewTestUtils.ts
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
621
src/views/site/__tests__/SiteCardListView.spec.ts
Normal file
621
src/views/site/__tests__/SiteCardListView.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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')"
|
||||
|
||||
@@ -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')"
|
||||
|
||||
@@ -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')
|
||||
|
||||
401
src/views/subscribe/__tests__/SubscribePopularView.spec.ts
Normal file
401
src/views/subscribe/__tests__/SubscribePopularView.spec.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
441
src/views/subscribe/__tests__/SubscribeShareView.spec.ts
Normal file
441
src/views/subscribe/__tests__/SubscribeShareView.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
179
src/views/system/__tests__/WordsView.spec.ts
Normal file
179
src/views/system/__tests__/WordsView.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
269
tests/config/eslint-config.spec.ts
Normal file
269
tests/config/eslint-config.spec.ts
Normal 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)
|
||||
})
|
||||
214
tests/config/format-changed.spec.ts
Normal file
214
tests/config/format-changed.spec.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
22
tests/config/frontend-workflow.spec.ts
Normal file
22
tests/config/frontend-workflow.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
92
tests/support/factories/site.ts
Normal file
92
tests/support/factories/site.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
46
tests/support/msw/handlers/discover.ts
Normal file
46
tests/support/msw/handlers/discover.ts
Normal 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 })
|
||||
})
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
}
|
||||
|
||||
241
tests/support/msw/handlers/site.ts
Normal file
241
tests/support/msw/handlers/site.ts
Normal 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
Reference in New Issue
Block a user