test(media): cover cards and virtual slides (#558)

This commit is contained in:
InfinityPacer
2026-07-18 18:03:39 +08:00
committed by GitHub
parent 395401e493
commit c491195b19
8 changed files with 1174 additions and 24 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<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,
response: MediaInfo,

View File

@@ -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,