mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-04 23:18:44 +08:00
test(site): cover site card and icon cache (#561)
This commit is contained in:
@@ -179,11 +179,6 @@
|
|||||||
"count": 2
|
"count": 2
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/components/cards/SiteCard.vue": {
|
|
||||||
"@typescript-eslint/no-explicit-any": {
|
|
||||||
"count": 2
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/components/cards/SubscribeCard.vue": {
|
"src/components/cards/SubscribeCard.vue": {
|
||||||
"@typescript-eslint/no-explicit-any": {
|
"@typescript-eslint/no-explicit-any": {
|
||||||
"count": 5
|
"count": 5
|
||||||
|
|||||||
+161
-144
@@ -4,7 +4,7 @@ import { getLogoUrl } from '@/utils/imageUtils'
|
|||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import type { Site, SiteStatistic, SiteUserData } from '@/api/types'
|
import type { ApiResponse, Site, SiteStatistic, SiteUserData } from '@/api/types'
|
||||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||||
import { formatFileSize } from '@/@core/utils/formatters'
|
import { formatFileSize } from '@/@core/utils/formatters'
|
||||||
import { useConfirm } from '@/composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
@@ -79,17 +79,17 @@ async function testSite() {
|
|||||||
testButtonText.value = t('site.testing')
|
testButtonText.value = t('site.testing')
|
||||||
testButtonDisable.value = true
|
testButtonDisable.value = true
|
||||||
|
|
||||||
const result: { [key: string]: any } = await api.get(`site/test/${cardProps.site?.id}`)
|
const result = (await api.get(`site/test/${cardProps.site?.id}`)) as ApiResponse<unknown>
|
||||||
if (result.success) $toast.success(t('site.testSuccess', { name: cardProps.site?.name }))
|
if (result.success) $toast.success(t('site.testSuccess', { name: cardProps.site?.name }))
|
||||||
else $toast.error(t('site.testFailed', { name: cardProps.site?.name, message: result.message }))
|
else $toast.error(t('site.testFailed', { name: cardProps.site?.name, message: result.message }))
|
||||||
|
|
||||||
testButtonText.value = t('site.testConnectivity')
|
|
||||||
testButtonDisable.value = false
|
|
||||||
|
|
||||||
// 测试完成后刷新统计数据
|
// 测试完成后刷新统计数据
|
||||||
emit('refresh-stats', cardProps.site?.domain)
|
emit('refresh-stats', cardProps.site?.domain)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
} finally {
|
||||||
|
testButtonText.value = t('site.testConnectivity')
|
||||||
|
testButtonDisable.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,7 +166,7 @@ async function deleteSiteInfo() {
|
|||||||
if (!isConfirmed) return
|
if (!isConfirmed) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.delete(`site/${cardProps.site?.id}`)
|
const result = (await api.delete(`site/${cardProps.site?.id}`)) as ApiResponse<unknown>
|
||||||
if (result.success) emit('remove')
|
if (result.success) emit('remove')
|
||||||
else $toast.error(t('site.deleteFailed', { name: cardProps.site?.name, message: result.message }))
|
else $toast.error(t('site.deleteFailed', { name: cardProps.site?.name, message: result.message }))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -260,167 +260,182 @@ onMounted(() => {
|
|||||||
:hover="!cardProps.sortable"
|
:hover="!cardProps.sortable"
|
||||||
@click="handleCardClick"
|
@click="handleCardClick"
|
||||||
>
|
>
|
||||||
<!-- 装饰性状态指示器 -->
|
<!-- 装饰性状态指示器 -->
|
||||||
<div v-if="cardProps.site?.is_active" class="site-status-indicator" :class="statColor"></div>
|
<div v-if="cardProps.site?.is_active" class="site-status-indicator" :class="statColor"></div>
|
||||||
|
|
||||||
<!-- 主体部分 -->
|
<!-- 主体部分 -->
|
||||||
<div class="relative z-1 flex flex-1 flex-col p-3 pr-12">
|
<div class="relative z-1 flex flex-1 flex-col p-3 pr-12">
|
||||||
<!-- 顶部:图标和站点名称 -->
|
<!-- 顶部:图标和站点名称 -->
|
||||||
<div class="mb-1 flex min-w-0 items-center gap-2">
|
<div class="mb-1 flex min-w-0 items-center gap-2">
|
||||||
<!-- 站点图标 -->
|
<!-- 站点图标 -->
|
||||||
<VAvatar
|
<VAvatar
|
||||||
tile
|
tile
|
||||||
rounded="lg"
|
rounded="lg"
|
||||||
size="32"
|
size="32"
|
||||||
class="shrink-0"
|
class="shrink-0"
|
||||||
:class="{ 'cursor-move': cardProps.sortable && display.mdAndUp.value }"
|
:class="{ 'cursor-move': cardProps.sortable && display.mdAndUp.value }"
|
||||||
>
|
>
|
||||||
<VImg :src="siteIcon" class="w-full h-full" :alt="cardProps.site?.name" cover>
|
<VImg :src="siteIcon" class="w-full h-full" :alt="cardProps.site?.name" cover>
|
||||||
<template #placeholder>
|
<template #placeholder>
|
||||||
<div class="w-full h-full">
|
<div class="w-full h-full">
|
||||||
<VSkeletonLoader class="object-cover aspect-square" />
|
<VSkeletonLoader class="object-cover aspect-square" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</VImg>
|
||||||
|
</VAvatar>
|
||||||
|
|
||||||
|
<!-- 站点名称和特性图标 -->
|
||||||
|
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||||
|
<h3 class="min-w-0 flex-1 truncate text-lg font-semibold leading-tight">{{ cardProps.site?.name }}</h3>
|
||||||
|
|
||||||
|
<!-- 站点特性图标 -->
|
||||||
|
<div class="ml-auto flex shrink-0 items-center gap-2">
|
||||||
|
<div
|
||||||
|
v-if="cardProps.site?.limit_interval"
|
||||||
|
:class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'"
|
||||||
|
>
|
||||||
|
<VIcon
|
||||||
|
icon="mdi-speedometer"
|
||||||
|
size="16"
|
||||||
|
color="primary"
|
||||||
|
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="cardProps.site?.proxy"
|
||||||
|
:class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'"
|
||||||
|
>
|
||||||
|
<VIcon
|
||||||
|
icon="mdi-network-outline"
|
||||||
|
size="16"
|
||||||
|
color="primary"
|
||||||
|
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="cardProps.site?.render"
|
||||||
|
:class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'"
|
||||||
|
>
|
||||||
|
<VIcon
|
||||||
|
icon="mdi-apple-safari"
|
||||||
|
size="16"
|
||||||
|
color="primary"
|
||||||
|
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="cardProps.site?.filter"
|
||||||
|
:class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'"
|
||||||
|
>
|
||||||
|
<VIcon
|
||||||
|
icon="mdi-filter-cog-outline"
|
||||||
|
size="16"
|
||||||
|
color="primary"
|
||||||
|
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
|
||||||
</VImg>
|
|
||||||
</VAvatar>
|
|
||||||
|
|
||||||
<!-- 站点名称和特性图标 -->
|
|
||||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
|
||||||
<h3 class="min-w-0 flex-1 truncate text-lg font-semibold leading-tight">{{ cardProps.site?.name }}</h3>
|
|
||||||
|
|
||||||
<!-- 站点特性图标 -->
|
|
||||||
<div class="ml-auto flex shrink-0 items-center gap-2">
|
|
||||||
<div v-if="cardProps.site?.limit_interval" :class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'">
|
|
||||||
<VIcon
|
|
||||||
icon="mdi-speedometer"
|
|
||||||
size="16"
|
|
||||||
color="primary"
|
|
||||||
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div v-if="cardProps.site?.proxy" :class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'">
|
|
||||||
<VIcon
|
|
||||||
icon="mdi-network-outline"
|
|
||||||
size="16"
|
|
||||||
color="primary"
|
|
||||||
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div v-if="cardProps.site?.render" :class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'">
|
|
||||||
<VIcon
|
|
||||||
icon="mdi-apple-safari"
|
|
||||||
size="16"
|
|
||||||
color="primary"
|
|
||||||
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div v-if="cardProps.site?.filter" :class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'">
|
|
||||||
<VIcon
|
|
||||||
icon="mdi-filter-cog-outline"
|
|
||||||
size="16"
|
|
||||||
color="primary"
|
|
||||||
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 中间部分:网址 -->
|
<!-- 中间部分:网址 -->
|
||||||
<div class="my-3">
|
<div class="my-3">
|
||||||
<div class="min-w-0 truncate text-sm text-medium-emphasis" @click.stop="handleSiteUrlClick">
|
<div class="min-w-0 truncate text-sm text-medium-emphasis" @click.stop="handleSiteUrlClick">
|
||||||
{{ cardProps.site?.url }}
|
{{ cardProps.site?.url }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 底部:数据统计 -->
|
<!-- 底部:数据统计 -->
|
||||||
<div class="flex-1 flex flex-col justify-end">
|
<div class="flex-1 flex flex-col justify-end">
|
||||||
<!-- 更直观的上传下载数据条 -->
|
<!-- 更直观的上传下载数据条 -->
|
||||||
<div class="border-t mt-1.5 pt-1.5">
|
<div class="border-t mt-1.5 pt-1.5">
|
||||||
<!-- 上传数据 -->
|
<!-- 上传数据 -->
|
||||||
<div class="flex items-center justify-between gap-3 mb-1.5">
|
<div class="flex items-center justify-between gap-3 mb-1.5">
|
||||||
<div class="text-sm text-medium-emphasis min-w-[70px]">
|
<div class="text-sm text-medium-emphasis min-w-[70px]">
|
||||||
<VIcon icon="mdi-arrow-up" size="14" color="info" class="mr-1" />
|
<VIcon icon="mdi-arrow-up" size="14" color="info" class="mr-1" />
|
||||||
<span>{{ formatFileSize(cardProps.data?.upload || 0) }}</span>
|
<span>{{ formatFileSize(cardProps.data?.upload || 0) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow h-1 rounded bg-on-surface/8 relative overflow-hidden">
|
||||||
|
<VProgressLinear :model-value="getUploadPercent" color="info" height="4" rounded="lg" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow h-1 rounded bg-on-surface/8 relative overflow-hidden">
|
|
||||||
<VProgressLinear :model-value="getUploadPercent" color="info" height="4" rounded="lg" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 下载数据 -->
|
<!-- 下载数据 -->
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<div class="flex items-center text-[0.8rem] text-medium-emphasis min-w-[70px]">
|
<div class="flex items-center text-[0.8rem] text-medium-emphasis min-w-[70px]">
|
||||||
<VIcon icon="mdi-arrow-down" size="14" color="success" class="mr-1" />
|
<VIcon icon="mdi-arrow-down" size="14" color="success" class="mr-1" />
|
||||||
<span>{{ formatFileSize(cardProps.data?.download || 0) }}</span>
|
<span>{{ formatFileSize(cardProps.data?.download || 0) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow h-1 rounded bg-on-surface/8 relative overflow-hidden">
|
<div class="flex-grow h-1 rounded bg-on-surface/8 relative overflow-hidden">
|
||||||
<VProgressLinear :model-value="getDownloadPercent" color="warning" height="4" rounded="lg" />
|
<VProgressLinear :model-value="getDownloadPercent" color="warning" height="4" rounded="lg" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 右侧操作按钮区 -->
|
<!-- 右侧操作按钮区 -->
|
||||||
<VSheet v-if="!cardProps.sortable" class="site-card-actions absolute inset-y-0 right-0 z-20 flex flex-col py-2 px-1">
|
<VSheet
|
||||||
<!-- 测试按钮 -->
|
v-if="!cardProps.sortable"
|
||||||
<VBtn
|
class="site-card-actions absolute inset-y-0 right-0 z-20 flex flex-col py-2 px-1"
|
||||||
icon
|
|
||||||
variant="text"
|
|
||||||
density="comfortable"
|
|
||||||
class="mb-1 relative flex items-center justify-center rounded-full mx-auto"
|
|
||||||
:disabled="testButtonDisable"
|
|
||||||
@click.stop="testSite"
|
|
||||||
size="36"
|
|
||||||
>
|
>
|
||||||
<div class="relative flex items-center justify-center w-full h-full">
|
<!-- 测试按钮 -->
|
||||||
<div
|
<VBtn
|
||||||
class="w-[20px] h-[20px] rounded-full shadow-[inset_0_0_0_2px_rgba(var(--v-theme-on-surface),0.1)] pulse-dot"
|
icon
|
||||||
:class="statColor"
|
variant="text"
|
||||||
></div>
|
density="comfortable"
|
||||||
</div>
|
class="mb-1 relative flex items-center justify-center rounded-full mx-auto"
|
||||||
<div
|
:disabled="testButtonDisable"
|
||||||
v-if="testButtonDisable"
|
@click.stop="testSite"
|
||||||
class="absolute inset-0 flex flex-col items-center justify-center bg-surface/95 rounded-full shadow-md animate-fade-in"
|
size="36"
|
||||||
>
|
>
|
||||||
<div class="relative w-6 h-6">
|
<div class="relative flex items-center justify-center w-full h-full">
|
||||||
<div class="spinner-circle"></div>
|
<div
|
||||||
|
class="w-[20px] h-[20px] rounded-full shadow-[inset_0_0_0_2px_rgba(var(--v-theme-on-surface),0.1)] pulse-dot"
|
||||||
|
:class="statColor"
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div
|
||||||
</VBtn>
|
v-if="testButtonDisable"
|
||||||
|
class="absolute inset-0 flex flex-col items-center justify-center bg-surface/95 rounded-full shadow-md animate-fade-in"
|
||||||
|
>
|
||||||
|
<div class="relative w-6 h-6">
|
||||||
|
<div class="spinner-circle"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</VBtn>
|
||||||
|
|
||||||
<!-- 用户数据按钮 -->
|
<!-- 用户数据按钮 -->
|
||||||
<VBtn icon variant="text" @click.stop="handleSiteUserData" size="36">
|
<VBtn icon variant="text" @click.stop="handleSiteUserData" size="36">
|
||||||
<VIcon icon="mdi-chart-bell-curve" size="20" />
|
<VIcon icon="mdi-chart-bell-curve" size="20" />
|
||||||
</VBtn>
|
</VBtn>
|
||||||
|
|
||||||
<!-- 更新按钮 -->
|
<!-- 更新按钮 -->
|
||||||
<VBtn icon variant="text" @click.stop="handleSiteUpdate" size="36">
|
<VBtn icon variant="text" @click.stop="handleSiteUpdate" size="36">
|
||||||
<VIcon icon="mdi-refresh" size="20" />
|
<VIcon icon="mdi-refresh" size="20" />
|
||||||
</VBtn>
|
</VBtn>
|
||||||
|
|
||||||
<!-- 更多选项按钮 -->
|
<!-- 更多选项按钮 -->
|
||||||
<VBtn icon variant="text" class="mt-auto" size="36" @click.stop>
|
<VBtn icon variant="text" class="mt-auto" size="36" @click.stop>
|
||||||
<VIcon icon="mdi-dots-vertical" size="20" />
|
<VIcon icon="mdi-dots-vertical" size="20" />
|
||||||
<VMenu :activator="'parent'" :close-on-content-click="true" :location="'left'">
|
<VMenu :activator="'parent'" :close-on-content-click="true" :location="'left'">
|
||||||
<VList>
|
<VList>
|
||||||
<VListItem @click="handleSiteEdit" base-color="info">
|
<VListItem @click="handleSiteEdit" base-color="info">
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<VIcon icon="mdi-file-edit-outline" size="20" />
|
<VIcon icon="mdi-file-edit-outline" size="20" />
|
||||||
</template>
|
</template>
|
||||||
<VListItemTitle>{{ t('site.actions.edit') }}</VListItemTitle>
|
<VListItemTitle>{{ t('site.actions.edit') }}</VListItemTitle>
|
||||||
</VListItem>
|
</VListItem>
|
||||||
<VListItem @click="deleteSiteInfo">
|
<VListItem @click="deleteSiteInfo">
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<VIcon icon="mdi-delete-outline" size="20" color="error" />
|
<VIcon icon="mdi-delete-outline" size="20" color="error" />
|
||||||
</template>
|
</template>
|
||||||
<VListItemTitle class="text-error">{{ t('site.deleteSite') }}</VListItemTitle>
|
<VListItemTitle class="text-error">{{ t('site.deleteSite') }}</VListItemTitle>
|
||||||
</VListItem>
|
</VListItem>
|
||||||
</VList>
|
</VList>
|
||||||
</VMenu>
|
</VMenu>
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</VSheet>
|
</VSheet>
|
||||||
</VCard>
|
</VCard>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -442,7 +457,9 @@ onMounted(() => {
|
|||||||
inset-block-start: 0;
|
inset-block-start: 0;
|
||||||
inset-inline: 0;
|
inset-inline: 0;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
transition: block-size 0.3s ease, opacity 0.3s ease;
|
transition:
|
||||||
|
block-size 0.3s ease,
|
||||||
|
opacity 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-status-indicator.error {
|
.site-status-indicator.error {
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import type { Site, SiteStatistic, SiteUserData } from '@/api/types'
|
||||||
|
import SiteCard from '@/components/cards/SiteCard.vue'
|
||||||
|
import { getActiveRequestsCount } from '@/utils/requestOptimizer'
|
||||||
|
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||||
|
import { createSite, createSiteStatistic, createSiteUserData } from '@tests/support/factories/site'
|
||||||
|
import { deleteSiteHandler, siteIconHandler, testSiteConnectionHandler } from '@tests/support/msw/handlers/site'
|
||||||
|
import { server } from '@tests/support/msw/server'
|
||||||
|
import { renderWithProviders } from '@tests/support/render'
|
||||||
|
import { defineComponent, h } from 'vue'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
confirm: vi.fn(),
|
||||||
|
openSharedDialog: vi.fn(),
|
||||||
|
toastError: vi.fn(),
|
||||||
|
toastSuccess: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('vue-toastification', () => ({
|
||||||
|
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/useConfirm', () => ({
|
||||||
|
useConfirm: () => mocks.confirm,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/useSharedDialog', () => ({
|
||||||
|
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const ImageStub = defineComponent({
|
||||||
|
inheritAttrs: false,
|
||||||
|
props: {
|
||||||
|
alt: String,
|
||||||
|
src: String,
|
||||||
|
},
|
||||||
|
setup: props => () => h('img', { alt: props.alt, src: props.src }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const imageStubs = { VImg: ImageStub }
|
||||||
|
|
||||||
|
async function renderCard(
|
||||||
|
siteOverrides: Partial<Site> = {},
|
||||||
|
props: Partial<{ data: SiteUserData; sortable: boolean; stats: SiteStatistic }> = {},
|
||||||
|
) {
|
||||||
|
const site = createSite(siteOverrides)
|
||||||
|
server.use(siteIconHandler(site.id, `https://images.example.com/site-${site.id}.png`))
|
||||||
|
const result = await renderWithProviders(SiteCard, {
|
||||||
|
global: { stubs: imageStubs },
|
||||||
|
props: { site, ...props },
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.container.querySelector<HTMLImageElement>('img')?.src).toContain(`site-${site.id}.png`)
|
||||||
|
})
|
||||||
|
await waitFor(() => expect(getActiveRequestsCount()).toBe(0))
|
||||||
|
return { ...result, site }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActionButton(container: Element, index: number) {
|
||||||
|
const button = container.querySelectorAll<HTMLButtonElement>('.site-card-actions > button')[index]
|
||||||
|
if (!button) throw new Error(`Missing action button ${index}`)
|
||||||
|
return button
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTestButton(container: Element) {
|
||||||
|
const button = container.querySelector('.pulse-dot')?.closest('button')
|
||||||
|
if (!button) throw new Error('Missing connectivity test button')
|
||||||
|
return button
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDialogCall(index = 0) {
|
||||||
|
const [, props, events, options] = mocks.openSharedDialog.mock.calls[index] as [
|
||||||
|
unknown,
|
||||||
|
Record<string, unknown>,
|
||||||
|
Record<string, () => void>,
|
||||||
|
Record<string, unknown>,
|
||||||
|
]
|
||||||
|
return { events, options, props }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SiteCard display', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.confirm.mockResolvedValue(true)
|
||||||
|
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders active site metadata, transfer values, feature flags, and a healthy border', async () => {
|
||||||
|
const { container, site } = await renderCard(
|
||||||
|
{ filter: 'free', limit_interval: 10, proxy: true, render: true },
|
||||||
|
{
|
||||||
|
data: createSiteUserData({ download: 1024, upload: 2048 }),
|
||||||
|
stats: createSiteStatistic({ lst_state: 0, seconds: 2 }),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByText(site.name)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(site.url)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('2.00 KB')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('1.00 KB')).toBeInTheDocument()
|
||||||
|
expect(container.querySelector('.site-card')).toHaveClass('border-success')
|
||||||
|
expect(container.querySelectorAll('.ml-auto.flex.shrink-0.items-center.gap-2 > div')).toHaveLength(4)
|
||||||
|
expect(
|
||||||
|
[...container.querySelectorAll('.border-t .v-progress-linear')].map(progress =>
|
||||||
|
progress.getAttribute('aria-valuenow'),
|
||||||
|
),
|
||||||
|
).toEqual(['100', '50'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['failed', createSiteStatistic({ lst_state: 1 }), 'border-error'],
|
||||||
|
['slow', createSiteStatistic({ lst_state: 0, seconds: 5 }), 'border-warning'],
|
||||||
|
['unknown without stats', undefined, null],
|
||||||
|
['unknown without duration', createSiteStatistic({ lst_state: 0, seconds: 0 }), null],
|
||||||
|
] as const)('projects %s connection state without inventing status', async (_case, stats, borderClass) => {
|
||||||
|
const { container } = await renderCard({}, stats ? { stats } : {})
|
||||||
|
const card = container.querySelector('.site-card')
|
||||||
|
|
||||||
|
if (borderClass) expect(card).toHaveClass(borderClass)
|
||||||
|
else expect(card).not.toHaveClass('border-error', 'border-warning', 'border-success')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps zero transfer data visible with stable minimum progress', async () => {
|
||||||
|
await renderCard({ is_active: false }, { data: createSiteUserData({ download: 0, upload: 0 }) })
|
||||||
|
|
||||||
|
expect(screen.getAllByText('0.00 B')).toHaveLength(2)
|
||||||
|
expect(
|
||||||
|
[...document.querySelectorAll('.border-t .v-progress-linear')].map(progress =>
|
||||||
|
progress.getAttribute('aria-valuenow'),
|
||||||
|
),
|
||||||
|
).toEqual(['3', '3'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('SiteCard interactions', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.confirm.mockResolvedValue(true)
|
||||||
|
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['success', 200, { success: true }, 'success'],
|
||||||
|
['business failure', 200, { message: '认证失败', success: false }, 'error'],
|
||||||
|
] as const)(
|
||||||
|
'reports connectivity %s and refreshes the current domain',
|
||||||
|
async (_case, status, response, toastType) => {
|
||||||
|
const requested = vi.fn()
|
||||||
|
const { container, emitted, site } = await renderCard()
|
||||||
|
server.use(testSiteConnectionHandler(site.id, response, status, requested))
|
||||||
|
|
||||||
|
await fireEvent.click(getTestButton(container))
|
||||||
|
|
||||||
|
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
|
||||||
|
const toast = toastType === 'success' ? mocks.toastSuccess : mocks.toastError
|
||||||
|
await waitFor(() => expect(toast).toHaveBeenCalledOnce())
|
||||||
|
expect(emitted('refresh-stats')).toEqual([[site.domain]])
|
||||||
|
expect(getTestButton(container)).not.toBeDisabled()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
it('restores connectivity controls after an HTTP failure', async () => {
|
||||||
|
const requested = vi.fn()
|
||||||
|
const { container, emitted, site } = await renderCard()
|
||||||
|
server.use(testSiteConnectionHandler(site.id, { message: 'server down', success: false }, 500, requested))
|
||||||
|
|
||||||
|
await fireEvent.click(getTestButton(container))
|
||||||
|
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
|
||||||
|
|
||||||
|
await waitFor(() => expect(getTestButton(container)).not.toBeDisabled())
|
||||||
|
expect(emitted('refresh-stats') ?? []).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['cancelled', false, 200, { success: true }, false, null],
|
||||||
|
['success', true, 200, { success: true }, true, null],
|
||||||
|
['business failure', true, 200, { message: '仍在使用', success: false }, false, '仍在使用'],
|
||||||
|
['HTTP failure', true, 500, { message: 'server down', success: false }, false, null],
|
||||||
|
] as const)('handles deletion when %s', async (_case, confirmed, status, response, removed, expectedMessage) => {
|
||||||
|
const requested = vi.fn()
|
||||||
|
mocks.confirm.mockResolvedValue(confirmed)
|
||||||
|
const { container, emitted, site } = await renderCard()
|
||||||
|
server.use(deleteSiteHandler(site.id, response, status, requested))
|
||||||
|
|
||||||
|
await fireEvent.click(getActionButton(container, 3))
|
||||||
|
await fireEvent.click(await screen.findByText('删除站点'))
|
||||||
|
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledOnce())
|
||||||
|
|
||||||
|
if (confirmed) await waitFor(() => expect(requested).toHaveBeenCalledOnce())
|
||||||
|
else expect(requested).not.toHaveBeenCalled()
|
||||||
|
expect(emitted('remove') ?? []).toHaveLength(removed ? 1 : 0)
|
||||||
|
if (expectedMessage) expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining(expectedMessage))
|
||||||
|
if (_case === 'HTTP failure') expect(mocks.toastError).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens each shared dialog with exact props, close events, and refresh ownership', async () => {
|
||||||
|
const { container, emitted, site } = await renderCard()
|
||||||
|
|
||||||
|
await fireEvent.click(container.querySelector('.site-card') as Element)
|
||||||
|
expect(getDialogCall().props).toEqual({ site })
|
||||||
|
expect(getDialogCall().options).toEqual({ closeOn: ['close'] })
|
||||||
|
getDialogCall().events.close()
|
||||||
|
expect(emitted('refresh-stats')).toEqual([[site.domain]])
|
||||||
|
|
||||||
|
await fireEvent.click(getActionButton(container, 1))
|
||||||
|
expect(getDialogCall(1).props).toEqual({ site })
|
||||||
|
expect(getDialogCall(1).options).toEqual({ closeOn: ['close'] })
|
||||||
|
|
||||||
|
await fireEvent.click(getActionButton(container, 2))
|
||||||
|
expect(getDialogCall(2).props).toEqual({ site })
|
||||||
|
expect(getDialogCall(2).options).toEqual({ closeOn: ['close', 'done'] })
|
||||||
|
getDialogCall(2).events.done()
|
||||||
|
expect(emitted('refresh-stats')).toEqual([[site.domain], [site.domain]])
|
||||||
|
|
||||||
|
await fireEvent.click(getActionButton(container, 3))
|
||||||
|
await fireEvent.click(await screen.findByText('编辑站点'))
|
||||||
|
expect(getDialogCall(3).props).toEqual({ siteid: site.id })
|
||||||
|
expect(getDialogCall(3).options).toEqual({ closeOn: ['close', 'save', 'remove'] })
|
||||||
|
getDialogCall(3).events.save()
|
||||||
|
getDialogCall(3).events.remove()
|
||||||
|
expect(emitted('update')).toHaveLength(1)
|
||||||
|
expect(emitted('remove')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens the site URL normally and isolates every card action in sortable mode', async () => {
|
||||||
|
const open = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||||
|
const { container, rerender, site } = await renderCard()
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByText(site.url))
|
||||||
|
expect(open).toHaveBeenCalledWith(site.url, '_blank')
|
||||||
|
|
||||||
|
await rerender({ site, sortable: true })
|
||||||
|
expect(container.querySelector('.site-card-actions')).not.toBeInTheDocument()
|
||||||
|
await fireEvent.click(screen.getByText(site.url))
|
||||||
|
await fireEvent.click(container.querySelector('.site-card') as Element)
|
||||||
|
expect(open).toHaveBeenCalledOnce()
|
||||||
|
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the default icon when the icon request fails', async () => {
|
||||||
|
const site = createSite()
|
||||||
|
const requested = vi.fn()
|
||||||
|
server.use(siteIconHandler(site.id, null, 500, requested))
|
||||||
|
const { container } = await renderWithProviders(SiteCard, {
|
||||||
|
global: { stubs: imageStubs },
|
||||||
|
props: { site },
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
|
||||||
|
await waitFor(() => expect(getActiveRequestsCount()).toBe(0))
|
||||||
|
await waitFor(() => expect(container.querySelector<HTMLImageElement>('img')?.src).toContain('/site.webp'))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { getCachedSiteIcon } from '@/utils/siteIconCache'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
let keySeed = 0
|
||||||
|
|
||||||
|
function nextKey() {
|
||||||
|
keySeed += 1
|
||||||
|
return `site-icon-${keySeed}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void
|
||||||
|
let reject!: (reason?: unknown) => void
|
||||||
|
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||||
|
resolve = resolvePromise
|
||||||
|
reject = rejectPromise
|
||||||
|
})
|
||||||
|
|
||||||
|
return { promise, reject, resolve }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('site icon cache', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.setSystemTime(new Date('2026-07-19T00:00:00Z'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('isolates site ids and reuses each value within the TTL', async () => {
|
||||||
|
const firstLoader = vi.fn().mockResolvedValue('first-icon')
|
||||||
|
const secondLoader = vi.fn().mockResolvedValue('second-icon')
|
||||||
|
const firstKey = nextKey()
|
||||||
|
const secondKey = nextKey()
|
||||||
|
|
||||||
|
await expect(getCachedSiteIcon(firstKey, firstLoader)).resolves.toBe('first-icon')
|
||||||
|
await expect(getCachedSiteIcon(secondKey, secondLoader)).resolves.toBe('second-icon')
|
||||||
|
await expect(getCachedSiteIcon(firstKey, vi.fn().mockResolvedValue('stale'))).resolves.toBe('first-icon')
|
||||||
|
|
||||||
|
expect(firstLoader).toHaveBeenCalledOnce()
|
||||||
|
expect(secondLoader).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('coalesces concurrent requests for the same site', async () => {
|
||||||
|
const pending = deferred<string>()
|
||||||
|
const loader = vi.fn().mockReturnValue(pending.promise)
|
||||||
|
const key = nextKey()
|
||||||
|
|
||||||
|
const firstRequest = getCachedSiteIcon(key, loader)
|
||||||
|
const secondRequest = getCachedSiteIcon(key, loader)
|
||||||
|
expect(loader).toHaveBeenCalledOnce()
|
||||||
|
|
||||||
|
pending.resolve('shared-icon')
|
||||||
|
await expect(firstRequest).resolves.toBe('shared-icon')
|
||||||
|
await expect(secondRequest).resolves.toBe('shared-icon')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reloads an expired value after ten minutes', async () => {
|
||||||
|
const key = nextKey()
|
||||||
|
await expect(getCachedSiteIcon(key, vi.fn().mockResolvedValue('old-icon'))).resolves.toBe('old-icon')
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(10 * 60 * 1000)
|
||||||
|
const refreshedLoader = vi.fn().mockResolvedValue('fresh-icon')
|
||||||
|
|
||||||
|
await expect(getCachedSiteIcon(key, refreshedLoader)).resolves.toBe('fresh-icon')
|
||||||
|
expect(refreshedLoader).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows a retry after the loader rejects', async () => {
|
||||||
|
const key = nextKey()
|
||||||
|
await expect(getCachedSiteIcon(key, vi.fn().mockRejectedValue(new Error('temporary failure')))).rejects.toThrow(
|
||||||
|
'temporary failure',
|
||||||
|
)
|
||||||
|
|
||||||
|
const retryLoader = vi.fn().mockResolvedValue('recovered-icon')
|
||||||
|
await expect(getCachedSiteIcon(key, retryLoader)).resolves.toBe('recovered-icon')
|
||||||
|
expect(retryLoader).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
import type { Site, SiteStatistic, SiteUserData } from '@/api/types'
|
import type { ApiResponse, Site, SiteStatistic, SiteUserData } from '@/api/types'
|
||||||
import { HttpResponse, http, type JsonBodyType } from 'msw'
|
import { HttpResponse, http, type JsonBodyType } from 'msw'
|
||||||
|
|
||||||
const API_BASE_URL = 'http://localhost/api/v1/'
|
const API_BASE_URL = 'http://localhost/api/v1/'
|
||||||
|
|
||||||
export const siteApiUrls = {
|
export const siteApiUrls = {
|
||||||
|
delete: (id: number) => new URL(`site/${id}`, API_BASE_URL).href,
|
||||||
|
icon: (id: number) => new URL(`site/icon/${id}`, API_BASE_URL).href,
|
||||||
list: new URL('site/', API_BASE_URL).href,
|
list: new URL('site/', API_BASE_URL).href,
|
||||||
priorities: new URL('site/priorities', 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,
|
statistic: (domain: string) => new URL(`site/statistic/${domain}`, API_BASE_URL).href,
|
||||||
statistics: new URL('site/statistic', API_BASE_URL).href,
|
statistics: new URL('site/statistic', API_BASE_URL).href,
|
||||||
|
test: (id: number) => new URL(`site/test/${id}`, API_BASE_URL).href,
|
||||||
userDataLatest: new URL('site/userdata/latest', API_BASE_URL).href,
|
userDataLatest: new URL('site/userdata/latest', API_BASE_URL).href,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,3 +74,43 @@ export function saveSitePrioritiesHandler(
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function response(body: JsonBodyType, status: number) {
|
||||||
|
return HttpResponse.json(body, { status })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function siteIconHandler(
|
||||||
|
id: number,
|
||||||
|
icon: string | null,
|
||||||
|
status = 200,
|
||||||
|
onRequest: () => void | Promise<void> = () => {},
|
||||||
|
) {
|
||||||
|
return http.get(siteApiUrls.icon(id), async () => {
|
||||||
|
await onRequest()
|
||||||
|
return response({ data: icon ? { icon } : {}, success: Boolean(icon) }, status)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function testSiteConnectionHandler(
|
||||||
|
id: number,
|
||||||
|
result: Pick<ApiResponse<never>, 'message' | 'success'>,
|
||||||
|
status = 200,
|
||||||
|
onRequest: () => void | Promise<void> = () => {},
|
||||||
|
) {
|
||||||
|
return http.get(siteApiUrls.test(id), async () => {
|
||||||
|
await onRequest()
|
||||||
|
return response(result, status)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteSiteHandler(
|
||||||
|
id: number,
|
||||||
|
result: Pick<ApiResponse<never>, 'message' | 'success'> = { success: true },
|
||||||
|
status = 200,
|
||||||
|
onRequest: () => void | Promise<void> = () => {},
|
||||||
|
) {
|
||||||
|
return http.delete(siteApiUrls.delete(id), async () => {
|
||||||
|
await onRequest()
|
||||||
|
return response(result, status)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -305,10 +305,12 @@ export default defineConfig(({ mode }) => ({
|
|||||||
'src/views/discover/MediaCardListView.vue',
|
'src/views/discover/MediaCardListView.vue',
|
||||||
'src/views/discover/MediaDetailView.vue',
|
'src/views/discover/MediaDetailView.vue',
|
||||||
'src/components/cards/MediaCard.vue',
|
'src/components/cards/MediaCard.vue',
|
||||||
|
'src/components/cards/SiteCard.vue',
|
||||||
'src/components/slide/VirtualSlideView.vue',
|
'src/components/slide/VirtualSlideView.vue',
|
||||||
'src/views/discover/PersonCardSlideView.vue',
|
'src/views/discover/PersonCardSlideView.vue',
|
||||||
'src/utils/mediaStatusCache.ts',
|
'src/utils/mediaStatusCache.ts',
|
||||||
'src/views/site/SiteCardListView.vue',
|
'src/views/site/SiteCardListView.vue',
|
||||||
|
'src/utils/siteIconCache.ts',
|
||||||
],
|
],
|
||||||
provider: 'v8',
|
provider: 'v8',
|
||||||
reporter: ['text', 'json-summary', 'html'],
|
reporter: ['text', 'json-summary', 'html'],
|
||||||
@@ -492,6 +494,18 @@ export default defineConfig(({ mode }) => ({
|
|||||||
lines: 90,
|
lines: 90,
|
||||||
statements: 90,
|
statements: 90,
|
||||||
},
|
},
|
||||||
|
'src/components/cards/SiteCard.vue': {
|
||||||
|
branches: 85,
|
||||||
|
functions: 90,
|
||||||
|
lines: 90,
|
||||||
|
statements: 90,
|
||||||
|
},
|
||||||
|
'src/utils/siteIconCache.ts': {
|
||||||
|
branches: 85,
|
||||||
|
functions: 90,
|
||||||
|
lines: 90,
|
||||||
|
statements: 90,
|
||||||
|
},
|
||||||
'src/components/slide/VirtualSlideView.vue': {
|
'src/components/slide/VirtualSlideView.vue': {
|
||||||
branches: 85,
|
branches: 85,
|
||||||
functions: 90,
|
functions: 90,
|
||||||
|
|||||||
Reference in New Issue
Block a user