diff --git a/src/components/cards/__tests__/MediaCard.spec.ts b/src/components/cards/__tests__/MediaCard.spec.ts new file mode 100644 index 00000000..23099afe --- /dev/null +++ b/src/components/cards/__tests__/MediaCard.spec.ts @@ -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 + 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('.media-card') + expect(card).not.toBeNull() + return card as HTMLElement +} + +function getHoverArea(container: Element) { + const area = container.querySelector('.media-card-hover-area') + expect(area).not.toBeNull() + return area as HTMLElement +} + +function getActionButtons(container: Element) { + return [...container.querySelectorAll('.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[], 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: '
', + } + 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] + 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] + 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('.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' }))) + }) +}) diff --git a/src/components/slide/__tests__/VirtualSlideView.spec.ts b/src/components/slide/__tests__/VirtualSlideView.spec.ts new file mode 100644 index 00000000..ddae484e --- /dev/null +++ b/src/components/slide/__tests__/VirtualSlideView.spec.ts @@ -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() + 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 = {}) { + return renderWithProviders(VirtualSlideView, { + props: { + getItemKey: (item: { id: number }) => item.id, + itemGap: 10, + itemWidth: 100, + items, + overscanItems: 1, + ...overrides, + }, + slots: { + empty: '

没有内容

', + item: '', + loading: '

正在加载

', + title: '

测试轨道

', + }, + }) +} + +function configureScroller(container: Element, clientWidth = 220, scrollWidth = 2190) { + const scroller = container.querySelector('.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('.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: ` + + + + + + + + + `, + }) + 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)) + }) +}) diff --git a/src/stores/auth.ts b/src/stores/auth.ts index 386dda94..46c2ce9d 100644 --- a/src/stores/auth.ts +++ b/src/stores/auth.ts @@ -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() }, }, diff --git a/src/utils/__tests__/mediaStatusCache.spec.ts b/src/utils/__tests__/mediaStatusCache.spec.ts new file mode 100644 index 00000000..7854a66a --- /dev/null +++ b/src/utils/__tests__/mediaStatusCache.spec.ts @@ -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() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((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() + 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() + 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() + 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() + const newAccountRequest = deferred() + 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) + }) +}) diff --git a/src/utils/mediaStatusCache.ts b/src/utils/mediaStatusCache.ts index 54ca025b..91795f00 100644 --- a/src/utils/mediaStatusCache.ts +++ b/src/utils/mediaStatusCache.ts @@ -3,75 +3,132 @@ type StatusCacheEntry = { value: boolean } +type StatusCacheState = { + entries: Map + explicitValues: Map + generation: number + requests: Map> + versions: Map +} + const STATUS_CACHE_TTL = 3 * 60 * 1000 -const existsStatusCache = new Map() -const existsStatusRequests = new Map>() -const subscribeStatusCache = new Map() -const subscribeStatusRequests = new Map>() +const existsStatusState: StatusCacheState = { + entries: new Map(), + explicitValues: new Map(), + generation: 0, + requests: new Map(), + versions: new Map(), +} -function getCachedValue(cache: Map, 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, 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, - requests: Map>, + state: StatusCacheState, key: string, loader: () => Promise, ): Promise { - 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 } = {} 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) { - 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) { - 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() } diff --git a/src/views/discover/__tests__/PersonCardSlideView.spec.ts b/src/views/discover/__tests__/PersonCardSlideView.spec.ts new file mode 100644 index 00000000..2a6e4d69 --- /dev/null +++ b/src/views/discover/__tests__/PersonCardSlideView.spec.ts @@ -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, + 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, + 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: ` + + + + + + `, + }) +} + +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) + }) +}) diff --git a/tests/support/msw/handlers/media.ts b/tests/support/msw/handlers/media.ts index 8fe99e3f..3a1728d3 100644 --- a/tests/support/msw/handlers/media.ts +++ b/tests/support/msw/handlers/media.ts @@ -5,11 +5,23 @@ const API_BASE_URL = 'http://localhost/api/v1/' export const mediaApiUrls = { episodeGroups: (tmdbId: number) => new URL(`media/groups/${tmdbId}`, API_BASE_URL).href, + exists: new URL('mediaserver/exists', 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, seasons: new URL('media/seasons', API_BASE_URL).href, } +export function mediaExistsHandler( + response: { data?: Record; message?: string; success: boolean }, + status = 200, + onRequest: (url: URL) => void | Promise = () => {}, +) { + 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, response: MediaInfo, diff --git a/vite.config.ts b/vite.config.ts index 4a19ee4e..e2f2b901 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -260,10 +260,7 @@ export default defineConfig(({ mode }) => ({ url: 'http://localhost/', }, }, - include: [ - 'src/**/__tests__/**/*.spec.ts', - 'tests/config/**/*.spec.ts', - ], + include: ['src/**/__tests__/**/*.spec.ts', 'tests/config/**/*.spec.ts'], restoreMocks: true, server: { deps: { @@ -304,6 +301,10 @@ export default defineConfig(({ mode }) => ({ 'src/views/discover/BangumiView.vue', 'src/views/discover/ExtraSourceView.vue', 'src/views/discover/MediaCardListView.vue', + 'src/components/cards/MediaCard.vue', + 'src/components/slide/VirtualSlideView.vue', + 'src/views/discover/PersonCardSlideView.vue', + 'src/utils/mediaStatusCache.ts', ], provider: 'v8', reporter: ['text', 'json-summary', 'html'], @@ -463,6 +464,30 @@ export default defineConfig(({ mode }) => ({ lines: 80, statements: 80, }, + 'src/components/cards/MediaCard.vue': { + branches: 85, + functions: 90, + lines: 90, + statements: 90, + }, + 'src/components/slide/VirtualSlideView.vue': { + branches: 85, + functions: 90, + lines: 90, + statements: 90, + }, + 'src/views/discover/PersonCardSlideView.vue': { + branches: 75, + functions: 80, + lines: 80, + statements: 80, + }, + 'src/utils/mediaStatusCache.ts': { + branches: 85, + functions: 90, + lines: 90, + statements: 90, + }, 'src/views/subscribe/FullCalendarView.vue': { branches: 85, functions: 90,