test(site): cover site list flows (#560)

This commit is contained in:
InfinityPacer
2026-07-19 08:57:44 +08:00
committed by GitHub
parent 126a204b2a
commit 374a0866be
7 changed files with 823 additions and 24 deletions

View File

@@ -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

View 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()
})
})

View File

@@ -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"

View 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()
})
})

View 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,
}
}

View 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 },
)
})
}

View File

@@ -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,