mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-12 00:54:42 +08:00
test(discover): cover source and media list flows (#556)
This commit is contained in:
@@ -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
|
||||
|
||||
137
src/components/dialog/__tests__/DiscoverTabOrderDialog.spec.ts
Normal file
137
src/components/dialog/__tests__/DiscoverTabOrderDialog.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
101
src/pages/__tests__/browse.spec.ts
Normal file
101
src/pages/__tests__/browse.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
425
src/pages/__tests__/discover.spec.ts
Normal file
425
src/pages/__tests__/discover.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
// 如果当前没有选中任何标签页,或者当前选中的标签页不存在,则选中第一个标签页
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
92
src/views/discover/__tests__/BangumiView.spec.ts
Normal file
92
src/views/discover/__tests__/BangumiView.spec.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
106
src/views/discover/__tests__/DoubanView.spec.ts
Normal file
106
src/views/discover/__tests__/DoubanView.spec.ts
Normal 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: '喜剧',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
230
src/views/discover/__tests__/ExtraSourceView.spec.ts
Normal file
230
src/views/discover/__tests__/ExtraSourceView.spec.ts
Normal 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))
|
||||
})
|
||||
})
|
||||
})
|
||||
429
src/views/discover/__tests__/MediaCardListView.spec.ts
Normal file
429
src/views/discover/__tests__/MediaCardListView.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
132
src/views/discover/__tests__/TheMovieDbView.spec.ts
Normal file
132
src/views/discover/__tests__/TheMovieDbView.spec.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
48
src/views/discover/__tests__/sourceViewTestUtils.ts
Normal file
48
src/views/discover/__tests__/sourceViewTestUtils.ts
Normal 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
|
||||
}
|
||||
46
tests/support/msw/handlers/discover.ts
Normal file
46
tests/support/msw/handlers/discover.ts
Normal 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 })
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user