mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-08 09:16:58 +08:00
test(site): cover site user data dialog (#564)
This commit is contained in:
@@ -385,11 +385,6 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/components/dialog/SiteUserDataDialog.vue": {
|
|
||||||
"@typescript-eslint/no-explicit-any": {
|
|
||||||
"count": 5
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/components/dialog/SmbConfigDialog.vue": {
|
"src/components/dialog/SmbConfigDialog.vue": {
|
||||||
"@typescript-eslint/no-explicit-any": {
|
"@typescript-eslint/no-explicit-any": {
|
||||||
"count": 2
|
"count": 2
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { Site, SiteUserData } from '@/api/types'
|
import type { ApiResponse, Site, SiteUserData } from '@/api/types'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { useDisplay, useTheme } from 'vuetify'
|
import { useDisplay, useTheme } from 'vuetify'
|
||||||
import { formatFileSize } from '@/@core/utils/formatters'
|
import { formatFileSize } from '@/@core/utils/formatters'
|
||||||
@@ -23,6 +23,9 @@ const emit = defineEmits(['close'])
|
|||||||
// 进度框
|
// 进度框
|
||||||
const progressDialog = ref(false)
|
const progressDialog = ref(false)
|
||||||
|
|
||||||
|
// 失败状态保留对应操作,重试时不改变已展示的站点数据。
|
||||||
|
const failedOperation = ref<'load' | 'refresh'>()
|
||||||
|
|
||||||
const vuetifyTheme = useTheme()
|
const vuetifyTheme = useTheme()
|
||||||
|
|
||||||
const currentTheme = controlledComputed(
|
const currentTheme = controlledComputed(
|
||||||
@@ -33,6 +36,9 @@ const currentTheme = controlledComputed(
|
|||||||
// 站点数据列表
|
// 站点数据列表
|
||||||
const siteDatas = ref<SiteUserData[]>([])
|
const siteDatas = ref<SiteUserData[]>([])
|
||||||
|
|
||||||
|
// 只有最近一次读取可以更新弹窗状态,避免首载慢响应覆盖手动刷新结果。
|
||||||
|
let fetchGeneration = 0
|
||||||
|
|
||||||
// 最新一天的数据
|
// 最新一天的数据
|
||||||
const siteData = computed(() => siteDatas.value[siteDatas.value.length - 1])
|
const siteData = computed(() => siteDatas.value[siteDatas.value.length - 1])
|
||||||
|
|
||||||
@@ -58,8 +64,8 @@ const historyChartOptions = computed(() => {
|
|||||||
parentHeightOffset: 0,
|
parentHeightOffset: 0,
|
||||||
toolbar: { show: false },
|
toolbar: { show: false },
|
||||||
animations: { enabled: true },
|
animations: { enabled: true },
|
||||||
background: currentTheme.value.surface, // 新增背景色同步
|
background: currentTheme.value.surface, // 图表背景随应用主题切换
|
||||||
foreColor: currentTheme.value.onSurface, // 新增文字颜色同步
|
foreColor: currentTheme.value.onSurface, // 图表文字随应用主题切换
|
||||||
dataLabels: {
|
dataLabels: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
@@ -139,10 +145,12 @@ const historyChartOptions = computed(() => {
|
|||||||
|
|
||||||
// 做种分布列,seeding_info的格式为[[x, y], [x, y], ...],x为做种数,y为做种体积,做种体积需要转换为GB
|
// 做种分布列,seeding_info的格式为[[x, y], [x, y], ...],x为做种数,y为做种体积,做种体积需要转换为GB
|
||||||
const seedingSeries = computed(() => {
|
const seedingSeries = computed(() => {
|
||||||
|
const seedingInfo = siteData.value?.seeding_info as [number?, number?][] | undefined
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
name: t('dialog.siteUserData.volumeTitle'),
|
name: t('dialog.siteUserData.volumeTitle'),
|
||||||
data: siteData.value?.seeding_info?.map(item => [item[0] ?? 0, Math.round((item[1] ?? 0) / 1024 / 1024 / 1024)]),
|
data: seedingInfo?.map(item => [item[0] ?? 0, Math.round((item[1] ?? 0) / 1024 / 1024 / 1024)]) ?? [],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
@@ -155,8 +163,8 @@ const seedingChartOptions = computed(() => {
|
|||||||
parentHeightOffset: 0,
|
parentHeightOffset: 0,
|
||||||
toolbar: { show: false },
|
toolbar: { show: false },
|
||||||
animations: { enabled: true },
|
animations: { enabled: true },
|
||||||
background: currentTheme.value.surface, // 新增背景色同步
|
background: currentTheme.value.surface, // 图表背景随应用主题切换
|
||||||
foreColor: currentTheme.value.onSurface, // 新增文字颜色同步
|
foreColor: currentTheme.value.onSurface, // 图表文字随应用主题切换
|
||||||
zoom: {
|
zoom: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
allowMouseWheelZoom: false,
|
allowMouseWheelZoom: false,
|
||||||
@@ -214,7 +222,7 @@ const seedingChartOptions = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 根据传入属性,计算列表数据中第一条与第二条的差值,如果没有第二条则差值为全部
|
// 根据传入属性,计算列表数据中第一条与第二条的差值,如果没有第二条则差值为全部
|
||||||
const diffData: { [key: string]: any } = computed(() => {
|
const diffData = computed(() => {
|
||||||
if (siteDatas.value.length < 2) {
|
if (siteDatas.value.length < 2) {
|
||||||
return siteData.value
|
return siteData.value
|
||||||
}
|
}
|
||||||
@@ -253,34 +261,58 @@ function getDiffClass(diff: number | undefined) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 查询站点用户数据
|
// 查询站点用户数据
|
||||||
async function fetchSiteUserData() {
|
async function fetchSiteUserData(failureOperation: 'load' | 'refresh' = 'load') {
|
||||||
|
const generation = ++fetchGeneration
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.get(`site/userdata/${props.site?.id}`)
|
const result = await api.get<ApiResponse<SiteUserData[]>, ApiResponse<SiteUserData[]>>(
|
||||||
|
`site/userdata/${props.site?.id}`,
|
||||||
|
)
|
||||||
|
if (generation !== fetchGeneration) return false
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// 使用nextTick确保DOM更新完成后再更新图表数据
|
// 使用nextTick确保DOM更新完成后再更新图表数据
|
||||||
await nextTick()
|
await nextTick()
|
||||||
siteDatas.value = result.data.sort((a: { updated_day: any }, b: { updated_day: any }) =>
|
siteDatas.value = result.data.sort((a, b) => (a.updated_day || '').localeCompare(b.updated_day || ''))
|
||||||
(a.updated_day || '').localeCompare(b.updated_day || ''),
|
failedOperation.value = undefined
|
||||||
)
|
return true
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (generation !== fetchGeneration) return false
|
||||||
|
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
failedOperation.value = failureOperation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 刷新站点数据
|
// 刷新站点数据
|
||||||
async function refreshSiteData() {
|
async function refreshSiteData() {
|
||||||
progressDialog.value = true
|
progressDialog.value = true
|
||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.post(`site/userdata/${props.site?.id}`)
|
const result = await api.post<ApiResponse<unknown>, ApiResponse<unknown>>(`site/userdata/${props.site?.id}`)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
await fetchSiteUserData()
|
await fetchSiteUserData('refresh')
|
||||||
|
} else {
|
||||||
|
failedOperation.value = 'refresh'
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.error(error)
|
||||||
}
|
failedOperation.value = 'refresh'
|
||||||
|
} finally {
|
||||||
progressDialog.value = false
|
progressDialog.value = false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重试最近失败的请求,刷新失败时继续保留当前数据。
|
||||||
|
function retryFailedOperation() {
|
||||||
|
if (failedOperation.value === 'refresh') {
|
||||||
|
refreshSiteData()
|
||||||
|
} else {
|
||||||
|
fetchSiteUserData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onBeforeMount(() => {
|
onBeforeMount(() => {
|
||||||
// 延迟加载,确保组件完全挂载
|
// 延迟加载,确保组件完全挂载
|
||||||
@@ -302,6 +334,20 @@ onBeforeMount(() => {
|
|||||||
</VCardItem>
|
</VCardItem>
|
||||||
<VDivider />
|
<VDivider />
|
||||||
<VCardText class="pt-5">
|
<VCardText class="pt-5">
|
||||||
|
<VAlert v-if="failedOperation" type="error" variant="tonal" class="mb-5">
|
||||||
|
<div class="d-flex flex-wrap align-center justify-space-between gap-3">
|
||||||
|
<span>
|
||||||
|
{{
|
||||||
|
failedOperation === 'refresh'
|
||||||
|
? t('dialog.siteUserData.refreshFailed')
|
||||||
|
: t('dialog.siteUserData.loadFailed')
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
<VBtn size="small" variant="tonal" prepend-icon="mdi-refresh" @click="retryFailedOperation">
|
||||||
|
{{ t('common.retry') }}
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
|
</VAlert>
|
||||||
<VRow class="match-height">
|
<VRow class="match-height">
|
||||||
<!-- 用户信息 -->
|
<!-- 用户信息 -->
|
||||||
<VCol cols="12" md="3">
|
<VCol cols="12" md="3">
|
||||||
|
|||||||
@@ -0,0 +1,346 @@
|
|||||||
|
import SiteUserDataDialog from '@/components/dialog/SiteUserDataDialog.vue'
|
||||||
|
import type { ApiResponse, SiteUserData } from '@/api/types'
|
||||||
|
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||||
|
import { createSite, createSiteUserData } from '@tests/support/factories/site'
|
||||||
|
import { refreshSiteUserDataHandler, siteUserDataHandler } from '@tests/support/msw/handlers/site'
|
||||||
|
import { server } from '@tests/support/msw/server'
|
||||||
|
import { renderWithProviders } from '@tests/support/render'
|
||||||
|
import { getActiveRequestsCount } from '@/utils/requestOptimizer'
|
||||||
|
import { flushPromises } from '@vue/test-utils'
|
||||||
|
import { defineComponent, h, type PropType } from 'vue'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
type ChartInput = {
|
||||||
|
options: Record<string, unknown>
|
||||||
|
series: { data?: unknown[]; name?: string }[]
|
||||||
|
type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartInputs: ChartInput[] = []
|
||||||
|
|
||||||
|
const ApexChartStub = defineComponent({
|
||||||
|
name: 'VApexChart',
|
||||||
|
props: {
|
||||||
|
options: { type: Object as PropType<Record<string, unknown>>, required: true },
|
||||||
|
series: { type: Array as PropType<ChartInput['series']>, required: true },
|
||||||
|
type: { type: String, required: true },
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
chartInputs.push(props)
|
||||||
|
return () => h('div', { 'data-testid': `chart-${props.type}` })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const ProgressDialogStub = defineComponent({
|
||||||
|
name: 'ProgressDialog',
|
||||||
|
props: { text: String },
|
||||||
|
setup: props => () => h('div', { 'data-testid': 'refresh-progress' }, props.text),
|
||||||
|
})
|
||||||
|
|
||||||
|
const DialogCloseButtonStub = defineComponent({
|
||||||
|
name: 'VDialogCloseBtn',
|
||||||
|
setup:
|
||||||
|
(_, { attrs }) =>
|
||||||
|
() =>
|
||||||
|
h('button', { ...attrs, 'aria-label': '关闭' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void
|
||||||
|
const promise = new Promise<T>(resolvePromise => {
|
||||||
|
resolve = resolvePromise
|
||||||
|
})
|
||||||
|
|
||||||
|
return { promise, resolve }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderDialog(
|
||||||
|
initialResult: Pick<ApiResponse<SiteUserData[]>, 'data' | 'message' | 'success'> = { data: [], success: false },
|
||||||
|
status = 200,
|
||||||
|
onRequest: () => void | Promise<void> = () => {},
|
||||||
|
) {
|
||||||
|
const site = createSite()
|
||||||
|
server.use(siteUserDataHandler(site.id, initialResult, status, onRequest))
|
||||||
|
const result = await renderWithProviders(SiteUserDataDialog, {
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
ProgressDialog: ProgressDialogStub,
|
||||||
|
VApexChart: ApexChartStub,
|
||||||
|
VDialogCloseBtn: DialogCloseButtonStub,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
props: { site },
|
||||||
|
})
|
||||||
|
return { ...result, site }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChart(type: string) {
|
||||||
|
const chart = chartInputs.find(input => input.type === type)
|
||||||
|
if (!chart) throw new Error(`Missing ${type} chart input`)
|
||||||
|
return chart
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRefreshButton() {
|
||||||
|
const button = document.querySelector<HTMLButtonElement>('.v-card-title button')
|
||||||
|
if (!button) throw new Error('Missing refresh button')
|
||||||
|
return button
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRetryButton() {
|
||||||
|
const button = Array.from(document.querySelectorAll<HTMLButtonElement>('.v-alert button')).find(element =>
|
||||||
|
element.textContent?.includes('重试'),
|
||||||
|
)
|
||||||
|
if (!button) throw new Error('Missing retry button')
|
||||||
|
return button
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SiteUserDataDialog projections', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
chartInputs.length = 0
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sorts history, renders the latest record, and projects positive, negative, and zero deltas', async () => {
|
||||||
|
const older = createSiteUserData({
|
||||||
|
bonus: 100,
|
||||||
|
download: 4 * 1024 ** 3,
|
||||||
|
ratio: 1.5,
|
||||||
|
seeding: 2,
|
||||||
|
seeding_size: 3 * 1024 ** 3,
|
||||||
|
updated_day: '2026-07-17',
|
||||||
|
upload: 1024 ** 3,
|
||||||
|
user_level: 'Old',
|
||||||
|
})
|
||||||
|
const latest = createSiteUserData({
|
||||||
|
bonus: 150,
|
||||||
|
download: 2 * 1024 ** 3,
|
||||||
|
ratio: 1.25,
|
||||||
|
seeding: 2,
|
||||||
|
seeding_info: [
|
||||||
|
[10, 2 * 1024 ** 3],
|
||||||
|
[0, 0],
|
||||||
|
],
|
||||||
|
seeding_size: 4 * 1024 ** 3,
|
||||||
|
updated_day: '2026-07-19',
|
||||||
|
upload: 3 * 1024 ** 3,
|
||||||
|
user_level: 'Elite',
|
||||||
|
})
|
||||||
|
await renderDialog({ data: [latest, older], success: true })
|
||||||
|
|
||||||
|
await screen.findByText('Elite')
|
||||||
|
|
||||||
|
expect(screen.getByText('150')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('(+50)')).toHaveClass('text-success')
|
||||||
|
expect(screen.getByText('(-0.25)')).toHaveClass('text-error')
|
||||||
|
expect(screen.getByText('(+0)')).not.toHaveClass('text-success', 'text-error')
|
||||||
|
expect(screen.getByText('3.00 GB')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('(+2.00 GB)')).toHaveClass('text-success')
|
||||||
|
expect(screen.getByText('2.00 GB')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('(-2.00 GB)')).toHaveClass('text-error')
|
||||||
|
expect(document.body).toHaveTextContent('2024-01-02')
|
||||||
|
|
||||||
|
const history = getChart('line')
|
||||||
|
expect(history.series).toEqual([
|
||||||
|
{ data: [1, 3], name: '上传量' },
|
||||||
|
{ data: [4, 2], name: '下载量' },
|
||||||
|
])
|
||||||
|
expect((history.options.xaxis as { categories: string[] }).categories).toEqual(['2026-07-17', '2026-07-19'])
|
||||||
|
expect((history.options.theme as { mode: string }).mode).toBe('light')
|
||||||
|
expect((history.options.chart as { background: string; foreColor: string }).background).toBeTruthy()
|
||||||
|
expect(
|
||||||
|
(history.options.xaxis as { labels: { formatter: (value: string) => string } }).labels.formatter('2026-07-19'),
|
||||||
|
).toBe(new Date('2026-07-19').toLocaleDateString('zh-CN'))
|
||||||
|
expect((history.options.yaxis as { labels: { formatter: (value: number) => string } }).labels.formatter(1234)).toBe(
|
||||||
|
(1234).toLocaleString(),
|
||||||
|
)
|
||||||
|
|
||||||
|
const seeding = getChart('scatter')
|
||||||
|
expect(seeding.series).toEqual([
|
||||||
|
{
|
||||||
|
data: [
|
||||||
|
[10, 2],
|
||||||
|
[0, 0],
|
||||||
|
],
|
||||||
|
name: '体积',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect((seeding.options.theme as { mode: string }).mode).toBe('light')
|
||||||
|
expect((seeding.options.tooltip as { x: { formatter: (value: number) => string } }).x.formatter(1234)).toBe(
|
||||||
|
`数量:${(1234).toLocaleString()}`,
|
||||||
|
)
|
||||||
|
expect((seeding.options.xaxis as { labels: { formatter: (value: number) => string } }).labels.formatter(1.6)).toBe(
|
||||||
|
'2',
|
||||||
|
)
|
||||||
|
expect((seeding.options.yaxis as { labels: { formatter: (value: number) => string } }).labels.formatter(2048)).toBe(
|
||||||
|
`${(2048).toLocaleString()} GB`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the only record as both latest data and the baseline delta', async () => {
|
||||||
|
const only = createSiteUserData({ bonus: 42, ratio: 0, seeding: 5, updated_day: '2026-07-18' })
|
||||||
|
await renderDialog({ data: [only], success: true })
|
||||||
|
|
||||||
|
await screen.findByText('42')
|
||||||
|
|
||||||
|
expect(screen.getByText('(+42)')).toHaveClass('text-success')
|
||||||
|
expect(screen.getByText('(+0)')).not.toHaveClass('text-success', 'text-error')
|
||||||
|
expect(screen.getByText('(+5)')).toHaveClass('text-success')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('normalizes missing historical counters and seeding tuple values to zero', async () => {
|
||||||
|
const sparseRecord = () =>
|
||||||
|
createSiteUserData({
|
||||||
|
bonus: undefined,
|
||||||
|
download: undefined,
|
||||||
|
ratio: undefined,
|
||||||
|
seeding: undefined,
|
||||||
|
seeding_info: [[undefined, undefined]],
|
||||||
|
seeding_size: undefined,
|
||||||
|
updated_day: undefined,
|
||||||
|
upload: undefined,
|
||||||
|
})
|
||||||
|
await renderDialog({ data: [sparseRecord(), sparseRecord()], success: true })
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(getChart('line').series).toEqual([
|
||||||
|
{ data: [0, 0], name: '上传量' },
|
||||||
|
{ data: [0, 0], name: '下载量' },
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
expect(getChart('scatter').series).toEqual([{ data: [[0, 0]], name: '体积' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a legal empty response renderable without fabricated values', async () => {
|
||||||
|
const requested = vi.fn()
|
||||||
|
await renderDialog({ data: [], success: false }, 200, requested)
|
||||||
|
|
||||||
|
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
|
||||||
|
await waitFor(() => expect(getActiveRequestsCount()).toBe(0))
|
||||||
|
expect(getChart('line').series).toEqual([
|
||||||
|
{ data: [], name: '上传量' },
|
||||||
|
{ data: [], name: '下载量' },
|
||||||
|
])
|
||||||
|
expect(screen.getByText('无')).toBeInTheDocument()
|
||||||
|
expect(getChart('scatter').series).toEqual([{ data: [], name: '体积' }])
|
||||||
|
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('SiteUserDataDialog refresh and recovery', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
chartInputs.length = 0
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reloads history after a successful refresh and keeps progress until both requests finish', async () => {
|
||||||
|
const initial = createSiteUserData({ bonus: 1 })
|
||||||
|
const refreshed = createSiteUserData({ bonus: 222, updated_day: '2026-07-19' })
|
||||||
|
const refreshRequest = deferred<void>()
|
||||||
|
let loadCount = 0
|
||||||
|
const { site } = await renderDialog({ data: [initial], success: true }, 200, () => {
|
||||||
|
loadCount += 1
|
||||||
|
})
|
||||||
|
server.use(refreshSiteUserDataHandler(site.id, { data: {}, success: true }, 200, () => refreshRequest.promise))
|
||||||
|
await screen.findByText('1')
|
||||||
|
|
||||||
|
await fireEvent.click(getRefreshButton())
|
||||||
|
expect(screen.getByTestId('refresh-progress')).toHaveTextContent('正在刷新站点数据...')
|
||||||
|
server.use(
|
||||||
|
siteUserDataHandler(site.id, { data: [refreshed], success: true }, 200, () => {
|
||||||
|
loadCount += 1
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
refreshRequest.resolve()
|
||||||
|
|
||||||
|
await screen.findByText('222')
|
||||||
|
await waitFor(() => expect(screen.queryByTestId('refresh-progress')).not.toBeInTheDocument())
|
||||||
|
expect(loadCount).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['late success', 200, { data: [createSiteUserData({ bonus: 1 })] as SiteUserData[], success: true }],
|
||||||
|
['late HTTP failure', 500, { data: [] as SiteUserData[], message: 'old request failed', success: false }],
|
||||||
|
] as const)('ignores an initial request that settles after refreshed data on %s', async (_case, status, response) => {
|
||||||
|
const initialRequest = deferred<void>()
|
||||||
|
const initialRequested = vi.fn()
|
||||||
|
const initialReleased = vi.fn()
|
||||||
|
const refreshed = createSiteUserData({ bonus: 222, updated_day: '2026-07-19' })
|
||||||
|
const { site } = await renderDialog(response, status, async () => {
|
||||||
|
initialRequested()
|
||||||
|
await initialRequest.promise
|
||||||
|
initialReleased()
|
||||||
|
})
|
||||||
|
await waitFor(() => expect(initialRequested).toHaveBeenCalledOnce())
|
||||||
|
server.use(
|
||||||
|
refreshSiteUserDataHandler(site.id, { data: {}, success: true }),
|
||||||
|
siteUserDataHandler(site.id, { data: [refreshed], success: true }),
|
||||||
|
)
|
||||||
|
|
||||||
|
await fireEvent.click(getRefreshButton())
|
||||||
|
await screen.findByText('222')
|
||||||
|
|
||||||
|
initialRequest.resolve()
|
||||||
|
await waitFor(() => expect(initialReleased).toHaveBeenCalledOnce())
|
||||||
|
await waitFor(() => expect(getActiveRequestsCount()).toBe(0))
|
||||||
|
await flushPromises()
|
||||||
|
expect(screen.getByText('222')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('1')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['business failure', 200, { data: {}, message: '站点不支持刷新', success: false }],
|
||||||
|
['HTTP failure', 500, { data: {}, message: 'server down', success: false }],
|
||||||
|
] as const)('ends progress and exposes a retry after refresh %s', async (_case, status, response) => {
|
||||||
|
const refreshRequested = vi.fn()
|
||||||
|
const { site } = await renderDialog({ data: [createSiteUserData()], success: true })
|
||||||
|
server.use(refreshSiteUserDataHandler(site.id, response, status, refreshRequested))
|
||||||
|
await screen.findByText('Elite')
|
||||||
|
|
||||||
|
await fireEvent.click(getRefreshButton())
|
||||||
|
|
||||||
|
await waitFor(() => expect(refreshRequested).toHaveBeenCalledOnce())
|
||||||
|
await waitFor(() => expect(screen.queryByTestId('refresh-progress')).not.toBeInTheDocument())
|
||||||
|
expect(await screen.findByText(/刷新站点数据失败/)).toBeInTheDocument()
|
||||||
|
expect(getRetryButton()).toBeInTheDocument()
|
||||||
|
|
||||||
|
server.use(refreshSiteUserDataHandler(site.id, { data: {}, success: true }, 200, refreshRequested))
|
||||||
|
await fireEvent.click(getRetryButton())
|
||||||
|
|
||||||
|
await waitFor(() => expect(refreshRequested).toHaveBeenCalledTimes(2))
|
||||||
|
await waitFor(() => expect(screen.queryByText(/刷新站点数据失败/)).not.toBeInTheDocument())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('distinguishes initial HTTP failure from empty data and retries the same site', async () => {
|
||||||
|
let attempt = 0
|
||||||
|
const { site } = await renderDialog({ data: [], success: false }, 500, () => {
|
||||||
|
attempt += 1
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await screen.findByText(/加载站点数据失败/)).toBeInTheDocument()
|
||||||
|
server.use(
|
||||||
|
siteUserDataHandler(site.id, { data: [createSiteUserData({ bonus: 88 })], success: true }, 200, () => {
|
||||||
|
attempt += 1
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await fireEvent.click(getRetryButton())
|
||||||
|
|
||||||
|
await screen.findByText('88')
|
||||||
|
expect(attempt).toBe(2)
|
||||||
|
expect(screen.queryByText(/加载站点数据失败/)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits close without mutating loaded user data', async () => {
|
||||||
|
const { emitted } = await renderDialog({ data: [createSiteUserData()], success: true })
|
||||||
|
await screen.findByText('Elite')
|
||||||
|
|
||||||
|
const closeButton = document.querySelector<HTMLButtonElement>('button[aria-label="关闭"]')
|
||||||
|
if (!closeButton) throw new Error('Missing close button')
|
||||||
|
await fireEvent.click(closeButton)
|
||||||
|
|
||||||
|
expect(emitted('close')).toHaveLength(1)
|
||||||
|
expect(screen.getByText('Elite')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -3254,6 +3254,8 @@ export default {
|
|||||||
countTitle: 'Count:',
|
countTitle: 'Count:',
|
||||||
noData: 'None',
|
noData: 'None',
|
||||||
refreshing: 'Refreshing site data...',
|
refreshing: 'Refreshing site data...',
|
||||||
|
loadFailed: 'Failed to load site data',
|
||||||
|
refreshFailed: 'Failed to refresh site data',
|
||||||
close: 'Close',
|
close: 'Close',
|
||||||
},
|
},
|
||||||
siteResource: {
|
siteResource: {
|
||||||
|
|||||||
@@ -3202,6 +3202,8 @@ export default {
|
|||||||
countTitle: '数量:',
|
countTitle: '数量:',
|
||||||
noData: '无',
|
noData: '无',
|
||||||
refreshing: '正在刷新站点数据...',
|
refreshing: '正在刷新站点数据...',
|
||||||
|
loadFailed: '加载站点数据失败',
|
||||||
|
refreshFailed: '刷新站点数据失败',
|
||||||
close: '关闭',
|
close: '关闭',
|
||||||
},
|
},
|
||||||
siteResource: {
|
siteResource: {
|
||||||
|
|||||||
@@ -3201,6 +3201,8 @@ export default {
|
|||||||
countTitle: '數量:',
|
countTitle: '數量:',
|
||||||
noData: '無',
|
noData: '無',
|
||||||
refreshing: '正在刷新站點數據...',
|
refreshing: '正在刷新站點數據...',
|
||||||
|
loadFailed: '載入站點數據失敗',
|
||||||
|
refreshFailed: '刷新站點數據失敗',
|
||||||
close: '關閉',
|
close: '關閉',
|
||||||
},
|
},
|
||||||
siteResource: {
|
siteResource: {
|
||||||
|
|||||||
@@ -31,9 +31,14 @@ export function createSiteUserData(overrides: Partial<SiteUserData> = {}): SiteU
|
|||||||
return {
|
return {
|
||||||
bonus: 100,
|
bonus: 100,
|
||||||
download: 1024,
|
download: 1024,
|
||||||
|
join_at: '2024-01-02 10:00:00',
|
||||||
ratio: 2,
|
ratio: 2,
|
||||||
seeding: 3,
|
seeding: 3,
|
||||||
|
seeding_info: [],
|
||||||
|
seeding_size: 4 * 1024 ** 3,
|
||||||
|
updated_day: '2026-07-18',
|
||||||
upload: 2048,
|
upload: 2048,
|
||||||
|
user_level: 'Elite',
|
||||||
username: 'site-user',
|
username: 'site-user',
|
||||||
...overrides,
|
...overrides,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export const siteApiUrls = {
|
|||||||
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,
|
test: (id: number) => new URL(`site/test/${id}`, API_BASE_URL).href,
|
||||||
|
userData: (id: number) => new URL(`site/userdata/${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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +81,30 @@ export function siteUserDataLatestHandler(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function siteUserDataHandler(
|
||||||
|
id: number,
|
||||||
|
result: Pick<ApiResponse<SiteUserData[]>, 'data' | 'message' | 'success'>,
|
||||||
|
status = 200,
|
||||||
|
onRequest: () => void | Promise<void> = () => {},
|
||||||
|
) {
|
||||||
|
return http.get(siteApiUrls.userData(id), async () => {
|
||||||
|
await onRequest()
|
||||||
|
return response(result as unknown as JsonBodyType, status)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function refreshSiteUserDataHandler(
|
||||||
|
id: number,
|
||||||
|
result: Pick<ApiResponse<Record<string, unknown>>, 'data' | 'message' | 'success'>,
|
||||||
|
status = 200,
|
||||||
|
onRequest: () => void | Promise<void> = () => {},
|
||||||
|
) {
|
||||||
|
return http.post(siteApiUrls.userData(id), async () => {
|
||||||
|
await onRequest()
|
||||||
|
return response(result as unknown as JsonBodyType, status)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function saveSitePrioritiesHandler(
|
export function saveSitePrioritiesHandler(
|
||||||
onSave: (priorities: Array<{ id: number; pri: number }>) => void | Promise<void> = () => {},
|
onSave: (priorities: Array<{ id: number; pri: number }>) => void | Promise<void> = () => {},
|
||||||
options: { status?: number; success?: boolean } = {},
|
options: { status?: number; success?: boolean } = {},
|
||||||
|
|||||||
@@ -302,6 +302,7 @@ export default defineConfig(({ mode }) => ({
|
|||||||
'src/components/dialog/SubscribeShareStatisticsDialog.vue',
|
'src/components/dialog/SubscribeShareStatisticsDialog.vue',
|
||||||
'src/components/dialog/DiscoverTabOrderDialog.vue',
|
'src/components/dialog/DiscoverTabOrderDialog.vue',
|
||||||
'src/components/dialog/SiteResourceDialog.vue',
|
'src/components/dialog/SiteResourceDialog.vue',
|
||||||
|
'src/components/dialog/SiteUserDataDialog.vue',
|
||||||
'src/views/discover/TheMovieDbView.vue',
|
'src/views/discover/TheMovieDbView.vue',
|
||||||
'src/views/discover/DoubanView.vue',
|
'src/views/discover/DoubanView.vue',
|
||||||
'src/views/discover/BangumiView.vue',
|
'src/views/discover/BangumiView.vue',
|
||||||
@@ -492,6 +493,12 @@ export default defineConfig(({ mode }) => ({
|
|||||||
lines: 90,
|
lines: 90,
|
||||||
statements: 90,
|
statements: 90,
|
||||||
},
|
},
|
||||||
|
'src/components/dialog/SiteUserDataDialog.vue': {
|
||||||
|
branches: 85,
|
||||||
|
functions: 90,
|
||||||
|
lines: 90,
|
||||||
|
statements: 90,
|
||||||
|
},
|
||||||
'src/views/discover/TheMovieDbView.vue': {
|
'src/views/discover/TheMovieDbView.vue': {
|
||||||
branches: 75,
|
branches: 75,
|
||||||
functions: 80,
|
functions: 80,
|
||||||
|
|||||||
Reference in New Issue
Block a user