mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-13 09:34:47 +08:00
Enhance name recognition results with source metadata
This commit is contained in:
@@ -63,6 +63,10 @@ const bodyClasses = computed(() => [
|
||||
},
|
||||
])
|
||||
|
||||
// 允许工具视图在执行后续操作前主动关闭外层弹窗。
|
||||
function closeDialog() {
|
||||
visible.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -77,7 +81,7 @@ const bodyClasses = computed(() => [
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText :class="bodyClasses">
|
||||
<Component :is="props.view" v-bind="props.viewProps" />
|
||||
<Component :is="props.view" v-bind="props.viewProps" @close="closeDialog" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
|
||||
34
src/components/dialog/__tests__/ShortcutToolDialog.spec.ts
Normal file
34
src/components/dialog/__tests__/ShortcutToolDialog.spec.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import ShortcutToolDialog from '@/components/dialog/ShortcutToolDialog.vue'
|
||||
import { screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { defineComponent, markRaw } from 'vue'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const ToolView = defineComponent({
|
||||
emits: ['close'],
|
||||
template: '<button type="button" @click="$emit(\'close\')">关闭工具</button>',
|
||||
})
|
||||
|
||||
describe('ShortcutToolDialog', () => {
|
||||
it('closes the outer dialog when the active tool requests it', async () => {
|
||||
const user = userEvent.setup()
|
||||
const result = await renderWithProviders(ShortcutToolDialog, {
|
||||
props: {
|
||||
modelValue: true,
|
||||
title: '测试工具',
|
||||
view: markRaw(ToolView),
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
VDialogCloseBtn: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '关闭工具' }))
|
||||
|
||||
expect(result.emitted()['update:modelValue']).toEqual([[false]])
|
||||
expect(result.emitted().close).toEqual([[]])
|
||||
})
|
||||
})
|
||||
@@ -1625,9 +1625,13 @@ export default {
|
||||
title: 'Meta Info',
|
||||
caption: 'Parsed name, episodes, and resource terms',
|
||||
},
|
||||
source: {
|
||||
title: 'Recognition Source',
|
||||
caption: 'The media source matched by this recognition',
|
||||
},
|
||||
media: {
|
||||
title: 'Media Match',
|
||||
caption: 'Final matched media source',
|
||||
title: 'Media ID',
|
||||
caption: 'The native media ID from the matched source',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1614,9 +1614,13 @@ export default {
|
||||
title: '元信息',
|
||||
caption: '解析出的名称、季集和资源信息',
|
||||
},
|
||||
source: {
|
||||
title: '识别数据源',
|
||||
caption: '本次识别匹配到的媒体数据源',
|
||||
},
|
||||
media: {
|
||||
title: '媒体匹配',
|
||||
caption: '最终匹配到的媒体数据源',
|
||||
title: '媒体 ID',
|
||||
caption: '最终匹配到的媒体数据源原生 ID',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1613,9 +1613,13 @@ export default {
|
||||
title: '元資訊',
|
||||
caption: '解析出的名稱、季集和資源資訊',
|
||||
},
|
||||
source: {
|
||||
title: '識別資料源',
|
||||
caption: '本次識別匹配到的媒體資料源',
|
||||
},
|
||||
media: {
|
||||
title: '媒體匹配',
|
||||
caption: '最終匹配到的媒體資料源',
|
||||
title: '媒體 ID',
|
||||
caption: '最終匹配到的媒體資料源原生 ID',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
13
src/styles/__tests__/textMoviePilotTheme.spec.ts
Normal file
13
src/styles/__tests__/textMoviePilotTheme.spec.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('text-moviepilot theme color', () => {
|
||||
it('derives its gradient from the active Vuetify primary color', () => {
|
||||
const commonStyles = readFileSync(resolve(process.cwd(), 'src/styles/common.scss'), 'utf8')
|
||||
const textMoviePilotRule = commonStyles.match(/\.text-moviepilot\s*\{(?<rule>[\s\S]*?)\n\}/)?.groups?.rule
|
||||
|
||||
expect(textMoviePilotRule).toContain('var(--v-theme-primary)')
|
||||
expect(textMoviePilotRule).not.toMatch(/#818cf8|#c084fc/i)
|
||||
})
|
||||
})
|
||||
@@ -1377,12 +1377,8 @@ html[data-theme='transparent'].transparent-glass-realtime .v-theme--transparent
|
||||
// 文本样式
|
||||
.text-moviepilot {
|
||||
background-clip: text;
|
||||
background-image: linear-gradient(to bottom right, var(--tw-gradient-stops));
|
||||
background-image: linear-gradient(to bottom right, rgba(var(--v-theme-primary), 0.72), rgb(var(--v-theme-primary)));
|
||||
color: transparent;
|
||||
|
||||
--tw-gradient-from: #818cf8;
|
||||
--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
|
||||
--tw-gradient-to: #c084fc;
|
||||
}
|
||||
|
||||
// 登录页与主框架共用的品牌文字效果。
|
||||
|
||||
@@ -1,20 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { computed, nextTick, reactive, ref } from 'vue'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { requiredValidator } from '@/@validators'
|
||||
import api from '@/api'
|
||||
import type { Context, MediaDataSource, MediaInfo } from '@/api/types'
|
||||
import { getMediaSubscribeId } from '@/composables/useMediaSubscribe'
|
||||
import { getMediaSubscribeId, getMediaSubscribeIdentity } from '@/composables/useMediaSubscribe'
|
||||
import router from '@/router'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
interface PipelineStep {
|
||||
icon: string
|
||||
identity?: MediaIdentity
|
||||
source?: MediaSourceDisplay
|
||||
title: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface MediaIdentity {
|
||||
id: string
|
||||
link?: string
|
||||
source: string
|
||||
sourceKey: string
|
||||
}
|
||||
|
||||
interface MediaSourceDisplay {
|
||||
icon?: string
|
||||
image?: string
|
||||
key: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const MEDIA_SOURCE_LABELS: Record<string, string> = {
|
||||
anilist: 'AniList',
|
||||
bangumi: 'Bangumi',
|
||||
douban: 'Douban',
|
||||
themoviedb: 'TheMovieDb',
|
||||
}
|
||||
|
||||
const MEDIA_SOURCE_LOGOS: Record<string, string> = {
|
||||
bangumi: getLogoUrl('bangumi'),
|
||||
douban: getLogoUrl('douban'),
|
||||
themoviedb: getLogoUrl('tmdb'),
|
||||
}
|
||||
|
||||
const NAME_TEST_TITLE_HISTORY_KEY = 'MP_NAME_TEST_TITLE_HISTORY'
|
||||
const NAME_TEST_TITLE_HISTORY_LIMIT = 5
|
||||
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
@@ -40,12 +75,47 @@ const nameTestResult = ref<Context>()
|
||||
|
||||
// 名称识别表单
|
||||
const nameTestForm = reactive({
|
||||
title: '',
|
||||
subtitle: '',
|
||||
customWords: '',
|
||||
title: null,
|
||||
subtitle: null,
|
||||
customWords: null,
|
||||
source: getDefaultMediaSource(),
|
||||
})
|
||||
|
||||
/** 从本地存储读取最近使用的识别标题。 */
|
||||
function loadTitleHistory() {
|
||||
try {
|
||||
const storedHistory: unknown = JSON.parse(localStorage.getItem(NAME_TEST_TITLE_HISTORY_KEY) || '[]')
|
||||
if (!Array.isArray(storedHistory)) return []
|
||||
|
||||
return storedHistory
|
||||
.filter((title): title is string => typeof title === 'string' && Boolean(title.trim()))
|
||||
.map(title => title.trim())
|
||||
.filter((title, index, titles) => titles.indexOf(title) === index)
|
||||
.slice(0, NAME_TEST_TITLE_HISTORY_LIMIT)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const nameTestTitleHistory = ref<string[]>(loadTitleHistory())
|
||||
|
||||
/** 将本次提交的标题移到历史记录首位,并只保留最新五条。 */
|
||||
function saveTitleHistory(title: string) {
|
||||
const normalizedTitle = title.trim()
|
||||
if (!normalizedTitle) return
|
||||
|
||||
nameTestTitleHistory.value = [
|
||||
normalizedTitle,
|
||||
...nameTestTitleHistory.value.filter(historyTitle => historyTitle !== normalizedTitle),
|
||||
].slice(0, NAME_TEST_TITLE_HISTORY_LIMIT)
|
||||
|
||||
try {
|
||||
localStorage.setItem(NAME_TEST_TITLE_HISTORY_KEY, JSON.stringify(nameTestTitleHistory.value))
|
||||
} catch {
|
||||
// 本地存储不可用时仍继续执行本次识别。
|
||||
}
|
||||
}
|
||||
|
||||
// 识别按钮状态
|
||||
const nameTestLoading = ref(false)
|
||||
|
||||
@@ -91,17 +161,53 @@ const canViewMediaDetail = computed(() =>
|
||||
),
|
||||
)
|
||||
|
||||
/** 生成识别结果中的数据源原生ID摘要,并兼容旧接口字段。 */
|
||||
function getMediaIdentityLabel(media?: MediaInfo) {
|
||||
if (!media) return t('nameTest.unrecognized')
|
||||
if (media.media_id) return `${media.source || media.mediaid_prefix} ${media.media_id}`
|
||||
if (media.tmdb_id) return `TMDB ${media.tmdb_id}`
|
||||
if (media.douban_id) return `Douban ${media.douban_id}`
|
||||
if (media.bangumi_id) return `Bangumi ${media.bangumi_id}`
|
||||
if (media.anilist_id) return `AniList ${media.anilist_id}`
|
||||
return media.title || t('nameTest.unrecognized')
|
||||
/** 生成媒体源官方详情页地址。 */
|
||||
function getMediaOfficialLink(media: MediaInfo, source: string, mediaId: string) {
|
||||
const encodedId = encodeURIComponent(mediaId)
|
||||
|
||||
switch (source) {
|
||||
case 'themoviedb': {
|
||||
const mediaType = media.type?.trim().toLowerCase()
|
||||
return `https://www.themoviedb.org/${mediaType === '电影' || mediaType === 'movie' ? 'movie' : 'tv'}/${encodedId}`
|
||||
}
|
||||
case 'douban':
|
||||
return `https://movie.douban.com/subject/${encodedId}`
|
||||
case 'bangumi':
|
||||
return `https://bgm.tv/subject/${encodedId}`
|
||||
case 'anilist':
|
||||
return `https://anilist.co/anime/${encodedId}`
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** 生成识别结果中的数据源原生 ID,并兼容旧接口字段。 */
|
||||
function getMediaIdentity(media?: MediaInfo): MediaIdentity | undefined {
|
||||
if (!media) return undefined
|
||||
|
||||
const identity = getMediaSubscribeIdentity(media)
|
||||
if (!identity) return undefined
|
||||
|
||||
return {
|
||||
id: identity.mediaId,
|
||||
link: getMediaOfficialLink(media, identity.source, identity.mediaId),
|
||||
source: MEDIA_SOURCE_LABELS[identity.source] || identity.source,
|
||||
sourceKey: identity.source,
|
||||
}
|
||||
}
|
||||
|
||||
const mediaIdentity = computed(() => getMediaIdentity(mediaInfo.value))
|
||||
const recognizedMediaSource = computed<MediaSourceDisplay>(() => {
|
||||
const sourceKey = mediaIdentity.value?.sourceKey || nameTestForm.source
|
||||
|
||||
return {
|
||||
icon: sourceKey === 'anilist' ? 'mdi-alpha-a-circle' : undefined,
|
||||
image: MEDIA_SOURCE_LOGOS[sourceKey],
|
||||
key: sourceKey,
|
||||
label: mediaIdentity.value?.source || MEDIA_SOURCE_LABELS[sourceKey] || sourceKey,
|
||||
}
|
||||
})
|
||||
|
||||
const pipelineSteps = computed<PipelineStep[]>(() => [
|
||||
{
|
||||
icon: 'mdi-file-document-outline',
|
||||
@@ -117,9 +223,16 @@ const pipelineSteps = computed<PipelineStep[]>(() => [
|
||||
.join(' · ') || '-',
|
||||
},
|
||||
{
|
||||
icon: 'mdi-movie-search-outline',
|
||||
icon: 'mdi-database-search-outline',
|
||||
source: recognizedMediaSource.value,
|
||||
title: t('nameTest.steps.source.title'),
|
||||
value: recognizedMediaSource.value.label,
|
||||
},
|
||||
{
|
||||
icon: 'mdi-identifier',
|
||||
identity: mediaIdentity.value,
|
||||
title: t('nameTest.steps.media.title'),
|
||||
value: getMediaIdentityLabel(mediaInfo.value),
|
||||
value: mediaIdentity.value?.id || t('nameTest.unrecognized'),
|
||||
},
|
||||
])
|
||||
|
||||
@@ -129,11 +242,11 @@ function getPosterImage(url = '') {
|
||||
return url.replace('original', 'w500')
|
||||
}
|
||||
|
||||
/** 跳转查看当前识别结果匹配到的媒体详情。 */
|
||||
function viewMediaDetail() {
|
||||
/** 关闭识别测试弹窗后,跳转查看当前识别结果匹配到的媒体详情。 */
|
||||
async function viewMediaDetail() {
|
||||
if (!canViewMediaDetail.value || !mediaInfo.value) return
|
||||
|
||||
router.push({
|
||||
const target = {
|
||||
path: '/media',
|
||||
query: {
|
||||
mediaid: getMediaSubscribeId(mediaInfo.value),
|
||||
@@ -141,12 +254,20 @@ function viewMediaDetail() {
|
||||
year: mediaInfo.value.year,
|
||||
type: mediaInfo.value.type,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
emit('close')
|
||||
await nextTick()
|
||||
await router.push(target)
|
||||
}
|
||||
|
||||
/** 调用媒体识别接口并刷新解析工作台,输入的识别词会临时应用于本次识别测试。 */
|
||||
async function nameTest() {
|
||||
if (!nameTestForm.title) return
|
||||
const normalizedTitle = nameTestForm.title?.trim() || ''
|
||||
if (!normalizedTitle) return
|
||||
|
||||
nameTestForm.title = normalizedTitle
|
||||
saveTitleHistory(normalizedTitle)
|
||||
|
||||
try {
|
||||
nameTestLoading.value = true
|
||||
@@ -157,7 +278,7 @@ async function nameTest() {
|
||||
params: {
|
||||
title: nameTestForm.title,
|
||||
subtitle: nameTestForm.subtitle,
|
||||
custom_words: nameTestForm.customWords || undefined,
|
||||
custom_words: nameTestForm.customWords?.trim() || undefined,
|
||||
source: nameTestForm.source,
|
||||
},
|
||||
})
|
||||
@@ -180,7 +301,7 @@ function parseCustomWordLines(text: string) {
|
||||
async function saveCustomWords() {
|
||||
if (savingCustomWords.value) return
|
||||
|
||||
const newLines = parseCustomWordLines(nameTestForm.customWords)
|
||||
const newLines = parseCustomWordLines(nameTestForm.customWords || '')
|
||||
if (!newLines.length) return
|
||||
|
||||
savingCustomWords.value = true
|
||||
@@ -216,8 +337,9 @@ async function saveCustomWords() {
|
||||
<VForm validate-on="submit lazy" @submit.prevent="nameTest">
|
||||
<VRow class="shortcut-form">
|
||||
<VCol cols="12" class="shortcut-form-col">
|
||||
<VTextField
|
||||
<VCombobox
|
||||
v-model="nameTestForm.title"
|
||||
:items="nameTestTitleHistory"
|
||||
:label="t('nameTest.title')"
|
||||
:hint="t('nameTest.titleHint')"
|
||||
persistent-hint
|
||||
@@ -263,7 +385,7 @@ async function saveCustomWords() {
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
:disabled="!nameTestForm.customWords.trim()"
|
||||
:disabled="!nameTestForm.customWords?.trim()"
|
||||
:loading="savingCustomWords"
|
||||
@click="saveCustomWords"
|
||||
>
|
||||
@@ -359,7 +481,42 @@ async function saveCustomWords() {
|
||||
{{ step.title }}
|
||||
</div>
|
||||
<div class="text-body-2 font-weight-medium pipeline-value">
|
||||
{{ step.value }}
|
||||
<span
|
||||
v-if="step.source"
|
||||
class="media-source-display"
|
||||
:aria-label="step.source.label"
|
||||
:data-source="step.source.key"
|
||||
:title="step.source.label"
|
||||
data-testid="recognition-source"
|
||||
>
|
||||
<VImg
|
||||
v-if="step.source.image"
|
||||
class="media-source-logo"
|
||||
:src="step.source.image"
|
||||
:alt="step.source.label"
|
||||
/>
|
||||
<VIcon
|
||||
v-else-if="step.source.icon"
|
||||
class="media-source-logo"
|
||||
color="#02a9ff"
|
||||
:icon="step.source.icon"
|
||||
/>
|
||||
<span>{{ step.source.label }}</span>
|
||||
</span>
|
||||
<template v-else-if="step.identity">
|
||||
<a
|
||||
v-if="step.identity.link"
|
||||
class="media-id-link"
|
||||
:href="step.identity.link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@click.stop
|
||||
>
|
||||
{{ step.identity.id }}
|
||||
</a>
|
||||
<span v-else>{{ step.identity.id }}</span>
|
||||
</template>
|
||||
<template v-else>{{ step.value }}</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -534,6 +691,24 @@ async function saveCustomWords() {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.media-id-link {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.15em;
|
||||
}
|
||||
|
||||
.media-source-display {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.media-source-logo {
|
||||
flex: 0 0 1.4rem;
|
||||
block-size: 1.4rem;
|
||||
inline-size: 1.4rem;
|
||||
}
|
||||
|
||||
.applied-words {
|
||||
display: grid;
|
||||
border-block-start: var(--app-surface-border);
|
||||
|
||||
206
src/views/system/__tests__/NameTestView.spec.ts
Normal file
206
src/views/system/__tests__/NameTestView.spec.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import NameTestView from '@/views/system/NameTestView.vue'
|
||||
import { screen, waitFor } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
apiGet: vi.fn(),
|
||||
apiPost: vi.fn(),
|
||||
routerPush: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
toastWarning: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: {
|
||||
get: mocks.apiGet,
|
||||
post: mocks.apiPost,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/router', () => ({
|
||||
default: {
|
||||
push: mocks.routerPush,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('vue-toastification', () => ({
|
||||
useToast: () => ({
|
||||
error: mocks.toastError,
|
||||
success: mocks.toastSuccess,
|
||||
warning: mocks.toastWarning,
|
||||
}),
|
||||
}))
|
||||
|
||||
interface RecognizedMedia {
|
||||
media_id: string
|
||||
source: string
|
||||
title: string
|
||||
type: string
|
||||
year: string
|
||||
}
|
||||
|
||||
async function renderRecognizedMedia(media: RecognizedMedia, onClose = vi.fn()) {
|
||||
mocks.apiGet.mockResolvedValueOnce({
|
||||
media_info: media,
|
||||
meta_info: {
|
||||
apply_words: [],
|
||||
name: media.title,
|
||||
org_string: 'Test.Release',
|
||||
},
|
||||
torrent_info: {},
|
||||
})
|
||||
|
||||
const result = await renderWithProviders(NameTestView, {
|
||||
attrs: { onClose },
|
||||
initialState: {
|
||||
globalSettings: {
|
||||
data: { RECOGNIZE_SOURCE: 'themoviedb' },
|
||||
},
|
||||
},
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
|
||||
await user.type(screen.getByLabelText('标题'), 'Test.Release')
|
||||
await user.click(screen.getByRole('button', { name: '识别' }))
|
||||
await screen.findByRole('link', { name: media.media_id })
|
||||
|
||||
return { ...result, onClose, user }
|
||||
}
|
||||
|
||||
describe('NameTestView media identity', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
mocks.routerPush.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'TheMovieDb',
|
||||
{ media_id: '271016', source: 'themoviedb', title: '测试剧集', type: '电视剧', year: '2026' },
|
||||
'https://www.themoviedb.org/tv/271016',
|
||||
],
|
||||
[
|
||||
'Douban',
|
||||
{ media_id: '1295644', source: 'douban', title: '测试电影', type: '电影', year: '1994' },
|
||||
'https://movie.douban.com/subject/1295644',
|
||||
],
|
||||
[
|
||||
'Bangumi',
|
||||
{ media_id: '485', source: 'bangumi', title: '测试动画', type: '电视剧', year: '2026' },
|
||||
'https://bgm.tv/subject/485',
|
||||
],
|
||||
[
|
||||
'AniList',
|
||||
{ media_id: '154587', source: 'anilist', title: '测试番剧', type: '电视剧', year: '2026' },
|
||||
'https://anilist.co/anime/154587',
|
||||
],
|
||||
])('formats %s and links its native media ID', async (sourceLabel, media, expectedLink) => {
|
||||
await renderRecognizedMedia(media)
|
||||
|
||||
const mediaIdLink = screen.getByRole('link', { name: media.media_id })
|
||||
expect(mediaIdLink).toHaveAttribute('href', expectedLink)
|
||||
expect(mediaIdLink).toHaveAttribute('target', '_blank')
|
||||
expect(mediaIdLink).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
expect(mediaIdLink.closest('.pipeline-step')).toHaveTextContent(`媒体 ID${media.media_id}`)
|
||||
expect(mediaIdLink.closest('.pipeline-step')).not.toHaveTextContent(sourceLabel)
|
||||
const sourceDisplay = screen.getByTestId('recognition-source')
|
||||
expect(sourceDisplay).toHaveAttribute('data-source', media.source)
|
||||
expect(sourceDisplay).toHaveAccessibleName(sourceLabel)
|
||||
expect(sourceDisplay.closest('.pipeline-step')).toHaveTextContent(`识别数据源${sourceLabel}`)
|
||||
expect(sourceDisplay.querySelector('.media-source-logo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('closes the recognition dialog before navigating to the media detail', async () => {
|
||||
const eventOrder: string[] = []
|
||||
const onClose = vi.fn(() => eventOrder.push('close'))
|
||||
mocks.routerPush.mockImplementation(async () => {
|
||||
eventOrder.push('push')
|
||||
})
|
||||
const media = {
|
||||
media_id: '271016',
|
||||
source: 'themoviedb',
|
||||
title: '测试剧集',
|
||||
type: '电视剧',
|
||||
year: '2026',
|
||||
}
|
||||
const { user } = await renderRecognizedMedia(media, onClose)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '查看详情' }))
|
||||
|
||||
expect(eventOrder).toEqual(['close', 'push'])
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
expect(mocks.routerPush).toHaveBeenCalledWith({
|
||||
path: '/media',
|
||||
query: {
|
||||
mediaid: 'tmdb:271016',
|
||||
title: '测试剧集',
|
||||
type: '电视剧',
|
||||
year: '2026',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('persists the five most recent unique titles and restores them in the combobox', async () => {
|
||||
mocks.apiGet.mockImplementation(async (_endpoint: string, options: { params: { title: string } }) => ({
|
||||
media_info: {
|
||||
media_id: '271016',
|
||||
source: 'themoviedb',
|
||||
title: options.params.title,
|
||||
type: '电视剧',
|
||||
year: '2026',
|
||||
},
|
||||
meta_info: {
|
||||
apply_words: [],
|
||||
name: options.params.title,
|
||||
org_string: options.params.title,
|
||||
},
|
||||
torrent_info: {},
|
||||
}))
|
||||
const result = await renderWithProviders(NameTestView, {
|
||||
initialState: {
|
||||
globalSettings: {
|
||||
data: { RECOGNIZE_SOURCE: 'themoviedb' },
|
||||
},
|
||||
},
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
const titleInput = screen.getByLabelText('标题')
|
||||
const submittedTitles = ['标题一', '标题二', '标题三', '标题四', '标题五', '标题六', '标题三']
|
||||
|
||||
for (const [index, title] of submittedTitles.entries()) {
|
||||
await user.clear(titleInput)
|
||||
await user.type(titleInput, ` ${title} `)
|
||||
await user.click(screen.getByRole('button', { name: index === 0 ? '识别' : '重新识别' }))
|
||||
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledTimes(index + 1))
|
||||
}
|
||||
|
||||
expect(JSON.parse(localStorage.getItem('MP_NAME_TEST_TITLE_HISTORY') || '[]')).toEqual([
|
||||
'标题三',
|
||||
'标题六',
|
||||
'标题五',
|
||||
'标题四',
|
||||
'标题二',
|
||||
])
|
||||
|
||||
result.unmount()
|
||||
await renderWithProviders(NameTestView, {
|
||||
initialState: {
|
||||
globalSettings: {
|
||||
data: { RECOGNIZE_SOURCE: 'themoviedb' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const restoredInput = screen.getByLabelText('标题')
|
||||
await user.click(restoredInput)
|
||||
const historyOptions = await screen.findAllByRole('option')
|
||||
expect(historyOptions.map(option => option.textContent)).toEqual(['标题三', '标题六', '标题五', '标题四', '标题二'])
|
||||
await user.click(screen.getByRole('option', { name: '标题六' }))
|
||||
|
||||
expect(restoredInput).toHaveValue('标题六')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user