From 807711cae863f82f31dbf4173a77a11c9113f3a8 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:57:28 +0800 Subject: [PATCH] test(subscribe): cover card actions (#539) --- src/components/cards/SubscribeCard.vue | 5 + .../cards/__tests__/SubscribeCard.spec.ts | 438 ++++++++++++++++++ tests/support/msw/handlers/subscribe.ts | 26 ++ vite.config.ts | 7 + 4 files changed, 476 insertions(+) create mode 100644 src/components/cards/__tests__/SubscribeCard.spec.ts diff --git a/src/components/cards/SubscribeCard.vue b/src/components/cards/SubscribeCard.vue index 8714625e..07581725 100644 --- a/src/components/cards/SubscribeCard.vue +++ b/src/components/cards/SubscribeCard.vue @@ -173,6 +173,7 @@ async function removeSubscribe() { emit('remove') } } catch (e) { + $toast.error(t('subscribe.requestFailed')) console.log(e) } } @@ -184,7 +185,9 @@ async function searchSubscribe() { // 提示 if (result.success) $toast.success(`${props.media?.name} 提交搜索请求成功!`) + else $toast.error(t('subscribe.requestFailed')) } catch (e) { + $toast.error(t('subscribe.requestFailed')) console.log(e) } } @@ -211,6 +214,7 @@ async function toggleSubscribeStatus(state: 'R' | 'S') { $toast.error(t('subscribe.toggleFailed', { action, message: result.message })) } } catch (e) { + $toast.error(t('subscribe.requestFailed')) console.log(e) } } @@ -233,6 +237,7 @@ async function resetSubscribe() { emit('save') } else $toast.error(t('subscribe.resetFailed', { name: props.media?.name, message: result.message })) } catch (e) { + $toast.error(t('subscribe.requestFailed')) console.log(e) } } diff --git a/src/components/cards/__tests__/SubscribeCard.spec.ts b/src/components/cards/__tests__/SubscribeCard.spec.ts new file mode 100644 index 00000000..b7b2e357 --- /dev/null +++ b/src/components/cards/__tests__/SubscribeCard.spec.ts @@ -0,0 +1,438 @@ +import { formatDateDifference } from '@/@core/utils/formatters' +import type { Subscribe } from '@/api/types' +import SubscribeCard from '@/components/cards/SubscribeCard.vue' +import { fireEvent, screen, waitFor } from '@testing-library/vue' +import { createSubscribe } from '@tests/support/factories/subscribe' +import { + deleteSubscribeByIdHandler, + resetSubscribeByIdHandler, + searchSubscribeByIdHandler, + updateSubscribeStatusHandler, +} from '@tests/support/msw/handlers/subscribe' +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(() => ({ + confirm: vi.fn(), + openSharedDialog: vi.fn(), + routerPush: 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), +})) + +vi.mock('@/router', () => ({ + default: { push: (...args: unknown[]) => mocks.routerPush(...args) }, +})) + +function setViewport(width: number) { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: width, writable: true }) + window.dispatchEvent(new Event('resize')) +} + +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( + mediaOverrides: Partial = {}, + props: Partial<{ batchMode: boolean; selected: boolean; sortable: boolean }> = {}, + globalImageCache = false, +) { + const media = createSubscribe({ + backdrop: 'https://images.example.com/backdrop.jpg', + id: 2501, + last_update: '2026-07-16 12:00:00', + name: '卡片测试媒体', + poster: 'https://images.example.com/poster.jpg', + ...mediaOverrides, + }) + const result = await renderWithProviders(SubscribeCard, { + initialState: { + globalSettings: { + data: { GLOBAL_IMAGE_CACHE: globalImageCache }, + initialized: true, + loading: false, + }, + }, + props: { media, ...props }, + }) + return { ...result, media } +} + +function getMenuButton(container: Element) { + const selector = window.innerWidth < 600 ? '.subscribe-card-mobile-menu' : '.absolute.top-1.right-4 .v-btn' + const button = container.querySelector(selector) + expect(button).not.toBeNull() + return button as HTMLButtonElement +} + +async function openMenu(container: Element) { + await fireEvent.click(getMenuButton(container)) +} + +async function chooseMenuItem(container: Element, label: string) { + await openMenu(container) + await fireEvent.click(await screen.findByText(label)) +} + +function getDialogCall(index = 0) { + const [, props, events, options] = mocks.openSharedDialog.mock.calls[index] as [ + unknown, + Record, + Record void>, + Record, + ] + return { events, options, props } +} + +describe('SubscribeCard display and progress', () => { + beforeEach(() => { + setViewport(1024) + observeElementsImmediately() + mocks.confirm.mockResolvedValue(true) + mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + + it('renders stable movie metadata and omits episode progress without a total', async () => { + const { container, media } = await renderCard({ total_episode: undefined, type: '电影', year: '2025' }, {}, true) + + expect(screen.getByText(media.name)).toBeInTheDocument() + expect(screen.getByText('2025')).toBeInTheDocument() + expect(screen.getByText(media.username)).toHaveAttribute('title', media.username) + const image = container.querySelector('img') + expect(image).not.toBeNull() + expect((image as HTMLImageElement).src).toContain('system/cache/image?url=') + expect((image as HTMLImageElement).src).toContain(encodeURIComponent(media.backdrop || '')) + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument() + expect(screen.queryByText(/\d+ \/ \d+/)).not.toBeInTheDocument() + }) + + it.each([ + ['regular progress', 10, 4, '6 / 10', '60'], + ['negative missing episodes', 10, -2, '10 / 10', '100'], + ['missing episodes above the total', 10, 12, '0 / 10', null], + ['zero total', 0, 0, null, null], + ])('normalizes %s', async (_case, totalEpisode, lackEpisode, expectedText, expectedProgress) => { + await renderCard({ lack_episode: lackEpisode, season: 2, total_episode: totalEpisode, type: '电视剧' }) + + if (expectedText) expect(screen.getByText(expectedText)).toBeInTheDocument() + else expect(screen.queryByText(/\d+ \/ \d+/)).not.toBeInTheDocument() + + if (expectedProgress) expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', expectedProgress) + else expect(screen.queryByRole('progressbar')).not.toBeInTheDocument() + expect(screen.getByText(/卡片测试媒体 S02/)).toBeInTheDocument() + }) + + it.each([ + ['boolean flag with tv type', true, false, 3, 'tv', 30, true, false], + ['numeric flags with negative completed episodes', 1, 1, -2, '电视剧', 0, true, true], + ['string flags with completed episodes above the total', '1', '1', 12, '电影', 100, true, true], + ['disabled flag', false, true, 3, '电视剧', 80, false, false], + ])( + 'normalizes %s for wash progress and badges', + async (_case, bestVersion, bestVersionFull, completedEpisode, type, expectedProgress, expectedWash, expectedFull) => { + const { container } = await renderCard({ + best_version: bestVersion, + best_version_full: bestVersionFull, + completed_episode: completedEpisode, + lack_episode: 2, + total_episode: 10, + type, + }) + const image = container.querySelector('img') + expect(image).not.toBeNull() + await fireEvent.load(image as HTMLImageElement) + + const progress = screen.getByRole('progressbar') + expect(progress).toHaveAttribute('aria-valuenow', String(expectedProgress)) + expect(progress.querySelector('.v-progress-linear__buffer')).toHaveStyle({ width: expectedWash ? '80%' : '0%' }) + expect(Boolean(container.querySelector('.best-version-badge'))).toBe(expectedWash) + expect(Boolean(container.querySelector('.best-version-badge-full'))).toBe(expectedFull) + }, + ) + + it('keeps mobile wash progress compact while preserving P, S, and R metadata', async () => { + setViewport(480) + const { media, rerender } = await renderCard({ + best_version: true, + completed_episode: 3, + lack_episode: 2, + state: 'P', + total_episode: 10, + type: '电视剧', + }) + const lastUpdateText = formatDateDifference(media.last_update) + + expect(screen.getByLabelText('待定中')).toBeInTheDocument() + expect(screen.getByText('8 / 10')).toBeInTheDocument() + expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '30') + expect(screen.getByText(lastUpdateText)).toBeInTheDocument() + expect(document.querySelector('.subscribe-card-mobile-menu')).toBeInTheDocument() + + await rerender({ media: { ...media, state: 'S' } }) + expect(screen.getByLabelText('已暂停')).toBeInTheDocument() + expect(screen.getByText(lastUpdateText)).toBeInTheDocument() + + await rerender({ media: { ...media, state: 'R' } }) + expect(screen.getByLabelText('订阅中')).toBeInTheDocument() + expect(screen.getByText(lastUpdateText)).toBeInTheDocument() + }) + + it('synchronizes desktop P, S, and R state from updated media props', async () => { + const { container, media, rerender } = await renderCard({ state: 'P' }) + const lastUpdateText = formatDateDifference(media.last_update) + + expect(screen.getByText('待定中')).toBeInTheDocument() + expect(screen.queryByText(lastUpdateText)).not.toBeInTheDocument() + + await rerender({ media: { ...media, state: 'S' } }) + expect(screen.getByText('已暂停')).toBeInTheDocument() + expect(container.querySelector('.subscribe-card')).toHaveClass('subscribe-card-paused') + + await rerender({ media: { ...media, state: 'R' } }) + expect(screen.getByText(lastUpdateText)).toBeInTheDocument() + expect(screen.queryByText('已暂停')).not.toBeInTheDocument() + expect(container.querySelector('.subscribe-card')).not.toHaveClass('subscribe-card-paused') + }) +}) + +describe('SubscribeCard interaction boundaries', () => { + beforeEach(() => { + setViewport(1024) + observeElementsImmediately() + mocks.confirm.mockResolvedValue(true) + mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + + it('routes normal, batch, selected, and sortable card clicks without overlap', async () => { + const { container, emitted, media, rerender } = await renderCard() + const card = container.querySelector('.subscribe-card') as HTMLElement + + await fireEvent.click(card) + expect(mocks.openSharedDialog).toHaveBeenCalledOnce() + + await rerender({ batchMode: true, media, selected: true }) + await fireEvent.click(card) + expect(emitted('select')).toHaveLength(1) + expect(container.querySelector('.subscribe-card-shell')).toHaveClass('subscribe-card-shell--selected') + expect(mocks.openSharedDialog).toHaveBeenCalledOnce() + expect(container.querySelector('.absolute.top-1.right-4 .v-btn')).toBeInTheDocument() + + await rerender({ batchMode: true, media, selected: true, sortable: true }) + await fireEvent.click(card) + expect(emitted('select')).toHaveLength(1) + expect(mocks.openSharedDialog).toHaveBeenCalledOnce() + expect(container.querySelector('.absolute.top-1.right-4 .v-btn')).not.toBeInTheDocument() + }) + + it('opens page-selected editing and forwards only save and remove events', async () => { + const { emitted, media } = await renderCard({ page_open: true }) + + await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce()) + const dialog = getDialogCall() + expect(dialog.props).toEqual({ subid: media.id }) + expect(dialog.options).toEqual({ closeOn: ['close', 'save', 'remove'] }) + + dialog.events.save() + dialog.events.remove() + expect(emitted('save')).toHaveLength(1) + expect(emitted('remove')).toHaveLength(1) + }) + + it('passes exact file and TV share data while keeping compatibility TV values unshared', async () => { + const { container, media, rerender } = await renderCard({ season: 1, total_episode: 12, type: '电视剧' }) + + await chooseMenuItem(container, '分享') + expect(getDialogCall().props).toEqual({ sub: media }) + expect(getDialogCall().options).toEqual({ closeOn: ['close'] }) + + await chooseMenuItem(container, '文件统计') + expect(getDialogCall(1).props).toEqual({ subid: media.id }) + expect(getDialogCall(1).options).toEqual({ closeOn: ['close'] }) + + await rerender({ media: { ...media, type: 'tv' } }) + await openMenu(container) + expect(screen.queryByText('分享')).not.toBeInTheDocument() + }) + + it.each([ + ['TMDB before all fallbacks', { bangumiid: '33', doubanid: '22', mediaid: 'custom:44', tmdbid: 11 }, 'tmdb:11'], + ['Douban before Bangumi', { bangumiid: '33', doubanid: '22', mediaid: 'custom:44', tmdbid: 0 }, 'douban:22'], + ['Bangumi before custom', { bangumiid: '33', doubanid: undefined, mediaid: 'custom:44', tmdbid: 0 }, 'bangumi:33'], + ['custom media ID last', { bangumiid: undefined, doubanid: undefined, mediaid: 'custom:44', tmdbid: 0 }, 'custom:44'], + ])('routes media details with %s', async (_case, identifiers, expectedMediaId) => { + const { container, media } = await renderCard(identifiers) + + await chooseMenuItem(container, '媒体详情') + + expect(mocks.routerPush).toHaveBeenCalledWith({ + path: '/media', + query: { + mediaid: expectedMediaId, + title: media.name, + type: media.type, + year: media.year, + }, + }) + }) +}) + +describe('SubscribeCard item operations', () => { + beforeEach(() => { + setViewport(1024) + observeElementsImmediately() + mocks.confirm.mockResolvedValue(true) + mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + + it.each([ + ['success', 200, { success: true }, 'success', '卡片测试媒体 提交搜索请求成功!'], + ['business failure', 200, { message: 'rejected', success: false }, 'error', '请求失败,请稍后重试'], + ['HTTP failure', 500, { message: 'server down', success: false }, 'error', '请求失败,请稍后重试'], + ] as const)('reports search %s through the exact endpoint', async (_case, status, response, toastType, message) => { + const requested = vi.fn() + const { container, media } = await renderCard() + server.use(searchSubscribeByIdHandler(media.id, response, status, requested)) + + await chooseMenuItem(container, '搜索') + + await waitFor(() => expect(requested).toHaveBeenCalledOnce()) + const toast = toastType === 'success' ? mocks.toastSuccess : mocks.toastError + await waitFor(() => expect(toast).toHaveBeenCalledWith(message)) + }) + + it('pauses and enables only after confirmed successful status responses', async () => { + const requested: URL[] = [] + const { container, emitted, media } = await renderCard({ state: 'R' }) + server.use( + updateSubscribeStatusHandler(media.id, { success: true }, 200, url => { + requested.push(url) + }), + ) + + await chooseMenuItem(container, '暂停') + await waitFor(() => expect(requested).toHaveLength(1)) + expect(requested[0].searchParams.get('state')).toBe('S') + expect(container.querySelector('.subscribe-card')).toHaveClass('subscribe-card-paused') + expect(mocks.toastSuccess).toHaveBeenCalledWith(`${media.name} 已暂停!`) + + await chooseMenuItem(container, '启用') + await waitFor(() => expect(requested).toHaveLength(2)) + expect(requested[1].searchParams.get('state')).toBe('R') + expect(container.querySelector('.subscribe-card')).not.toHaveClass('subscribe-card-paused') + expect(mocks.toastSuccess).toHaveBeenCalledWith(`${media.name} 已启用!`) + expect(emitted('save')).toHaveLength(2) + }) + + it.each([ + ['confirmation cancellation', false, 200, { success: true }, null], + ['business failure', true, 200, { message: 'rejected', success: false }, '暂停失败:rejected'], + ['HTTP failure', true, 500, { message: 'server down', success: false }, '请求失败,请稍后重试'], + ] as const)( + 'keeps status unchanged after %s', + async (_case, confirmed, status, response, expectedError) => { + const requested = vi.fn() + mocks.confirm.mockResolvedValue(confirmed) + const { container, emitted, media } = await renderCard({ state: 'R' }) + server.use(updateSubscribeStatusHandler(media.id, response, status, requested)) + + await chooseMenuItem(container, '暂停') + await waitFor(() => expect(mocks.confirm).toHaveBeenCalledOnce()) + + if (confirmed) await waitFor(() => expect(requested).toHaveBeenCalledOnce()) + else expect(requested).not.toHaveBeenCalled() + if (expectedError) await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expectedError)) + else expect(mocks.toastError).not.toHaveBeenCalled() + expect(container.querySelector('.subscribe-card')).not.toHaveClass('subscribe-card-paused') + expect(emitted('save') ?? []).toHaveLength(0) + }, + ) + + it.each([ + ['success', true, 200, { success: true }, 'success', '卡片测试媒体 重置成功!'], + ['confirmation cancellation', false, 200, { success: true }, null, null], + ['business failure', true, 200, { message: 'rejected', success: false }, 'error', '卡片测试媒体 重置失败:rejected'], + ['HTTP failure', true, 500, { message: 'server down', success: false }, 'error', '请求失败,请稍后重试'], + ] as const)( + 'handles reset %s without speculative state', + async (_case, confirmed, status, response, toastType, message) => { + const requested = vi.fn() + mocks.confirm.mockResolvedValue(confirmed) + const { container, emitted, media } = await renderCard({ state: 'S' }) + server.use(resetSubscribeByIdHandler(media.id, response, status, requested)) + + await chooseMenuItem(container, '重置') + await waitFor(() => expect(mocks.confirm).toHaveBeenCalledOnce()) + + if (confirmed) await waitFor(() => expect(requested).toHaveBeenCalledOnce()) + else expect(requested).not.toHaveBeenCalled() + if (toastType && message) { + const toast = toastType === 'success' ? mocks.toastSuccess : mocks.toastError + await waitFor(() => expect(toast).toHaveBeenCalledWith(message)) + } else { + expect(mocks.toastSuccess).not.toHaveBeenCalled() + expect(mocks.toastError).not.toHaveBeenCalled() + } + + if (_case === 'success') { + expect(container.querySelector('.subscribe-card')).not.toHaveClass('subscribe-card-paused') + expect(emitted('save')).toHaveLength(1) + } else { + expect(container.querySelector('.subscribe-card')).toHaveClass('subscribe-card-paused') + expect(emitted('save') ?? []).toHaveLength(0) + } + }, + ) + + it.each([ + ['success', 200, { success: true }, true, null], + ['HTTP failure', 500, { message: 'server down', success: false }, false, '请求失败,请稍后重试'], + ] as const)('handles delete %s without a synthetic business-failure branch', async (_case, status, response, removed, error) => { + const requested = vi.fn() + const { container, emitted, media } = await renderCard() + server.use(deleteSubscribeByIdHandler(media.id, response, status, requested)) + + await chooseMenuItem(container, '取消订阅') + + await waitFor(() => expect(requested).toHaveBeenCalledOnce()) + expect(emitted('remove') ?? []).toHaveLength(removed ? 1 : 0) + if (error) await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(error)) + else expect(mocks.toastError).not.toHaveBeenCalled() + }) +}) diff --git a/tests/support/msw/handlers/subscribe.ts b/tests/support/msw/handlers/subscribe.ts index b0984068..61081385 100644 --- a/tests/support/msw/handlers/subscribe.ts +++ b/tests/support/msw/handlers/subscribe.ts @@ -29,6 +29,8 @@ export const subscribeApiUrls = { list: new URL('subscribe/', API_BASE_URL).href, orderConfig: (type: SubscribeMediaType) => new URL(`user/config/${type === '电影' ? 'SubscribeMovieOrder' : 'SubscribeTvOrder'}`, API_BASE_URL).href, + resetById: (id: number) => new URL(`subscribe/reset/${id}`, API_BASE_URL).href, + searchById: (id: number) => new URL(`subscribe/search/${id}`, API_BASE_URL).href, sites: new URL('site/rss', API_BASE_URL).href, statusById: (id: number) => new URL(`subscribe/status/${id}`, API_BASE_URL).href, update: new URL('subscribe/', API_BASE_URL).href, @@ -86,6 +88,30 @@ export function updateSubscribeStatusHandler( }) } +export function searchSubscribeByIdHandler( + id: number, + response: SubscribeMutationResponse = { success: true }, + status = 200, + onRequest: (url: URL) => void = () => {}, +) { + return http.get(subscribeApiUrls.searchById(id), ({ request }) => { + onRequest(new URL(request.url)) + return jsonResponse(response, status) + }) +} + +export function resetSubscribeByIdHandler( + id: number, + response: SubscribeMutationResponse = { success: true }, + status = 200, + onRequest: (url: URL) => void = () => {}, +) { + return http.get(subscribeApiUrls.resetById(id), ({ request }) => { + onRequest(new URL(request.url)) + return jsonResponse(response, status) + }) +} + export function createSubscribeHandler( response: SubscribeMutationResponse = { data: { id: 1 }, success: true }, status = 200, diff --git a/vite.config.ts b/vite.config.ts index 6d382509..3739e649 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -280,6 +280,7 @@ export default defineConfig(({ mode }) => ({ 'src/views/dashboard/MediaRecommend.vue', 'src/views/subscribe/SubscribeListView.vue', 'src/composables/useMediaSubscribe.ts', + 'src/components/cards/SubscribeCard.vue', 'src/components/dialog/SubscribeEditDialog.vue', ], provider: 'v8', @@ -290,6 +291,12 @@ export default defineConfig(({ mode }) => ({ functions: 85, lines: 85, statements: 85, + 'src/components/cards/SubscribeCard.vue': { + branches: 75, + functions: 80, + lines: 80, + statements: 80, + }, 'src/components/dialog/SubscribeEditDialog.vue': { branches: 75, functions: 80,