test(discover): cover source and media list flows (#556)

This commit is contained in:
InfinityPacer
2026-07-18 15:41:40 +08:00
committed by GitHub
parent f6d8ab8aeb
commit 52479ebbd5
16 changed files with 1967 additions and 114 deletions

View File

@@ -1020,11 +1020,6 @@
"count": 1
}
},
"src/views/discover/MediaCardListView.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/views/discover/MediaCardSlideView.vue": {
"@typescript-eslint/no-unused-vars": {
"count": 1

View File

@@ -0,0 +1,137 @@
import DiscoverTabOrderDialog from '@/components/dialog/DiscoverTabOrderDialog.vue'
import type { DiscoverSource } from '@/api/types'
import { screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { renderWithProviders } from '@tests/support/render'
import { defineComponent, h, type PropType } from 'vue'
import { describe, expect, it, vi } from 'vitest'
vi.mock('vuedraggable', async () => {
const { defineComponent, h } = await import('vue')
return {
default: defineComponent({
name: 'DraggableStub',
props: {
modelValue: { type: Array as PropType<DiscoverSource[]>, required: true },
},
emits: ['update:modelValue'],
setup(props, { emit, slots }) {
return () =>
h('div', [
h(
'button',
{
onClick: () => emit('update:modelValue', [...props.modelValue].reverse()),
type: 'button',
},
'反转顺序',
),
...props.modelValue.map(element => slots.item?.({ element })),
])
},
}),
}
})
const DialogCloseButtonStub = defineComponent({
name: 'VDialogCloseBtn',
props: {
modelValue: { type: Boolean, default: true },
},
emits: ['update:modelValue'],
setup(_props, { emit }) {
return () => h('button', { onClick: () => emit('update:modelValue', false), type: 'button' }, '关闭')
},
})
function createSource(name: string, prefix: string): DiscoverSource {
return {
api_path: `discover/${prefix}`,
filter_params: { type: prefix },
filter_ui: [],
mediaid_prefix: prefix,
name,
}
}
async function renderDialog(tabs: DiscoverSource[]) {
const close = vi.fn()
const save = vi.fn()
const updateModelValue = vi.fn()
const result = await renderWithProviders(DiscoverTabOrderDialog, {
props: {
modelValue: true,
onClose: close,
onSave: save,
'onUpdate:modelValue': updateModelValue,
tabs,
},
global: {
components: {
VDialogCloseBtn: DialogCloseButtonStub,
},
},
})
return { ...result, close, save, updateModelValue }
}
describe('DiscoverTabOrderDialog', () => {
it('reorders a shallow local copy without mutating the parent tabs', async () => {
const tabs = [createSource('来源甲', 'source-a'), createSource('来源乙', 'source-b')]
const originalOrder = [...tabs]
const user = userEvent.setup()
const { save } = await renderDialog(tabs)
await user.click(screen.getByRole('button', { name: '反转顺序' }))
await user.click(screen.getByRole('button', { name: '保存' }))
expect(tabs).toEqual(originalOrder)
expect(save).toHaveBeenCalledOnce()
const savedTabs = save.mock.calls[0][0] as DiscoverSource[]
expect(savedTabs.map(item => item.mediaid_prefix)).toEqual(['source-b', 'source-a'])
expect(savedTabs[0]).not.toBe(tabs[1])
expect(savedTabs[0].filter_params).toStrictEqual(tabs[1].filter_params)
})
it('resets the local order when the tabs prop changes', async () => {
const user = userEvent.setup()
const { rerender, save } = await renderDialog([
createSource('来源甲', 'source-a'),
createSource('来源乙', 'source-b'),
])
await user.click(screen.getByRole('button', { name: '反转顺序' }))
await rerender({
modelValue: true,
tabs: [createSource('来源丙', 'source-c'), createSource('来源丁', 'source-d')],
})
await user.click(screen.getByRole('button', { name: '保存' }))
const savedTabs = save.mock.calls[0][0] as DiscoverSource[]
expect(savedTabs.map(item => item.mediaid_prefix)).toEqual(['source-c', 'source-d'])
})
it('emits both model closure and close from the close control', async () => {
const user = userEvent.setup()
const { close, updateModelValue } = await renderDialog([createSource('来源甲', 'source-a')])
await user.click(screen.getByRole('button', { name: '关闭' }))
expect(updateModelValue).toHaveBeenCalledOnce()
expect(updateModelValue).toHaveBeenCalledWith(false)
expect(close).toHaveBeenCalledOnce()
})
it('emits only the current local order when saving', async () => {
const user = userEvent.setup()
const { close, save, updateModelValue } = await renderDialog([createSource('来源甲', 'source-a')])
await user.click(screen.getByRole('button', { name: '保存' }))
expect(save).toHaveBeenCalledOnce()
expect(close).not.toHaveBeenCalled()
expect(updateModelValue).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,101 @@
import BrowsePage from '@/pages/browse.vue'
import { screen } from '@testing-library/vue'
import { renderWithProviders } from '@tests/support/render'
import { defineComponent, h, type PropType } from 'vue'
import { describe, expect, it } from 'vitest'
const MediaCardListViewStub = defineComponent({
name: 'MediaCardListView',
props: {
apipath: String,
params: Object as PropType<Record<string, unknown>>,
},
setup(props) {
return () =>
h('section', { 'aria-label': '媒体 browse 列表', 'data-api-path': props.apipath }, [
h('output', { 'aria-label': '媒体 browse 查询' }, JSON.stringify(props.params)),
])
},
})
const PersonCardListViewStub = defineComponent({
name: 'PersonCardListView',
props: {
apipath: String,
params: Object as PropType<Record<string, unknown>>,
},
setup(props) {
return () =>
h('section', { 'aria-label': '人物 browse 列表', 'data-api-path': props.apipath }, [
h('output', { 'aria-label': '人物 browse 查询' }, JSON.stringify(props.params)),
])
},
})
const PageContentTitleStub = defineComponent({
name: 'VPageContentTitle',
props: { title: String },
setup(props) {
return () => h('h1', props.title)
},
})
async function renderBrowse(paths: string[], query: Record<string, string | string[] | null>) {
return renderWithProviders(BrowsePage, {
initialRoute: { path: '/browse', query },
props: { paths },
global: {
stubs: {
MediaCardListView: MediaCardListViewStub,
PersonCardListView: PersonCardListViewStub,
VPageContentTitle: PageContentTitleStub,
VScrollToTopBtn: true,
},
},
})
}
function projectedQuery(label: string) {
return JSON.parse(screen.getByRole('status', { name: label }).textContent || '{}') as Record<string, unknown>
}
describe('browse page', () => {
it('joins array paths and projects the complete route query to the media list', async () => {
const query = {
genre: ['动作', '科幻'],
page: '7',
sort: 'vote',
title: '相似媒体',
type: 'movie',
}
await renderBrowse(['tmdb', 'recommend', '123', 'movie'], query)
expect(screen.getByRole('heading', { name: '相似媒体' })).toBeInTheDocument()
expect(screen.getByRole('region', { name: '媒体 browse 列表' })).toHaveAttribute(
'data-api-path',
'tmdb/recommend/123/movie',
)
expect(projectedQuery('媒体 browse 查询')).toEqual(query)
expect(screen.queryByRole('region', { name: '人物 browse 列表' })).not.toBeInTheDocument()
})
it('routes person queries to the person list and prefixes the title', async () => {
const query = {
page: '3',
source: 'themoviedb',
title: '张三',
type: 'person',
}
await renderBrowse(['person', 'search'], query)
expect(screen.getByRole('heading', { name: '演员: 张三' })).toBeInTheDocument()
expect(screen.getByRole('region', { name: '人物 browse 列表' })).toHaveAttribute(
'data-api-path',
'person/search',
)
expect(projectedQuery('人物 browse 查询')).toEqual(query)
expect(screen.queryByRole('region', { name: '媒体 browse 列表' })).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,425 @@
import DiscoverPage from '@/pages/discover.vue'
import type { DiscoverSource } from '@/api/types'
import { fireEvent, waitFor } from '@testing-library/vue'
import { renderWithProviders } from '@tests/support/render'
import {
discoverApiUrls,
discoverOrderConfigHandler,
discoverSourcesHandler,
saveDiscoverOrderHandler,
type DiscoverOrderItem,
} from '@tests/support/msw/handlers/discover'
import { server } from '@tests/support/msw/server'
import { HttpResponse, http } from 'msw'
import { defineComponent, h, ref, unref, type ComputedRef, type Ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
interface HeaderTabItem {
tab: string
title: string
}
interface HeaderTabConfig {
appendButtons: Array<{ action: () => void }>
items: ComputedRef<HeaderTabItem[]> | Ref<HeaderTabItem[]> | HeaderTabItem[]
modelValue: Ref<string>
}
interface SharedDialogEvents {
close: () => void
save: (tabs: DiscoverSource[]) => Promise<void>
'update:modelValue': (value: boolean) => void
}
interface SharedDialogController {
close: ReturnType<typeof vi.fn>
id: number
updateProps: ReturnType<typeof vi.fn>
}
const mocks = vi.hoisted(() => ({
controllers: [] as SharedDialogController[],
openSharedDialog: vi.fn(),
registerHeaderTab: vi.fn(),
}))
vi.mock('@/composables/useDynamicHeaderTab', () => ({
useDynamicHeaderTab: () => ({ registerHeaderTab: mocks.registerHeaderTab }),
}))
vi.mock('@/composables/useSharedDialog', () => ({
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
}))
const BuiltInViewStub = defineComponent({
name: 'BuiltInViewStub',
setup: () => () => h('section', '内置发现内容'),
})
const ExtraSourceViewStub = defineComponent({
name: 'ExtraSourceView',
props: {
source: { type: Object, required: true },
},
setup(props) {
return () => h('section', { 'data-testid': 'extra-source' }, (props.source as DiscoverSource).name)
},
})
function createSource(name: string, prefix: string, apiPath = `discover/${prefix}`): DiscoverSource {
return {
api_path: apiPath,
filter_params: { type: prefix },
filter_ui: [],
mediaid_prefix: prefix,
name,
}
}
function keepAliveHarness() {
return defineComponent({
components: { DiscoverPage },
setup() {
const active = ref(true)
return { active }
},
template: `
<button type="button" @click="active = false">停用发现页</button>
<button type="button" @click="active = true">启用发现页</button>
<KeepAlive><DiscoverPage v-if="active" /></KeepAlive>
`,
})
}
async function renderDiscover() {
const componentError = vi.fn()
const result = await renderWithProviders(keepAliveHarness(), {
initialRoute: '/discover',
global: {
config: {
errorHandler: componentError,
},
stubs: {
BangumiView: BuiltInViewStub,
DoubanView: BuiltInViewStub,
ExtraSourceView: ExtraSourceViewStub,
TheMovieDbView: BuiltInViewStub,
VScrollToTopBtn: true,
},
},
})
return { ...result, componentError }
}
function getHeaderConfig() {
const config = mocks.registerHeaderTab.mock.calls[0]?.[0] as HeaderTabConfig | undefined
if (!config) throw new Error('发现页未注册动态标签')
return config
}
function getHeaderItems() {
return unref(getHeaderConfig().items)
}
function getDialogCall(index = 0) {
const call = mocks.openSharedDialog.mock.calls[index]
if (!call) throw new Error(`未找到第 ${index + 1} 个排序弹窗`)
return {
events: call[2] as SharedDialogEvents,
tabs: (call[1] as { tabs: DiscoverSource[] }).tabs,
}
}
async function reactivateDiscover() {
await fireEvent.click(document.querySelector('button') as HTMLButtonElement)
const buttons = Array.from(document.querySelectorAll('button'))
const activate = buttons.find(button => button.textContent === '启用发现页')
if (!activate) throw new Error('未找到启用发现页按钮')
await fireEvent.click(activate)
}
describe('discover page', () => {
beforeEach(() => {
mocks.controllers.length = 0
mocks.openSharedDialog.mockImplementation(() => {
const controller: SharedDialogController = {
close: vi.fn(),
id: mocks.controllers.length + 1,
updateProps: vi.fn(),
}
mocks.controllers.push(controller)
return controller
})
})
it('uses local order, merges sources by prefix, and keeps unconfigured tabs stable', async () => {
const configRequested = vi.fn()
localStorage.setItem(
'MP_DISCOVER_TAB_ORDER',
JSON.stringify([{ name: '豆瓣' }, { name: '自定义来源' }]),
)
server.use(
discoverOrderConfigHandler([], 200, configRequested),
discoverSourcesHandler([
createSource('自定义来源', 'custom'),
createSource('重复自定义来源', 'custom'),
createSource('伪豆瓣', 'douban'),
]),
)
await renderDiscover()
await waitFor(() =>
expect(getHeaderItems().map(item => item.title)).toEqual(['豆瓣', '自定义来源', 'TheMovieDb', 'Bangumi']),
)
expect(configRequested).not.toHaveBeenCalled()
expect(getHeaderItems().map(item => item.tab)).toEqual(['douban', 'custom', 'themoviedb', 'bangumi'])
})
it('loads remote order when local order is absent and backfills localStorage', async () => {
const configRequested = vi.fn()
const remoteOrder = [{ name: 'Bangumi' }, { name: 'TheMovieDb' }]
server.use(
discoverOrderConfigHandler(remoteOrder, 200, configRequested),
discoverSourcesHandler([createSource('自定义来源', 'custom')]),
)
await renderDiscover()
await waitFor(() => expect(configRequested).toHaveBeenCalledOnce())
await waitFor(() =>
expect(getHeaderItems().map(item => item.title)).toEqual(['Bangumi', 'TheMovieDb', '豆瓣', '自定义来源']),
)
expect(localStorage.getItem('MP_DISCOVER_TAB_ORDER')).toBe(JSON.stringify(remoteOrder))
})
it('falls back to remote order when local JSON is malformed', async () => {
const configRequested = vi.fn()
const remoteOrder = [{ name: 'Bangumi' }, { name: 'TheMovieDb' }]
localStorage.setItem('MP_DISCOVER_TAB_ORDER', '{malformed')
server.use(discoverOrderConfigHandler(remoteOrder, 200, configRequested), discoverSourcesHandler([]))
const { componentError } = await renderDiscover()
await waitFor(() => expect(configRequested).toHaveBeenCalledOnce())
await waitFor(() => expect(getHeaderItems().map(item => item.title)).toEqual(['Bangumi', 'TheMovieDb', '豆瓣']))
expect(localStorage.getItem('MP_DISCOVER_TAB_ORDER')).toBe(JSON.stringify(remoteOrder))
expect(componentError).not.toHaveBeenCalled()
})
it('keeps built-in and extra sources usable when the order config request fails', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.spyOn(console, 'log').mockImplementation(() => {})
server.use(
discoverOrderConfigHandler(null, 500),
discoverSourcesHandler([createSource('可用扩展源', 'available')]),
)
await renderDiscover()
await waitFor(() => expect(getHeaderItems().map(item => item.title)).toContain('可用扩展源'))
expect(getHeaderItems().map(item => item.title)).toEqual(['TheMovieDb', '豆瓣', 'Bangumi', '可用扩展源'])
expect(getHeaderConfig().modelValue.value).toBe('themoviedb')
})
it('reuses the source request during initial KeepAlive activation', async () => {
const requested = vi.fn()
let releaseResponse = () => {}
const responseGate = new Promise<void>(resolve => {
releaseResponse = resolve
})
localStorage.setItem('MP_DISCOVER_TAB_ORDER', JSON.stringify([]))
server.use(
discoverSourcesHandler([createSource('延迟来源', 'delayed')], 200, async () => {
requested()
await responseGate
}),
)
await renderDiscover()
await waitFor(() => expect(requested).toHaveBeenCalled())
await new Promise(resolve => window.setTimeout(resolve, 25))
const requestCount = requested.mock.calls.length
releaseResponse()
await waitFor(() => expect(getHeaderItems().map(item => item.title)).toContain('延迟来源'))
expect(requestCount).toBe(1)
})
it('does not refetch sources when the initial response settles before activation', async () => {
const requested = vi.fn()
localStorage.setItem('MP_DISCOVER_TAB_ORDER', JSON.stringify([]))
server.use(discoverSourcesHandler([createSource('快速来源', 'fast')], 200, requested))
await renderDiscover()
await waitFor(() => expect(getHeaderItems().map(item => item.title)).toContain('快速来源'))
await new Promise(resolve => window.setTimeout(resolve, 25))
expect(requested).toHaveBeenCalledOnce()
})
it('removes a withdrawn source and falls back to the first sorted tab after reactivation', async () => {
let sources = [createSource('已撤销来源', 'withdrawn')]
const requested = vi.fn()
localStorage.setItem(
'MP_DISCOVER_TAB_ORDER',
JSON.stringify([{ name: '已撤销来源' }, { name: 'TheMovieDb' }]),
)
server.use(
http.get(discoverApiUrls.sources, () => {
requested()
return HttpResponse.json(sources)
}),
)
await renderDiscover()
await waitFor(() => expect(getHeaderItems().map(item => item.title)).toContain('已撤销来源'))
await waitFor(() => expect(getHeaderConfig().modelValue.value).toBe('withdrawn'))
const requestsBeforeReactivation = requested.mock.calls.length
sources = []
await reactivateDiscover()
await waitFor(() => expect(requested).toHaveBeenCalledTimes(requestsBeforeReactivation + 1))
await waitFor(() => expect(getHeaderItems().map(item => item.title)).not.toContain('已撤销来源'))
await waitFor(() => expect(getHeaderConfig().modelValue.value).toBe('themoviedb'))
})
it('keeps the last successful source snapshot when reactivation fails', async () => {
let status = 200
const requested = vi.fn()
localStorage.setItem('MP_DISCOVER_TAB_ORDER', JSON.stringify([]))
vi.spyOn(console, 'log').mockImplementation(() => {})
server.use(
http.get(discoverApiUrls.sources, () => {
requested()
return HttpResponse.json(status === 200 ? [createSource('缓存来源', 'cached')] : [], { status })
}),
)
await renderDiscover()
await waitFor(() => expect(getHeaderItems().map(item => item.title)).toContain('缓存来源'))
const requestsBeforeReactivation = requested.mock.calls.length
status = 500
await reactivateDiscover()
await waitFor(() => expect(requested).toHaveBeenCalledTimes(requestsBeforeReactivation + 1))
expect(getHeaderItems().map(item => item.title)).toContain('缓存来源')
expect(getHeaderItems().map(item => item.title)).toEqual(['TheMovieDb', '豆瓣', 'Bangumi', '缓存来源'])
})
it('replaces the header metadata when a source with the same prefix changes', async () => {
let sources = [createSource('旧来源名称', 'mutable', 'discover/old')]
const requested = vi.fn()
localStorage.setItem('MP_DISCOVER_TAB_ORDER', JSON.stringify([]))
server.use(
http.get(discoverApiUrls.sources, () => {
requested()
return HttpResponse.json(sources)
}),
)
await renderDiscover()
await waitFor(() => expect(getHeaderItems().map(item => item.title)).toContain('旧来源名称'))
const requestsBeforeReactivation = requested.mock.calls.length
sources = [createSource('新来源名称', 'mutable', 'discover/new')]
await reactivateDiscover()
await waitFor(() => expect(requested).toHaveBeenCalledTimes(requestsBeforeReactivation + 1))
await waitFor(() => expect(getHeaderItems().map(item => item.title)).toContain('新来源名称'))
expect(getHeaderItems().map(item => item.title)).not.toContain('旧来源名称')
})
it('saves the exact visible order through the shared dialog boundary', async () => {
const savedOrders: DiscoverOrderItem[][] = []
localStorage.setItem('MP_DISCOVER_TAB_ORDER', JSON.stringify([]))
server.use(
discoverSourcesHandler([createSource('自定义来源', 'custom')]),
saveDiscoverOrderHandler(order => {
savedOrders.push(order)
}),
)
await renderDiscover()
await waitFor(() => expect(getHeaderItems()).toHaveLength(4))
getHeaderConfig().appendButtons[0].action()
const { events, tabs } = getDialogCall()
const reorderedTabs = [tabs[3], tabs[1], tabs[0], tabs[2]]
await events.save(reorderedTabs)
const expectedOrder = reorderedTabs.map(item => ({ name: item.name }))
expect(savedOrders).toEqual([expectedOrder])
expect(localStorage.getItem('MP_DISCOVER_TAB_ORDER')).toBe(JSON.stringify(expectedOrder))
expect(getHeaderItems().map(item => item.title)).toEqual(reorderedTabs.map(item => item.name))
expect(mocks.controllers[0].close).toHaveBeenCalledOnce()
})
it('closes the previous controller before opening another order dialog', async () => {
localStorage.setItem('MP_DISCOVER_TAB_ORDER', JSON.stringify([]))
server.use(discoverSourcesHandler([createSource('弹窗就绪来源', 'dialog-ready')]))
await renderDiscover()
await waitFor(() => expect(getHeaderItems().map(item => item.title)).toContain('弹窗就绪来源'))
const action = getHeaderConfig().appendButtons[0].action
action()
action()
expect(mocks.openSharedDialog).toHaveBeenCalledTimes(2)
expect(mocks.controllers[0].close).toHaveBeenCalledOnce()
expect(mocks.controllers[1].close).not.toHaveBeenCalled()
})
it('releases only the controller named by close and model update events', async () => {
localStorage.setItem('MP_DISCOVER_TAB_ORDER', JSON.stringify([]))
server.use(discoverSourcesHandler([createSource('弹窗就绪来源', 'dialog-ready')]))
await renderDiscover()
await waitFor(() => expect(getHeaderItems().map(item => item.title)).toContain('弹窗就绪来源'))
const action = getHeaderConfig().appendButtons[0].action
action()
getDialogCall(0).events.close()
action()
expect(mocks.controllers[0].close).not.toHaveBeenCalled()
getDialogCall(1).events['update:modelValue'](true)
action()
expect(mocks.controllers[1].close).toHaveBeenCalledOnce()
getDialogCall(2).events['update:modelValue'](false)
action()
expect(mocks.controllers[2].close).not.toHaveBeenCalled()
})
it('does not let a pending save from an old dialog close a newer controller', async () => {
let releaseSave = () => {}
const saveGate = new Promise<void>(resolve => {
releaseSave = resolve
})
const saveStarted = vi.fn()
localStorage.setItem('MP_DISCOVER_TAB_ORDER', JSON.stringify([]))
server.use(
discoverSourcesHandler([createSource('弹窗就绪来源', 'dialog-ready')]),
saveDiscoverOrderHandler(async () => {
saveStarted()
await saveGate
}),
)
await renderDiscover()
await waitFor(() => expect(getHeaderItems().map(item => item.title)).toContain('弹窗就绪来源'))
const action = getHeaderConfig().appendButtons[0].action
action()
const firstDialog = getDialogCall(0)
const pendingSave = firstDialog.events.save(firstDialog.tabs)
await waitFor(() => expect(saveStarted).toHaveBeenCalledOnce())
firstDialog.events.close()
action()
expect(mocks.controllers).toHaveLength(2)
releaseSave()
await pendingSave
expect(mocks.controllers[1].close).not.toHaveBeenCalled()
})
})

View File

@@ -3,37 +3,33 @@ import MediaCardListView from '@/views/discover/MediaCardListView.vue'
import PersonCardListView from '@/views/discover/PersonCardListView.vue'
import { useI18n } from 'vue-i18n'
// 国际化
const { t } = useI18n()
// 输入参数
const props = defineProps({
// API路径
paths: Array as PropType<string[]> | PropType<string>,
// `/browse/:paths+` 捕获的 API 路径段。
paths: {
type: Array as PropType<string[]>,
required: true,
},
})
// 路由参数
const route = useRoute()
// 标题
let title = route.query?.title?.toString()
// 类型
const type = route.query?.type?.toString()
if (type === 'person') title = t('browse.actor') + ': ' + title
// 计算API路径
function getApiPath(paths: string[] | string) {
if (Array.isArray(paths)) return paths.join('/')
else return paths
function getApiPath(paths: string[]) {
return paths.join('/')
}
</script>
<template>
<div>
<VPageContentTitle :title="title" />
<PersonCardListView v-if="type === 'person'" :apipath="getApiPath(props.paths || '')" :params="route.query" />
<MediaCardListView v-else :apipath="getApiPath(props.paths || '')" :params="route.query" />
<PersonCardListView v-if="type === 'person'" :apipath="getApiPath(props.paths)" :params="route.query" />
<MediaCardListView v-else :apipath="getApiPath(props.paths)" :params="route.query" />
<Teleport to="body">
<VScrollToTopBtn />
</Teleport>

View File

@@ -41,61 +41,82 @@ const discoverTabItems = computed(() => {
const extraDiscoverSources = ref<DiscoverSource[]>([])
let orderDialogController: ReturnType<typeof openSharedDialog> | null = null
let extraSourcesRequest: Promise<void> | null = null
let initialLoadPromise: Promise<void> | null = null
// 打开发现页标签排序共享弹窗。
function openOrderConfigDialog() {
orderDialogController?.close()
orderDialogController = openSharedDialog(
const releaseController = () => {
if (orderDialogController === controller) orderDialogController = null
}
const controller = openSharedDialog(
DiscoverTabOrderDialog,
{
tabs: discoverTabs.value,
},
{
close: () => {
orderDialogController = null
},
save: saveTabOrder,
close: releaseController,
save: tabs => saveTabOrder(tabs, controller),
'update:modelValue': (value: boolean) => {
if (!value) orderDialogController = null
if (!value) releaseController()
},
},
{ closeOn: ['close', 'update:modelValue'] },
)
orderDialogController = controller
}
// 关闭发现页标签排序共享弹窗。
function closeOrderConfigDialog() {
orderDialogController?.close()
function closeOrderConfigDialog(controller = orderDialogController) {
if (!controller || orderDialogController !== controller) return
controller.close()
orderDialogController = null
}
// 初始化发现标签
function initDiscoverTabs() {
// 构造内置发现标签,扩展来源刷新时以此为稳定基线。
function createBuiltInDiscoverTabs() {
const tabs = getDiscoverTabs(t)
for (const tab of tabs) {
discoverTabs.value.push({
return tabs.map(tab => {
return {
name: tab.title,
mediaid_prefix: tab.tab,
api_path: '',
filter_params: {},
filter_ui: [],
})
}
}
})
}
// 加载额外的发现数据源
async function loadExtraDiscoverSources() {
// 用最近一次成功快照替换扩展来源,并保持 mediaid_prefix 唯一。
function replaceExtraDiscoverSources(sources: DiscoverSource[]) {
const builtInTabs = createBuiltInDiscoverTabs()
const seenPrefixes = new Set(builtInTabs.map(tab => tab.mediaid_prefix))
const nextExtraSources = sources.filter(source => {
if (seenPrefixes.has(source.mediaid_prefix)) return false
seenPrefixes.add(source.mediaid_prefix)
return true
})
extraDiscoverSources.value = nextExtraSources
discoverTabs.value = [...builtInTabs, ...nextExtraSources]
}
// 加载额外的发现数据源;并发生命周期钩子共享同一次请求。
function loadExtraDiscoverSources() {
if (extraSourcesRequest) return extraSourcesRequest
extraSourcesRequest = refreshExtraDiscoverSources().finally(() => {
extraSourcesRequest = null
})
return extraSourcesRequest
}
async function refreshExtraDiscoverSources() {
try {
extraDiscoverSources.value = await api.get('discover/source')
if (extraDiscoverSources.value.length === 0) {
return
}
for (const source of extraDiscoverSources.value) {
if (discoverTabs.value.find(tab => tab.mediaid_prefix === source.mediaid_prefix)) {
continue
}
discoverTabs.value.push(source)
}
const sources: DiscoverSource[] = await api.get('discover/source')
replaceExtraDiscoverSources(sources)
} catch (error) {
console.log(error)
}
@@ -121,18 +142,23 @@ async function loadOrderConfig() {
// 顺序配置
const local_order = localStorage.getItem(localOrderKey)
if (local_order) {
orderConfig.value = JSON.parse(local_order)
} else {
const response = await api.get(`/user/config/${localOrderKey}`)
if (response && response.data && response.data.value) {
orderConfig.value = response.data.value
localStorage.setItem(localOrderKey, JSON.stringify(orderConfig.value))
try {
orderConfig.value = JSON.parse(local_order)
return
} catch {
localStorage.removeItem(localOrderKey)
}
}
const response = await api.get(`/user/config/${localOrderKey}`)
if (response && response.data && response.data.value) {
orderConfig.value = response.data.value
localStorage.setItem(localOrderKey, JSON.stringify(orderConfig.value))
}
}
// 保存顺序设置
async function saveTabOrder(tabs = discoverTabs.value) {
async function saveTabOrder(tabs = discoverTabs.value, controller = orderDialogController) {
discoverTabs.value = [...tabs]
// 顺序配置
const orderObj = discoverTabs.value.map(item => ({ name: item.name }))
@@ -146,7 +172,7 @@ async function saveTabOrder(tabs = discoverTabs.value) {
} catch (error) {
console.error(error)
}
closeOrderConfigDialog()
closeOrderConfigDialog(controller)
}
// 使用动态标签页
@@ -168,18 +194,33 @@ registerHeaderTab({
],
})
onBeforeMount(async () => {
initDiscoverTabs()
await loadOrderConfig()
async function initializeDiscover() {
discoverTabs.value = createBuiltInDiscoverTabs()
try {
await loadOrderConfig()
} catch (error) {
console.log(error)
}
await loadExtraDiscoverSources()
sortSubscribeOrder()
// 选中第一个标签页
if (discoverTabs.value.length > 0) {
activeTab.value = discoverTabs.value[0].mediaid_prefix
}
}
onBeforeMount(() => {
initialLoadPromise = initializeDiscover().finally(() => {
initialLoadPromise = null
})
})
onActivated(async () => {
if (initialLoadPromise) {
await initialLoadPromise
return
}
await loadExtraDiscoverSources()
sortSubscribeOrder()
// 如果当前没有选中任何标签页,或者当前选中的标签页不存在,则选中第一个标签页

View File

@@ -2,7 +2,7 @@
import { DiscoverSource } from '@/api/types'
import MediaCardListView from '@/views/discover/MediaCardListView.vue'
import FormRender from '@/components/render/FormRender.vue'
import { cloneDeep } from 'lodash-es'
import { cloneDeep, isEqual } from 'lodash-es'
// 输入参数
const props = defineProps<{
@@ -10,10 +10,10 @@ const props = defineProps<{
}>()
// 默认输入参数
const default_params = cloneDeep(props.source.filter_params)
let defaultParams = cloneDeep(props.source.filter_params)
// 过滤参数
const filterParams = reactive(props.source.filter_params)
const filterParams = reactive(cloneDeep(props.source.filter_params))
// 前一次的过滤参数
let previousParams = cloneDeep(props.source.filter_params)
@@ -21,13 +21,27 @@ let previousParams = cloneDeep(props.source.filter_params)
// 当前Key
const currentKey = ref(0)
// 来源筛选契约变化时重建模型;语义相同的来源快照保留用户当前选择。
watch(
() => props.source.filter_params,
newDefaultParams => {
if (isEqual(newDefaultParams, defaultParams)) return
defaultParams = cloneDeep(newDefaultParams)
for (const key of Object.keys(filterParams)) delete filterParams[key]
Object.assign(filterParams, cloneDeep(newDefaultParams))
previousParams = cloneDeep(newDefaultParams)
},
{ deep: true },
)
// 类型和过滤参数变化后重新刷新列表
watch(filterParams, newParams => {
// 检查每个值
for (const key in newParams) {
// 如果没有值但有默认值时,设置为默认值
if (!newParams[key] && default_params[key]) {
filterParams[key] = default_params[key]
if (!newParams[key] && defaultParams[key]) {
filterParams[key] = defaultParams[key]
}
// 检查依赖关系
const depends = props.source?.depends
@@ -53,7 +67,7 @@ watch(filterParams, newParams => {
<FormRender v-for="(element, index) in source.filter_ui" :key="index" :config="element" :model="filterParams" />
</div>
<div>
<MediaCardListView :key="currentKey" :apipath="source.api_path" :params="filterParams" />
<MediaCardListView :key="`${source.api_path}:${currentKey}`" :apipath="source.api_path" :params="filterParams" />
</div>
</template>

View File

@@ -8,10 +8,12 @@ import { useI18n } from 'vue-i18n'
const { t } = useI18n()
// 输入参数
const props = defineProps({
apipath: String,
params: Object as PropType<{ [key: string]: any }>,
apipath: {
type: String,
required: true,
},
params: Object as PropType<Record<string, unknown>>,
})
// 判断是否有滚动条
@@ -25,6 +27,9 @@ const page = ref(1)
// 是否加载中
const loading = ref(false)
// 首次成功响应前,请求失败只展示错误和重试入口。
const loadFailed = ref(false)
// 是否加载完成
const isRefreshed = ref(false)
@@ -34,14 +39,15 @@ const dataList = shallowRef<MediaInfo[]>([])
// 用于保存已处理过的 key
const seenKeys = new Set<string>()
// 保存本次列表生命周期内已经处理过的原始页签名。
const seenPageSignatures = new Set<string>()
// 拼装参数
function getParams() {
let params = {
return {
...props.params,
page: page.value,
}
if (props.params) params = { ...params, ...props.params }
return params
}
// MediaInfo 去重的字段
@@ -58,9 +64,14 @@ const dedupFields = [
'media_id',
] as const
// 去重、分页终止和渲染必须共用同一媒体身份,避免状态与 DOM key 分叉。
function getMediaIdentity(item: MediaInfo) {
return JSON.stringify(dedupFields.map(field => item[field] ?? null))
}
function deduplicate(items: MediaInfo[]): MediaInfo[] {
return items.filter(item => {
const key = dedupFields.map(field => String(item[field])).join('~')
const key = getMediaIdentity(item)
if (seenKeys.has(key)) {
return false
}
@@ -74,92 +85,87 @@ function appendData(items: MediaInfo[]) {
}
async function loadPageData() {
const rawData: MediaInfo[] = await api.get(props.apipath!, {
const rawData: MediaInfo[] = await api.get(props.apipath, {
params: getParams(),
})
const pageSignature = [...new Set(rawData.map(getMediaIdentity))].sort().join('\n')
const isTerminal = rawData.length === 0 || seenPageSignatures.has(pageSignature)
if (!isTerminal) seenPageSignatures.add(pageSignature)
return {
rawCount: rawData.length,
uniqueData: deduplicate(rawData),
isTerminal,
uniqueData: isTerminal ? [] : deduplicate(rawData),
}
}
// 获取列表数据
async function fetchData({ done }: { done: any }) {
async function fetchData({
done,
}: {
done: (status: 'empty' | 'error' | 'loading' | 'ok') => void
}) {
if (loading.value) {
done('ok')
return
}
loading.value = true
loadFailed.value = false
try {
if (!props.apipath) return
// 如果正在加载中,直接返回
if (loading.value) {
done('ok')
return
}
// 加载到满屏或者加载出错
// 未形成滚动区域时连续填页;已有滚动区域时每次只消费一页。
if (!hasScroll()) {
// 加载多次
while (!hasScroll()) {
// 设置加载中
loading.value = true
// 请求API
const { rawCount, uniqueData } = await loadPageData()
// 取消加载中
loading.value = false
// 标计为已请求完成
const { isTerminal, uniqueData } = await loadPageData()
isRefreshed.value = true
if (rawCount === 0) {
// 如果没有数据,跳出
if (isTerminal) {
done('empty')
return
}
// 合并数据
appendData(uniqueData)
// 页码+1
page.value++
// 返回加载成功
done('ok')
await nextTick()
}
} else {
// 加载一次
// 设置加载中
loading.value = true
// 请求API
const { rawCount, uniqueData } = await loadPageData()
// 标计为已请求完成
const { isTerminal, uniqueData } = await loadPageData()
isRefreshed.value = true
if (rawCount === 0) {
// 如果没有数据,跳出
if (isTerminal) {
done('empty')
return
} else {
// 合并数据
appendData(uniqueData)
// 页码+1
page.value++
// 返回加载成功
done('ok')
}
}
// 取消加载中
loading.value = false
done('ok')
} catch (error) {
console.error(error)
// 返回加载失败
loadFailed.value = true
done('error')
} finally {
loading.value = false
}
}
</script>
<template>
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
<LoadingBanner v-if="!isRefreshed && !loadFailed" class="mt-12" />
<VInfiniteScroll mode="intersect" side="end" :items="dataList" class="overflow-visible pt-3 px-2" @load="fetchData">
<template #loading />
<template #empty />
<template #error="{ props: retryProps }">
<div class="d-flex flex-column align-center ga-2 py-4">
<span class="text-body-2 text-medium-emphasis">{{ t('error.networkError') }}</span>
<VBtn v-bind="retryProps" prepend-icon="mdi-refresh" size="small" variant="tonal">
{{ t('common.retry') }}
</VBtn>
</div>
</template>
<ProgressiveCardGrid
v-if="dataList.length > 0"
:items="dataList"
:item-aspect-ratio="1.5"
:get-item-key="item => item.tmdb_id || item.douban_id || item.bangumi_id || item.media_id || item.title"
:get-item-key="getMediaIdentity"
tabindex="0"
>
<template #default="{ item }">
@@ -170,7 +176,6 @@ async function fetchData({ done }: { done: any }) {
v-if="dataList.length === 0 && isRefreshed"
error-code="404"
:error-title="t('common.noData')"
:error-description="t('error.networkError')"
/>
</VInfiniteScroll>
</template>

View File

@@ -0,0 +1,92 @@
import BangumiView from '@/views/discover/BangumiView.vue'
import { screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { renderWithProviders } from '@tests/support/render'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createMediaListHarness, latestMediaListRequest } from './sourceViewTestUtils'
describe('BangumiView', () => {
let mediaList: ReturnType<typeof createMediaListHarness>
beforeEach(() => {
mediaList = createMediaListHarness()
})
async function renderView() {
return renderWithProviders(BangumiView, {
global: {
stubs: {
MediaCardListView: mediaList.stub,
},
},
})
}
it('starts with number, string, and null values and builds ten years from a fixed clock', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-07-18T12:00:00+08:00'))
await renderView()
expect(latestMediaListRequest(mediaList)).toEqual({
apipath: 'discover/bangumi',
params: {
type: 2,
cat: null,
sort: 'rank',
year: null,
},
})
for (const year of ['2026', '2025', '2024', '2023', '2022', '2021', '2020', '2019', '2018', '2017']) {
expect(screen.getByText(year)).toBeInTheDocument()
}
expect(screen.queryByText('2016')).not.toBeInTheDocument()
})
it('passes object keys as strings while keeping the fixed Bangumi type numeric', async () => {
const user = userEvent.setup()
const currentYear = String(new Date().getFullYear())
await renderView()
await user.click(screen.getByText('Movie'))
await user.click(screen.getByText('日期'))
await user.click(screen.getByText(currentYear))
await waitFor(() => {
expect(latestMediaListRequest(mediaList)).toEqual({
apipath: 'discover/bangumi',
params: {
type: 2,
cat: '3',
sort: 'date',
year: currentYear,
},
})
})
})
it('leaves deselected optional chip values undefined instead of normalizing them', async () => {
const user = userEvent.setup()
const currentYear = String(new Date().getFullYear())
await renderView()
await user.click(screen.getByText('Movie'))
await user.click(screen.getByText('日期'))
await user.click(screen.getByText(currentYear))
await user.click(screen.getByText('Movie'))
await user.click(screen.getByText('日期'))
await user.click(screen.getByText(currentYear))
await waitFor(() => {
expect(latestMediaListRequest(mediaList)).toEqual({
apipath: 'discover/bangumi',
params: {
type: 2,
cat: undefined,
sort: undefined,
year: undefined,
},
})
})
})
})

View File

@@ -0,0 +1,106 @@
import DoubanView from '@/views/discover/DoubanView.vue'
import { screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { renderWithProviders } from '@tests/support/render'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createMediaListHarness, latestMediaListRequest } from './sourceViewTestUtils'
describe('DoubanView', () => {
let mediaList: ReturnType<typeof createMediaListHarness>
beforeEach(() => {
mediaList = createMediaListHarness()
})
async function renderView() {
return renderWithProviders(DoubanView, {
global: {
stubs: {
MediaCardListView: mediaList.stub,
},
},
})
}
it('starts with the frontend sort default and builds recent years from a fixed clock', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-07-18T12:00:00+08:00'))
await renderView()
expect(latestMediaListRequest(mediaList)).toEqual({
apipath: 'discover/douban_movies',
params: {
sort: 'U',
tags: '',
},
})
for (const year of ['2026', '2025', '2024', '2023', '2022', '2021']) {
expect(screen.getByText(year)).toBeInTheDocument()
}
expect(screen.queryByText('2020')).not.toBeInTheDocument()
expect(screen.getByText('2020年代')).toBeInTheDocument()
})
it('keeps tags in genre-zone-year order across selection, replacement, and clearing', async () => {
const user = userEvent.setup()
await renderView()
await user.click(screen.getByText('2020年代'))
await user.click(screen.getByText('日本'))
await user.click(screen.getByText('喜剧'))
await waitFor(() => {
expect(latestMediaListRequest(mediaList)).toEqual({
apipath: 'discover/douban_movies',
params: {
sort: 'U',
tags: '喜剧,日本,2020年代',
},
})
})
await user.click(screen.getByText('动作'))
await user.click(screen.getByText('日本'))
await user.click(screen.getByText('2020年代'))
await waitFor(() => {
expect(latestMediaListRequest(mediaList).params.tags).toBe('动作')
})
await user.click(screen.getByText('动作'))
await waitFor(() => expect(latestMediaListRequest(mediaList).params.tags).toBe(''))
})
it('preserves filters across type changes and restores deselected type and sort defaults', async () => {
const user = userEvent.setup()
await renderView()
await user.click(screen.getByText('高分优先'))
await user.click(screen.getByText('喜剧'))
await user.click(screen.getByText('电视剧'))
await waitFor(() => {
expect(latestMediaListRequest(mediaList)).toEqual({
apipath: 'discover/douban_tvs',
params: {
sort: 'S',
tags: '喜剧',
},
})
})
await user.click(screen.getByText('电视剧'))
await user.click(screen.getByText('高分优先'))
await waitFor(() => {
expect(latestMediaListRequest(mediaList)).toEqual({
apipath: 'discover/douban_movies',
params: {
sort: 'U',
tags: '喜剧',
},
})
})
})
})

View File

@@ -0,0 +1,230 @@
import type { DiscoverSource, RenderProps } from '@/api/types'
import ExtraSourceView from '@/views/discover/ExtraSourceView.vue'
import { screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { renderWithProviders } from '@tests/support/render'
import { defineComponent, h, type PropType, ref } from 'vue'
import { beforeEach, describe, expect, it } from 'vitest'
import { createMediaListHarness, latestMediaListRequest } from './sourceViewTestUtils'
function filterControl(label: string, model: string, value: unknown): RenderProps {
return {
component: 'VBtn',
text: label,
props: {
model,
testValue: value,
},
}
}
function createSource(overrides: Partial<DiscoverSource> = {}): DiscoverSource {
return {
name: '扩展测试源',
mediaid_prefix: 'fixture',
api_path: 'plugin/fixture/discover',
filter_params: {
mtype: 'movie',
genre: null,
keyword: 'featured',
},
filter_ui: [
filterControl('选择动作类型', 'genre', 'action'),
filterControl('切换媒体类型', 'mtype', 'series'),
filterControl('清空媒体类型', 'mtype', null),
],
depends: {
genre: ['mtype'],
},
...overrides,
}
}
const FormRenderStub = defineComponent({
name: 'FormRender',
props: {
config: {
type: Object as PropType<RenderProps>,
required: true,
},
model: {
type: Object as PropType<Record<string, unknown>>,
required: true,
},
},
setup(props) {
return () => {
const field = String(props.config.props?.model)
return h(
'button',
{
onClick: () => {
props.model[field] = props.config.props?.testValue
},
type: 'button',
},
props.config.text,
)
}
},
})
describe('ExtraSourceView', () => {
let mediaList: ReturnType<typeof createMediaListHarness>
beforeEach(() => {
mediaList = createMediaListHarness()
})
async function renderSource(source: DiscoverSource = createSource()) {
return renderWithProviders(ExtraSourceView, {
props: { source },
global: {
stubs: {
FormRender: FormRenderStub,
MediaCardListView: mediaList.stub,
},
},
})
}
function sourceUpdateHarness(initial: DiscoverSource, next: () => DiscoverSource) {
return defineComponent({
setup() {
const source = ref(initial)
return () => [
h(
'button',
{
onClick: () => {
source.value = next()
},
type: 'button',
},
'刷新扩展源契约',
),
h(ExtraSourceView, { source: source.value }),
]
},
})
}
async function renderSourceUpdateHarness(initial: DiscoverSource, next: () => DiscoverSource) {
return renderWithProviders(sourceUpdateHarness(initial, next), {
global: {
stubs: {
FormRender: FormRenderStub,
MediaCardListView: mediaList.stub,
},
},
})
}
it('initializes the form and list from the source contract', async () => {
await renderSource()
expect(latestMediaListRequest(mediaList)).toEqual({
apipath: 'plugin/fixture/discover',
params: {
mtype: 'movie',
genre: null,
keyword: 'featured',
},
})
expect(screen.getByRole('button', { name: '选择动作类型' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '切换媒体类型' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '清空媒体类型' })).toBeInTheDocument()
})
it('restores a truthy root default and clears only fields that declare the changed dependency', async () => {
const user = userEvent.setup()
await renderSource()
await user.click(screen.getByRole('button', { name: '选择动作类型' }))
await waitFor(() => expect(latestMediaListRequest(mediaList).params.genre).toBe('action'))
await user.click(screen.getByRole('button', { name: '切换媒体类型' }))
await waitFor(() => {
expect(latestMediaListRequest(mediaList).params).toEqual({
mtype: 'series',
genre: null,
keyword: 'featured',
})
})
await user.click(screen.getByRole('button', { name: '清空媒体类型' }))
await waitFor(() => {
expect(latestMediaListRequest(mediaList).params).toEqual({
mtype: 'movie',
genre: null,
keyword: 'featured',
})
})
})
it('preserves user filters when a same-prefix source object keeps the same contract', async () => {
const user = userEvent.setup()
await renderSourceUpdateHarness(createSource(), () => createSource())
await user.click(screen.getByRole('button', { name: '切换媒体类型' }))
await waitFor(() => expect(latestMediaListRequest(mediaList).params.mtype).toBe('series'))
const requestsBeforeRefresh = mediaList.requests.length
await user.click(screen.getByRole('button', { name: '刷新扩展源契约' }))
await waitFor(() => {
expect(screen.getByLabelText('媒体列表请求')).toHaveAttribute('data-apipath', 'plugin/fixture/discover')
expect(screen.getByLabelText('媒体列表请求')).toHaveTextContent(
JSON.stringify({ mtype: 'series', genre: null, keyword: 'featured' }),
)
})
expect(mediaList.requests).toHaveLength(requestsBeforeRefresh)
})
it('preserves user filters but restarts the list when only the API path changes', async () => {
const user = userEvent.setup()
await renderSourceUpdateHarness(createSource(), () =>
createSource({ api_path: 'plugin/fixture/v2/discover' }),
)
await user.click(screen.getByRole('button', { name: '切换媒体类型' }))
await waitFor(() => expect(latestMediaListRequest(mediaList).params.mtype).toBe('series'))
const requestsBeforeRefresh = mediaList.requests.length
await user.click(screen.getByRole('button', { name: '刷新扩展源契约' }))
await waitFor(() => {
expect(latestMediaListRequest(mediaList)).toEqual({
apipath: 'plugin/fixture/v2/discover',
params: {
mtype: 'series',
genre: null,
keyword: 'featured',
},
})
})
expect(mediaList.requests).toHaveLength(requestsBeforeRefresh + 1)
})
it('switches to the new model when a same-prefix source changes its filter contract', async () => {
const user = userEvent.setup()
const nextParams = {
category: 'series',
year: null,
}
await renderSourceUpdateHarness(createSource(), () =>
createSource({
api_path: 'plugin/fixture/v2/discover',
filter_params: { ...nextParams },
filter_ui: [filterControl('选择新版分类', 'category', 'movie')],
depends: {},
}),
)
await user.click(screen.getByRole('button', { name: '刷新扩展源契约' }))
await waitFor(() => {
expect(screen.getByRole('button', { name: '选择新版分类' })).toBeInTheDocument()
expect(screen.getByLabelText('媒体列表请求')).toHaveAttribute('data-apipath', 'plugin/fixture/v2/discover')
expect(screen.getByLabelText('媒体列表请求')).toHaveTextContent(JSON.stringify(nextParams))
})
})
})

View File

@@ -0,0 +1,429 @@
import type { MediaInfo } from '@/api/types'
import MediaCardListView from '@/views/discover/MediaCardListView.vue'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createMediaInfo } from '@tests/support/factories/media'
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { HttpResponse, http, type JsonBodyType } from 'msw'
import { defineComponent, h, onMounted, ref, type PropType } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const API_BASE_URL = 'http://localhost/api/v1/'
const LIST_PATH = 'test/discover/list'
const LIST_URL = new URL(LIST_PATH, API_BASE_URL).href
type InfiniteScrollStatus = 'empty' | 'error' | 'ok'
const InfiniteScrollStub = defineComponent({
name: 'VInfiniteScroll',
props: {
items: {
type: Array as PropType<MediaInfo[]>,
default: () => [],
},
},
emits: ['load'],
setup(_props, { emit, slots }) {
const status = ref<'empty' | 'error' | 'idle' | 'loading'>('idle')
function load() {
status.value = 'loading'
emit('load', {
done(nextStatus: InfiniteScrollStatus) {
status.value = nextStatus === 'ok' ? 'idle' : nextStatus
},
})
}
onMounted(load)
return () =>
h('section', { 'aria-label': '媒体无限列表' }, [
h('output', { 'aria-label': '媒体无限列表状态' }, status.value),
status.value === 'error'
? slots.error?.({
side: 'end',
props: { color: undefined, onClick: load },
})
: null,
slots.default?.(),
h(
'button',
{
'aria-label': '触发媒体列表加载',
onClick: load,
type: 'button',
},
'加载更多',
),
])
},
})
const ProgressiveCardGridStub = defineComponent({
name: 'ProgressiveCardGrid',
props: {
getItemKey: {
type: Function as PropType<(item: MediaInfo, index: number) => string | number>,
required: true,
},
items: {
type: Array as PropType<MediaInfo[]>,
required: true,
},
},
setup(props, { slots }) {
return () =>
h('section', { 'aria-label': '媒体渐进网格' }, [
h(
'output',
{ 'aria-label': '媒体渐进网格键' },
JSON.stringify(props.items.map((item, index) => props.getItemKey(item, index))),
),
...props.items.flatMap(item => slots.default?.({ item }) ?? []),
])
},
})
const MediaCardStub = defineComponent({
name: 'MediaCard',
props: {
media: {
type: Object as PropType<MediaInfo>,
required: true,
},
},
setup(props) {
return () => h('article', { 'aria-label': `媒体卡片 ${props.media.title}` }, props.media.title)
},
})
const LoadingBannerStub = defineComponent({
name: 'LoadingBanner',
template: '<div role="status">正在加载媒体列表</div>',
})
const NoDataFoundStub = defineComponent({
name: 'NoDataFound',
props: {
errorDescription: String,
errorTitle: String,
},
template: '<section aria-label="媒体列表空态">{{ errorTitle }} {{ errorDescription }}</section>',
})
interface Deferred {
promise: Promise<void>
resolve: () => void
}
function createDeferred(): Deferred {
let resolve!: () => void
const promise = new Promise<void>(done => {
resolve = done
})
return { promise, resolve }
}
function setScrollHeight(getHeight: () => number) {
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(600)
vi.spyOn(document.body, 'scrollHeight', 'get').mockImplementation(getHeight)
}
async function renderList(
options: {
apipath?: string
params?: Record<string, unknown>
} = {},
) {
return renderWithProviders(MediaCardListView, {
props: {
apipath: Object.prototype.hasOwnProperty.call(options, 'apipath') ? options.apipath : LIST_PATH,
params: options.params,
},
global: {
stubs: {
LoadingBanner: LoadingBannerStub,
MediaCard: MediaCardStub,
NoDataFound: NoDataFoundStub,
ProgressiveCardGrid: ProgressiveCardGridStub,
VInfiniteScroll: InfiniteScrollStub,
},
},
})
}
function gridKeys() {
return JSON.parse(screen.getByRole('status', { name: '媒体渐进网格键' }).textContent || '[]') as Array<
string | number
>
}
describe('MediaCardListView', () => {
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {})
})
it('keeps the component-owned page ahead of a page value in browse query params', async () => {
setScrollHeight(() => 900)
const requests: URL[] = []
server.use(
http.get(LIST_URL, ({ request }) => {
requests.push(new URL(request.url))
return HttpResponse.json([createMediaInfo({ title: '内部页码结果' })])
}),
)
await renderList({ params: { genre: '科幻', page: '99' } })
expect(await screen.findByRole('article', { name: '媒体卡片 内部页码结果' })).toBeInTheDocument()
expect(requests).toHaveLength(1)
expect(requests[0].searchParams.get('page')).toBe('1')
expect(requests[0].searchParams.get('genre')).toBe('科幻')
})
it('loads only one page when the viewport is already scrollable', async () => {
setScrollHeight(() => 900)
const requestedPages: string[] = []
server.use(
http.get(LIST_URL, ({ request }) => {
const page = new URL(request.url).searchParams.get('page') ?? ''
requestedPages.push(page)
return HttpResponse.json([createMediaInfo({ title: '单页媒体' })])
}),
)
await renderList()
expect(await screen.findByRole('article', { name: '媒体卡片 单页媒体' })).toBeInTheDocument()
expect(requestedPages).toEqual(['1'])
})
it('loads consecutive pages until an underfilled viewport becomes scrollable', async () => {
const requestedPages: string[] = []
setScrollHeight(() => (requestedPages.length >= 2 ? 900 : 500))
server.use(
http.get(LIST_URL, ({ request }) => {
const page = new URL(request.url).searchParams.get('page') ?? ''
requestedPages.push(page)
return HttpResponse.json([
createMediaInfo({ title: page === '1' ? '未满屏第一页' : '未满屏第二页' }),
])
}),
)
await renderList()
expect(await screen.findByRole('article', { name: '媒体卡片 未满屏第一页' })).toBeInTheDocument()
expect(await screen.findByRole('article', { name: '媒体卡片 未满屏第二页' })).toBeInTheDocument()
expect(requestedPages).toEqual(['1', '2'])
})
it('deduplicates the complete composite identity and uses it for stable render keys', async () => {
setScrollHeight(() => 900)
const base = createMediaInfo({
bangumi_id: 'bangumi-42',
douban_id: 'douban-42',
imdb_id: 'tt0000042',
media_id: 'media-42',
mediaid_prefix: 'fixture',
season: 1,
source: 'themoviedb',
title: '复合标识基准',
tmdb_id: 42,
tvdb_id: 'tvdb-42',
type: '电视剧',
})
const variants: Array<Partial<MediaInfo>> = [
{ source: 'douban', title: '不同 source' },
{ type: '电影', title: '不同 type' },
{ season: 2, title: '不同 season' },
{ tmdb_id: 43, title: '不同 tmdb_id' },
{ imdb_id: 'tt0000043', title: '不同 imdb_id' },
{ tvdb_id: 'tvdb-43', title: '不同 tvdb_id' },
{ douban_id: 'douban-43', title: '不同 douban_id' },
{ bangumi_id: 'bangumi-43', title: '不同 bangumi_id' },
{ mediaid_prefix: 'fixture-v2', title: '不同 mediaid_prefix' },
{ media_id: 'media-43', title: '不同 media_id' },
]
const response = [
base,
{ ...base, title: '完全重复项' },
...variants.map(variant => ({ ...base, ...variant })),
]
server.use(http.get(LIST_URL, () => HttpResponse.json(response as unknown as JsonBodyType)))
await renderList()
expect(await screen.findByRole('article', { name: '媒体卡片 复合标识基准' })).toBeInTheDocument()
expect(screen.queryByRole('article', { name: '媒体卡片 完全重复项' })).not.toBeInTheDocument()
expect(screen.getAllByRole('article')).toHaveLength(variants.length + 1)
const identityFields = [
'source',
'type',
'season',
'tmdb_id',
'imdb_id',
'tvdb_id',
'douban_id',
'bangumi_id',
'mediaid_prefix',
'media_id',
] as const
const expectedItems = [base, ...variants.map(variant => ({ ...base, ...variant }))]
expect(gridKeys()).toEqual(
expectedItems.map(item => JSON.stringify(identityFields.map(field => item[field] ?? null))),
)
})
it('continues after a first-seen page signature whose items were all seen globally', async () => {
const requestedPages: string[] = []
setScrollHeight(() => (requestedPages.length >= 3 ? 900 : 500))
const first = createMediaInfo({ title: '签名第一页 A', tmdb_id: 101 })
const second = createMediaInfo({ title: '签名第一页 B', tmdb_id: 102 })
const later = createMediaInfo({ title: '签名第三页新媒体', tmdb_id: 103 })
server.use(
http.get(LIST_URL, ({ request }) => {
const page = new URL(request.url).searchParams.get('page') ?? ''
requestedPages.push(page)
if (page === '1') return HttpResponse.json([first, second] as unknown as JsonBodyType)
if (page === '2') return HttpResponse.json([{ ...first, title: '第二页全重复' }] as unknown as JsonBodyType)
return HttpResponse.json([later] as unknown as JsonBodyType)
}),
)
await renderList()
expect(await screen.findByRole('article', { name: '媒体卡片 签名第三页新媒体' })).toBeInTheDocument()
expect(screen.queryByRole('article', { name: '媒体卡片 第二页全重复' })).not.toBeInTheDocument()
expect(requestedPages).toEqual(['1', '2', '3'])
})
it('stops when a raw page repeats any historical identity set regardless of order and duplicate count', async () => {
const requestedPages: string[] = []
setScrollHeight(() => (requestedPages.length >= 3 ? 900 : 500))
const first = createMediaInfo({ title: '历史签名 A', tmdb_id: 201 })
const second = createMediaInfo({ title: '历史签名 B', tmdb_id: 202 })
const between = createMediaInfo({ title: '中间签名 C', tmdb_id: 203 })
server.use(
http.get(LIST_URL, ({ request }) => {
const page = new URL(request.url).searchParams.get('page') ?? ''
requestedPages.push(page)
if (page === '1') {
return HttpResponse.json([first, { ...first, title: '页内重复 A' }, second] as unknown as JsonBodyType)
}
if (page === '2') return HttpResponse.json([between] as unknown as JsonBodyType)
return HttpResponse.json([{ ...second }, { ...first }] as unknown as JsonBodyType)
}),
)
await renderList()
expect(await screen.findByRole('article', { name: '媒体卡片 中间签名 C' })).toBeInTheDocument()
await waitFor(() =>
expect(screen.getByRole('status', { name: '媒体无限列表状态' })).toHaveTextContent('empty'),
)
expect(requestedPages).toEqual(['1', '2', '3'])
expect(screen.getAllByRole('article')).toHaveLength(3)
})
it('marks an empty first page as the pagination terminal state', async () => {
setScrollHeight(() => 900)
server.use(http.get(LIST_URL, () => HttpResponse.json([])))
await renderList()
await waitFor(() =>
expect(screen.getByRole('status', { name: '媒体无限列表状态' })).toHaveTextContent('empty'),
)
expect(screen.queryByText('正在加载媒体列表')).not.toBeInTheDocument()
})
it('renders HTTP 200 plus an empty array as ordinary no-data rather than a network error', async () => {
setScrollHeight(() => 900)
server.use(http.get(LIST_URL, () => HttpResponse.json([])))
await renderList()
const emptyState = await screen.findByRole('region', { name: '媒体列表空态' })
expect(emptyState).toHaveTextContent('暂无数据')
expect(emptyState).not.toHaveTextContent('无法获取到媒体信息,请检查网络连接。')
})
it('retries the same first page after an initial HTTP failure', async () => {
setScrollHeight(() => 900)
const requestedPages: string[] = []
server.use(
http.get(LIST_URL, ({ request }) => {
const page = new URL(request.url).searchParams.get('page') ?? ''
requestedPages.push(page)
if (requestedPages.length === 1) return HttpResponse.json({ detail: 'failed' }, { status: 500 })
return HttpResponse.json([createMediaInfo({ title: '首载重试成功' })])
}),
)
const user = userEvent.setup()
await renderList()
const retryButton = await screen.findByRole('button', { name: '重试' })
expect(screen.queryByText('正在加载媒体列表')).not.toBeInTheDocument()
expect(screen.queryByRole('region', { name: '媒体列表空态' })).not.toBeInTheDocument()
await user.click(retryButton)
expect(await screen.findByRole('article', { name: '媒体卡片 首载重试成功' })).toBeInTheDocument()
expect(requestedPages).toEqual(['1', '1'])
})
it('keeps existing cards and retries the same later page after an HTTP failure', async () => {
setScrollHeight(() => 900)
const requestedPages: string[] = []
let pageTwoAttempts = 0
server.use(
http.get(LIST_URL, ({ request }) => {
const page = new URL(request.url).searchParams.get('page') ?? ''
requestedPages.push(page)
if (page === '1') return HttpResponse.json([createMediaInfo({ title: '保留第一页' })])
pageTwoAttempts += 1
if (pageTwoAttempts === 1) return HttpResponse.json({ detail: 'failed' }, { status: 500 })
return HttpResponse.json([createMediaInfo({ title: '第二页重试成功' })])
}),
)
const user = userEvent.setup()
await renderList()
expect(await screen.findByRole('article', { name: '媒体卡片 保留第一页' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '触发媒体列表加载' }))
await user.click(await screen.findByRole('button', { name: '重试' }))
expect(await screen.findByRole('article', { name: '媒体卡片 第二页重试成功' })).toBeInTheDocument()
expect(screen.getByRole('article', { name: '媒体卡片 保留第一页' })).toBeInTheDocument()
expect(requestedPages).toEqual(['1', '2', '2'])
})
it('deduplicates load events while the current page request is still in flight', async () => {
setScrollHeight(() => 900)
const gate = createDeferred()
const requests: URL[] = []
server.use(
http.get(LIST_URL, async ({ request }) => {
requests.push(new URL(request.url))
await gate.promise
return HttpResponse.json([createMediaInfo({ title: '在途请求结果' })])
}),
)
await renderList()
await waitFor(() => expect(requests).toHaveLength(1))
try {
await fireEvent.click(screen.getByRole('button', { name: '触发媒体列表加载' }))
await fireEvent.click(screen.getByRole('button', { name: '触发媒体列表加载' }))
expect(requests).toHaveLength(1)
} finally {
gate.resolve()
}
expect(await screen.findByRole('article', { name: '媒体卡片 在途请求结果' })).toBeInTheDocument()
expect(requests[0].searchParams.get('page')).toBe('1')
})
})

View File

@@ -0,0 +1,132 @@
import TheMovieDbView from '@/views/discover/TheMovieDbView.vue'
import { screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { renderWithProviders } from '@tests/support/render'
import { beforeEach, describe, expect, it } from 'vitest'
import { createMediaListHarness, latestMediaListRequest } from './sourceViewTestUtils'
const INITIAL_PARAMS = {
sort_by: 'popularity.desc',
with_genres: '',
with_original_language: '',
with_keywords: '',
with_watch_providers: '',
vote_average: 0,
vote_count: 10,
release_date: '',
}
describe('TheMovieDbView', () => {
let mediaList: ReturnType<typeof createMediaListHarness>
beforeEach(() => {
mediaList = createMediaListHarness()
})
async function renderView() {
return renderWithProviders(TheMovieDbView, {
global: {
stubs: {
MediaCardListView: mediaList.stub,
},
},
})
}
it('starts with the exact movie endpoint and filter contract', async () => {
await renderView()
expect(latestMediaListRequest(mediaList)).toEqual({
apipath: 'discover/tmdb_movies',
params: INITIAL_PARAMS,
})
})
it('repairs media-specific sort and genre values in both type directions', async () => {
const user = userEvent.setup()
await renderView()
await user.click(screen.getByText('上映日期降序'))
await user.click(screen.getByText('动作'))
await user.click(screen.getByText('电视剧'))
await waitFor(() => {
expect(latestMediaListRequest(mediaList)).toEqual({
apipath: 'discover/tmdb_tvs',
params: {
...INITIAL_PARAMS,
sort_by: 'popularity.desc',
with_genres: '',
},
})
})
await user.click(screen.getByText('首播日期降序'))
await user.click(screen.getByText('动作冒险'))
await user.click(screen.getByText('电影'))
await waitFor(() => {
expect(latestMediaListRequest(mediaList)).toEqual({
apipath: 'discover/tmdb_movies',
params: {
...INITIAL_PARAMS,
sort_by: 'popularity.desc',
with_genres: '',
},
})
})
})
it('preserves shared filters and the current numeric input types across type changes', async () => {
const user = userEvent.setup()
await renderView()
await user.click(screen.getByText('评分降序'))
await user.click(screen.getByText('动画'))
await user.click(screen.getByText('中文'))
screen.getByRole('slider').focus()
await user.keyboard('{ArrowRight}'.repeat(7))
const voteCount = screen.getByRole('spinbutton')
await user.clear(voteCount)
await user.type(voteCount, '25')
await user.click(screen.getByText('电视剧'))
await waitFor(() => {
expect(latestMediaListRequest(mediaList)).toEqual({
apipath: 'discover/tmdb_tvs',
params: {
...INITIAL_PARAMS,
sort_by: 'vote_average.desc',
with_genres: '16',
with_original_language: 'zh',
vote_average: 7,
vote_count: '25',
},
})
})
})
it('restores mandatory defaults while leaving optional chip filters unset', async () => {
const user = userEvent.setup()
await renderView()
await user.click(screen.getByText('电影'))
await user.click(screen.getByText('评分降序'))
await user.click(screen.getByText('评分降序'))
await user.click(screen.getByText('动画'))
await user.click(screen.getByText('动画'))
await user.click(screen.getByText('中文'))
await user.click(screen.getByText('中文'))
await waitFor(() => {
expect(latestMediaListRequest(mediaList)).toEqual({
apipath: 'discover/tmdb_movies',
params: {
...INITIAL_PARAMS,
with_genres: undefined,
with_original_language: undefined,
},
})
})
})
})

View File

@@ -0,0 +1,48 @@
import { defineComponent, h, type PropType } from 'vue'
export interface MediaListRequest {
apipath: string
params: Record<string, unknown>
}
export function createMediaListHarness() {
const requests: MediaListRequest[] = []
const stub = defineComponent({
name: 'MediaCardListView',
props: {
apipath: {
type: String,
default: '',
},
params: {
type: Object as PropType<Record<string, unknown>>,
default: () => ({}),
},
},
setup(props) {
requests.push({
apipath: props.apipath,
params: { ...props.params },
})
return () =>
h(
'output',
{
'aria-label': '媒体列表请求',
'data-apipath': props.apipath,
},
JSON.stringify(props.params, (_key, value) => (value === undefined ? '__undefined__' : value)),
)
},
})
return { requests, stub }
}
export function latestMediaListRequest(harness: ReturnType<typeof createMediaListHarness>): MediaListRequest {
const request = harness.requests.at(-1)
if (!request) throw new Error('MediaCardListView has not mounted')
return request
}

View File

@@ -0,0 +1,46 @@
import type { DiscoverSource } from '@/api/types'
import { HttpResponse, http, type JsonBodyType } from 'msw'
const API_BASE_URL = 'http://localhost/api/v1/'
export interface DiscoverOrderItem {
name: string
}
export const discoverApiUrls = {
orderConfig: new URL('user/config/MP_DISCOVER_TAB_ORDER', API_BASE_URL).href,
sources: new URL('discover/source', API_BASE_URL).href,
}
export function discoverSourcesHandler(
sources: DiscoverSource[],
status = 200,
onRequest: () => void | Promise<void> = () => {},
) {
return http.get(discoverApiUrls.sources, async () => {
await onRequest()
return HttpResponse.json(sources as unknown as JsonBodyType, { status })
})
}
export function discoverOrderConfigHandler(
order: DiscoverOrderItem[] | null,
status = 200,
onRequest: () => void | Promise<void> = () => {},
) {
return http.get(discoverApiUrls.orderConfig, async () => {
await onRequest()
return HttpResponse.json({ data: { value: order }, success: status < 400 }, { status })
})
}
export function saveDiscoverOrderHandler(
onSave: (order: DiscoverOrderItem[]) => void | Promise<void> = () => {},
status = 200,
) {
return http.post(discoverApiUrls.orderConfig, async ({ request }) => {
const order = (await request.json()) as DiscoverOrderItem[]
await onSave(order)
return HttpResponse.json({ success: status < 400 }, { status })
})
}

View File

@@ -279,6 +279,8 @@ export default defineConfig(({ mode }) => ({
'src/utils/permission.ts',
'src/stores/auth.ts',
'src/pages/recommend.vue',
'src/pages/discover.vue',
'src/pages/browse.vue',
'src/pages/subscribe.vue',
'src/views/dashboard/MediaRecommend.vue',
'src/views/discover/MediaCardSlideView.vue',
@@ -296,6 +298,12 @@ export default defineConfig(({ mode }) => ({
'src/components/dialog/SubscribeSeasonDialog.vue',
'src/components/dialog/SubscribeShareDialog.vue',
'src/components/dialog/SubscribeShareStatisticsDialog.vue',
'src/components/dialog/DiscoverTabOrderDialog.vue',
'src/views/discover/TheMovieDbView.vue',
'src/views/discover/DoubanView.vue',
'src/views/discover/BangumiView.vue',
'src/views/discover/ExtraSourceView.vue',
'src/views/discover/MediaCardListView.vue',
],
provider: 'v8',
reporter: ['text', 'json-summary', 'html'],
@@ -407,6 +415,54 @@ export default defineConfig(({ mode }) => ({
lines: 80,
statements: 80,
},
'src/pages/discover.vue': {
branches: 85,
functions: 90,
lines: 90,
statements: 90,
},
'src/views/discover/MediaCardListView.vue': {
branches: 85,
functions: 90,
lines: 90,
statements: 90,
},
'src/pages/browse.vue': {
branches: 75,
functions: 80,
lines: 80,
statements: 80,
},
'src/components/dialog/DiscoverTabOrderDialog.vue': {
branches: 75,
functions: 80,
lines: 80,
statements: 80,
},
'src/views/discover/TheMovieDbView.vue': {
branches: 75,
functions: 80,
lines: 80,
statements: 80,
},
'src/views/discover/DoubanView.vue': {
branches: 75,
functions: 80,
lines: 80,
statements: 80,
},
'src/views/discover/BangumiView.vue': {
branches: 75,
functions: 80,
lines: 80,
statements: 80,
},
'src/views/discover/ExtraSourceView.vue': {
branches: 75,
functions: 80,
lines: 80,
statements: 80,
},
'src/views/subscribe/FullCalendarView.vue': {
branches: 85,
functions: 90,