mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-07 00:36:41 +08:00
perf(list): prefetch infinite scroll pages (#622)
This commit is contained in:
@@ -147,7 +147,14 @@ async function fetchData({ done }: { done: (status: 'empty' | 'error' | 'loading
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<LoadingBanner v-if="!isRefreshed && !loadFailed" 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">
|
<VInfiniteScroll
|
||||||
|
mode="intersect"
|
||||||
|
side="end"
|
||||||
|
:items="dataList"
|
||||||
|
:margin="dataList.length > 0 ? 600 : 0"
|
||||||
|
class="overflow-visible pt-3 px-2"
|
||||||
|
@load="fetchData"
|
||||||
|
>
|
||||||
<template #loading />
|
<template #loading />
|
||||||
<template #empty />
|
<template #empty />
|
||||||
<template #error="{ props: retryProps }">
|
<template #error="{ props: retryProps }">
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ const loading = ref(false)
|
|||||||
// 是否加载完成
|
// 是否加载完成
|
||||||
const isRefreshed = ref(false)
|
const isRefreshed = ref(false)
|
||||||
|
|
||||||
|
// 首次成功响应前,请求失败只展示错误和重试入口。
|
||||||
|
const loadFailed = ref(false)
|
||||||
|
|
||||||
// 使用 shallowRef 避免长列表中的深层代理开销
|
// 使用 shallowRef 避免长列表中的深层代理开销
|
||||||
const dataList = shallowRef<Person[]>([])
|
const dataList = shallowRef<Person[]>([])
|
||||||
|
|
||||||
@@ -56,6 +59,7 @@ function getParams() {
|
|||||||
async function fetchData({ done }: { done: any }) {
|
async function fetchData({ done }: { done: any }) {
|
||||||
try {
|
try {
|
||||||
if (!props.apipath) return
|
if (!props.apipath) return
|
||||||
|
loadFailed.value = false
|
||||||
|
|
||||||
// 如果正在加载中,直接返回
|
// 如果正在加载中,直接返回
|
||||||
if (loading.value) {
|
if (loading.value) {
|
||||||
@@ -71,8 +75,6 @@ async function fetchData({ done }: { done: any }) {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
// 请求API
|
// 请求API
|
||||||
const currentData = await loadPageData()
|
const currentData = await loadPageData()
|
||||||
// 取消加载中
|
|
||||||
loading.value = false
|
|
||||||
// 标计为已请求完成
|
// 标计为已请求完成
|
||||||
isRefreshed.value = true
|
isRefreshed.value = true
|
||||||
if (currentData.length === 0) {
|
if (currentData.length === 0) {
|
||||||
@@ -108,22 +110,38 @@ async function fetchData({ done }: { done: any }) {
|
|||||||
// 返回加载成功
|
// 返回加载成功
|
||||||
done('ok')
|
done('ok')
|
||||||
}
|
}
|
||||||
// 取消加载中
|
|
||||||
loading.value = false
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
loadFailed.value = true
|
||||||
// 返回加载失败
|
// 返回加载失败
|
||||||
done('error')
|
done('error')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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 px-3" @load="fetchData">
|
<VInfiniteScroll
|
||||||
|
mode="intersect"
|
||||||
|
side="end"
|
||||||
|
:items="dataList"
|
||||||
|
:margin="dataList.length > 0 ? 600 : 0"
|
||||||
|
class="overflow-visible px-3"
|
||||||
|
@load="fetchData"
|
||||||
|
>
|
||||||
<template #loading />
|
<template #loading />
|
||||||
<template #empty />
|
<template #empty />
|
||||||
|
<template #error="{ props: retryProps }">
|
||||||
|
<div class="d-flex flex-column align-center ga-2 py-4" role="alert">
|
||||||
|
<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
|
<ProgressiveCardGrid
|
||||||
v-if="dataList.length > 0"
|
v-if="dataList.length > 0"
|
||||||
:items="dataList"
|
:items="dataList"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const LIST_PATH = 'test/discover/list'
|
|||||||
const LIST_URL = new URL(LIST_PATH, API_BASE_URL).href
|
const LIST_URL = new URL(LIST_PATH, API_BASE_URL).href
|
||||||
|
|
||||||
type InfiniteScrollStatus = 'empty' | 'error' | 'ok'
|
type InfiniteScrollStatus = 'empty' | 'error' | 'ok'
|
||||||
|
const initialLoadMargins: number[] = []
|
||||||
|
|
||||||
const InfiniteScrollStub = defineComponent({
|
const InfiniteScrollStub = defineComponent({
|
||||||
name: 'VInfiniteScroll',
|
name: 'VInfiniteScroll',
|
||||||
@@ -22,12 +23,17 @@ const InfiniteScrollStub = defineComponent({
|
|||||||
type: Array as PropType<MediaInfo[]>,
|
type: Array as PropType<MediaInfo[]>,
|
||||||
default: () => [],
|
default: () => [],
|
||||||
},
|
},
|
||||||
|
margin: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
emits: ['load'],
|
emits: ['load'],
|
||||||
setup(_props, { emit, slots }) {
|
setup(props, { emit, slots }) {
|
||||||
const status = ref<'empty' | 'error' | 'idle' | 'loading'>('idle')
|
const status = ref<'empty' | 'error' | 'idle' | 'loading'>('idle')
|
||||||
|
|
||||||
function load() {
|
function load() {
|
||||||
|
initialLoadMargins.push(props.margin)
|
||||||
status.value = 'loading'
|
status.value = 'loading'
|
||||||
emit('load', {
|
emit('load', {
|
||||||
done(nextStatus: InfiniteScrollStatus) {
|
done(nextStatus: InfiniteScrollStatus) {
|
||||||
@@ -39,7 +45,7 @@ const InfiniteScrollStub = defineComponent({
|
|||||||
onMounted(load)
|
onMounted(load)
|
||||||
|
|
||||||
return () =>
|
return () =>
|
||||||
h('section', { 'aria-label': '媒体无限列表' }, [
|
h('section', { 'aria-label': '媒体无限列表', 'data-margin': String(props.margin) }, [
|
||||||
h('output', { 'aria-label': '媒体无限列表状态' }, status.value),
|
h('output', { 'aria-label': '媒体无限列表状态' }, status.value),
|
||||||
status.value === 'error'
|
status.value === 'error'
|
||||||
? slots.error?.({
|
? slots.error?.({
|
||||||
@@ -162,6 +168,7 @@ function gridKeys() {
|
|||||||
|
|
||||||
describe('MediaCardListView', () => {
|
describe('MediaCardListView', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
initialLoadMargins.length = 0
|
||||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -178,6 +185,8 @@ describe('MediaCardListView', () => {
|
|||||||
await renderList({ params: { genre: '科幻', page: '99' } })
|
await renderList({ params: { genre: '科幻', page: '99' } })
|
||||||
|
|
||||||
expect(await screen.findByRole('article', { name: '媒体卡片 内部页码结果' })).toBeInTheDocument()
|
expect(await screen.findByRole('article', { name: '媒体卡片 内部页码结果' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('媒体无限列表')).toHaveAttribute('data-margin', '600')
|
||||||
|
expect(initialLoadMargins[0]).toBe(0)
|
||||||
expect(requests).toHaveLength(1)
|
expect(requests).toHaveLength(1)
|
||||||
expect(requests[0].searchParams.get('page')).toBe('1')
|
expect(requests[0].searchParams.get('page')).toBe('1')
|
||||||
expect(requests[0].searchParams.get('genre')).toBe('科幻')
|
expect(requests[0].searchParams.get('genre')).toBe('科幻')
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import type { Person } from '@/api/types'
|
||||||
|
import PersonCardListView from '@/views/discover/PersonCardListView.vue'
|
||||||
|
import { screen } from '@testing-library/vue'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { server } from '@tests/support/msw/server'
|
||||||
|
import { renderWithProviders } from '@tests/support/render'
|
||||||
|
import { HttpResponse, http } from 'msw'
|
||||||
|
import { defineComponent, h, onMounted, ref, type PropType } from 'vue'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const LIST_PATH = 'test/discover/people'
|
||||||
|
const LIST_URL = new URL(LIST_PATH, 'http://localhost/api/v1/').href
|
||||||
|
|
||||||
|
type InfiniteScrollStatus = 'empty' | 'error' | 'ok'
|
||||||
|
const initialLoadMargins: number[] = []
|
||||||
|
|
||||||
|
const InfiniteScrollStub = defineComponent({
|
||||||
|
name: 'VInfiniteScroll',
|
||||||
|
props: {
|
||||||
|
margin: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
emits: ['load'],
|
||||||
|
setup(props, { emit, slots }) {
|
||||||
|
const status = ref<'empty' | 'error' | 'idle' | 'loading'>('idle')
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
initialLoadMargins.push(props.margin)
|
||||||
|
status.value = 'loading'
|
||||||
|
emit('load', {
|
||||||
|
done(nextStatus: InfiniteScrollStatus) {
|
||||||
|
status.value = nextStatus === 'ok' ? 'idle' : nextStatus
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
|
||||||
|
return () =>
|
||||||
|
h('section', { 'aria-label': '人物无限列表', 'data-margin': String(props.margin) }, [
|
||||||
|
h('output', { 'aria-label': '人物无限列表状态' }, status.value),
|
||||||
|
status.value === 'error'
|
||||||
|
? slots.error?.({
|
||||||
|
side: 'end',
|
||||||
|
props: { color: undefined, onClick: load },
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
slots.default?.(),
|
||||||
|
])
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const ProgressiveCardGridStub = defineComponent({
|
||||||
|
name: 'ProgressiveCardGrid',
|
||||||
|
props: {
|
||||||
|
items: {
|
||||||
|
type: Array as PropType<Person[]>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup(props, { slots }) {
|
||||||
|
return () =>
|
||||||
|
h(
|
||||||
|
'section',
|
||||||
|
props.items.flatMap(item => slots.default?.({ item }) ?? []),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const PersonCardStub = defineComponent({
|
||||||
|
name: 'PersonCard',
|
||||||
|
props: {
|
||||||
|
person: {
|
||||||
|
type: Object as PropType<Person>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
return () => h('article', props.person.name)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const LoadingBannerStub = defineComponent({
|
||||||
|
name: 'LoadingBanner',
|
||||||
|
template: '<div role="status">正在加载人物列表</div>',
|
||||||
|
})
|
||||||
|
|
||||||
|
async function renderList() {
|
||||||
|
return renderWithProviders(PersonCardListView, {
|
||||||
|
props: { apipath: LIST_PATH },
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
LoadingBanner: LoadingBannerStub,
|
||||||
|
NoDataFound: true,
|
||||||
|
PersonCard: PersonCardStub,
|
||||||
|
ProgressiveCardGrid: ProgressiveCardGridStub,
|
||||||
|
VInfiniteScroll: InfiniteScrollStub,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PersonCardListView', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
initialLoadMargins.length = 0
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(600)
|
||||||
|
vi.spyOn(document.body, 'scrollHeight', 'get').mockReturnValue(900)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads people with the configured prefetch margin', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(LIST_URL, () =>
|
||||||
|
HttpResponse.json([{ id: 101, name: '探索人物', source: 'themoviedb' } satisfies Person]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
await renderList()
|
||||||
|
|
||||||
|
expect(await screen.findByText('探索人物')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('人物无限列表')).toHaveAttribute('data-margin', '600')
|
||||||
|
expect(initialLoadMargins[0]).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows an inline retry and retries the same page after a request failure', async () => {
|
||||||
|
let requests = 0
|
||||||
|
server.use(
|
||||||
|
http.get(LIST_URL, () => {
|
||||||
|
requests++
|
||||||
|
if (requests === 1) return HttpResponse.json({ detail: 'failed' }, { status: 500 })
|
||||||
|
return HttpResponse.json([{ id: 202, name: '人物重试结果', source: 'themoviedb' } satisfies Person])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
await renderList()
|
||||||
|
const retry = await screen.findByRole('button', { name: '重试' })
|
||||||
|
expect(screen.queryByText('正在加载人物列表')).not.toBeInTheDocument()
|
||||||
|
await user.click(retry)
|
||||||
|
|
||||||
|
expect(await screen.findByText('人物重试结果')).toBeInTheDocument()
|
||||||
|
expect(requests).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1706,6 +1706,7 @@ onUnmounted(() => {
|
|||||||
mode="intersect"
|
mode="intersect"
|
||||||
side="end"
|
side="end"
|
||||||
:items="mobileDataList"
|
:items="mobileDataList"
|
||||||
|
:margin="mobileDataList.length > 0 ? 280 : 0"
|
||||||
class="transfer-history-mobile-scroll"
|
class="transfer-history-mobile-scroll"
|
||||||
@load="loadMobileHistory"
|
@load="loadMobileHistory"
|
||||||
>
|
>
|
||||||
@@ -1715,6 +1716,14 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #empty />
|
<template #empty />
|
||||||
|
<template #error="{ props: retryProps }">
|
||||||
|
<div class="transfer-history-mobile-state d-flex flex-column ga-2" role="alert">
|
||||||
|
<span class="text-body-2 text-medium-emphasis">{{ t('common.serverConnectionFailed') }}</span>
|
||||||
|
<VBtn v-bind="retryProps" prepend-icon="mdi-refresh" size="small" variant="tonal">
|
||||||
|
{{ t('common.retry') }}
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<ProgressiveCardGrid
|
<ProgressiveCardGrid
|
||||||
v-if="mobileDataList.length > 0"
|
v-if="mobileDataList.length > 0"
|
||||||
|
|||||||
@@ -129,21 +129,36 @@ type InfiniteStatus = 'empty' | 'error' | 'ok'
|
|||||||
|
|
||||||
const InfiniteScrollStub = defineComponent({
|
const InfiniteScrollStub = defineComponent({
|
||||||
name: 'VInfiniteScroll',
|
name: 'VInfiniteScroll',
|
||||||
|
props: {
|
||||||
|
margin: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
emits: ['load'],
|
emits: ['load'],
|
||||||
setup(_props, { emit, slots }) {
|
setup(props, { emit, slots }) {
|
||||||
const status = ref('idle')
|
const status = ref('idle')
|
||||||
function load() {
|
function load() {
|
||||||
|
status.value = 'loading'
|
||||||
emit('load', {
|
emit('load', {
|
||||||
done(nextStatus: InfiniteStatus) {
|
done(nextStatus: InfiniteStatus) {
|
||||||
status.value = nextStatus
|
status.value = nextStatus === 'ok' ? 'idle' : nextStatus
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return () =>
|
return () =>
|
||||||
h('section', { 'aria-label': '整理历史无限列表' }, [
|
h('section', { 'aria-label': '整理历史无限列表', 'data-margin': String(props.margin) }, [
|
||||||
h('output', { 'aria-label': '整理历史无限列表状态' }, status.value),
|
h('output', { 'aria-label': '整理历史无限列表状态' }, status.value),
|
||||||
|
status.value === 'loading' ? slots.loading?.({}) : null,
|
||||||
|
status.value === 'error'
|
||||||
|
? slots.error?.({
|
||||||
|
side: 'end',
|
||||||
|
props: { color: undefined, onClick: load },
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
status.value === 'empty' ? slots.empty?.({}) : null,
|
||||||
slots.default?.(),
|
slots.default?.(),
|
||||||
h('button', { onClick: load, type: 'button' }, '加载下一页'),
|
status.value === 'idle' ? h('button', { onClick: load, type: 'button' }, '加载下一页') : null,
|
||||||
])
|
])
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -445,8 +460,10 @@ describe('TransferHistoryView', () => {
|
|||||||
|
|
||||||
await renderHistory('/history?search=%E7%A7%BB%E5%8A%A8')
|
await renderHistory('/history?search=%E7%A7%BB%E5%8A%A8')
|
||||||
|
|
||||||
|
expect(screen.getByLabelText('整理历史无限列表')).toHaveAttribute('data-margin', '0')
|
||||||
await fireEvent.click(screen.getByRole('button', { name: '加载下一页' }))
|
await fireEvent.click(screen.getByRole('button', { name: '加载下一页' }))
|
||||||
expect(await screen.findByText('记录 25')).toBeInTheDocument()
|
expect(await screen.findByText('记录 25')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('整理历史无限列表')).toHaveAttribute('data-margin', '280')
|
||||||
await fireEvent.click(screen.getByRole('button', { name: '加载下一页' }))
|
await fireEvent.click(screen.getByRole('button', { name: '加载下一页' }))
|
||||||
expect(await screen.findByText('追加 4')).toBeInTheDocument()
|
expect(await screen.findByText('追加 4')).toBeInTheDocument()
|
||||||
expect(document.querySelectorAll('[data-mobile-key="25"]')).toHaveLength(1)
|
expect(document.querySelectorAll('[data-mobile-key="25"]')).toHaveLength(1)
|
||||||
@@ -467,7 +484,7 @@ describe('TransferHistoryView', () => {
|
|||||||
|
|
||||||
await fireEvent.click(screen.getByRole('button', { name: '加载下一页' }))
|
await fireEvent.click(screen.getByRole('button', { name: '加载下一页' }))
|
||||||
await waitFor(() => expect(screen.getByRole('status', { name: '整理历史无限列表状态' })).toHaveTextContent('error'))
|
await waitFor(() => expect(screen.getByRole('status', { name: '整理历史无限列表状态' })).toHaveTextContent('error'))
|
||||||
await fireEvent.click(screen.getByRole('button', { name: '加载下一页' }))
|
await fireEvent.click(screen.getByRole('button', { name: '重试' }))
|
||||||
expect(await screen.findByText('重试结果')).toBeInTheDocument()
|
expect(await screen.findByText('重试结果')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -309,6 +309,7 @@ async function fetchData({ done }: { done: (status: 'empty' | 'error' | 'ok') =>
|
|||||||
mode="intersect"
|
mode="intersect"
|
||||||
side="end"
|
side="end"
|
||||||
:items="dataList"
|
:items="dataList"
|
||||||
|
:margin="dataList.length > 0 ? 480 : 0"
|
||||||
class="overflow-visible px-2"
|
class="overflow-visible px-2"
|
||||||
@load="fetchData"
|
@load="fetchData"
|
||||||
:key="currentKey"
|
:key="currentKey"
|
||||||
|
|||||||
@@ -269,6 +269,7 @@ function removeData(id: number) {
|
|||||||
mode="intersect"
|
mode="intersect"
|
||||||
side="end"
|
side="end"
|
||||||
:items="dataList"
|
:items="dataList"
|
||||||
|
:margin="dataList.length > 0 ? 480 : 0"
|
||||||
class="overflow-visible px-2"
|
class="overflow-visible px-2"
|
||||||
@load="fetchData"
|
@load="fetchData"
|
||||||
:key="currentKey"
|
:key="currentKey"
|
||||||
|
|||||||
@@ -12,14 +12,22 @@ import { defineComponent, h, onMounted, ref, type PropType } from 'vue'
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
type InfiniteScrollStatus = 'empty' | 'error' | 'ok'
|
type InfiniteScrollStatus = 'empty' | 'error' | 'ok'
|
||||||
|
const initialLoadMargins: number[] = []
|
||||||
|
|
||||||
const InfiniteScrollStub = defineComponent({
|
const InfiniteScrollStub = defineComponent({
|
||||||
name: 'VInfiniteScroll',
|
name: 'VInfiniteScroll',
|
||||||
|
props: {
|
||||||
|
margin: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
emits: ['load'],
|
emits: ['load'],
|
||||||
setup(_props, { emit, slots }) {
|
setup(props, { emit, slots }) {
|
||||||
const status = ref<'empty' | 'error' | 'idle' | 'loading'>('idle')
|
const status = ref<'empty' | 'error' | 'idle' | 'loading'>('idle')
|
||||||
|
|
||||||
function load() {
|
function load() {
|
||||||
|
initialLoadMargins.push(props.margin)
|
||||||
status.value = 'loading'
|
status.value = 'loading'
|
||||||
emit('load', {
|
emit('load', {
|
||||||
done(nextStatus: InfiniteScrollStatus) {
|
done(nextStatus: InfiniteScrollStatus) {
|
||||||
@@ -31,7 +39,7 @@ const InfiniteScrollStub = defineComponent({
|
|||||||
onMounted(load)
|
onMounted(load)
|
||||||
|
|
||||||
return () =>
|
return () =>
|
||||||
h('section', { 'aria-label': '热门订阅无限列表' }, [
|
h('section', { 'aria-label': '热门订阅无限列表', 'data-margin': String(props.margin) }, [
|
||||||
h('output', { 'aria-label': '热门订阅无限列表状态' }, status.value),
|
h('output', { 'aria-label': '热门订阅无限列表状态' }, status.value),
|
||||||
status.value === 'loading' ? slots.loading?.({}) : null,
|
status.value === 'loading' ? slots.loading?.({}) : null,
|
||||||
status.value === 'error'
|
status.value === 'error'
|
||||||
@@ -142,6 +150,7 @@ async function renderPopular(type: '电影' | '电视剧' = '电影') {
|
|||||||
|
|
||||||
describe('SubscribePopularView', () => {
|
describe('SubscribePopularView', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
initialLoadMargins.length = 0
|
||||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
setHasScroll(true)
|
setHasScroll(true)
|
||||||
})
|
})
|
||||||
@@ -160,6 +169,8 @@ describe('SubscribePopularView', () => {
|
|||||||
await renderPopular(type)
|
await renderPopular(type)
|
||||||
|
|
||||||
expect(await screen.findByText(type === '电影' ? '默认热门电影' : '默认热门剧集')).toBeInTheDocument()
|
expect(await screen.findByText(type === '电影' ? '默认热门电影' : '默认热门剧集')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('热门订阅无限列表')).toHaveAttribute('data-margin', '480')
|
||||||
|
expect(initialLoadMargins[0]).toBe(0)
|
||||||
expect(screen.getByText(type === '电影' ? '18' : '27')).toBeInTheDocument()
|
expect(screen.getByText(type === '电影' ? '18' : '27')).toBeInTheDocument()
|
||||||
expect(requests).toHaveLength(1)
|
expect(requests).toHaveLength(1)
|
||||||
expect(requests[0].searchParams.get('stype')).toBe(type)
|
expect(requests[0].searchParams.get('stype')).toBe(type)
|
||||||
|
|||||||
@@ -12,14 +12,22 @@ import { defineComponent, h, onMounted, ref, type PropType } from 'vue'
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
type InfiniteScrollStatus = 'empty' | 'error' | 'ok'
|
type InfiniteScrollStatus = 'empty' | 'error' | 'ok'
|
||||||
|
const initialLoadMargins: number[] = []
|
||||||
|
|
||||||
const InfiniteScrollStub = defineComponent({
|
const InfiniteScrollStub = defineComponent({
|
||||||
name: 'VInfiniteScroll',
|
name: 'VInfiniteScroll',
|
||||||
|
props: {
|
||||||
|
margin: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
emits: ['load'],
|
emits: ['load'],
|
||||||
setup(_props, { emit, slots }) {
|
setup(props, { emit, slots }) {
|
||||||
const status = ref<'empty' | 'error' | 'idle' | 'loading'>('idle')
|
const status = ref<'empty' | 'error' | 'idle' | 'loading'>('idle')
|
||||||
|
|
||||||
function load() {
|
function load() {
|
||||||
|
initialLoadMargins.push(props.margin)
|
||||||
status.value = 'loading'
|
status.value = 'loading'
|
||||||
emit('load', {
|
emit('load', {
|
||||||
done(nextStatus: InfiniteScrollStatus) {
|
done(nextStatus: InfiniteScrollStatus) {
|
||||||
@@ -31,7 +39,7 @@ const InfiniteScrollStub = defineComponent({
|
|||||||
onMounted(load)
|
onMounted(load)
|
||||||
|
|
||||||
return () =>
|
return () =>
|
||||||
h('section', { 'aria-label': '订阅分享无限列表' }, [
|
h('section', { 'aria-label': '订阅分享无限列表', 'data-margin': String(props.margin) }, [
|
||||||
h('output', { 'aria-label': '订阅分享无限列表状态' }, status.value),
|
h('output', { 'aria-label': '订阅分享无限列表状态' }, status.value),
|
||||||
status.value === 'loading' ? slots.loading?.({}) : null,
|
status.value === 'loading' ? slots.loading?.({}) : null,
|
||||||
status.value === 'error'
|
status.value === 'error'
|
||||||
@@ -162,6 +170,7 @@ async function renderShare(keyword = '') {
|
|||||||
|
|
||||||
describe('SubscribeShareView', () => {
|
describe('SubscribeShareView', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
initialLoadMargins.length = 0
|
||||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
setHasScroll(true)
|
setHasScroll(true)
|
||||||
})
|
})
|
||||||
@@ -178,6 +187,8 @@ describe('SubscribeShareView', () => {
|
|||||||
await renderShare()
|
await renderShare()
|
||||||
|
|
||||||
expect(await screen.findByText('默认分享卡片')).toBeInTheDocument()
|
expect(await screen.findByText('默认分享卡片')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('订阅分享无限列表')).toHaveAttribute('data-margin', '480')
|
||||||
|
expect(initialLoadMargins[0]).toBe(0)
|
||||||
expect(requests).toHaveLength(1)
|
expect(requests).toHaveLength(1)
|
||||||
expect(requests[0].searchParams.get('page')).toBe('1')
|
expect(requests[0].searchParams.get('page')).toBe('1')
|
||||||
expect(requests[0].searchParams.get('count')).toBe('30')
|
expect(requests[0].searchParams.get('count')).toBe('30')
|
||||||
@@ -262,9 +273,9 @@ describe('SubscribeShareView', () => {
|
|||||||
const first = createSubscribeShare({ share_title: '未满屏分享第一页' })
|
const first = createSubscribeShare({ share_title: '未满屏分享第一页' })
|
||||||
const second = createSubscribeShare({ share_title: '未满屏分享第二页' })
|
const second = createSubscribeShare({ share_title: '未满屏分享第二页' })
|
||||||
const requestedPages: string[] = []
|
const requestedPages: string[] = []
|
||||||
const scrollHeight = vi.spyOn(document.body, 'scrollHeight', 'get').mockImplementation(() =>
|
const scrollHeight = vi
|
||||||
requestedPages.length >= 2 ? 900 : 500,
|
.spyOn(document.body, 'scrollHeight', 'get')
|
||||||
)
|
.mockImplementation(() => (requestedPages.length >= 2 ? 900 : 500))
|
||||||
server.use(
|
server.use(
|
||||||
http.get(subscribeApiUrls.shares, ({ request }) => {
|
http.get(subscribeApiUrls.shares, ({ request }) => {
|
||||||
const page = new URL(request.url).searchParams.get('page') ?? ''
|
const page = new URL(request.url).searchParams.get('page') ?? ''
|
||||||
|
|||||||
Reference in New Issue
Block a user