mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-10 08:03:45 +08:00
Improve card badge layouts and expand card tests
This commit is contained in:
@@ -447,7 +447,7 @@ function handleCardClick() {
|
||||
:ripple="display.smAndUp.value && !props.batchMode && !props.sortable"
|
||||
>
|
||||
<div
|
||||
v-if="bestVersionBadge && imageLoaded"
|
||||
v-if="bestVersionBadge && imageLoaded && display.smAndUp.value"
|
||||
class="best-version-badge"
|
||||
:class="{ 'best-version-badge-full': bestVersionBadge.full }"
|
||||
>
|
||||
@@ -498,11 +498,7 @@ function handleCardClick() {
|
||||
</VImg>
|
||||
<div class="subscribe-card-mobile-image-scrim subscribe-card-background"></div>
|
||||
|
||||
<div
|
||||
v-if="props.media?.username || lastUpdateText"
|
||||
class="subscribe-card-mobile-image-meta"
|
||||
:class="{ 'subscribe-card-mobile-image-meta--with-badge': bestVersionBadge }"
|
||||
>
|
||||
<div v-if="props.media?.username || lastUpdateText" class="subscribe-card-mobile-image-meta">
|
||||
<div
|
||||
v-if="props.media?.username"
|
||||
class="subscribe-card-mobile-image-meta__item subscribe-card-mobile-image-meta__user"
|
||||
@@ -521,13 +517,22 @@ function handleCardClick() {
|
||||
</div>
|
||||
|
||||
<div class="subscribe-card-mobile-title">
|
||||
<span>{{ props.media?.name }}</span>
|
||||
<span
|
||||
v-if="formatSeasonLabel(props.media?.season, t('media.specials'))"
|
||||
class="subscribe-card-mobile-season"
|
||||
v-if="bestVersionBadge && imageLoaded"
|
||||
class="best-version-badge subscribe-card-mobile-best-version-badge"
|
||||
:class="{ 'best-version-badge-full': bestVersionBadge.full }"
|
||||
>
|
||||
{{ formatSeasonLabel(props.media?.season, t('media.specials')) }}
|
||||
<VIcon :icon="bestVersionBadge.icon" color="white" size="14" />
|
||||
</span>
|
||||
<div class="subscribe-card-mobile-title-text">
|
||||
<span>{{ props.media?.name }}</span>
|
||||
<span
|
||||
v-if="formatSeasonLabel(props.media?.season, t('media.specials'))"
|
||||
class="subscribe-card-mobile-season"
|
||||
>
|
||||
{{ formatSeasonLabel(props.media?.season, t('media.specials')) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -761,10 +766,6 @@ function handleCardClick() {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-image-meta--with-badge {
|
||||
padding-inline-start: 2rem;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-image-meta__user span {
|
||||
min-inline-size: 0;
|
||||
overflow: hidden;
|
||||
@@ -789,18 +790,36 @@ function handleCardClick() {
|
||||
.subscribe-card-mobile-title {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.375rem;
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
font-weight: 650;
|
||||
inset-block-end: 0;
|
||||
inset-inline: 0;
|
||||
line-height: 1.3;
|
||||
padding: 1.75rem 0.75rem 0.625rem;
|
||||
padding: 1rem 0.75rem 0.625rem;
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.95);
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-title-text {
|
||||
display: -webkit-box;
|
||||
min-inline-size: 0;
|
||||
max-block-size: 3.9em;
|
||||
overflow: hidden;
|
||||
flex: 1 1 auto;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
|
||||
.best-version-badge.subscribe-card-mobile-best-version-badge {
|
||||
position: static;
|
||||
z-index: auto;
|
||||
block-size: 1.25rem;
|
||||
flex: 0 0 1.25rem;
|
||||
inline-size: 1.25rem;
|
||||
margin-block-start: 0.05rem;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-season {
|
||||
@@ -907,7 +926,7 @@ function handleCardClick() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 洗版标识:卡片左上角 24x24 圆形徽标
|
||||
* 洗版标识:桌面端左上角使用 24x24 圆形徽标,移动端缩小后放在标题前。
|
||||
* 分集:深色半透底 + 模糊
|
||||
* 全集:磨砂玻璃半透白底 + 大模糊
|
||||
*/
|
||||
@@ -947,10 +966,6 @@ function handleCardClick() {
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.best-version-badge {
|
||||
inset-inline-start: 0.5rem;
|
||||
}
|
||||
|
||||
.subscribe-card-pending-tint::after {
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(var(--v-theme-info), 0.28),
|
||||
|
||||
@@ -4,9 +4,16 @@ import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { deleteDownloadHandler, downloadActionHandler } from '@tests/support/msw/handlers/download'
|
||||
import { server } from '@tests/support/msw/server'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
function downloading(overrides: Partial<DownloadingInfo> = {}): DownloadingInfo {
|
||||
interface DownloadingCardInfo extends DownloadingInfo {
|
||||
site_name?: string
|
||||
trackers?: string[]
|
||||
}
|
||||
|
||||
/** 创建可按用例覆盖字段的下载任务数据。 */
|
||||
function downloading(overrides: Partial<DownloadingCardInfo> = {}): DownloadingCardInfo {
|
||||
return {
|
||||
dlspeed: '2 MiB',
|
||||
hash: 'hash-1',
|
||||
@@ -28,12 +35,14 @@ function downloading(overrides: Partial<DownloadingInfo> = {}): DownloadingInfo
|
||||
}
|
||||
}
|
||||
|
||||
/** 使用生产插件和指定下载器渲染下载任务卡片。 */
|
||||
async function renderCard(info = downloading(), downloaderName = 'qb-main') {
|
||||
return renderWithProviders(DownloadingCard, {
|
||||
props: { downloaderName, info },
|
||||
})
|
||||
}
|
||||
|
||||
/** 获取卡片的继续/暂停和删除操作按钮。 */
|
||||
function actionButtons(container: Element) {
|
||||
const buttons = [...container.querySelectorAll<HTMLButtonElement>('.v-card-actions button')]
|
||||
expect(buttons).toHaveLength(2)
|
||||
@@ -71,6 +80,89 @@ describe('DownloadingCard display and pause state', () => {
|
||||
expect(container.querySelector('.v-card-text .v-progress-linear')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('normalizes media types, progress bounds, speeds and missing metadata', async () => {
|
||||
const { container, rerender } = await renderCard(
|
||||
downloading({
|
||||
dlspeed: ' 3 MiB/s ',
|
||||
left_time: ' ',
|
||||
media: { title: '英文电影类型', type: 'movie' },
|
||||
progress: 101,
|
||||
size: 0,
|
||||
upspeed: ' ',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(container.querySelector('.downloading-card__chips')).toHaveTextContent('电影')
|
||||
expect(container.querySelector('.downloading-card__progress')).toHaveTextContent('100%')
|
||||
expect(container.querySelector('.downloading-card__progress')).toHaveTextContent('--')
|
||||
expect(container.querySelector('.downloading-card__speeds')).toHaveTextContent('3 MiB/s')
|
||||
expect(container.querySelector('.downloading-card__speeds')).toHaveTextContent('0 B/s')
|
||||
expect(container.querySelector('.downloading-card__chips')).toHaveTextContent('0.00 B')
|
||||
|
||||
for (const [type, label] of [
|
||||
['电影', '电影'],
|
||||
['tv', '电视剧'],
|
||||
['电视剧', '电视剧'],
|
||||
['纪录片', '纪录片'],
|
||||
]) {
|
||||
await rerender({
|
||||
downloaderName: 'qb-main',
|
||||
info: downloading({ media: { title: `${type}标题`, type }, progress: -1 }),
|
||||
})
|
||||
expect(container.querySelector('.downloading-card__chips')).toHaveTextContent(label)
|
||||
expect(container.querySelector('.downloading-card__progress')).not.toBeInTheDocument()
|
||||
}
|
||||
|
||||
await rerender({
|
||||
downloaderName: 'qb-main',
|
||||
info: downloading({
|
||||
dlspeed: undefined,
|
||||
media: { title: '无类型电影' },
|
||||
progress: Number.NaN,
|
||||
season_episode: undefined,
|
||||
upspeed: '1 MiB/s',
|
||||
}),
|
||||
})
|
||||
expect(container.querySelector('.downloading-card__chips')).toHaveTextContent('电影')
|
||||
expect(container.querySelector('.downloading-card__speeds')).toHaveTextContent('0 B/s')
|
||||
expect(container.querySelector('.downloading-card__speeds')).toHaveTextContent('1 MiB/s')
|
||||
|
||||
await rerender({
|
||||
downloaderName: 'qb-main',
|
||||
info: downloading({ media: { season: 'S02' }, name: '', season_episode: undefined, title: '任务标题回退' }),
|
||||
})
|
||||
expect(container.querySelector('.downloading-card__chips')).toHaveTextContent('电视剧')
|
||||
expect(container.querySelector('.downloading-card__title')).toHaveTextContent('任务标题回退')
|
||||
|
||||
await rerender({ downloaderName: '', info: undefined })
|
||||
expect(container.querySelector('.downloading-card__title')).toHaveTextContent('未知')
|
||||
expect(container.querySelectorAll('.downloading-card__chips .v-chip')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('prefers explicit site names and safely reduces tracker URLs to hostnames', async () => {
|
||||
const { container, rerender } = await renderCard(downloading({ site_name: ' M-Team ' }))
|
||||
|
||||
expect(container.querySelector('.downloading-card__chips')).toHaveTextContent('M-Team')
|
||||
|
||||
await rerender({
|
||||
downloaderName: 'qb-main',
|
||||
info: downloading({ media: { ...downloading().media, site_name: '媒体站点' }, site_name: undefined }),
|
||||
})
|
||||
expect(container.querySelector('.downloading-card__chips')).toHaveTextContent('媒体站点')
|
||||
|
||||
await rerender({
|
||||
downloaderName: 'qb-main',
|
||||
info: downloading({
|
||||
media: downloading().media,
|
||||
site_name: undefined,
|
||||
trackers: ['', 'not-a-url', 'https://www.tracker.example.com/announce?passkey=secret'],
|
||||
}),
|
||||
})
|
||||
expect(container.querySelector('.downloading-card__chips')).toHaveTextContent('tracker.example.com')
|
||||
expect(container).not.toHaveTextContent('passkey')
|
||||
expect(container).not.toHaveTextContent('secret')
|
||||
})
|
||||
|
||||
it('only renders a centered cover image when a poster is available', async () => {
|
||||
const { container, rerender } = await renderCard()
|
||||
|
||||
@@ -85,6 +177,30 @@ describe('DownloadingCard display and pause state', () => {
|
||||
expect(container.querySelector('.downloading-card__image')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides a failed poster and retries when the task receives a new poster', async () => {
|
||||
const VImgStub = defineComponent({
|
||||
name: 'VImg',
|
||||
emits: ['error'],
|
||||
/** 提供可由用例主动触发失败事件的图片替身。 */
|
||||
setup(_props, { emit }) {
|
||||
return () => h('button', { 'aria-label': '图片加载失败', onClick: () => emit('error') })
|
||||
},
|
||||
})
|
||||
const { container, rerender } = await renderWithProviders(DownloadingCard, {
|
||||
props: { downloaderName: 'qb-main', info: downloading() },
|
||||
global: { stubs: { VImg: VImgStub } },
|
||||
})
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: '图片加载失败' }))
|
||||
await waitFor(() => expect(container.querySelector('.downloading-card')).toHaveClass('downloading-card--no-image'))
|
||||
|
||||
await rerender({
|
||||
downloaderName: 'qb-main',
|
||||
info: downloading({ media: { ...downloading().media, poster: 'https://images.example.com/new-poster.jpg' } }),
|
||||
})
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '图片加载失败' })).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('applies the shared lift state to the outer shell on hover', async () => {
|
||||
const { container } = await renderCard()
|
||||
const hoverArea = container.querySelector('.downloading-card-hover-area')!
|
||||
|
||||
@@ -32,17 +32,22 @@ const selectedSitesUrl = new URL('system/setting/public/IndexerSites', API_BASE_
|
||||
|
||||
let intersectionObservers: IntersectionObserverMock[] = []
|
||||
|
||||
/** 提供可手动触发的视口观察器,供媒体卡片测试验证懒加载行为。 */
|
||||
class IntersectionObserverMock implements IntersectionObserver {
|
||||
readonly root: Element | Document | null
|
||||
readonly rootMargin: string
|
||||
readonly thresholds: readonly number[]
|
||||
/** 记录观察器释放调用。 */
|
||||
readonly disconnect = vi.fn()
|
||||
/** 记录被观察元素,供后续构造交叉状态。 */
|
||||
readonly observe = vi.fn((target: Element) => {
|
||||
this.target = target
|
||||
})
|
||||
/** 记录停止观察调用。 */
|
||||
readonly unobserve = vi.fn()
|
||||
private target: Element = document.body
|
||||
|
||||
/** 创建使用指定回调和阈值的测试观察器。 */
|
||||
constructor(
|
||||
private readonly callback: IntersectionObserverCallback,
|
||||
options: IntersectionObserverInit = {},
|
||||
@@ -53,10 +58,12 @@ class IntersectionObserverMock implements IntersectionObserver {
|
||||
intersectionObservers.push(this)
|
||||
}
|
||||
|
||||
/** 返回测试期间未消费的观察记录。 */
|
||||
takeRecords(): IntersectionObserverEntry[] {
|
||||
return []
|
||||
}
|
||||
|
||||
/** 手动向组件发送进入或离开视口的交叉状态。 */
|
||||
trigger(isIntersecting = true) {
|
||||
const bounds = this.target.getBoundingClientRect()
|
||||
this.callback(
|
||||
@@ -565,15 +572,19 @@ describe('MediaCard', () => {
|
||||
h('div', [h('button', { 'aria-label': '图片加载成功', onClick: () => emit('load') }), slots.default?.()])
|
||||
},
|
||||
})
|
||||
const VIconStub = {
|
||||
props: ['icon'],
|
||||
template: '<i :data-icon="icon" />',
|
||||
}
|
||||
const { container } = await renderWithProviders(MediaCard, {
|
||||
props: { media, width: '9rem' },
|
||||
initialState: { user: { superUser: true } },
|
||||
global: { stubs: { VImg: VImgStub } },
|
||||
global: { stubs: { VIcon: VIconStub, VImg: VImgStub } },
|
||||
})
|
||||
|
||||
await fireEvent.click(container.querySelector('[aria-label="图片加载成功"]') as HTMLElement)
|
||||
|
||||
await waitFor(() => expect(container.querySelector('.v-avatar .iconify--mdi')).not.toBeNull())
|
||||
await waitFor(() => expect(container.querySelector('[data-icon="mdi-alpha-a-circle"]')).not.toBeNull())
|
||||
})
|
||||
|
||||
it('hides search and subscribe actions when the user lacks both permissions', async () => {
|
||||
|
||||
@@ -196,7 +196,7 @@ describe('SubscribeCard display and progress', () => {
|
||||
|
||||
it('keeps mobile wash progress compact while preserving P, S, and R metadata', async () => {
|
||||
setViewport(480)
|
||||
const { media, rerender } = await renderCard({
|
||||
const { container, media, rerender } = await renderCard({
|
||||
best_version: true,
|
||||
completed_episode: 3,
|
||||
lack_episode: 2,
|
||||
@@ -216,6 +216,16 @@ describe('SubscribeCard display and progress', () => {
|
||||
expect(document.querySelector('.subscribe-card-mobile-image-meta__updated')).toHaveTextContent(lastUpdateText)
|
||||
expect(document.querySelector('.subscribe-card-mobile-body')).not.toHaveTextContent('卡片测试媒体')
|
||||
expect(document.querySelector('.subscribe-card-mobile-season')).toHaveTextContent('S01')
|
||||
expect(document.querySelector('.subscribe-card-mobile-title-text')).toHaveTextContent('卡片测试媒体S01')
|
||||
const image = container.querySelector<HTMLImageElement>('img')
|
||||
expect(image).not.toBeNull()
|
||||
await fireEvent.load(image as HTMLImageElement)
|
||||
expect(document.querySelector('.subscribe-card-mobile-title')).toContainElement(
|
||||
document.querySelector('.subscribe-card-mobile-best-version-badge'),
|
||||
)
|
||||
expect(document.querySelector('.subscribe-card-mobile-image-meta')).not.toHaveClass(
|
||||
'subscribe-card-mobile-image-meta--with-badge',
|
||||
)
|
||||
|
||||
await rerender({ media: { ...media, state: 'S' } })
|
||||
expect(screen.getByLabelText('已暂停')).toBeInTheDocument()
|
||||
|
||||
Reference in New Issue
Block a user