mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-11 08:33:46 +08:00
test(site): cover site list flows (#560)
This commit is contained in:
@@ -1161,11 +1161,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/views/site/SiteCardListView.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"src/views/subscribe/FullCalendarView.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
|
||||
23
src/pages/__tests__/site.spec.ts
Normal file
23
src/pages/__tests__/site.spec.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import SitePage from '@/pages/site.vue'
|
||||
import { screen } from '@testing-library/vue'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const SiteCardListViewStub = defineComponent({
|
||||
name: 'SiteCardListView',
|
||||
setup: () => () => h('section', { 'aria-label': '站点列表视图' }, '站点列表'),
|
||||
})
|
||||
|
||||
describe('site page', () => {
|
||||
it('renders the site list view as the page content', async () => {
|
||||
await renderWithProviders(SitePage, {
|
||||
initialRoute: '/site',
|
||||
global: {
|
||||
stubs: { SiteCardListView: SiteCardListViewStub },
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByRole('region', { name: '站点列表视图' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api'
|
||||
import type { Site, SiteUserData } from '@/api/types'
|
||||
import type { ApiResponse, Site, SiteStatistic, SiteUserData } from '@/api/types'
|
||||
import SiteCard from '@/components/cards/SiteCard.vue'
|
||||
import NoDataFound from '@/components/states/NoDataFound.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
@@ -38,11 +38,14 @@ const SiteImportDialog = defineAsyncComponent(() => import('@/components/dialog/
|
||||
// 站点列表
|
||||
const siteList = ref<Site[]>([])
|
||||
|
||||
// 排序失败时只能回退到最近一次由服务端确认的列表,不能保留未保存的拖拽结果。
|
||||
const confirmedSiteList = ref<Site[]>([])
|
||||
|
||||
// 站点数据列表
|
||||
const userDataList = ref<SiteUserData[]>([])
|
||||
|
||||
// 站点统计数据列表
|
||||
const siteStatsList = ref<{ [domain: string]: any }>({})
|
||||
const siteStatsList = ref<Record<string, SiteStatistic>>({})
|
||||
|
||||
// 是否刷新过
|
||||
const isRefreshed = ref(false)
|
||||
@@ -50,8 +53,14 @@ const isRefreshed = ref(false)
|
||||
// 是否加载中
|
||||
const loading = ref(false)
|
||||
|
||||
// 首次加载失败时保留独立错误态,避免把网络异常显示成空站点。
|
||||
const loadError = ref(false)
|
||||
|
||||
const sortMode = ref(false)
|
||||
|
||||
// 排序保存期间锁定拖拽,确保一次请求只确认它实际提交的顺序快照。
|
||||
const savingPriority = ref(false)
|
||||
|
||||
// 筛选相关
|
||||
const filterMenu = ref(false)
|
||||
const filterOption = ref('all') // all, active, inactive, connected, slow, failed, unknown
|
||||
@@ -124,6 +133,7 @@ async function fetchData(context: KeepAliveRefreshContext = {}) {
|
||||
try {
|
||||
if (showLoading) {
|
||||
loading.value = true
|
||||
loadError.value = false
|
||||
}
|
||||
|
||||
const [sites] = await Promise.all([
|
||||
@@ -132,9 +142,14 @@ async function fetchData(context: KeepAliveRefreshContext = {}) {
|
||||
fetchSiteStats(),
|
||||
])
|
||||
siteList.value = sites
|
||||
confirmedSiteList.value = [...sites]
|
||||
isRefreshed.value = true
|
||||
loadError.value = false
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
if (!isRefreshed.value) {
|
||||
loadError.value = true
|
||||
}
|
||||
} finally {
|
||||
if (showLoading) {
|
||||
loading.value = false
|
||||
@@ -155,18 +170,15 @@ async function fetchUserData() {
|
||||
async function fetchSiteStats() {
|
||||
try {
|
||||
// 使用批量接口一次性获取所有站点统计数据
|
||||
const response = await api.get('site/statistic')
|
||||
const stats = response.data || response
|
||||
const stats: SiteStatistic[] = await api.get('site/statistic')
|
||||
|
||||
// 将数组转换为以domain为键的对象
|
||||
const statsMap: { [domain: string]: any } = {}
|
||||
if (Array.isArray(stats)) {
|
||||
stats.forEach((stat: any) => {
|
||||
if (stat.domain) {
|
||||
statsMap[stat.domain] = stat
|
||||
}
|
||||
})
|
||||
}
|
||||
const statsMap: Record<string, SiteStatistic> = {}
|
||||
stats.forEach(stat => {
|
||||
if (stat.domain) {
|
||||
statsMap[stat.domain] = stat
|
||||
}
|
||||
})
|
||||
siteStatsList.value = statsMap
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch site statistics:', error)
|
||||
@@ -193,19 +205,28 @@ function getConnectionStatus(domain: string) {
|
||||
// 保存站点排序
|
||||
async function savaSitesPriority() {
|
||||
// 只在显示全部站点时允许排序
|
||||
if (filterOption.value !== 'all') {
|
||||
if (filterOption.value !== 'all' || savingPriority.value) {
|
||||
return
|
||||
}
|
||||
|
||||
// 重新排序
|
||||
const priorities = draggableSiteList.value.map((site, index) => ({ id: site.id, pri: index + 1 }))
|
||||
const submittedSiteList = [...draggableSiteList.value]
|
||||
const priorities = submittedSiteList.map((site, index) => ({ id: site.id, pri: index + 1 }))
|
||||
savingPriority.value = true
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post('site/priorities', priorities)
|
||||
const result: ApiResponse<unknown> = await api.post('site/priorities', priorities)
|
||||
if (!result.success) {
|
||||
fetchData()
|
||||
siteList.value = [...confirmedSiteList.value]
|
||||
await fetchData()
|
||||
} else {
|
||||
siteList.value = [...submittedSiteList]
|
||||
confirmedSiteList.value = [...submittedSiteList]
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
siteList.value = [...confirmedSiteList.value]
|
||||
await fetchData()
|
||||
} finally {
|
||||
savingPriority.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,7 +235,7 @@ async function handleRefreshStats(domain?: string) {
|
||||
if (domain) {
|
||||
// 刷新特定站点的统计数据
|
||||
try {
|
||||
const stats = await api.get(`site/statistic/${domain}`)
|
||||
const stats = await api.get<SiteStatistic, SiteStatistic>(`site/statistic/${domain}`)
|
||||
siteStatsList.value[domain] = stats
|
||||
} catch (error) {
|
||||
console.error(`Failed to refresh stats for ${domain}:`, error)
|
||||
@@ -430,10 +451,11 @@ useDynamicButton({
|
||||
</div>
|
||||
</VAlert>
|
||||
|
||||
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
||||
<LoadingBanner v-if="!isRefreshed && !loadError" class="mt-12" />
|
||||
<Draggable
|
||||
v-if="draggableSiteList.length > 0 && canDragSort"
|
||||
v-model="draggableSiteList"
|
||||
:disabled="savingPriority"
|
||||
@end="savaSitesPriority"
|
||||
item-key="id"
|
||||
tag="div"
|
||||
@@ -472,6 +494,18 @@ useDynamicButton({
|
||||
</template>
|
||||
</ProgressiveCardGrid>
|
||||
</div>
|
||||
<NoDataFound
|
||||
v-if="loadError && !isRefreshed"
|
||||
error-code="500"
|
||||
:error-title="t('common.serverConnectionFailed')"
|
||||
:error-description="t('common.troubleshooting')"
|
||||
>
|
||||
<template #button>
|
||||
<VBtn color="primary" variant="tonal" @click="fetchData()">
|
||||
{{ t('common.retry') }}
|
||||
</VBtn>
|
||||
</template>
|
||||
</NoDataFound>
|
||||
<NoDataFound
|
||||
v-if="draggableSiteList.length === 0 && isRefreshed"
|
||||
error-code="404"
|
||||
|
||||
621
src/views/site/__tests__/SiteCardListView.spec.ts
Normal file
621
src/views/site/__tests__/SiteCardListView.spec.ts
Normal file
@@ -0,0 +1,621 @@
|
||||
import type { DynamicButtonMenuItem } from '@/composables/useDynamicButton'
|
||||
import type { Site, SiteStatistic, SiteUserData } from '@/api/types'
|
||||
import SiteCardListView from '@/views/site/SiteCardListView.vue'
|
||||
import { DEFAULT_PERMISSIONS } from '@/utils/permission'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createSite, createSiteStatistic, createSiteUserData } from '@tests/support/factories/site'
|
||||
import {
|
||||
saveSitePrioritiesHandler,
|
||||
siteListHandler,
|
||||
siteStatisticHandler,
|
||||
siteStatisticsHandler,
|
||||
siteUserDataLatestHandler,
|
||||
} from '@tests/support/msw/handlers/site'
|
||||
import { server } from '@tests/support/msw/server'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { defineComponent, h, nextTick, ref, unref, type ComputedRef, type PropType, type Ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
appMode: false,
|
||||
openSharedDialog: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
useDynamicButton: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('vue-toastification', () => ({
|
||||
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useDynamicButton', () => ({
|
||||
useDynamicButton: (options: unknown) => mocks.useDynamicButton(options),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/usePWA', async () => {
|
||||
const { computed } = await import('vue')
|
||||
return { usePWA: () => ({ appMode: computed(() => mocks.appMode) }) }
|
||||
})
|
||||
|
||||
vi.mock('@/composables/useSharedDialog', () => ({
|
||||
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
|
||||
}))
|
||||
|
||||
type MaybeRef<T> = T | Ref<T> | ComputedRef<T>
|
||||
|
||||
interface DynamicButtonConfig {
|
||||
menuItems: MaybeRef<DynamicButtonMenuItem[]>
|
||||
onClick: () => void
|
||||
show: MaybeRef<boolean>
|
||||
}
|
||||
|
||||
const SiteCardStub = defineComponent({
|
||||
name: 'SiteCard',
|
||||
props: {
|
||||
data: Object as PropType<SiteUserData>,
|
||||
site: { type: Object as PropType<Site>, required: true },
|
||||
sortable: Boolean,
|
||||
stats: Object as PropType<SiteStatistic>,
|
||||
},
|
||||
emits: ['refresh-stats', 'remove', 'update'],
|
||||
setup(props, { emit }) {
|
||||
return () =>
|
||||
h('article', { 'data-testid': `site-card-${props.site.id}` }, [
|
||||
h('span', props.site.name),
|
||||
h('output', { 'aria-label': `site-domain-${props.site.id}` }, props.site.domain),
|
||||
h('output', { 'aria-label': `site-data-${props.site.id}` }, props.data?.username ?? ''),
|
||||
h('output', { 'aria-label': `site-state-${props.site.id}` }, JSON.stringify(props.stats ?? {})),
|
||||
h('output', { 'aria-label': `site-sortable-${props.site.id}` }, String(props.sortable)),
|
||||
h('button', { onClick: () => emit('remove'), type: 'button' }, `remove-${props.site.id}`),
|
||||
h('button', { onClick: () => emit('update'), type: 'button' }, `update-${props.site.id}`),
|
||||
h(
|
||||
'button',
|
||||
{ onClick: () => emit('refresh-stats', props.site.domain), type: 'button' },
|
||||
`refresh-${props.site.id}`,
|
||||
),
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
const ProgressiveCardGridStub = defineComponent({
|
||||
name: 'ProgressiveCardGrid',
|
||||
props: { items: { type: Array as PropType<Site[]>, required: true } },
|
||||
setup(props, { slots }) {
|
||||
return () =>
|
||||
h(
|
||||
'section',
|
||||
{ 'data-testid': 'site-grid' },
|
||||
props.items.flatMap(item => slots.default?.({ item }) ?? []),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const DraggableStub = defineComponent({
|
||||
name: 'Draggable',
|
||||
props: {
|
||||
disabled: Boolean,
|
||||
modelValue: { type: Array as PropType<Site[]>, required: true },
|
||||
},
|
||||
emits: ['end', 'update:modelValue'],
|
||||
setup(props, { emit, slots }) {
|
||||
async function reverseOrder() {
|
||||
if (props.disabled) return
|
||||
|
||||
emit('update:modelValue', [...props.modelValue].reverse())
|
||||
await nextTick()
|
||||
emit('end')
|
||||
}
|
||||
return () =>
|
||||
h('section', { 'data-testid': 'site-draggable' }, [
|
||||
...props.modelValue.flatMap(element => slots.item?.({ element }) ?? []),
|
||||
h('button', { disabled: props.disabled, onClick: reverseOrder, type: 'button' }, 'reverse-sites'),
|
||||
h('button', { onClick: () => emit('end'), type: 'button' }, 'emit-drag-end'),
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
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 }}<slot name="button" /></section>',
|
||||
})
|
||||
|
||||
const IconButtonStub = defineComponent({
|
||||
name: 'IconBtn',
|
||||
inheritAttrs: false,
|
||||
setup(_props, { attrs, slots }) {
|
||||
return () => h('button', { ...attrs, type: 'button' }, slots.default?.())
|
||||
},
|
||||
})
|
||||
|
||||
const IconStub = defineComponent({
|
||||
name: 'VIcon',
|
||||
props: { icon: String },
|
||||
setup(props) {
|
||||
return () => h('span', props.icon)
|
||||
},
|
||||
})
|
||||
|
||||
const KeepAliveHost = defineComponent({
|
||||
name: 'SiteKeepAliveHost',
|
||||
components: { SiteCardListView },
|
||||
setup() {
|
||||
const active = ref(true)
|
||||
return { active }
|
||||
},
|
||||
template: `
|
||||
<button type="button" @click="active = false">停用站点页</button>
|
||||
<button type="button" @click="active = true">启用站点页</button>
|
||||
<KeepAlive><SiteCardListView v-if="active" /></KeepAlive>
|
||||
`,
|
||||
})
|
||||
|
||||
interface RenderListOptions {
|
||||
appMode?: boolean
|
||||
initialRoute?: string
|
||||
listStatus?: number | (() => number)
|
||||
manage?: boolean
|
||||
sites?: Site[]
|
||||
statistics?: SiteStatistic[]
|
||||
statisticsStatus?: number
|
||||
onListRequest?: () => void | Promise<void>
|
||||
onStatisticsRequest?: () => void | Promise<void>
|
||||
onUserDataRequest?: () => void | Promise<void>
|
||||
userData?: SiteUserData[]
|
||||
useKeepAlive?: boolean
|
||||
}
|
||||
|
||||
async function renderList(options: RenderListOptions = {}) {
|
||||
mocks.appMode = options.appMode ?? false
|
||||
server.use(
|
||||
siteListHandler(options.sites ?? [], options.listStatus ?? 200, options.onListRequest),
|
||||
siteStatisticsHandler(options.statistics ?? [], options.statisticsStatus ?? 200, options.onStatisticsRequest),
|
||||
siteUserDataLatestHandler(options.userData ?? [], 200, options.onUserDataRequest),
|
||||
)
|
||||
|
||||
return renderWithProviders(options.useKeepAlive ? KeepAliveHost : SiteCardListView, {
|
||||
initialRoute: options.initialRoute ?? '/site',
|
||||
initialState: {
|
||||
user: {
|
||||
permissions: { ...DEFAULT_PERMISSIONS, manage: options.manage ?? true },
|
||||
superUser: false,
|
||||
},
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
IconBtn: IconButtonStub,
|
||||
LoadingBanner: LoadingBannerStub,
|
||||
NoDataFound: NoDataFoundStub,
|
||||
ProgressiveCardGrid: ProgressiveCardGridStub,
|
||||
SiteCard: SiteCardStub,
|
||||
VFab: IconButtonStub,
|
||||
VIcon: IconStub,
|
||||
VPageContentTitle: true,
|
||||
draggable: DraggableStub,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function getDynamicButtonConfig() {
|
||||
return mocks.useDynamicButton.mock.calls.at(-1)?.[0] as DynamicButtonConfig
|
||||
}
|
||||
|
||||
function getMenuItem(titleKey: string) {
|
||||
const item = unref(getDynamicButtonConfig().menuItems).find(candidate => candidate.titleKey === titleKey)
|
||||
if (!item) throw new Error(`未注册动态操作 ${titleKey}`)
|
||||
return item
|
||||
}
|
||||
|
||||
async function selectFilter(label: string) {
|
||||
await fireEvent.click(screen.getByText('mdi-filter-multiple-outline'))
|
||||
await fireEvent.click(await screen.findByText(label))
|
||||
}
|
||||
|
||||
function readBlob(blob: Blob) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.addEventListener('load', () => resolve(String(reader.result ?? '')))
|
||||
reader.addEventListener('error', () => reject(reader.error))
|
||||
reader.readAsText(blob)
|
||||
})
|
||||
}
|
||||
|
||||
describe('SiteCardListView', () => {
|
||||
beforeEach(() => {
|
||||
mocks.appMode = false
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
it('loads sites with statistics and maps latest user data by domain', async () => {
|
||||
const fast = createSite({ id: 101, name: '快速站点', domain: 'fast.example.com' })
|
||||
const failed = createSite({ id: 102, name: '失败站点', domain: 'failed.example.com' })
|
||||
|
||||
await renderList({
|
||||
sites: [fast, failed],
|
||||
statistics: [
|
||||
createSiteStatistic({ domain: fast.domain, seconds: 1, lst_state: 0 }),
|
||||
createSiteStatistic({ domain: failed.domain, lst_state: 1 }),
|
||||
],
|
||||
userData: [createSiteUserData({ domain: fast.domain, username: 'fast-user' })],
|
||||
})
|
||||
|
||||
expect(await screen.findByText('快速站点')).toBeInTheDocument()
|
||||
expect(screen.getByText('失败站点')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('site-data-101')).toHaveTextContent('fast-user')
|
||||
expect(screen.getByLabelText('site-data-102')).toBeEmptyDOMElement()
|
||||
expect(screen.getByLabelText('site-state-101')).toHaveTextContent('"seconds":1')
|
||||
expect(screen.getByLabelText('site-state-102')).toHaveTextContent('"lst_state":1')
|
||||
})
|
||||
|
||||
it('keeps a successful site list when loading statistics fails', async () => {
|
||||
const site = createSite({ id: 111, name: '无统计站点' })
|
||||
await renderList({ sites: [site], statisticsStatus: 500 })
|
||||
|
||||
expect(await screen.findByText('无统计站点')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('site-state-111')).toHaveTextContent('{}')
|
||||
expect(screen.queryByRole('region', { name: '站点空态' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['激活', '快速站点'],
|
||||
['未激活', '停用站点'],
|
||||
['连接正常', '快速站点'],
|
||||
['连接缓慢', '缓慢站点'],
|
||||
['连接失败', '失败站点'],
|
||||
['连接未知', '未知站点'],
|
||||
])('filters %s sites using active and statistic state', async (filterLabel, expectedSite) => {
|
||||
const sites = [
|
||||
createSite({ id: 201, name: '快速站点', domain: 'fast.example.com', is_active: true }),
|
||||
createSite({ id: 202, name: '停用站点', domain: 'inactive.example.com', is_active: false }),
|
||||
createSite({ id: 203, name: '缓慢站点', domain: 'slow.example.com', is_active: true }),
|
||||
createSite({ id: 204, name: '失败站点', domain: 'failed.example.com', is_active: true }),
|
||||
createSite({ id: 205, name: '未知站点', domain: 'unknown.example.com', is_active: true }),
|
||||
]
|
||||
await renderList({
|
||||
sites,
|
||||
statistics: [
|
||||
createSiteStatistic({ domain: 'fast.example.com', lst_state: 0, seconds: 1 }),
|
||||
createSiteStatistic({ domain: 'inactive.example.com', lst_state: 0, seconds: 1 }),
|
||||
createSiteStatistic({ domain: 'slow.example.com', lst_state: 0, seconds: 5 }),
|
||||
createSiteStatistic({ domain: 'failed.example.com', lst_state: 1 }),
|
||||
],
|
||||
})
|
||||
expect(await screen.findByText('快速站点')).toBeInTheDocument()
|
||||
|
||||
await selectFilter(filterLabel)
|
||||
|
||||
expect(screen.getByText(expectedSite)).toBeInTheDocument()
|
||||
const visibleCards = screen.getAllByTestId(/^site-card-/)
|
||||
if (filterLabel === '激活') expect(visibleCards).toHaveLength(4)
|
||||
else if (filterLabel === '连接正常') expect(visibleCards).toHaveLength(2)
|
||||
else expect(visibleCards).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('reorders only the complete list and posts exact priorities', async () => {
|
||||
const saved = vi.fn()
|
||||
const first = createSite({ id: 301, name: '第一站' })
|
||||
const second = createSite({ id: 302, name: '第二站' })
|
||||
server.use(saveSitePrioritiesHandler(saved))
|
||||
await renderList({ sites: [first, second] })
|
||||
expect(await screen.findByText('第一站')).toBeInTheDocument()
|
||||
|
||||
await fireEvent.click(screen.getByText('mdi-sort-variant'))
|
||||
expect(screen.getByText('已进入拖拽排序模式')).toBeInTheDocument()
|
||||
await fireEvent.click(screen.getByText('reverse-sites'))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(saved).toHaveBeenCalledWith([
|
||||
{ id: 302, pri: 1 },
|
||||
{ id: 301, pri: 2 },
|
||||
]),
|
||||
)
|
||||
expect(screen.getByLabelText('site-sortable-302')).toHaveTextContent('true')
|
||||
|
||||
await selectFilter('未激活')
|
||||
expect(screen.queryByText('已进入拖拽排序模式')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('locks drag interactions until the submitted priority order is confirmed', async () => {
|
||||
let resolveSave: (() => void) | undefined
|
||||
const savePending = new Promise<void>(resolve => {
|
||||
resolveSave = resolve
|
||||
})
|
||||
const saved = vi.fn(async () => savePending)
|
||||
const first = createSite({ id: 306, name: '锁定第一站' })
|
||||
const second = createSite({ id: 307, name: '锁定第二站' })
|
||||
server.use(saveSitePrioritiesHandler(saved))
|
||||
await renderList({ sites: [first, second] })
|
||||
expect(await screen.findByText('锁定第一站')).toBeInTheDocument()
|
||||
|
||||
await fireEvent.click(screen.getByText('mdi-sort-variant'))
|
||||
const reverseButton = screen.getByRole('button', { name: 'reverse-sites' })
|
||||
await fireEvent.click(reverseButton)
|
||||
|
||||
await waitFor(() => expect(saved).toHaveBeenCalledOnce())
|
||||
expect(reverseButton).toBeDisabled()
|
||||
expect(screen.getAllByTestId(/^site-card-/).map(card => card.textContent)).toEqual([
|
||||
expect.stringContaining('锁定第二站'),
|
||||
expect.stringContaining('锁定第一站'),
|
||||
])
|
||||
|
||||
await fireEvent.click(reverseButton)
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'emit-drag-end' }))
|
||||
expect(saved).toHaveBeenCalledOnce()
|
||||
expect(screen.getAllByTestId(/^site-card-/).map(card => card.textContent)).toEqual([
|
||||
expect.stringContaining('锁定第二站'),
|
||||
expect.stringContaining('锁定第一站'),
|
||||
])
|
||||
|
||||
resolveSave?.()
|
||||
await waitFor(() => expect(reverseButton).toBeEnabled())
|
||||
expect(screen.getAllByTestId(/^site-card-/).map(card => card.textContent)).toEqual([
|
||||
expect.stringContaining('锁定第二站'),
|
||||
expect.stringContaining('锁定第一站'),
|
||||
])
|
||||
})
|
||||
|
||||
it('restores the server order when saving priorities fails with an HTTP error', async () => {
|
||||
const first = createSite({ id: 311, name: '服务端第一站' })
|
||||
const second = createSite({ id: 312, name: '服务端第二站' })
|
||||
const listRequests = vi.fn()
|
||||
server.use(saveSitePrioritiesHandler(() => {}, { status: 500 }))
|
||||
await renderList({ onListRequest: listRequests, sites: [first, second] })
|
||||
expect(await screen.findByText('服务端第一站')).toBeInTheDocument()
|
||||
|
||||
await fireEvent.click(screen.getByText('mdi-sort-variant'))
|
||||
await fireEvent.click(screen.getByText('reverse-sites'))
|
||||
|
||||
await waitFor(() => expect(listRequests).toHaveBeenCalledTimes(2))
|
||||
expect(screen.getAllByTestId(/^site-card-/).map(card => card.textContent)).toEqual([
|
||||
expect.stringContaining('服务端第一站'),
|
||||
expect.stringContaining('服务端第二站'),
|
||||
])
|
||||
})
|
||||
|
||||
it('restores the last confirmed order when both saving and reloading fail', async () => {
|
||||
const first = createSite({ id: 316, name: '确认第一站' })
|
||||
const second = createSite({ id: 317, name: '确认第二站' })
|
||||
const listRequests = vi.fn()
|
||||
const statisticsRequests = vi.fn()
|
||||
let listResponse = 0
|
||||
server.use(saveSitePrioritiesHandler(() => {}, { status: 500 }))
|
||||
await renderList({
|
||||
listStatus: () => (listResponse++ === 0 ? 200 : 500),
|
||||
onListRequest: listRequests,
|
||||
onStatisticsRequest: statisticsRequests,
|
||||
sites: [first, second],
|
||||
})
|
||||
expect(await screen.findByText('确认第一站')).toBeInTheDocument()
|
||||
|
||||
await fireEvent.click(screen.getByText('mdi-sort-variant'))
|
||||
await fireEvent.click(screen.getByText('reverse-sites'))
|
||||
|
||||
await waitFor(() => expect(listRequests).toHaveBeenCalledTimes(2))
|
||||
await waitFor(() => expect(statisticsRequests).toHaveBeenCalledTimes(2))
|
||||
await waitFor(() => expect(console.error).toHaveBeenCalledTimes(2))
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByTestId(/^site-card-/).map(card => card.textContent)).toEqual([
|
||||
expect.stringContaining('确认第一站'),
|
||||
expect.stringContaining('确认第二站'),
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('restores the server order when saving priorities is rejected', async () => {
|
||||
const first = createSite({ id: 321, name: '业务第一站' })
|
||||
const second = createSite({ id: 322, name: '业务第二站' })
|
||||
const listRequests = vi.fn()
|
||||
server.use(saveSitePrioritiesHandler(() => {}, { success: false }))
|
||||
await renderList({ onListRequest: listRequests, sites: [first, second] })
|
||||
expect(await screen.findByText('业务第一站')).toBeInTheDocument()
|
||||
|
||||
await fireEvent.click(screen.getByText('mdi-sort-variant'))
|
||||
await fireEvent.click(screen.getByText('reverse-sites'))
|
||||
|
||||
await waitFor(() => expect(listRequests).toHaveBeenCalledTimes(2))
|
||||
expect(screen.getAllByTestId(/^site-card-/).map(card => card.textContent)).toEqual([
|
||||
expect.stringContaining('业务第一站'),
|
||||
expect.stringContaining('业务第二站'),
|
||||
])
|
||||
})
|
||||
|
||||
it('refreshes one site statistic without reloading the site list', async () => {
|
||||
const site = createSite({ id: 331, name: '统计站点', domain: 'stats.example.com' })
|
||||
const listRequests = vi.fn()
|
||||
const statisticRequests = vi.fn()
|
||||
server.use(
|
||||
siteStatisticHandler(
|
||||
site.domain,
|
||||
createSiteStatistic({ domain: site.domain, seconds: 8, lst_state: 0 }),
|
||||
200,
|
||||
statisticRequests,
|
||||
),
|
||||
)
|
||||
await renderList({
|
||||
onListRequest: listRequests,
|
||||
sites: [site],
|
||||
statistics: [createSiteStatistic({ domain: site.domain, seconds: 1, lst_state: 0 })],
|
||||
})
|
||||
expect(await screen.findByLabelText('site-state-331')).toHaveTextContent('"seconds":1')
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'refresh-331' }))
|
||||
|
||||
await waitFor(() => expect(screen.getByLabelText('site-state-331')).toHaveTextContent('"seconds":8'))
|
||||
expect(statisticRequests).toHaveBeenCalledOnce()
|
||||
expect(listRequests).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each(['remove', 'update'])('reloads the site list after a card %s event', async eventName => {
|
||||
const listRequests = vi.fn()
|
||||
const site = createSite({ id: 341, name: '事件站点' })
|
||||
await renderList({ onListRequest: listRequests, sites: [site] })
|
||||
expect(await screen.findByText('事件站点')).toBeInTheDocument()
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: `${eventName}-341` }))
|
||||
|
||||
await waitFor(() => expect(listRequests).toHaveBeenCalledTimes(2))
|
||||
})
|
||||
|
||||
it('opens add, import and statistics dialogs with their declared event contracts', async () => {
|
||||
const site = createSite({ id: 401, name: '弹窗站点' })
|
||||
const listRequests = vi.fn()
|
||||
await renderList({ onListRequest: listRequests, sites: [site] })
|
||||
expect(await screen.findByText('弹窗站点')).toBeInTheDocument()
|
||||
|
||||
getDynamicButtonConfig().onClick()
|
||||
getMenuItem('site.actions.import').action()
|
||||
getMenuItem('site.statistics').action()
|
||||
|
||||
expect(mocks.openSharedDialog).toHaveBeenCalledTimes(3)
|
||||
expect(mocks.openSharedDialog.mock.calls[0]?.[1]).toEqual({ oper: 'add' })
|
||||
expect(mocks.openSharedDialog.mock.calls[0]?.[3]).toEqual({ closeOn: ['close', 'save'] })
|
||||
expect(mocks.openSharedDialog.mock.calls[1]?.[2]).toHaveProperty('import-success')
|
||||
expect(mocks.openSharedDialog.mock.calls[2]?.[1]).toEqual({ sites: [site] })
|
||||
|
||||
const save = mocks.openSharedDialog.mock.calls[0]?.[2]?.save as () => void
|
||||
save()
|
||||
await waitFor(() => expect(listRequests).toHaveBeenCalledTimes(2))
|
||||
})
|
||||
|
||||
it('shows management actions only on the site route for users with manage permission', async () => {
|
||||
await renderList({ manage: false, sites: [createSite()] })
|
||||
expect(await screen.findByTestId(/^site-card-/)).toBeInTheDocument()
|
||||
expect(unref(getDynamicButtonConfig().show)).toBe(false)
|
||||
expect(document.querySelector('.compact-fab-stack')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('registers the app-mode dynamic menu after the first successful load', async () => {
|
||||
await renderList({ appMode: true, sites: [createSite()] })
|
||||
expect(await screen.findByTestId(/^site-card-/)).toBeInTheDocument()
|
||||
|
||||
expect(unref(getDynamicButtonConfig().show)).toBe(true)
|
||||
expect(unref(getDynamicButtonConfig().menuItems).map(item => item.titleKey)).toEqual([
|
||||
'site.actions.add',
|
||||
'site.actions.import',
|
||||
'site.actions.export',
|
||||
'site.statistics',
|
||||
])
|
||||
})
|
||||
|
||||
it('exports the current complete site fields and releases the object URL', async () => {
|
||||
const site = createSite({
|
||||
apikey: 'api-key',
|
||||
cookie: 'cookie-value',
|
||||
id: 501,
|
||||
name: '导出站点',
|
||||
token: 'token-value',
|
||||
})
|
||||
const createObjectURL = vi.fn<(blob: Blob) => string>(() => 'blob:site-export')
|
||||
const revokeObjectURL = vi.fn()
|
||||
class TestURL extends URL {}
|
||||
TestURL.createObjectURL = createObjectURL
|
||||
TestURL.revokeObjectURL = revokeObjectURL
|
||||
vi.stubGlobal('URL', TestURL)
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
await renderList({ sites: [site] })
|
||||
expect(await screen.findByText('导出站点')).toBeInTheDocument()
|
||||
|
||||
await getMenuItem('site.actions.export').action()
|
||||
|
||||
await waitFor(() => expect(createObjectURL).toHaveBeenCalledOnce())
|
||||
const blob = createObjectURL.mock.calls[0]?.[0] as Blob
|
||||
const body = JSON.parse(await readBlob(blob)) as Array<Record<string, unknown>>
|
||||
expect(body).toEqual([
|
||||
expect.objectContaining({
|
||||
apikey: 'api-key',
|
||||
cookie: 'cookie-value',
|
||||
domain: site.domain,
|
||||
name: '导出站点',
|
||||
token: 'token-value',
|
||||
url: site.url,
|
||||
}),
|
||||
])
|
||||
expect(body[0]).not.toHaveProperty('id')
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith('blob:site-export')
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('站点导出成功')
|
||||
})
|
||||
|
||||
it('reports an export failure without creating a download', async () => {
|
||||
const createObjectURL = vi.fn()
|
||||
class TestURL extends URL {}
|
||||
TestURL.createObjectURL = createObjectURL
|
||||
vi.stubGlobal('URL', TestURL)
|
||||
await renderList({ sites: [createSite({ name: '不可导出站点' })] })
|
||||
expect(await screen.findByText('不可导出站点')).toBeInTheDocument()
|
||||
server.use(siteListHandler([], 500))
|
||||
|
||||
await getMenuItem('site.actions.export').action()
|
||||
|
||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('站点导出失败'))
|
||||
expect(createObjectURL).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the empty state after a successful empty response', async () => {
|
||||
await renderList()
|
||||
|
||||
expect(await screen.findByRole('region', { name: '站点空态' })).toHaveTextContent(
|
||||
'没有站点 已添加并支持的站点将会在这里显示。',
|
||||
)
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a retryable error after the initial site request fails', async () => {
|
||||
await renderList({ listStatus: 500 })
|
||||
|
||||
expect(await screen.findByRole('region', { name: '站点空态' })).toHaveTextContent('服务器连接失败')
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
|
||||
server.use(siteListHandler([createSite({ name: '重试恢复站点' })]))
|
||||
await fireEvent.click(screen.getByRole('button', { name: '重试' }))
|
||||
|
||||
expect(await screen.findByText('重试恢复站点')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('refreshes list, statistics and latest user data after KeepAlive reactivation', async () => {
|
||||
const listRequests = vi.fn()
|
||||
const statsRequests = vi.fn()
|
||||
const userDataRequests = vi.fn()
|
||||
await renderList({
|
||||
onListRequest: listRequests,
|
||||
onStatisticsRequest: statsRequests,
|
||||
onUserDataRequest: userDataRequests,
|
||||
sites: [createSite({ name: '缓存站点' })],
|
||||
useKeepAlive: true,
|
||||
})
|
||||
expect(await screen.findByText('缓存站点')).toBeInTheDocument()
|
||||
expect(listRequests).toHaveBeenCalledOnce()
|
||||
|
||||
await userEvent.click(screen.getByText('停用站点页'))
|
||||
await userEvent.click(screen.getByText('启用站点页'))
|
||||
|
||||
await waitFor(() => expect(listRequests).toHaveBeenCalledTimes(2))
|
||||
expect(statsRequests).toHaveBeenCalledTimes(2)
|
||||
expect(userDataRequests).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps the existing list when a silent KeepAlive refresh fails', async () => {
|
||||
const listRequests = vi.fn()
|
||||
let listResponse = 0
|
||||
await renderList({
|
||||
listStatus: () => (listResponse++ === 0 ? 200 : 500),
|
||||
onListRequest: listRequests,
|
||||
sites: [createSite({ name: '静默刷新保留站点' })],
|
||||
useKeepAlive: true,
|
||||
})
|
||||
expect(await screen.findByText('静默刷新保留站点')).toBeInTheDocument()
|
||||
|
||||
await userEvent.click(screen.getByText('停用站点页'))
|
||||
await userEvent.click(screen.getByText('启用站点页'))
|
||||
|
||||
await waitFor(() => expect(listRequests).toHaveBeenCalledTimes(2))
|
||||
expect(screen.getByText('静默刷新保留站点')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('region', { name: '站点空态' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
39
tests/support/factories/site.ts
Normal file
39
tests/support/factories/site.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { Site, SiteStatistic, SiteUserData } from '@/api/types'
|
||||
|
||||
let siteSeed = 0
|
||||
|
||||
export function createSite(overrides: Partial<Site> = {}): Site {
|
||||
siteSeed += 1
|
||||
return {
|
||||
domain: `site-${siteSeed}.example.com`,
|
||||
downloader: '',
|
||||
id: siteSeed,
|
||||
is_active: true,
|
||||
name: `测试站点 ${siteSeed}`,
|
||||
pri: siteSeed,
|
||||
url: `https://site-${siteSeed}.example.com/`,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export function createSiteStatistic(overrides: Partial<SiteStatistic> = {}): SiteStatistic {
|
||||
return {
|
||||
fail: 0,
|
||||
lst_state: 0,
|
||||
seconds: 1,
|
||||
success: 10,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export function createSiteUserData(overrides: Partial<SiteUserData> = {}): SiteUserData {
|
||||
return {
|
||||
bonus: 100,
|
||||
download: 1024,
|
||||
ratio: 2,
|
||||
seeding: 3,
|
||||
upload: 2048,
|
||||
username: 'site-user',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
73
tests/support/msw/handlers/site.ts
Normal file
73
tests/support/msw/handlers/site.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { Site, SiteStatistic, SiteUserData } from '@/api/types'
|
||||
import { HttpResponse, http, type JsonBodyType } from 'msw'
|
||||
|
||||
const API_BASE_URL = 'http://localhost/api/v1/'
|
||||
|
||||
export const siteApiUrls = {
|
||||
list: new URL('site/', API_BASE_URL).href,
|
||||
priorities: new URL('site/priorities', API_BASE_URL).href,
|
||||
statistic: (domain: string) => new URL(`site/statistic/${domain}`, API_BASE_URL).href,
|
||||
statistics: new URL('site/statistic', API_BASE_URL).href,
|
||||
userDataLatest: new URL('site/userdata/latest', API_BASE_URL).href,
|
||||
}
|
||||
|
||||
export function siteListHandler(
|
||||
sites: Site[],
|
||||
status: number | (() => number) = 200,
|
||||
onRequest: () => void | Promise<void> = () => {},
|
||||
) {
|
||||
return http.get(siteApiUrls.list, async () => {
|
||||
await onRequest()
|
||||
return HttpResponse.json(sites as unknown as JsonBodyType, {
|
||||
status: typeof status === 'function' ? status() : status,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function siteStatisticsHandler(
|
||||
statistics: SiteStatistic[],
|
||||
status = 200,
|
||||
onRequest: () => void | Promise<void> = () => {},
|
||||
) {
|
||||
return http.get(siteApiUrls.statistics, async () => {
|
||||
await onRequest()
|
||||
return HttpResponse.json(statistics as unknown as JsonBodyType, { status })
|
||||
})
|
||||
}
|
||||
|
||||
export function siteStatisticHandler(
|
||||
domain: string,
|
||||
statistic: SiteStatistic,
|
||||
status = 200,
|
||||
onRequest: () => void | Promise<void> = () => {},
|
||||
) {
|
||||
return http.get(siteApiUrls.statistic(domain), async () => {
|
||||
await onRequest()
|
||||
return HttpResponse.json(statistic as unknown as JsonBodyType, { status })
|
||||
})
|
||||
}
|
||||
|
||||
export function siteUserDataLatestHandler(
|
||||
userData: SiteUserData[],
|
||||
status = 200,
|
||||
onRequest: () => void | Promise<void> = () => {},
|
||||
) {
|
||||
return http.get(siteApiUrls.userDataLatest, async () => {
|
||||
await onRequest()
|
||||
return HttpResponse.json(userData as unknown as JsonBodyType, { status })
|
||||
})
|
||||
}
|
||||
|
||||
export function saveSitePrioritiesHandler(
|
||||
onSave: (priorities: Array<{ id: number; pri: number }>) => void | Promise<void> = () => {},
|
||||
options: { status?: number; success?: boolean } = {},
|
||||
) {
|
||||
return http.post(siteApiUrls.priorities, async ({ request }) => {
|
||||
const priorities = (await request.json()) as Array<{ id: number; pri: number }>
|
||||
await onSave(priorities)
|
||||
return HttpResponse.json(
|
||||
{ success: options.success ?? (options.status ?? 200) < 400 },
|
||||
{ status: options.status ?? 200 },
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -279,6 +279,7 @@ export default defineConfig(({ mode }) => ({
|
||||
'src/pages/discover.vue',
|
||||
'src/pages/browse.vue',
|
||||
'src/pages/media.vue',
|
||||
'src/pages/site.vue',
|
||||
'src/pages/subscribe.vue',
|
||||
'src/views/dashboard/MediaRecommend.vue',
|
||||
'src/views/discover/MediaCardSlideView.vue',
|
||||
@@ -307,6 +308,7 @@ export default defineConfig(({ mode }) => ({
|
||||
'src/components/slide/VirtualSlideView.vue',
|
||||
'src/views/discover/PersonCardSlideView.vue',
|
||||
'src/utils/mediaStatusCache.ts',
|
||||
'src/views/site/SiteCardListView.vue',
|
||||
],
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json-summary', 'html'],
|
||||
@@ -388,6 +390,12 @@ export default defineConfig(({ mode }) => ({
|
||||
lines: 80,
|
||||
statements: 80,
|
||||
},
|
||||
'src/pages/site.vue': {
|
||||
branches: 75,
|
||||
functions: 80,
|
||||
lines: 80,
|
||||
statements: 80,
|
||||
},
|
||||
'src/pages/subscribe.vue': {
|
||||
branches: 75,
|
||||
functions: 80,
|
||||
@@ -502,6 +510,12 @@ export default defineConfig(({ mode }) => ({
|
||||
lines: 90,
|
||||
statements: 90,
|
||||
},
|
||||
'src/views/site/SiteCardListView.vue': {
|
||||
branches: 85,
|
||||
functions: 90,
|
||||
lines: 90,
|
||||
statements: 90,
|
||||
},
|
||||
'src/views/subscribe/FullCalendarView.vue': {
|
||||
branches: 85,
|
||||
functions: 90,
|
||||
|
||||
Reference in New Issue
Block a user