From c1bb54a31edfe0de6e63f1049d40cc0e33a153f4 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Wed, 22 Jul 2026 06:59:27 +0800 Subject: [PATCH] ci(test): run frontend checks on v2 pushes (#572) --- .github/workflows/test.yml | 8 ++- docs/code-quality.md | 2 +- docs/testing.md | 2 +- .../cards/__tests__/MediaCard.spec.ts | 49 +++++++++++++++++++ .../__tests__/SubscribeSeasonDialog.spec.ts | 12 +++++ .../__tests__/MediaDetailView.spec.ts | 33 +++++++++++++ tests/config/frontend-workflow.spec.ts | 28 ++++++++++- 7 files changed, 128 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 74ae3a6f..bdcffe66 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,16 +4,20 @@ on: pull_request: branches: - v2 + push: + branches: + - v2 permissions: contents: read concurrency: - group: frontend-tests-${{ github.event.pull_request.number }} + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: format: + if: github.event_name == 'pull_request' runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -58,7 +62,7 @@ jobs: - name: Lint run: yarn lint - unit-tests: + typecheck-and-coverage: runs-on: ubuntu-latest timeout-minutes: 20 steps: diff --git a/docs/code-quality.md b/docs/code-quality.md index 872dc3c5..d2b3e33a 100644 --- a/docs/code-quality.md +++ b/docs/code-quality.md @@ -80,7 +80,7 @@ yarn build 第二阶段在 Pull Request workflow 中增加独立的全仓 `yarn lint` job: -1. lint 与既有 `unit-tests` 使用不同 job,避免改变现有测试 check 的名称和职责。 +1. lint 与 `typecheck-and-coverage` 使用不同 job,保持静态检查和测试覆盖率职责独立。 2. 初始阶段作为普通 check 运行,不立即配置 required check。 3. workflow 使用 Node 24、frozen lockfile 和只读 `yarn lint`,不执行自动修复或更新 baseline。 4. 观察 fork PR、依赖缓存、执行时间、误报和路径范围。 diff --git a/docs/testing.md b/docs/testing.md index bc6e12ad..c674faf2 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -92,4 +92,4 @@ yarn lint yarn build ``` -Pull Request 工作流使用 Node 24 LTS 和 frozen lockfile。`unit-tests` job 依次执行类型检查和覆盖率门禁;独立的 `lint` job 执行全仓只读 ESLint 检查,当前处于普通 check 观察阶段,不改变既有 required checks。Prettier 和 Node 兼容范围按[前端代码质量工具链演进](code-quality.md)继续渐进接入,新增测试代码不得引入新的 lint 或格式问题。 +`Frontend Tests` 工作流使用 Node 24 LTS 和 frozen lockfile,在面向 `v2` 的 Pull Request 和推送到 `v2` 时运行。`typecheck-and-coverage` job 依次执行类型检查和覆盖率门禁;独立的 `lint` job 执行全仓只读 ESLint 检查。变更文件格式检查依赖 Pull Request 的 base/head SHA,因此只在 Pull Request 事件运行。Prettier 和 Node 兼容范围按[前端代码质量工具链演进](code-quality.md)继续渐进接入,新增测试代码不得引入新的 lint 或格式问题。 diff --git a/src/components/cards/__tests__/MediaCard.spec.ts b/src/components/cards/__tests__/MediaCard.spec.ts index 23099afe..25233cf6 100644 --- a/src/components/cards/__tests__/MediaCard.spec.ts +++ b/src/components/cards/__tests__/MediaCard.spec.ts @@ -461,6 +461,55 @@ describe('MediaCard', () => { expect(dialogProps).toMatchObject({ subscribedSeasons: [2] }) }) + it.each([ + [ + 'structured TMDB identity', + createMediaInfo({ + media_id: 'series-9554', + mediaid_prefix: undefined, + season: 2, + source: 'themoviedb', + tmdb_id: undefined, + type: '电视剧', + }), + 'tmdb:series-9554', + [ + { id: 93, media_id: 'series-9554', media_source: 'themoviedb', season: 4, type: '电视剧' }, + { id: 94, media_id: 'other', media_source: 'themoviedb', season: 5, type: '电视剧' }, + ], + [4], + ], + [ + 'legacy AniList identity', + createMediaInfo({ anilist_id: 154588, season: 2, source: 'anilist', tmdb_id: undefined, type: '电视剧' }), + 'anilist:154588', + [ + { anilistid: 154588, id: 95, season: 1, type: '电视剧' }, + { anilistid: 154589, id: 96, season: 3, type: '电视剧' }, + ], + [1], + ], + ])('matches %s when collecting subscribed TV seasons', async (_label, media, mediaId, subscribes, expected) => { + server.use( + querySubscribeByMediaHandler(mediaId, { id: 93, season: 2 }), + mediaExistsHandler({ data: { item: {} }, success: false }), + subscribeListHandler(subscribes), + 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] + expect(dialogProps).toMatchObject({ subscribedSeasons: expected }) + }) + it('updates image badges on load and falls back after an image error', async () => { const media = createMediaInfo({ poster_path: '/original/poster.jpg', diff --git a/src/components/dialog/__tests__/SubscribeSeasonDialog.spec.ts b/src/components/dialog/__tests__/SubscribeSeasonDialog.spec.ts index e308b3b3..5f3d6e06 100644 --- a/src/components/dialog/__tests__/SubscribeSeasonDialog.spec.ts +++ b/src/components/dialog/__tests__/SubscribeSeasonDialog.spec.ts @@ -154,6 +154,18 @@ describe('SubscribeSeasonDialog', () => { }, 'custom:custom-7305', ], + [ + 'source-only TMDB', + { + bangumi_id: undefined, + douban_id: undefined, + media_id: 'source-7306', + mediaid_prefix: undefined, + source: 'themoviedb', + tmdb_id: undefined, + }, + 'tmdb:source-7306', + ], ] as const)('uses the %s media identifier without requesting TMDB groups', async (_label, overrides, mediaId) => { const media = createTvMedia(overrides) const requested = vi.fn() diff --git a/src/views/discover/__tests__/MediaDetailView.spec.ts b/src/views/discover/__tests__/MediaDetailView.spec.ts index 62e6e0d9..b62e424b 100644 --- a/src/views/discover/__tests__/MediaDetailView.spec.ts +++ b/src/views/discover/__tests__/MediaDetailView.spec.ts @@ -892,6 +892,39 @@ describe('MediaDetailView subscriptions, seasons, and episode groups', () => { expect(container.querySelector('.v-expansion-panel')).toHaveTextContent('第 2 季') }) + it('scrolls the episode-group rail in both directions', async () => { + const media = createSubscribeTv({ + season_info: [createMediaSeason({ season_number: 1 })], + title: '剧集组滚动剧', + tmdb_id: 8718, + }) + const groups = Array.from({ length: 5 }, (_, index) => ({ + episode_count: 8 + index, + group_count: 1, + id: `group-${index}`, + name: `剧集组 ${index}`, + })) + const user = userEvent.setup() + const { container } = await renderDetail({ episodeGroups: groups, media, type: '电视剧' }) + const rail = container.querySelector('.episode-group-rail') + expect(rail).not.toBeNull() + 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 as HTMLElement) + + await user.click(screen.getByRole('button', { name: '查看更多剧集组' })) + expect(rail!.scrollBy).toHaveBeenCalledWith({ behavior: 'smooth', left: 288 }) + + rail!.scrollLeft = 300 + await fireEvent.scroll(rail as HTMLElement) + await user.click(screen.getByRole('button', { name: '查看上一组剧集组' })) + expect(rail!.scrollBy).toHaveBeenCalledWith({ behavior: 'smooth', left: -288 }) + }) + it('ignores stale episode-group seasons after a newer group is selected', async () => { const media = createSubscribeTv({ season_info: [createMediaSeason({ season_number: 1 })], diff --git a/tests/config/frontend-workflow.spec.ts b/tests/config/frontend-workflow.spec.ts index 4c2da69a..bdffae9b 100644 --- a/tests/config/frontend-workflow.spec.ts +++ b/tests/config/frontend-workflow.spec.ts @@ -3,14 +3,22 @@ import { resolve } from 'node:path' import { describe, expect, it } from 'vitest' const workflowPath = resolve(process.cwd(), '.github/workflows/test.yml') +const testingGuidePath = resolve(process.cwd(), 'docs/testing.md') +const codeQualityGuidePath = resolve(process.cwd(), 'docs/code-quality.md') -describe('前端 Pull Request workflow', () => { - it('使用只读的变更文件格式检查,并显式比较事件 base/head SHA', () => { +describe('前端测试 workflow', () => { + it('在 PR 与 v2 push 上运行,并将变更文件格式检查限制为 PR', () => { const workflow = readFileSync(workflowPath, 'utf8') const formatJob = workflow.match(/\n {2}format:\n(?[\s\S]*?)(?=\n {2}[\w-]+:\n|$)/)?.groups?.job + const qualityJob = workflow.match(/\n {2}typecheck-and-coverage:\n(?[\s\S]*?)(?=\n {2}[\w-]+:\n|$)/)?.groups + ?.job expect(workflow).toContain('permissions:\n contents: read') + expect(workflow).toContain('pull_request:\n branches:\n - v2') + expect(workflow).toContain('push:\n branches:\n - v2') + expect(workflow).toContain('group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}') expect(formatJob).toBeDefined() + expect(formatJob).toContain("if: github.event_name == 'pull_request'") expect(formatJob).toContain('fetch-depth: 0') expect(formatJob).toContain("node-version: '24'") expect(formatJob).toContain('run: yarn --frozen-lockfile') @@ -18,5 +26,21 @@ describe('前端 Pull Request workflow', () => { 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') + expect(qualityJob).toBeDefined() + expect(qualityJob).toContain('run: yarn typecheck') + expect(qualityJob).toContain('run: yarn test:coverage') + expect(workflow).not.toContain('\n unit-tests:\n') + }) + + it('文档使用当前测试 job 名称和触发范围', () => { + const testingGuide = readFileSync(testingGuidePath, 'utf8') + const codeQualityGuide = readFileSync(codeQualityGuidePath, 'utf8') + + expect(testingGuide).toContain('`typecheck-and-coverage` job') + expect(testingGuide).toContain('推送到 `v2`') + expect(testingGuide).toContain('只在 Pull Request 事件运行') + expect(codeQualityGuide).toContain('lint 与 `typecheck-and-coverage` 使用不同 job') + expect(testingGuide).not.toContain('`unit-tests`') + expect(codeQualityGuide).not.toContain('`unit-tests`') }) })