mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-11 08:33:46 +08:00
test(media): cover cards and virtual slides (#558)
This commit is contained in:
539
src/components/cards/__tests__/MediaCard.spec.ts
Normal file
539
src/components/cards/__tests__/MediaCard.spec.ts
Normal file
@@ -0,0 +1,539 @@
|
||||
import type { MediaInfo } from '@/api/types'
|
||||
import MediaCard from '@/components/cards/MediaCard.vue'
|
||||
import { clearCachedMediaSubscribeStatuses } from '@/utils/mediaStatusCache'
|
||||
import { fireEvent, waitFor } from '@testing-library/vue'
|
||||
import { createMediaInfo } from '@tests/support/factories/media'
|
||||
import { mediaExistsHandler } from '@tests/support/msw/handlers/media'
|
||||
import { querySubscribeByMediaHandler, subscribeListHandler } from '@tests/support/msw/handlers/subscribe'
|
||||
import { server } from '@tests/support/msw/server'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { HttpResponse, http } from 'msw'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
openSharedDialog: vi.fn(),
|
||||
routerPush: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useSharedDialog', () => ({
|
||||
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/router', () => ({
|
||||
default: {
|
||||
push: (...args: unknown[]) => mocks.routerPush(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
const API_BASE_URL = 'http://localhost/api/v1/'
|
||||
const siteListUrl = new URL('site/', API_BASE_URL).href
|
||||
const selectedSitesUrl = new URL('system/setting/public/IndexerSites', API_BASE_URL).href
|
||||
|
||||
let intersectionObservers: IntersectionObserverMock[] = []
|
||||
|
||||
class IntersectionObserverMock implements IntersectionObserver {
|
||||
readonly root: Element | Document | null
|
||||
readonly rootMargin: string
|
||||
readonly thresholds: readonly number[]
|
||||
readonly disconnect = vi.fn()
|
||||
readonly observe = vi.fn((target: Element) => {
|
||||
this.target = target
|
||||
})
|
||||
readonly unobserve = vi.fn()
|
||||
private target: Element = document.body
|
||||
|
||||
constructor(
|
||||
private readonly callback: IntersectionObserverCallback,
|
||||
options: IntersectionObserverInit = {},
|
||||
) {
|
||||
this.root = options.root ?? null
|
||||
this.rootMargin = options.rootMargin ?? '0px'
|
||||
this.thresholds = Array.isArray(options.threshold) ? options.threshold : [options.threshold ?? 0]
|
||||
intersectionObservers.push(this)
|
||||
}
|
||||
|
||||
takeRecords(): IntersectionObserverEntry[] {
|
||||
return []
|
||||
}
|
||||
|
||||
trigger(isIntersecting = true) {
|
||||
const bounds = this.target.getBoundingClientRect()
|
||||
this.callback(
|
||||
[
|
||||
{
|
||||
boundingClientRect: bounds,
|
||||
intersectionRatio: isIntersecting ? 1 : 0,
|
||||
intersectionRect: bounds,
|
||||
isIntersecting,
|
||||
rootBounds: null,
|
||||
target: this.target,
|
||||
time: 0,
|
||||
},
|
||||
],
|
||||
this,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
interface RenderCardOptions {
|
||||
permissions?: Record<string, boolean>
|
||||
superUser?: boolean
|
||||
}
|
||||
|
||||
async function renderCard(media: MediaInfo, options: RenderCardOptions = {}) {
|
||||
return renderWithProviders(MediaCard, {
|
||||
props: {
|
||||
media,
|
||||
width: '9rem',
|
||||
},
|
||||
initialState: {
|
||||
user: {
|
||||
permissions: options.permissions ?? {
|
||||
discovery: true,
|
||||
manage: false,
|
||||
search: true,
|
||||
subscribe: true,
|
||||
},
|
||||
superUser: options.superUser ?? true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function getCard(container: Element) {
|
||||
const card = container.querySelector<HTMLElement>('.media-card')
|
||||
expect(card).not.toBeNull()
|
||||
return card as HTMLElement
|
||||
}
|
||||
|
||||
function getHoverArea(container: Element) {
|
||||
const area = container.querySelector<HTMLElement>('.media-card-hover-area')
|
||||
expect(area).not.toBeNull()
|
||||
return area as HTMLElement
|
||||
}
|
||||
|
||||
function getActionButtons(container: Element) {
|
||||
return [...container.querySelectorAll<HTMLButtonElement>('.media-card .v-card-text button')]
|
||||
}
|
||||
|
||||
function getSearchButton(container: Element) {
|
||||
const button = getActionButtons(container)[0]
|
||||
expect(button).toBeDefined()
|
||||
return button
|
||||
}
|
||||
|
||||
function getStatusObservers() {
|
||||
return intersectionObservers.filter(observer => observer.thresholds.includes(0.1))
|
||||
}
|
||||
|
||||
function installSearchHandlers(sites: Record<string, unknown>[], selected: number[]) {
|
||||
server.use(
|
||||
http.get(siteListUrl, () => HttpResponse.json(sites)),
|
||||
http.get(selectedSitesUrl, () => HttpResponse.json({ data: { value: selected }, success: true })),
|
||||
)
|
||||
}
|
||||
|
||||
describe('MediaCard', () => {
|
||||
beforeEach(() => {
|
||||
intersectionObservers = []
|
||||
clearCachedMediaSubscribeStatuses()
|
||||
vi.stubGlobal('IntersectionObserver', IntersectionObserverMock)
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
|
||||
})
|
||||
|
||||
it('loads exact subscribe and exists status only after entering the viewport', async () => {
|
||||
const media = createMediaInfo({ season: 2, title: '视口状态剧集', tmdb_id: 9101, type: '电视剧' })
|
||||
const subscribeRequest = vi.fn<(url: URL) => void>()
|
||||
const existsRequest = vi.fn<(url: URL) => void>()
|
||||
server.use(
|
||||
querySubscribeByMediaHandler('tmdb:9101', { id: 71, season: 2 }, 200, subscribeRequest),
|
||||
mediaExistsHandler({ data: { item: { id: 'library-item' } }, success: true }, 200, existsRequest),
|
||||
)
|
||||
|
||||
const { container } = await renderCard(media)
|
||||
expect(subscribeRequest).not.toHaveBeenCalled()
|
||||
expect(existsRequest).not.toHaveBeenCalled()
|
||||
|
||||
getStatusObservers()[0]?.trigger()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(subscribeRequest).toHaveBeenCalledOnce()
|
||||
expect(existsRequest).toHaveBeenCalledOnce()
|
||||
})
|
||||
expect(subscribeRequest.mock.calls[0][0].searchParams.get('season')).toBe('2')
|
||||
expect(subscribeRequest.mock.calls[0][0].searchParams.get('title')).toBe('视口状态剧集')
|
||||
expect(Object.fromEntries(existsRequest.mock.calls[0][0].searchParams)).toEqual({
|
||||
mtype: '电视剧',
|
||||
season: '2',
|
||||
title: '视口状态剧集',
|
||||
tmdbid: '9101',
|
||||
year: '2026',
|
||||
})
|
||||
await waitFor(() => expect(getActionButtons(container).at(-1)).toHaveClass('text-error'))
|
||||
expect(getStatusObservers()[0]?.disconnect).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('coalesces status requests for duplicate cards while updating both card states', async () => {
|
||||
const media = createMediaInfo({ title: '重复媒体', tmdb_id: 9102 })
|
||||
const subscribeRequest = vi.fn<(url: URL) => void>()
|
||||
const existsRequest = vi.fn<(url: URL) => void>()
|
||||
server.use(
|
||||
querySubscribeByMediaHandler('tmdb:9102', { id: 72 }, 200, subscribeRequest),
|
||||
mediaExistsHandler({ data: { item: {} }, success: false }, 200, existsRequest),
|
||||
)
|
||||
|
||||
const Harness = {
|
||||
components: { MediaCard },
|
||||
data: () => ({ media }),
|
||||
template: '<div><MediaCard :media="media" width="9rem" /><MediaCard :media="media" width="9rem" /></div>',
|
||||
}
|
||||
const { container } = await renderWithProviders(Harness, {
|
||||
initialState: { user: { superUser: true } },
|
||||
})
|
||||
|
||||
expect(getStatusObservers()).toHaveLength(2)
|
||||
getStatusObservers().forEach(observer => observer.trigger())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(subscribeRequest).toHaveBeenCalledOnce()
|
||||
expect(existsRequest).toHaveBeenCalledOnce()
|
||||
expect(container.querySelectorAll('.media-card .v-card-text button.text-error')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['TMDB', createMediaInfo({ season: 3, tmdb_id: 9201, type: '电视剧' }), 'tmdb:9201', '3'],
|
||||
[
|
||||
'Douban',
|
||||
createMediaInfo({ douban_id: 'db-9202', season: undefined, tmdb_id: undefined }),
|
||||
'douban:db-9202',
|
||||
null,
|
||||
],
|
||||
[
|
||||
'Bangumi',
|
||||
createMediaInfo({ bangumi_id: '9203', season: 1, tmdb_id: undefined, type: '电视剧' }),
|
||||
'bangumi:9203',
|
||||
'1',
|
||||
],
|
||||
[
|
||||
'extension',
|
||||
createMediaInfo({ media_id: 'item-9204', mediaid_prefix: 'custom', tmdb_id: undefined }),
|
||||
'custom:item-9204',
|
||||
null,
|
||||
],
|
||||
])('queries the current %s media identifier and season', async (_source, media, mediaId, season) => {
|
||||
const subscribeRequest = vi.fn<(url: URL) => void>()
|
||||
server.use(
|
||||
querySubscribeByMediaHandler(mediaId, {}, 200, subscribeRequest),
|
||||
mediaExistsHandler({ data: { item: {} }, success: false }),
|
||||
)
|
||||
|
||||
await renderCard(media)
|
||||
getStatusObservers()[0]?.trigger()
|
||||
|
||||
await waitFor(() => expect(subscribeRequest).toHaveBeenCalledOnce())
|
||||
expect(subscribeRequest.mock.calls[0][0].searchParams.get('season')).toBe(season)
|
||||
})
|
||||
|
||||
it('skips status requests for collections and releases observer and touch listeners on unmount', async () => {
|
||||
vi.spyOn(window, 'matchMedia').mockReturnValue({
|
||||
...window.matchMedia(''),
|
||||
matches: true,
|
||||
})
|
||||
const subscribeRequest = vi.fn<(url: URL) => void>()
|
||||
const existsRequest = vi.fn<(url: URL) => void>()
|
||||
const addListener = vi.spyOn(document, 'addEventListener')
|
||||
const removeListener = vi.spyOn(document, 'removeEventListener')
|
||||
server.use(
|
||||
querySubscribeByMediaHandler('tmdb:9301', {}, 200, subscribeRequest),
|
||||
mediaExistsHandler({ data: { item: {} }, success: false }, 200, existsRequest),
|
||||
)
|
||||
|
||||
const { unmount } = await renderCard(createMediaInfo({ collection_id: 44, tmdb_id: 9301 }))
|
||||
getStatusObservers()[0]?.trigger()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(subscribeRequest).not.toHaveBeenCalled()
|
||||
expect(existsRequest).not.toHaveBeenCalled()
|
||||
expect(addListener).toHaveBeenCalledWith('pointerdown', expect.any(Function))
|
||||
|
||||
unmount()
|
||||
|
||||
expect(getStatusObservers()[0]?.disconnect).toHaveBeenCalledOnce()
|
||||
expect(removeListener).toHaveBeenCalledWith('pointerdown', expect.any(Function))
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'media details',
|
||||
createMediaInfo({ title: '详情电影', tmdb_id: 9401 }),
|
||||
'/media',
|
||||
{ mediaid: 'tmdb:9401', title: '详情电影', type: '电影', year: '2026' },
|
||||
],
|
||||
[
|
||||
'collection browse',
|
||||
createMediaInfo({ collection_id: 88, title: '合集入口', tmdb_id: 9402 }),
|
||||
'/browse/tmdb/collection/88',
|
||||
{ title: '合集入口' },
|
||||
],
|
||||
])('opens %s from the desktop hover state', async (_case, media, path, query) => {
|
||||
const { container } = await renderCard(media)
|
||||
|
||||
await fireEvent.mouseEnter(getHoverArea(container))
|
||||
await waitFor(() => expect(getCard(container)).toHaveClass('app-hover-lift-card--hovering'))
|
||||
await fireEvent.click(getCard(container))
|
||||
|
||||
await waitFor(() => expect(mocks.routerPush).toHaveBeenCalledWith({ path, query }))
|
||||
})
|
||||
|
||||
it('routes directly to resource search when no active sites are available', async () => {
|
||||
installSearchHandlers([], [3, 5])
|
||||
const media = createMediaInfo({ season: 4, title: '直接搜索剧集', tmdb_id: 9501, type: '电视剧' })
|
||||
const { container } = await renderCard(media)
|
||||
|
||||
await fireEvent.mouseEnter(getHoverArea(container))
|
||||
await fireEvent.click(getSearchButton(container))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.routerPush).toHaveBeenCalledWith({
|
||||
path: '/resource',
|
||||
query: {
|
||||
area: 'title',
|
||||
keyword: 'tmdb:9501',
|
||||
season: 4,
|
||||
sites: '3,5',
|
||||
title: '直接搜索剧集',
|
||||
type: '电视剧',
|
||||
year: '2026',
|
||||
},
|
||||
}),
|
||||
)
|
||||
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to global search when site settings cannot provide active selections', async () => {
|
||||
server.use(
|
||||
http.get(siteListUrl, () => HttpResponse.json({ message: 'temporary failure' }, { status: 500 })),
|
||||
http.get(selectedSitesUrl, () => HttpResponse.json({ success: true })),
|
||||
)
|
||||
const media = createMediaInfo({ title: '站点失败搜索', tmdb_id: 9503 })
|
||||
const { container } = await renderCard(media)
|
||||
|
||||
await fireEvent.mouseEnter(getHoverArea(container))
|
||||
await fireEvent.click(getSearchButton(container))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.routerPush).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ path: '/resource', query: expect.objectContaining({ sites: '' }) }),
|
||||
),
|
||||
)
|
||||
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens site selection and routes with the selected sites when active sites exist', async () => {
|
||||
installSearchHandlers(
|
||||
[
|
||||
{
|
||||
domain: 'tracker.example',
|
||||
downloader: 'default',
|
||||
id: 7,
|
||||
is_active: true,
|
||||
name: '测试站点',
|
||||
url: 'https://tracker.example',
|
||||
},
|
||||
],
|
||||
[7],
|
||||
)
|
||||
const media = createMediaInfo({ title: '多站搜索电影', tmdb_id: 9502 })
|
||||
const { container } = await renderCard(media)
|
||||
|
||||
await fireEvent.mouseEnter(getHoverArea(container))
|
||||
await fireEvent.click(getSearchButton(container))
|
||||
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
|
||||
|
||||
const [, dialogProps, dialogEvents] = mocks.openSharedDialog.mock.calls[0] as [
|
||||
unknown,
|
||||
{ selected: number[]; sites: Array<{ id: number }> },
|
||||
{ search: (sites: number[]) => void },
|
||||
]
|
||||
expect(dialogProps.selected).toEqual([7])
|
||||
expect(dialogProps.sites.map(site => site.id)).toEqual([7])
|
||||
dialogEvents.search([7, 9])
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.routerPush).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path: '/resource',
|
||||
query: expect.objectContaining({ sites: '7,9' }),
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it('opens active sites with an empty selection when the saved setting fails', async () => {
|
||||
server.use(
|
||||
http.get(siteListUrl, () =>
|
||||
HttpResponse.json([
|
||||
{
|
||||
domain: 'fallback.example',
|
||||
downloader: 'default',
|
||||
id: 8,
|
||||
is_active: true,
|
||||
name: '备用站点',
|
||||
url: 'https://fallback.example',
|
||||
},
|
||||
]),
|
||||
),
|
||||
http.get(selectedSitesUrl, () => HttpResponse.json({ message: 'temporary failure' }, { status: 500 })),
|
||||
)
|
||||
const { container } = await renderCard(createMediaInfo({ tmdb_id: 9504 }))
|
||||
|
||||
await fireEvent.mouseEnter(getHoverArea(container))
|
||||
await fireEvent.click(getSearchButton(container))
|
||||
|
||||
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
|
||||
const [, dialogProps] = mocks.openSharedDialog.mock.calls[0] as [unknown, { selected: number[] }]
|
||||
expect(dialogProps.selected).toEqual([])
|
||||
})
|
||||
|
||||
it('loads matching TV seasons before opening the subscription dialog', async () => {
|
||||
const media = createMediaInfo({ season: 2, title: '多季剧集', tmdb_id: 9551, type: '电视剧' })
|
||||
server.use(
|
||||
querySubscribeByMediaHandler('tmdb:9551', { id: 81, season: 2 }),
|
||||
mediaExistsHandler({ data: { item: {} }, success: false }),
|
||||
subscribeListHandler([
|
||||
{ best_version: 0, id: 81, season: 3, tmdbid: 9551, type: '电视剧' },
|
||||
{ best_version: 1, best_version_full: 1, id: 82, season: 1, tmdbid: 9551, type: '电视剧' },
|
||||
{ id: 83, season: 4, tmdbid: 9999, type: '电视剧' },
|
||||
{ id: 84, tmdbid: 9551, type: '电影' },
|
||||
]),
|
||||
http.get(new URL('system/setting/public/DefaultTvSubscribeConfig', API_BASE_URL).href, () =>
|
||||
HttpResponse.json({ data: { value: { best_version: 0 } }, success: true }),
|
||||
),
|
||||
)
|
||||
const { container } = await renderCard(media)
|
||||
getStatusObservers()[0]?.trigger()
|
||||
await waitFor(() => expect(getActionButtons(container).at(-1)).toHaveClass('text-error'))
|
||||
|
||||
await fireEvent.mouseEnter(getHoverArea(container))
|
||||
await fireEvent.click(getActionButtons(container).at(-1) as HTMLButtonElement)
|
||||
|
||||
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
|
||||
const [, dialogProps] = mocks.openSharedDialog.mock.calls[0] as [unknown, Record<string, unknown>]
|
||||
expect(dialogProps).toMatchObject({
|
||||
selectedSeason: undefined,
|
||||
subscribedSeasonModes: { 1: 'best_version_full', 3: 'normal' },
|
||||
subscribedSeasons: [1, 3],
|
||||
})
|
||||
})
|
||||
|
||||
it('matches custom media IDs when collecting subscribed TV seasons', async () => {
|
||||
const media = createMediaInfo({
|
||||
media_id: 'series-9553',
|
||||
mediaid_prefix: 'custom',
|
||||
season: 2,
|
||||
tmdb_id: undefined,
|
||||
type: '电视剧',
|
||||
})
|
||||
server.use(
|
||||
querySubscribeByMediaHandler('custom:series-9553', { id: 91, season: 2 }),
|
||||
mediaExistsHandler({ data: { item: {} }, success: false }),
|
||||
subscribeListHandler([
|
||||
{ id: 91, mediaid: 'custom:series-9553', season: 2, type: '电视剧' },
|
||||
{ id: 92, mediaid: 'custom:other', season: 5, type: '电视剧' },
|
||||
]),
|
||||
http.get(new URL('system/setting/public/DefaultTvSubscribeConfig', API_BASE_URL).href, () =>
|
||||
HttpResponse.json({ data: { value: {} }, success: true }),
|
||||
),
|
||||
)
|
||||
const { container } = await renderCard(media)
|
||||
getStatusObservers()[0]?.trigger()
|
||||
await waitFor(() => expect(getActionButtons(container).at(-1)).toHaveClass('text-error'))
|
||||
|
||||
await fireEvent.mouseEnter(getHoverArea(container))
|
||||
await fireEvent.click(getActionButtons(container).at(-1) as HTMLButtonElement)
|
||||
|
||||
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
|
||||
const [, dialogProps] = mocks.openSharedDialog.mock.calls[0] as [unknown, Record<string, unknown>]
|
||||
expect(dialogProps).toMatchObject({ subscribedSeasons: [2] })
|
||||
})
|
||||
|
||||
it('updates image badges on load and falls back after an image error', async () => {
|
||||
const media = createMediaInfo({
|
||||
poster_path: '/original/poster.jpg',
|
||||
source: 'themoviedb',
|
||||
tmdb_id: 9552,
|
||||
type: '电视剧',
|
||||
vote_average: 8.6,
|
||||
})
|
||||
const VImgStub = defineComponent({
|
||||
name: 'VImg',
|
||||
emits: ['error', 'load'],
|
||||
props: { src: String },
|
||||
setup(props, { emit, slots }) {
|
||||
return () =>
|
||||
h('div', { 'data-src': props.src }, [
|
||||
h('button', { 'aria-label': '图片加载成功', onClick: () => emit('load') }),
|
||||
h('button', { 'aria-label': '图片加载失败', onClick: () => emit('error') }),
|
||||
slots.default?.(),
|
||||
])
|
||||
},
|
||||
})
|
||||
const { container } = await renderWithProviders(MediaCard, {
|
||||
props: { media, width: '9rem' },
|
||||
initialState: { user: { superUser: true } },
|
||||
global: { stubs: { VImg: VImgStub } },
|
||||
})
|
||||
|
||||
await fireEvent.click(container.querySelector('[aria-label="图片加载成功"]') as HTMLElement)
|
||||
await waitFor(() => expect(container.querySelector('.media-card')).toHaveClass('ring-1'))
|
||||
expect(container).toHaveTextContent('TV')
|
||||
expect(container).toHaveTextContent('8.6')
|
||||
|
||||
await fireEvent.click(container.querySelector('[aria-label="图片加载失败"]') as HTMLElement)
|
||||
await waitFor(() =>
|
||||
expect(container.querySelector('.media-card-title')?.parentElement).not.toHaveStyle({ display: 'none' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('hides search and subscribe actions when the user lacks both permissions', async () => {
|
||||
const { container } = await renderCard(createMediaInfo({ tmdb_id: 9601 }), {
|
||||
permissions: {
|
||||
discovery: true,
|
||||
manage: false,
|
||||
search: false,
|
||||
subscribe: false,
|
||||
},
|
||||
superUser: false,
|
||||
})
|
||||
|
||||
await fireEvent.mouseEnter(getHoverArea(container))
|
||||
|
||||
expect(getActionButtons(container)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('uses first tap to reveal details, second tap to route, and outside pointerdown to collapse', async () => {
|
||||
vi.spyOn(window, 'matchMedia').mockReturnValue({
|
||||
...window.matchMedia(''),
|
||||
matches: true,
|
||||
})
|
||||
const media = createMediaInfo({ title: '触摸卡片', tmdb_id: 9701 })
|
||||
const { container } = await renderCard(media)
|
||||
const detail = container.querySelector<HTMLElement>('.media-card-title')?.parentElement
|
||||
expect(detail).not.toBeNull()
|
||||
|
||||
await fireEvent.click(getCard(container))
|
||||
expect(detail).not.toHaveStyle({ display: 'none' })
|
||||
expect(mocks.routerPush).not.toHaveBeenCalled()
|
||||
|
||||
await fireEvent.pointerDown(document.body)
|
||||
expect(detail).toHaveStyle({ display: 'none' })
|
||||
|
||||
await fireEvent.click(getCard(container))
|
||||
await fireEvent.click(getCard(container))
|
||||
await waitFor(() => expect(mocks.routerPush).toHaveBeenCalledWith(expect.objectContaining({ path: '/media' })))
|
||||
})
|
||||
})
|
||||
167
src/components/slide/__tests__/VirtualSlideView.spec.ts
Normal file
167
src/components/slide/__tests__/VirtualSlideView.spec.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import VirtualSlideView from '@/components/slide/VirtualSlideView.vue'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { defineComponent, nextTick, ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('vuetify', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('vuetify')>()
|
||||
return {
|
||||
...actual,
|
||||
useDisplay: () => ({ mobile: { value: false } }),
|
||||
}
|
||||
})
|
||||
|
||||
let resizeCallbacks: ResizeObserverCallback[] = []
|
||||
let resizeObservers: ResizeObserverMock[] = []
|
||||
|
||||
class ResizeObserverMock implements ResizeObserver {
|
||||
readonly disconnect = vi.fn()
|
||||
readonly observe = vi.fn()
|
||||
readonly unobserve = vi.fn()
|
||||
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
resizeCallbacks.push(callback)
|
||||
resizeObservers.push(this)
|
||||
}
|
||||
}
|
||||
|
||||
const items = Array.from({ length: 20 }, (_, index) => ({ id: index, label: `项目 ${index}` }))
|
||||
|
||||
async function renderSlide(overrides: Record<string, unknown> = {}) {
|
||||
return renderWithProviders(VirtualSlideView, {
|
||||
props: {
|
||||
getItemKey: (item: { id: number }) => item.id,
|
||||
itemGap: 10,
|
||||
itemWidth: 100,
|
||||
items,
|
||||
overscanItems: 1,
|
||||
...overrides,
|
||||
},
|
||||
slots: {
|
||||
empty: '<p>没有内容</p>',
|
||||
item: '<template #item="{ item, index }"><article :data-index="index">{{ item.label }}</article></template>',
|
||||
loading: '<p>正在加载</p>',
|
||||
title: '<h2>测试轨道</h2>',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function configureScroller(container: Element, clientWidth = 220, scrollWidth = 2190) {
|
||||
const scroller = container.querySelector<HTMLElement>('.slider-content')
|
||||
expect(scroller).not.toBeNull()
|
||||
Object.defineProperties(scroller, {
|
||||
clientWidth: { configurable: true, value: clientWidth },
|
||||
scrollWidth: { configurable: true, value: scrollWidth },
|
||||
})
|
||||
return scroller as HTMLElement
|
||||
}
|
||||
|
||||
describe('VirtualSlideView', () => {
|
||||
beforeEach(() => {
|
||||
resizeCallbacks = []
|
||||
resizeObservers = []
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverMock)
|
||||
vi.stubGlobal(
|
||||
'requestAnimationFrame',
|
||||
vi.fn((callback: FrameRequestCallback) => {
|
||||
callback(0)
|
||||
return 1
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('renders loading and empty slots without virtual content', async () => {
|
||||
const loading = await renderSlide({ loading: true })
|
||||
expect(screen.getByText('正在加载')).toBeInTheDocument()
|
||||
expect(loading.container.querySelector('.virtual-track')).not.toBeInTheDocument()
|
||||
loading.unmount()
|
||||
|
||||
const empty = await renderSlide({ items: [] })
|
||||
expect(screen.getByText('没有内容')).toBeInTheDocument()
|
||||
expect(empty.container.querySelector('.virtual-track')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps total width while moving the rendered range with scroll position', async () => {
|
||||
const { container } = await renderSlide()
|
||||
const scroller = configureScroller(container)
|
||||
resizeCallbacks[0]?.([], resizeObservers[0])
|
||||
|
||||
await waitFor(() => expect(container.querySelectorAll('.virtual-slide-item')).toHaveLength(3))
|
||||
expect(container.querySelector<HTMLElement>('.virtual-track')).toHaveStyle({ width: '2190px' })
|
||||
expect(screen.getByText('项目 0')).toHaveAttribute('data-index', '0')
|
||||
expect(screen.getByText('项目 2')).toHaveAttribute('data-index', '2')
|
||||
|
||||
scroller.scrollLeft = 550
|
||||
await fireEvent.scroll(scroller)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('项目 4')).toHaveAttribute('data-index', '4'))
|
||||
expect(screen.getByText('项目 7')).toHaveAttribute('data-index', '7')
|
||||
expect(container.querySelectorAll('.virtual-spacer')).toHaveLength(2)
|
||||
expect(container.querySelector('.nav-button-left')).toBeVisible()
|
||||
})
|
||||
|
||||
it('scrolls one viewport smoothly and updates navigation state', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { container } = await renderSlide()
|
||||
const scroller = configureScroller(container)
|
||||
const scrollTo = vi.fn(({ left }: ScrollToOptions) => {
|
||||
scroller.scrollLeft = left ?? 0
|
||||
})
|
||||
scroller.scrollTo = scrollTo as typeof scroller.scrollTo
|
||||
resizeCallbacks[0]?.([], resizeObservers[0])
|
||||
|
||||
await fireEvent.click(container.querySelector('.nav-button-right') as HTMLElement)
|
||||
expect(scrollTo).toHaveBeenCalledWith({ behavior: 'smooth', left: 220, top: 0 })
|
||||
expect(container.querySelector('.slider-container')).toHaveClass('is-scrolling')
|
||||
|
||||
await fireEvent.scroll(scroller)
|
||||
await nextTick()
|
||||
scroller.scrollLeft = 1970
|
||||
await fireEvent.scroll(scroller)
|
||||
await nextTick()
|
||||
expect(container.querySelector('.nav-button-right')).not.toBeVisible()
|
||||
|
||||
await fireEvent.click(container.querySelector('.nav-button-left') as HTMLElement)
|
||||
expect(scrollTo).toHaveBeenLastCalledWith({ behavior: 'smooth', left: 1760, top: 0 })
|
||||
vi.advanceTimersByTime(1500)
|
||||
await Promise.resolve()
|
||||
expect(container.querySelector('.slider-container')).not.toHaveClass('is-scrolling')
|
||||
})
|
||||
|
||||
it('restores the saved offset after KeepAlive activation and releases resources on unmount', async () => {
|
||||
const Harness = defineComponent({
|
||||
components: { VirtualSlideView },
|
||||
setup() {
|
||||
const active = ref(true)
|
||||
return { active, items }
|
||||
},
|
||||
template: `
|
||||
<button type="button" @click="active = false">停用轨道</button>
|
||||
<button type="button" @click="active = true">启用轨道</button>
|
||||
<KeepAlive>
|
||||
<VirtualSlideView v-if="active" :items="items" :item-width="100" :item-gap="10" :overscan-items="1">
|
||||
<template #title><h2>测试轨道</h2></template>
|
||||
<template #item="{ item }"><article>{{ item.label }}</article></template>
|
||||
</VirtualSlideView>
|
||||
</KeepAlive>
|
||||
`,
|
||||
})
|
||||
const removeListener = vi.spyOn(window, 'removeEventListener')
|
||||
const { container, unmount } = await renderWithProviders(Harness)
|
||||
let scroller = configureScroller(container)
|
||||
resizeCallbacks[0]?.([], resizeObservers[0])
|
||||
scroller.scrollLeft = 440
|
||||
await fireEvent.scroll(scroller)
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: '停用轨道' }))
|
||||
scroller.scrollLeft = 0
|
||||
await fireEvent.click(screen.getByRole('button', { name: '启用轨道' }))
|
||||
scroller = configureScroller(container)
|
||||
|
||||
await waitFor(() => expect(scroller.scrollLeft).toBe(440))
|
||||
unmount()
|
||||
expect(resizeObservers[0]?.disconnect).toHaveBeenCalledOnce()
|
||||
expect(removeListener).toHaveBeenCalledWith('resize', expect.any(Function))
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
},
|
||||
},
|
||||
|
||||
162
src/utils/__tests__/mediaStatusCache.spec.ts
Normal file
162
src/utils/__tests__/mediaStatusCache.spec.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import {
|
||||
getCachedMediaExistsStatus,
|
||||
getCachedMediaSubscribeStatus,
|
||||
setCachedMediaExistsStatus,
|
||||
setCachedMediaSubscribeStatus,
|
||||
} from '@/utils/mediaStatusCache'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
let keySequence = 0
|
||||
|
||||
function nextKey(label: string) {
|
||||
keySequence += 1
|
||||
return `${label}:${keySequence}`
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise
|
||||
reject = rejectPromise
|
||||
})
|
||||
|
||||
return { promise, reject, resolve }
|
||||
}
|
||||
|
||||
describe('media status cache', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-07-18T00:00:00Z'))
|
||||
})
|
||||
|
||||
it('isolates exists and subscribe values by cache and key within the TTL', async () => {
|
||||
const sharedKey = nextKey('isolated')
|
||||
const otherKey = nextKey('other')
|
||||
const existsLoader = vi.fn().mockResolvedValue(true)
|
||||
const subscribeLoader = vi.fn().mockResolvedValue(false)
|
||||
const otherLoader = vi.fn().mockResolvedValue(true)
|
||||
|
||||
await expect(getCachedMediaExistsStatus(sharedKey, existsLoader)).resolves.toBe(true)
|
||||
await expect(getCachedMediaSubscribeStatus(sharedKey, subscribeLoader)).resolves.toBe(false)
|
||||
await expect(getCachedMediaSubscribeStatus(otherKey, otherLoader)).resolves.toBe(true)
|
||||
|
||||
await expect(getCachedMediaExistsStatus(sharedKey, vi.fn().mockResolvedValue(false))).resolves.toBe(true)
|
||||
await expect(getCachedMediaSubscribeStatus(sharedKey, vi.fn().mockResolvedValue(true))).resolves.toBe(false)
|
||||
expect(existsLoader).toHaveBeenCalledOnce()
|
||||
expect(subscribeLoader).toHaveBeenCalledOnce()
|
||||
expect(otherLoader).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('coalesces concurrent requests and retries after a rejected loader', async () => {
|
||||
const key = nextKey('concurrent')
|
||||
const first = deferred<boolean>()
|
||||
const loader = vi.fn().mockReturnValue(first.promise)
|
||||
|
||||
const requestA = getCachedMediaExistsStatus(key, loader)
|
||||
const requestB = getCachedMediaExistsStatus(key, loader)
|
||||
expect(loader).toHaveBeenCalledOnce()
|
||||
|
||||
first.reject(new Error('temporary failure'))
|
||||
await expect(requestA).rejects.toThrow('temporary failure')
|
||||
await expect(requestB).rejects.toThrow('temporary failure')
|
||||
|
||||
const retryLoader = vi.fn().mockResolvedValue(true)
|
||||
await expect(getCachedMediaExistsStatus(key, retryLoader)).resolves.toBe(true)
|
||||
expect(retryLoader).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('reloads expired values and lets explicit values replace cached values', async () => {
|
||||
const existsKey = nextKey('expired')
|
||||
const subscribeKey = nextKey('explicit')
|
||||
|
||||
await expect(getCachedMediaExistsStatus(existsKey, vi.fn().mockResolvedValue(false))).resolves.toBe(false)
|
||||
vi.advanceTimersByTime(3 * 60 * 1000)
|
||||
const expiredLoader = vi.fn().mockResolvedValue(true)
|
||||
await expect(getCachedMediaExistsStatus(existsKey, expiredLoader)).resolves.toBe(true)
|
||||
|
||||
setCachedMediaExistsStatus(existsKey, false)
|
||||
await expect(getCachedMediaExistsStatus(existsKey, vi.fn().mockResolvedValue(true))).resolves.toBe(false)
|
||||
setCachedMediaSubscribeStatus(subscribeKey, true)
|
||||
await expect(getCachedMediaSubscribeStatus(subscribeKey, vi.fn().mockResolvedValue(false))).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('keeps an explicit mutation result when an older status request resolves later', async () => {
|
||||
const key = nextKey('mutation-race')
|
||||
const staleRequest = deferred<boolean>()
|
||||
const pendingStatus = getCachedMediaSubscribeStatus(key, () => staleRequest.promise)
|
||||
|
||||
setCachedMediaSubscribeStatus(key, true)
|
||||
staleRequest.resolve(false)
|
||||
|
||||
await expect(pendingStatus).resolves.toBe(true)
|
||||
await expect(getCachedMediaSubscribeStatus(key, vi.fn().mockResolvedValue(false))).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('keeps an explicit mutation result after its cache TTL when an older request resolves', async () => {
|
||||
const key = nextKey('expired-mutation-race')
|
||||
const staleRequest = deferred<boolean>()
|
||||
const pendingStatus = getCachedMediaSubscribeStatus(key, () => staleRequest.promise)
|
||||
|
||||
setCachedMediaSubscribeStatus(key, false)
|
||||
vi.advanceTimersByTime(3 * 60 * 1000)
|
||||
staleRequest.resolve(true)
|
||||
|
||||
await expect(pendingStatus).resolves.toBe(false)
|
||||
await expect(getCachedMediaSubscribeStatus(key, vi.fn().mockResolvedValue(true))).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('reloads a completed subscription value after logout and login', async () => {
|
||||
const key = nextKey('completed-session')
|
||||
const authStore = useAuthStore()
|
||||
|
||||
authStore.login({ token: 'account-a', remember: false })
|
||||
await expect(getCachedMediaSubscribeStatus(key, vi.fn().mockResolvedValue(true))).resolves.toBe(true)
|
||||
|
||||
authStore.logout()
|
||||
authStore.login({ token: 'account-b', remember: false })
|
||||
const accountBLoader = vi.fn().mockResolvedValue(false)
|
||||
|
||||
await expect(getCachedMediaSubscribeStatus(key, accountBLoader)).resolves.toBe(false)
|
||||
expect(accountBLoader).toHaveBeenCalledOnce()
|
||||
await expect(getCachedMediaSubscribeStatus(key, vi.fn().mockResolvedValue(true))).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('does not reuse a previous account subscription request or value after logout and login', async () => {
|
||||
const key = nextKey('session')
|
||||
const oldAccountRequest = deferred<boolean>()
|
||||
const newAccountRequest = deferred<boolean>()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
authStore.login({ token: 'account-a', remember: false })
|
||||
const accountAStatus = getCachedMediaSubscribeStatus(key, () => oldAccountRequest.promise)
|
||||
|
||||
authStore.logout()
|
||||
authStore.login({ token: 'account-b', remember: false })
|
||||
const accountBLoader = vi.fn().mockReturnValue(newAccountRequest.promise)
|
||||
const accountBStatus = getCachedMediaSubscribeStatus(key, accountBLoader)
|
||||
let accountASettled = false
|
||||
void accountAStatus.then(() => {
|
||||
accountASettled = true
|
||||
})
|
||||
|
||||
oldAccountRequest.resolve(true)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(accountASettled).toBe(false)
|
||||
const repeatedAccountBStatus = getCachedMediaSubscribeStatus(key, accountBLoader)
|
||||
expect(accountBLoader).toHaveBeenCalledOnce()
|
||||
|
||||
newAccountRequest.resolve(false)
|
||||
|
||||
await expect(accountAStatus).resolves.toBe(false)
|
||||
await expect(accountBStatus).resolves.toBe(false)
|
||||
await expect(repeatedAccountBStatus).resolves.toBe(false)
|
||||
expect(accountBLoader).toHaveBeenCalledOnce()
|
||||
await expect(getCachedMediaSubscribeStatus(key, vi.fn().mockResolvedValue(true))).resolves.toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
186
src/views/discover/__tests__/PersonCardSlideView.spec.ts
Normal file
186
src/views/discover/__tests__/PersonCardSlideView.spec.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import type { Person } from '@/api/types'
|
||||
import PersonCardSlideView from '@/views/discover/PersonCardSlideView.vue'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import { server } from '@tests/support/msw/server'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { HttpResponse, http } from 'msw'
|
||||
import { defineComponent, h, type PropType, ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const API_URL = 'http://localhost/api/v1/recommend/tmdb_person'
|
||||
let intersectionCallbacks: IntersectionObserverCallback[] = []
|
||||
|
||||
class IntersectionObserverMock implements IntersectionObserver {
|
||||
readonly root = null
|
||||
readonly rootMargin = '300px'
|
||||
readonly thresholds = [0]
|
||||
|
||||
constructor(callback: IntersectionObserverCallback) {
|
||||
intersectionCallbacks.push(callback)
|
||||
}
|
||||
|
||||
disconnect() {}
|
||||
observe() {}
|
||||
takeRecords(): IntersectionObserverEntry[] {
|
||||
return []
|
||||
}
|
||||
unobserve() {}
|
||||
}
|
||||
|
||||
const VirtualSlideViewStub = defineComponent({
|
||||
name: 'VirtualSlideView',
|
||||
props: {
|
||||
getItemKey: {
|
||||
type: Function as PropType<(item: Person) => string | number | undefined>,
|
||||
required: true,
|
||||
},
|
||||
items: {
|
||||
type: Array as PropType<Person[]>,
|
||||
required: true,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props, { slots }) {
|
||||
return () =>
|
||||
h('section', { 'aria-label': '人物横向列表', 'data-loading': String(props.loading) }, [
|
||||
h('output', { 'aria-label': '人物键' }, props.items.map(item => String(props.getItemKey(item))).join('|')),
|
||||
props.loading
|
||||
? h('span', { role: 'status' }, '正在加载人物')
|
||||
: props.items.flatMap(item => slots.item?.({ item }) ?? []),
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
const PersonCardStub = defineComponent({
|
||||
name: 'PersonCard',
|
||||
props: {
|
||||
person: {
|
||||
type: Object as PropType<Person>,
|
||||
required: true,
|
||||
},
|
||||
width: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
return () =>
|
||||
h('article', { 'aria-label': `人物卡片 ${props.person.name}`, 'data-width': props.width }, props.person.name)
|
||||
},
|
||||
})
|
||||
|
||||
function personResponse(people: Person[], status = 200, onRequest: () => void = () => {}) {
|
||||
return http.get(API_URL, () => {
|
||||
onRequest()
|
||||
return HttpResponse.json(people, { status })
|
||||
})
|
||||
}
|
||||
|
||||
function triggerIntersection(isIntersecting = true) {
|
||||
const callback = intersectionCallbacks.at(-1)
|
||||
expect(callback).toBeTypeOf('function')
|
||||
callback?.(
|
||||
[{ isIntersecting, target: document.body } as unknown as IntersectionObserverEntry],
|
||||
{} as IntersectionObserver,
|
||||
)
|
||||
}
|
||||
|
||||
async function renderSlide() {
|
||||
return renderWithProviders(PersonCardSlideView, {
|
||||
props: {
|
||||
apipath: 'recommend/tmdb_person',
|
||||
linkurl: '/browse/recommend/tmdb_person',
|
||||
title: '热门人物',
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
PersonCard: PersonCardStub,
|
||||
VirtualSlideView: VirtualSlideViewStub,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function keepAliveHarness() {
|
||||
return defineComponent({
|
||||
components: { PersonCardSlideView },
|
||||
setup() {
|
||||
const active = ref(true)
|
||||
return { active }
|
||||
},
|
||||
template: `
|
||||
<button type="button" @click="active = false">停用人物列表</button>
|
||||
<button type="button" @click="active = true">启用人物列表</button>
|
||||
<KeepAlive>
|
||||
<PersonCardSlideView
|
||||
v-if="active"
|
||||
apipath="recommend/tmdb_person"
|
||||
linkurl="/browse/recommend/tmdb_person"
|
||||
title="热门人物"
|
||||
/>
|
||||
</KeepAlive>
|
||||
`,
|
||||
})
|
||||
}
|
||||
|
||||
describe('PersonCardSlideView', () => {
|
||||
beforeEach(() => {
|
||||
intersectionCallbacks = []
|
||||
vi.stubGlobal('IntersectionObserver', IntersectionObserverMock)
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
it('loads once after intersection and projects stable person cards', async () => {
|
||||
const requested = vi.fn()
|
||||
server.use(
|
||||
personResponse(
|
||||
[
|
||||
{ id: 101, name: '人物甲', source: 'themoviedb' },
|
||||
{ id: 202, name: '人物乙', source: 'themoviedb' },
|
||||
],
|
||||
200,
|
||||
requested,
|
||||
),
|
||||
)
|
||||
|
||||
await renderSlide()
|
||||
expect(screen.getByLabelText('人物横向列表')).toHaveAttribute('data-loading', 'true')
|
||||
expect(requested).not.toHaveBeenCalled()
|
||||
|
||||
triggerIntersection(false)
|
||||
expect(requested).not.toHaveBeenCalled()
|
||||
triggerIntersection()
|
||||
|
||||
expect(await screen.findByRole('article', { name: '人物卡片 人物甲' })).toHaveAttribute('data-width', '9rem')
|
||||
expect(screen.getByRole('article', { name: '人物卡片 人物乙' })).toHaveAttribute('data-width', '9rem')
|
||||
expect(screen.getByLabelText('人物键')).toHaveTextContent('101|202')
|
||||
expect(screen.getByLabelText('人物横向列表')).toHaveAttribute('data-loading', 'false')
|
||||
expect(requested).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('retries an empty or failed load when the kept-alive view is activated', async () => {
|
||||
const requested = vi.fn()
|
||||
server.use(personResponse([], 500, requested))
|
||||
|
||||
await renderWithProviders(keepAliveHarness(), {
|
||||
global: {
|
||||
stubs: {
|
||||
PersonCard: PersonCardStub,
|
||||
VirtualSlideView: VirtualSlideViewStub,
|
||||
},
|
||||
},
|
||||
})
|
||||
triggerIntersection()
|
||||
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
|
||||
server.use(personResponse([{ id: 303, name: '重试人物', source: 'themoviedb' }], 200, requested))
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: '停用人物列表' }))
|
||||
await fireEvent.click(screen.getByRole('button', { name: '启用人物列表' }))
|
||||
|
||||
expect(await screen.findByRole('article', { name: '人物卡片 重试人物' })).toBeInTheDocument()
|
||||
expect(requested).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user