mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-06 16:16:42 +08:00
test(site): cover site resource dialog (#563)
This commit is contained in:
@@ -380,11 +380,6 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/components/dialog/SiteResourceDialog.vue": {
|
|
||||||
"@typescript-eslint/no-unused-vars": {
|
|
||||||
"count": 2
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/components/dialog/SiteStatisticsDialog.vue": {
|
"src/components/dialog/SiteStatisticsDialog.vue": {
|
||||||
"@typescript-eslint/no-explicit-any": {
|
"@typescript-eslint/no-explicit-any": {
|
||||||
"count": 1
|
"count": 1
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ const resourcePage = ref(1)
|
|||||||
// 加载状态
|
// 加载状态
|
||||||
const resourceLoading = ref(false)
|
const resourceLoading = ref(false)
|
||||||
|
|
||||||
|
const resourceError = ref(false)
|
||||||
|
|
||||||
|
// 只有最后发起的搜索可以更新资源数据、请求状态和移动端搜索面板。
|
||||||
|
let resourceRequestId = 0
|
||||||
|
|
||||||
// 移动端搜索栏是否展开
|
// 移动端搜索栏是否展开
|
||||||
const mobileSearchExpanded = ref(false)
|
const mobileSearchExpanded = ref(false)
|
||||||
|
|
||||||
@@ -134,35 +139,46 @@ async function addDownload(_torrent: TorrentInfo) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 添加下载成功
|
// 添加下载成功
|
||||||
function addDownloadSuccess(_url: string) {
|
function addDownloadSuccess() {
|
||||||
addDownloadDialog.value = false
|
addDownloadDialog.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加下载失败
|
// 添加下载失败
|
||||||
function addDownloadError(_error: string) {
|
function addDownloadError() {
|
||||||
addDownloadDialog.value = false
|
addDownloadDialog.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 调用API,查询站点资源
|
// 调用API,查询站点资源
|
||||||
async function getResourceList() {
|
async function getResourceList() {
|
||||||
|
const requestId = ++resourceRequestId
|
||||||
resourceLoading.value = true
|
resourceLoading.value = true
|
||||||
|
resourceError.value = false
|
||||||
resourcePage.value = 1
|
resourcePage.value = 1
|
||||||
|
|
||||||
try {
|
try {
|
||||||
resourceDataList.value = await api.get(`site/resource/${props.site?.id}`, {
|
const resources: TorrentInfo[] = await api.get(`site/resource/${props.site?.id}`, {
|
||||||
params: {
|
params: {
|
||||||
keyword: keyword.value,
|
keyword: keyword.value,
|
||||||
cat: selectCategory.value?.join(','),
|
cat: selectCategory.value?.join(','),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (requestId === resourceRequestId) {
|
||||||
|
resourceDataList.value = resources
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
if (requestId === resourceRequestId) {
|
||||||
}
|
console.error(error)
|
||||||
|
resourceError.value = true
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (requestId === resourceRequestId) {
|
||||||
|
resourceLoading.value = false
|
||||||
|
|
||||||
resourceLoading.value = false
|
if (isMobileLayout.value) {
|
||||||
|
mobileSearchExpanded.value = false
|
||||||
if (isMobileLayout.value) {
|
}
|
||||||
mobileSearchExpanded.value = false
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,10 +296,7 @@ onMounted(() => {
|
|||||||
</VCol>
|
</VCol>
|
||||||
</VRow>
|
</VRow>
|
||||||
|
|
||||||
<div
|
<div v-if="resourceTotalItems > 0" class="d-flex justify-space-between align-center flex-wrap gap-2 mt-3">
|
||||||
v-if="resourceTotalItems > 0"
|
|
||||||
class="d-flex justify-space-between align-center flex-wrap gap-2 mt-3"
|
|
||||||
>
|
|
||||||
<div class="text-body-2 text-medium-emphasis">
|
<div class="text-body-2 text-medium-emphasis">
|
||||||
{{ resultSummaryText }}
|
{{ resultSummaryText }}
|
||||||
</div>
|
</div>
|
||||||
@@ -350,7 +363,14 @@ onMounted(() => {
|
|||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
<VCol cols="12" class="d-flex gap-2">
|
<VCol cols="12" class="d-flex gap-2">
|
||||||
<VBtn color="primary" variant="flat" block rounded="lg" class="site-resource-search-btn" @click="getResourceList">
|
<VBtn
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
block
|
||||||
|
rounded="lg"
|
||||||
|
class="site-resource-search-btn"
|
||||||
|
@click="getResourceList"
|
||||||
|
>
|
||||||
{{ t('dialog.siteResource.search') }}
|
{{ t('dialog.siteResource.search') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
<VBtn variant="text" rounded="lg" @click="closeMobileSearch">
|
<VBtn variant="text" rounded="lg" @click="closeMobileSearch">
|
||||||
@@ -366,6 +386,20 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<VCardText class="site-resource-content px-0 py-0 my-0">
|
<VCardText class="site-resource-content px-0 py-0 my-0">
|
||||||
|
<VAlert
|
||||||
|
v-if="resourceError && !resourceLoading"
|
||||||
|
type="error"
|
||||||
|
variant="tonal"
|
||||||
|
class="mx-3 mb-3"
|
||||||
|
:text="t('dialog.siteResource.loadFailed')"
|
||||||
|
>
|
||||||
|
<template #append>
|
||||||
|
<VBtn variant="text" color="error" @click="getResourceList">
|
||||||
|
{{ t('common.retry') }}
|
||||||
|
</VBtn>
|
||||||
|
</template>
|
||||||
|
</VAlert>
|
||||||
|
|
||||||
<VDataTable
|
<VDataTable
|
||||||
v-if="display.mdAndUp.value"
|
v-if="display.mdAndUp.value"
|
||||||
v-model:page="resourcePage"
|
v-model:page="resourcePage"
|
||||||
|
|||||||
@@ -0,0 +1,428 @@
|
|||||||
|
import type { TorrentInfo } from '@/api/types'
|
||||||
|
import SiteResourceDialog from '@/components/dialog/SiteResourceDialog.vue'
|
||||||
|
import i18n from '@/plugins/i18n'
|
||||||
|
import { getActiveRequestsCount } from '@/utils/requestOptimizer'
|
||||||
|
import { screen, waitFor } from '@testing-library/vue'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { createSite, createSiteCategory, createTorrentInfo } from '@tests/support/factories/site'
|
||||||
|
import { siteApiUrls, siteCategoriesHandler, siteResourcesHandler } from '@tests/support/msw/handlers/site'
|
||||||
|
import { server } from '@tests/support/msw/server'
|
||||||
|
import { renderWithProviders } from '@tests/support/render'
|
||||||
|
import { HttpResponse, http } from 'msw'
|
||||||
|
import { defineComponent, h, type Component, type PropType } from 'vue'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const AddDownloadDialogStub = defineComponent({
|
||||||
|
name: 'AddDownloadDialog',
|
||||||
|
props: {
|
||||||
|
torrent: Object,
|
||||||
|
},
|
||||||
|
emits: ['close', 'done', 'error', 'update:modelValue'],
|
||||||
|
template: `
|
||||||
|
<section data-testid="add-download-dialog">
|
||||||
|
{{ torrent?.title }}
|
||||||
|
<button type="button" @click="$emit('done', 'url')">done</button>
|
||||||
|
<button type="button" @click="$emit('error', 'error')">error</button>
|
||||||
|
<button type="button" @click="$emit('close')">close-download</button>
|
||||||
|
</section>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
|
||||||
|
const DialogStub = defineComponent({
|
||||||
|
name: 'VDialog',
|
||||||
|
template: '<div role="dialog"><slot /></div>',
|
||||||
|
})
|
||||||
|
|
||||||
|
const ProgressiveCardGridStub = defineComponent({
|
||||||
|
name: 'ProgressiveCardGrid',
|
||||||
|
props: {
|
||||||
|
getItemKey: { type: Function as PropType<(item: TorrentInfo, index: number) => string>, required: true },
|
||||||
|
items: { type: Array as PropType<TorrentInfo[]>, required: true },
|
||||||
|
},
|
||||||
|
setup(props, { slots }) {
|
||||||
|
return () =>
|
||||||
|
h(
|
||||||
|
'section',
|
||||||
|
{ 'data-testid': 'progressive-grid' },
|
||||||
|
props.items.map((item, index) =>
|
||||||
|
h('article', { 'data-resource-key': props.getItemKey(item, index) }, slots.default?.({ item })),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const DataTableStub = defineComponent({
|
||||||
|
name: 'VDataTable',
|
||||||
|
props: {
|
||||||
|
items: { type: Array as PropType<TorrentInfo[]>, required: true },
|
||||||
|
itemsPerPage: { type: Number, required: true },
|
||||||
|
loading: Boolean,
|
||||||
|
page: { type: Number, required: true },
|
||||||
|
},
|
||||||
|
emits: ['update:itemsPerPage', 'update:page'],
|
||||||
|
setup(props, { emit, slots }) {
|
||||||
|
return () =>
|
||||||
|
h('section', { 'data-loading': String(props.loading), 'data-testid': 'resource-table' }, [
|
||||||
|
h('span', { 'data-testid': 'resource-page' }, String(props.page)),
|
||||||
|
h('button', { onClick: () => emit('update:page', 4), type: 'button' }, 'page-4'),
|
||||||
|
h('button', { onClick: () => emit('update:itemsPerPage', 100), type: 'button' }, 'per-page-100'),
|
||||||
|
...props.items.flatMap(item => slots['item.title']?.({ item }) ?? []),
|
||||||
|
props.items.length === 0 ? slots['no-data']?.({}) : null,
|
||||||
|
])
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function createDeferred<T>() {
|
||||||
|
let resolve!: (value: T) => void
|
||||||
|
const promise = new Promise<T>(done => {
|
||||||
|
resolve = done
|
||||||
|
})
|
||||||
|
return { promise, resolve }
|
||||||
|
}
|
||||||
|
|
||||||
|
function setViewport(width: number) {
|
||||||
|
Object.defineProperty(window, 'innerWidth', { configurable: true, value: width, writable: true })
|
||||||
|
window.dispatchEvent(new Event('resize'))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderDialog(
|
||||||
|
site = createSite({ id: 501, name: '资源测试站' }),
|
||||||
|
stubs: Record<string, boolean | Component> = {},
|
||||||
|
) {
|
||||||
|
const close = vi.fn()
|
||||||
|
const result = await renderWithProviders(SiteResourceDialog, {
|
||||||
|
props: { site, onClose: close },
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
AddDownloadDialog: AddDownloadDialogStub,
|
||||||
|
ProgressiveCardGrid: ProgressiveCardGridStub,
|
||||||
|
VDialog: DialogStub,
|
||||||
|
...stubs,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ...result, close, site }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SiteResourceDialog', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||||
|
setViewport(1280)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['success', 200],
|
||||||
|
['HTTP failure', 500],
|
||||||
|
] as const)('keeps the newest mobile search state when an older request ends with %s', async (_case, oldStatus) => {
|
||||||
|
setViewport(390)
|
||||||
|
const oldResponse = createDeferred<void>()
|
||||||
|
const latestResponse = createDeferred<TorrentInfo[]>()
|
||||||
|
const oldRequested = vi.fn()
|
||||||
|
const latestRequested = vi.fn()
|
||||||
|
const latestResource = createTorrentInfo({ title: '新条件结果' })
|
||||||
|
server.use(
|
||||||
|
siteCategoriesHandler(501, [createSiteCategory()]),
|
||||||
|
http.get(siteApiUrls.resources(501), async ({ request }) => {
|
||||||
|
const keyword = new URL(request.url).searchParams.get('keyword')
|
||||||
|
if (keyword) {
|
||||||
|
latestRequested()
|
||||||
|
return HttpResponse.json(await latestResponse.promise)
|
||||||
|
}
|
||||||
|
|
||||||
|
oldRequested()
|
||||||
|
await oldResponse.promise
|
||||||
|
return oldStatus === 200
|
||||||
|
? HttpResponse.json([createTorrentInfo({ title: '旧条件结果' })])
|
||||||
|
: HttpResponse.json({ detail: 'stale failure' }, { status: oldStatus })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
const { container } = await renderDialog()
|
||||||
|
await waitFor(() => expect(oldRequested).toHaveBeenCalledOnce())
|
||||||
|
await user.click(container.querySelector('.site-resource-mobile-search__toggle') as HTMLElement)
|
||||||
|
const keywordFields = screen.getAllByLabelText('搜索关键字')
|
||||||
|
const keyword = keywordFields[keywordFields.length - 1]
|
||||||
|
await user.type(keyword, 'new')
|
||||||
|
const searchButtons = screen.getAllByRole('button', { name: /搜索/ })
|
||||||
|
await user.click(searchButtons[searchButtons.length - 1])
|
||||||
|
await waitFor(() => expect(latestRequested).toHaveBeenCalledOnce())
|
||||||
|
await waitFor(() => expect(getActiveRequestsCount()).toBe(2))
|
||||||
|
|
||||||
|
oldResponse.resolve()
|
||||||
|
await waitFor(() => expect(getActiveRequestsCount()).toBe(1))
|
||||||
|
expect(screen.getByText('加载中...')).toBeInTheDocument()
|
||||||
|
expect(container.querySelector('.site-resource-filter-panel')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('资源加载失败,请重试')).not.toBeInTheDocument()
|
||||||
|
if (oldStatus === 500) expect(console.error).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
latestResponse.resolve([latestResource])
|
||||||
|
expect(await screen.findByText('新条件结果')).toBeInTheDocument()
|
||||||
|
await waitFor(() => expect(getActiveRequestsCount()).toBe(0))
|
||||||
|
|
||||||
|
expect(screen.queryByText('旧条件结果')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('加载中...')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByLabelText('搜索关键字')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('资源加载失败,请重试')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows a retry action after resource loading fails and recovers on the same query', async () => {
|
||||||
|
let attempts = 0
|
||||||
|
server.use(
|
||||||
|
siteCategoriesHandler(501, []),
|
||||||
|
http.get(siteApiUrls.resources(501), () => {
|
||||||
|
attempts += 1
|
||||||
|
if (attempts === 1) return HttpResponse.json({ detail: 'temporary failure' }, { status: 500 })
|
||||||
|
|
||||||
|
return HttpResponse.json([createTorrentInfo({ title: '重试恢复结果' })])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
await renderDialog()
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', { name: '重试' }))
|
||||||
|
expect(await screen.findByText('重试恢复结果')).toBeInTheDocument()
|
||||||
|
expect(attempts).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads categories and resources from their direct-array endpoints', async () => {
|
||||||
|
const categoryRequests: URL[] = []
|
||||||
|
const resourceRequests: URL[] = []
|
||||||
|
server.use(
|
||||||
|
siteCategoriesHandler(501, [createSiteCategory({ desc: '高清电影', id: 11 })], 200, url =>
|
||||||
|
categoryRequests.push(url),
|
||||||
|
),
|
||||||
|
siteResourcesHandler(501, [createTorrentInfo({ title: '首载资源' })], 200, url => resourceRequests.push(url)),
|
||||||
|
)
|
||||||
|
|
||||||
|
await renderDialog()
|
||||||
|
|
||||||
|
expect(await screen.findByText('首载资源')).toBeInTheDocument()
|
||||||
|
expect(categoryRequests).toHaveLength(1)
|
||||||
|
expect(resourceRequests).toHaveLength(1)
|
||||||
|
expect(resourceRequests[0].searchParams.get('keyword')).toBeNull()
|
||||||
|
expect(resourceRequests[0].searchParams.get('cat')).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends the exact keyword and selected category ids on every search', async () => {
|
||||||
|
const requests: URL[] = []
|
||||||
|
server.use(
|
||||||
|
siteCategoriesHandler(501, [
|
||||||
|
createSiteCategory({ desc: '电影', id: 11 }),
|
||||||
|
createSiteCategory({ cat: 'tv', desc: '剧集', id: 22 }),
|
||||||
|
]),
|
||||||
|
siteResourcesHandler(501, [], 200, url => requests.push(url)),
|
||||||
|
)
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
await renderDialog()
|
||||||
|
await waitFor(() => expect(requests).toHaveLength(1))
|
||||||
|
await user.type(screen.getByLabelText('搜索关键字'), '2160p')
|
||||||
|
await user.click(screen.getByLabelText('资源分类'))
|
||||||
|
await user.click(await screen.findByRole('option', { name: '电影' }))
|
||||||
|
await user.click(await screen.findByRole('option', { name: '剧集' }))
|
||||||
|
await user.click(screen.getByRole('button', { name: /搜索/ }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(requests).toHaveLength(2))
|
||||||
|
expect(requests[1].searchParams.get('keyword')).toBe('2160p')
|
||||||
|
expect(requests[1].searchParams.get('cat')).toBe('11,22')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('distinguishes a valid empty resource list from a category request failure', async () => {
|
||||||
|
server.use(siteCategoriesHandler(501, [], 500), siteResourcesHandler(501, []))
|
||||||
|
|
||||||
|
await renderDialog()
|
||||||
|
|
||||||
|
expect(await screen.findByText('没有数据')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('资源加载失败,请重试')).not.toBeInTheDocument()
|
||||||
|
await waitFor(() => expect(console.error).toHaveBeenCalledOnce())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('exposes loading during a repeated search and clears it when the latest request completes', async () => {
|
||||||
|
const nextResponse = createDeferred<TorrentInfo[]>()
|
||||||
|
let requestCount = 0
|
||||||
|
server.use(
|
||||||
|
siteCategoriesHandler(501, []),
|
||||||
|
http.get(siteApiUrls.resources(501), async () => {
|
||||||
|
requestCount += 1
|
||||||
|
if (requestCount === 1) return HttpResponse.json([])
|
||||||
|
return HttpResponse.json(await nextResponse.promise)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
await renderDialog(undefined, { VDataTable: DataTableStub })
|
||||||
|
await waitFor(() => expect(screen.getByTestId('resource-table')).toHaveAttribute('data-loading', 'false'))
|
||||||
|
await user.click(screen.getByRole('button', { name: /搜索/ }))
|
||||||
|
await waitFor(() => expect(screen.getByTestId('resource-table')).toHaveAttribute('data-loading', 'true'))
|
||||||
|
nextResponse.resolve([createTorrentInfo({ title: '重复搜索结果' })])
|
||||||
|
|
||||||
|
expect(await screen.findByText('重复搜索结果')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('resource-table')).toHaveAttribute('data-loading', 'false')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders resource metadata and every promotion style branch', async () => {
|
||||||
|
server.use(
|
||||||
|
siteCategoriesHandler(501, []),
|
||||||
|
siteResourcesHandler(501, [
|
||||||
|
createTorrentInfo({
|
||||||
|
date_elapsed: '2 小时前',
|
||||||
|
description: '完整资源说明',
|
||||||
|
downloadvolumefactor: 0,
|
||||||
|
freedate_diff: '剩余 1 天',
|
||||||
|
hit_and_run: true,
|
||||||
|
labels: ['原盘'],
|
||||||
|
pubdate: '2026-07-19',
|
||||||
|
title: '免费资源',
|
||||||
|
volume_factor: 'FREE',
|
||||||
|
}),
|
||||||
|
createTorrentInfo({ downloadvolumefactor: 0.5, title: '半价资源', volume_factor: '50%' }),
|
||||||
|
createTorrentInfo({ title: '双倍上传', uploadvolumefactor: 2, volume_factor: '2X' }),
|
||||||
|
createTorrentInfo({ title: '普通资源' }),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
await renderDialog()
|
||||||
|
|
||||||
|
expect(await screen.findByText('免费资源')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('完整资源说明')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('H&R')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('剩余 1 天')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('原盘')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('FREE').closest('.v-chip')).toHaveClass('bg-lime-500')
|
||||||
|
expect(screen.getByText('50%').closest('.v-chip')).toHaveClass('bg-green-500')
|
||||||
|
expect(screen.getByText('2X').closest('.v-chip')).toHaveClass('bg-sky-500')
|
||||||
|
expect(screen.queryByText('1x')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens and closes the add-download boundary through all child outcomes', async () => {
|
||||||
|
server.use(siteCategoriesHandler(501, []), siteResourcesHandler(501, [createTorrentInfo({ title: '待下载资源' })]))
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
await renderDialog()
|
||||||
|
const title = await screen.findByRole('button', { name: /待下载资源/ })
|
||||||
|
|
||||||
|
await user.click(title)
|
||||||
|
expect(screen.getByTestId('add-download-dialog')).toHaveTextContent('待下载资源')
|
||||||
|
await user.click(screen.getByRole('button', { name: 'done' }))
|
||||||
|
expect(screen.queryByTestId('add-download-dialog')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
await user.click(title)
|
||||||
|
await user.click(screen.getByRole('button', { name: 'error' }))
|
||||||
|
expect(screen.queryByTestId('add-download-dialog')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
await user.click(title)
|
||||||
|
await user.click(screen.getByRole('button', { name: 'close-download' }))
|
||||||
|
expect(screen.queryByTestId('add-download-dialog')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('corrects an out-of-range desktop page after the page size changes', async () => {
|
||||||
|
server.use(
|
||||||
|
siteCategoriesHandler(501, []),
|
||||||
|
siteResourcesHandler(
|
||||||
|
501,
|
||||||
|
Array.from({ length: 30 }, (_, index) => createTorrentInfo({ title: `分页资源 ${index + 1}` })),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
await renderDialog(undefined, { VDataTable: DataTableStub })
|
||||||
|
await screen.findByText('分页资源 1')
|
||||||
|
await user.click(screen.getByRole('button', { name: 'page-4' }))
|
||||||
|
expect(screen.getByTestId('resource-page')).toHaveTextContent('4')
|
||||||
|
await user.click(screen.getByRole('button', { name: 'per-page-100' }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId('resource-page')).toHaveTextContent('1'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses stable mobile keys and guards detail and torrent external links', async () => {
|
||||||
|
setViewport(390)
|
||||||
|
const open = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||||
|
server.use(
|
||||||
|
siteCategoriesHandler(501, []),
|
||||||
|
siteResourcesHandler(501, [
|
||||||
|
createTorrentInfo({
|
||||||
|
description: '移动端资源说明',
|
||||||
|
downloadvolumefactor: 0,
|
||||||
|
enclosure: 'https://tracker.example.com/download/one',
|
||||||
|
freedate_diff: '剩余 2 小时',
|
||||||
|
hit_and_run: true,
|
||||||
|
labels: ['移动标签'],
|
||||||
|
page_url: 'https://tracker.example.com/details/one',
|
||||||
|
title: '详情资源',
|
||||||
|
volume_factor: 'FREE',
|
||||||
|
}),
|
||||||
|
createTorrentInfo({ enclosure: 'magnet:?xt=urn:btih:test', page_url: '', title: '无外链资源' }),
|
||||||
|
createTorrentInfo({ enclosure: '', page_url: '', pubdate: '2026-07-19', title: '回退键资源' }),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
await renderDialog()
|
||||||
|
expect(await screen.findByText('详情资源')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('移动端资源说明')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('移动标签')).toBeInTheDocument()
|
||||||
|
const cards = screen.getByTestId('progressive-grid').querySelectorAll('[data-resource-key]')
|
||||||
|
expect(Array.from(cards, card => card.getAttribute('data-resource-key'))).toEqual([
|
||||||
|
'https://tracker.example.com/details/one',
|
||||||
|
'magnet:?xt=urn:btih:test',
|
||||||
|
'回退键资源-2026-07-19-2',
|
||||||
|
])
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /详情资源/ }))
|
||||||
|
await user.click(screen.getByRole('button', { name: 'close-download' }))
|
||||||
|
await user.click(screen.getAllByRole('button', { name: '添加下载' })[0])
|
||||||
|
await user.click(screen.getByRole('button', { name: 'close-download' }))
|
||||||
|
|
||||||
|
const detailButtons = screen.getAllByLabelText('查看详情')
|
||||||
|
await user.click(detailButtons[0])
|
||||||
|
await user.click(detailButtons[1])
|
||||||
|
await user.click(screen.getAllByLabelText('下载种子文件')[0])
|
||||||
|
expect(open).toHaveBeenNthCalledWith(1, 'https://tracker.example.com/details/one', '_blank')
|
||||||
|
expect(open).toHaveBeenNthCalledWith(2, 'https://tracker.example.com/download/one', '_blank')
|
||||||
|
expect(open).toHaveBeenCalledTimes(2)
|
||||||
|
expect(screen.getAllByLabelText('下载种子文件')[1]).toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('expands and closes mobile search without retaining it after a desktop switch', async () => {
|
||||||
|
setViewport(390)
|
||||||
|
server.use(siteCategoriesHandler(501, []), siteResourcesHandler(501, []))
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
const { container } = await renderDialog()
|
||||||
|
await screen.findByText('没有数据')
|
||||||
|
const toggle = container.querySelector('.site-resource-mobile-search__toggle')
|
||||||
|
expect(toggle).not.toBeNull()
|
||||||
|
await user.click(toggle as HTMLElement)
|
||||||
|
await user.type(screen.getByLabelText('搜索关键字'), 'mobile')
|
||||||
|
await user.click(screen.getByRole('button', { name: '取消' }))
|
||||||
|
expect(screen.queryByLabelText('搜索关键字')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
await user.click(toggle as HTMLElement)
|
||||||
|
setViewport(1280)
|
||||||
|
await waitFor(() => expect(container.querySelector('.site-resource-mobile-search')).toBeNull())
|
||||||
|
setViewport(390)
|
||||||
|
await waitFor(() => expect(screen.queryByLabelText('搜索关键字')).not.toBeInTheDocument())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits close and formats the result summary in English', async () => {
|
||||||
|
server.use(
|
||||||
|
siteCategoriesHandler(501, []),
|
||||||
|
siteResourcesHandler(501, [createTorrentInfo({ title: 'Language resource' })]),
|
||||||
|
)
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
const { close, container } = await renderDialog()
|
||||||
|
await screen.findByText('Language resource')
|
||||||
|
i18n.global.locale.value = 'en-US'
|
||||||
|
expect(await screen.findByText('1 results')).toBeInTheDocument()
|
||||||
|
|
||||||
|
const closeButton = container.querySelector('.v-toolbar-items .v-btn')
|
||||||
|
expect(closeButton).not.toBeNull()
|
||||||
|
await user.click(closeButton as HTMLElement)
|
||||||
|
expect(close).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -3264,6 +3264,7 @@ export default {
|
|||||||
itemsPerPage: 'Items Per Page',
|
itemsPerPage: 'Items Per Page',
|
||||||
noData: 'No Data',
|
noData: 'No Data',
|
||||||
loading: 'Loading...',
|
loading: 'Loading...',
|
||||||
|
loadFailed: 'Failed to load resources. Please retry.',
|
||||||
titleColumn: 'Title',
|
titleColumn: 'Title',
|
||||||
timeColumn: 'Time',
|
timeColumn: 'Time',
|
||||||
sizeColumn: 'Size',
|
sizeColumn: 'Size',
|
||||||
|
|||||||
@@ -3212,6 +3212,7 @@ export default {
|
|||||||
itemsPerPage: '每页条数',
|
itemsPerPage: '每页条数',
|
||||||
noData: '没有数据',
|
noData: '没有数据',
|
||||||
loading: '加载中...',
|
loading: '加载中...',
|
||||||
|
loadFailed: '资源加载失败,请重试',
|
||||||
titleColumn: '标题',
|
titleColumn: '标题',
|
||||||
timeColumn: '时间',
|
timeColumn: '时间',
|
||||||
sizeColumn: '大小',
|
sizeColumn: '大小',
|
||||||
|
|||||||
@@ -3212,6 +3212,7 @@ export default {
|
|||||||
pageText: '{0}-{1} 共 {2} 條',
|
pageText: '{0}-{1} 共 {2} 條',
|
||||||
noData: '沒有數據',
|
noData: '沒有數據',
|
||||||
loading: '加載中...',
|
loading: '加載中...',
|
||||||
|
loadFailed: '資源加載失敗,請重試',
|
||||||
titleColumn: '標題',
|
titleColumn: '標題',
|
||||||
timeColumn: '時間',
|
timeColumn: '時間',
|
||||||
sizeColumn: '大小',
|
sizeColumn: '大小',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { DownloaderConf, Site, SiteStatistic, SiteUserData } from '@/api/types'
|
import type { DownloaderConf, Site, SiteCategory, SiteStatistic, SiteUserData, TorrentInfo } from '@/api/types'
|
||||||
|
|
||||||
let siteSeed = 0
|
let siteSeed = 0
|
||||||
|
let torrentSeed = 0
|
||||||
|
|
||||||
export function createSite(overrides: Partial<Site> = {}): Site {
|
export function createSite(overrides: Partial<Site> = {}): Site {
|
||||||
siteSeed += 1
|
siteSeed += 1
|
||||||
@@ -49,3 +50,38 @@ export function createSiteDownloader(overrides: Partial<DownloaderConf> = {}): D
|
|||||||
...overrides,
|
...overrides,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 构造站点资源分类测试记录。 */
|
||||||
|
export function createSiteCategory(overrides: Partial<SiteCategory> = {}): SiteCategory {
|
||||||
|
return {
|
||||||
|
cat: 'movie',
|
||||||
|
desc: '电影',
|
||||||
|
id: 1,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构造满足资源列表展示与下载边界的种子记录。 */
|
||||||
|
export function createTorrentInfo(overrides: Partial<TorrentInfo> = {}): TorrentInfo {
|
||||||
|
torrentSeed += 1
|
||||||
|
return {
|
||||||
|
category: '电影',
|
||||||
|
downloadvolumefactor: 1,
|
||||||
|
freedate: '',
|
||||||
|
freedate_diff: '',
|
||||||
|
grabs: 10,
|
||||||
|
hit_and_run: false,
|
||||||
|
imdbid: '',
|
||||||
|
labels: [],
|
||||||
|
peers: 3,
|
||||||
|
pri_order: 0,
|
||||||
|
seeders: 12,
|
||||||
|
site_order: 0,
|
||||||
|
site_proxy: false,
|
||||||
|
size: 1_073_741_824,
|
||||||
|
title: `测试资源 ${torrentSeed}`,
|
||||||
|
uploadvolumefactor: 1,
|
||||||
|
volume_factor: '1x',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import type { ApiResponse, DownloaderConf, Site, SiteStatistic, SiteUserData } from '@/api/types'
|
import type {
|
||||||
|
ApiResponse,
|
||||||
|
DownloaderConf,
|
||||||
|
Site,
|
||||||
|
SiteCategory,
|
||||||
|
SiteStatistic,
|
||||||
|
SiteUserData,
|
||||||
|
TorrentInfo,
|
||||||
|
} 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/'
|
||||||
@@ -10,6 +18,7 @@ interface SiteMutationResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const siteApiUrls = {
|
export const siteApiUrls = {
|
||||||
|
categories: (siteId: number) => new URL(`site/category/${siteId}`, API_BASE_URL).href,
|
||||||
cookie: (id: number) => new URL(`site/cookie/${id}`, API_BASE_URL).href,
|
cookie: (id: number) => new URL(`site/cookie/${id}`, API_BASE_URL).href,
|
||||||
delete: (id: number) => new URL(`site/${id}`, API_BASE_URL).href,
|
delete: (id: number) => new URL(`site/${id}`, API_BASE_URL).href,
|
||||||
details: (id: number) => new URL(`site/${id}`, API_BASE_URL).href,
|
details: (id: number) => new URL(`site/${id}`, API_BASE_URL).href,
|
||||||
@@ -17,6 +26,7 @@ export const siteApiUrls = {
|
|||||||
icon: (id: number) => new URL(`site/icon/${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,
|
||||||
|
resources: (siteId: number) => new URL(`site/resource/${siteId}`, 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,
|
test: (id: number) => new URL(`site/test/${id}`, API_BASE_URL).href,
|
||||||
@@ -180,3 +190,27 @@ export function updateSiteCookieHandler(
|
|||||||
return HttpResponse.json(response, { status })
|
return HttpResponse.json(response, { status })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function siteCategoriesHandler(
|
||||||
|
siteId: number,
|
||||||
|
response: SiteCategory[],
|
||||||
|
status = 200,
|
||||||
|
onRequest: (url: URL) => void = () => {},
|
||||||
|
) {
|
||||||
|
return http.get(siteApiUrls.categories(siteId), ({ request }) => {
|
||||||
|
onRequest(new URL(request.url))
|
||||||
|
return HttpResponse.json(response, { status })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function siteResourcesHandler(
|
||||||
|
siteId: number,
|
||||||
|
response: TorrentInfo[],
|
||||||
|
status = 200,
|
||||||
|
onRequest: (url: URL) => void = () => {},
|
||||||
|
) {
|
||||||
|
return http.get(siteApiUrls.resources(siteId), ({ request }) => {
|
||||||
|
onRequest(new URL(request.url))
|
||||||
|
return HttpResponse.json(response, { status })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -301,6 +301,7 @@ export default defineConfig(({ mode }) => ({
|
|||||||
'src/components/dialog/SubscribeShareDialog.vue',
|
'src/components/dialog/SubscribeShareDialog.vue',
|
||||||
'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/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',
|
||||||
@@ -485,6 +486,12 @@ export default defineConfig(({ mode }) => ({
|
|||||||
lines: 80,
|
lines: 80,
|
||||||
statements: 80,
|
statements: 80,
|
||||||
},
|
},
|
||||||
|
'src/components/dialog/SiteResourceDialog.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