test(subscribe): cover subscription management flows (#530)

This commit is contained in:
InfinityPacer
2026-07-16 17:37:43 +08:00
committed by GitHub
parent 9900042bc3
commit 8a069db55e
12 changed files with 1534 additions and 30 deletions
+1 -1
View File
@@ -78,7 +78,7 @@ tests/
Vitest 只收集 `src/**/__tests__/**/*.spec.ts`。测试模式保留 Vue、Vue JSX、Vuetify、自动导入、自动组件和 i18n 插件,并禁用 PWA、模块联邦和 top-level-await 构建插件。 Vitest 只收集 `src/**/__tests__/**/*.spec.ts`。测试模式保留 Vue、Vue JSX、Vuetify、自动导入、自动组件和 i18n 插件,并禁用 PWA、模块联邦和 top-level-await 构建插件。
当前核心覆盖范围在 `vite.config.ts``coverage.include` 中显式维护。聚合门槛为 Lines、Statements、Functions 不低于 80%Branches 不低于 75%。覆盖率报告写入 `coverage/` 当前核心覆盖范围在 `vite.config.ts``coverage.include` 中显式维护。聚合门槛为 Lines、Statements、Functions 不低于 85%Branches 不低于 80%;每个显式核心文件的 Lines、Statements、Functions 不低于 80%Branches 不低于 75%。覆盖率报告写入 `coverage/`
## 命令与 CI ## 命令与 CI
+68 -11
View File
@@ -10,6 +10,17 @@ import { qualityOptions, resolutionOptions, effectOptions } from '@/api/constant
import { useUserStore } from '@/stores' import { useUserStore } from '@/stores'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission' import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
import { formatSeason } from '@/@core/utils/formatters' import { formatSeason } from '@/@core/utils/formatters'
// 从变更请求异常中提取可展示消息,并为非标准错误提供稳定兜底。
function getRequestErrorMessage(error: unknown, fallback: string) {
if (typeof error === 'object' && error !== null) {
const responseMessage = (error as { response?: { data?: { message?: unknown } } }).response?.data?.message
if (typeof responseMessage === 'string' && responseMessage) return responseMessage
}
if (error instanceof Error && error.message) return error.message
return fallback
}
// i18n // i18n
const { t } = useI18n() const { t } = useI18n()
const userStore = useUserStore() const userStore = useUserStore()
@@ -104,6 +115,12 @@ function getSubscribeDisplayName() {
return `${name} ${formatSeason(season.toString())}` return `${name} ${formatSeason(season.toString())}`
} }
function getDefaultSubscribeTypeName() {
if (props.type === '电影') return t('mediaType.movie')
if (props.type === '电视剧') return t('mediaType.tv')
return props.type ?? ''
}
// 剧集组选项属性 // 剧集组选项属性
function episodeGroupItemProps(item: { title: string; subtitle: string }) { function episodeGroupItemProps(item: { title: string; subtitle: string }) {
return { return {
@@ -162,18 +179,30 @@ const filterRuleGroupOptions = computed(() => {
// 调用API修改订阅 // 调用API修改订阅
async function updateSubscribeInfo() { async function updateSubscribeInfo() {
const displayName = getSubscribeDisplayName()
try { try {
const result: { [key: string]: any } = await api.put('subscribe/', subscribeForm.value) const result: { [key: string]: any } = await api.put('subscribe/', subscribeForm.value)
// 提示 // 提示
if (result.success) { if (result.success) {
$toast.success(`${getSubscribeDisplayName()} 更新成功!`) $toast.success(t('dialog.subscribeEdit.updateSuccess', { name: displayName }))
// 通知父组件刷新 // 通知父组件刷新
emit('save') emit('save', subscribeForm.value)
} else { } else {
$toast.error(`${getSubscribeDisplayName()} 更新失败:${result.message}`) $toast.error(
t('dialog.subscribeEdit.updateFailed', {
name: displayName,
message: result.message ?? t('subscribe.requestFailed'),
}),
)
} }
} catch (e) { } catch (e) {
console.log(e) console.log(e)
$toast.error(
t('dialog.subscribeEdit.updateFailed', {
name: displayName,
message: getRequestErrorMessage(e, t('subscribe.requestFailed')),
}),
)
} }
} }
@@ -181,19 +210,33 @@ async function updateSubscribeInfo() {
async function saveDefaultSubscribeConfig() { async function saveDefaultSubscribeConfig() {
if (!canAdmin.value) return if (!canAdmin.value) return
const typeName = getDefaultSubscribeTypeName()
try { try {
let subscribe_config_url = '' let subscribe_config_url = ''
if (props.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig' if (props.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
else subscribe_config_url = 'system/setting/DefaultTvSubscribeConfig' else subscribe_config_url = 'system/setting/DefaultTvSubscribeConfig'
const result: { [key: string]: any } = await api.post(subscribe_config_url, subscribeForm.value) const result: { [key: string]: any } = await api.post(subscribe_config_url, subscribeForm.value)
if (result.success) $toast.success(`${props.type}订阅默认规则保存成功`) if (result.success) {
else $toast.error(`${props.type}订阅默认规则保存失败!`) $toast.success(t('dialog.subscribeEdit.defaultSaveSuccess', { type: typeName }))
// 通知父组件刷新
// 通知父组件刷新 emit('save', subscribeForm.value)
emit('save') } else {
$toast.error(
t('dialog.subscribeEdit.defaultSaveFailed', {
type: typeName,
message: result.message ?? t('subscribe.requestFailed'),
}),
)
}
} catch (error) { } catch (error) {
console.log(error) console.log(error)
$toast.error(
t('dialog.subscribeEdit.defaultSaveFailed', {
type: typeName,
message: getRequestErrorMessage(error, t('subscribe.requestFailed')),
}),
)
} }
} }
@@ -262,16 +305,28 @@ async function removeSubscribe() {
}) })
if (!isConfirmed) return if (!isConfirmed) return
const displayName = getSubscribeDisplayName()
try { try {
const result: { [key: string]: any } = await api.delete(`subscribe/${props.subid}`) const result: { [key: string]: any } = await api.delete(`subscribe/${props.subid}`)
if (result.success) { if (result.success) {
$toast.success(`订阅 ${getSubscribeDisplayName()} 已取消!`) $toast.success(`${displayName} ${t('subscribe.cancelSuccess')}`)
// 通知父组件刷新 // 通知父组件刷新
emit('remove') emit('remove')
} else {
$toast.error(
`${displayName} ${t('subscribe.cancelFailed', {
message: result.message ?? t('subscribe.requestFailed'),
})}`,
)
} }
} catch (e) { } catch (e) {
console.log(e) console.log(e)
$toast.error(
`${displayName} ${t('subscribe.cancelFailed', {
message: getRequestErrorMessage(e, t('subscribe.requestFailed')),
})}`,
)
} }
} }
@@ -289,8 +344,10 @@ async function loadDownloadDirectories() {
// 保存目录下拉框 // 保存目录下拉框
const targetDirectories = computed(() => { const targetDirectories = computed(() => {
// 去重后的下载目录 const paths = downloadDirectories.value
return downloadDirectories.value.map(item => item.download_path) .map(item => item.download_path?.trim())
.filter((path): path is string => Boolean(path))
return [...new Set(paths)]
}) })
// 仅电视剧订阅支持全集洗版,电影保持原有洗版逻辑 // 仅电视剧订阅支持全集洗版,电影保持原有洗版逻辑
@@ -0,0 +1,451 @@
import SubscribeEditDialog from '@/components/dialog/SubscribeEditDialog.vue'
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import {
createSubscribe,
createSubscribeDirectory,
createSubscribeDownloader,
createSubscribeRuleGroup,
createSubscribeSite,
} from '@tests/support/factories/subscribe'
import {
defaultSubscribeConfigHandler,
deleteSubscribeByIdHandler,
saveDefaultSubscribeConfigHandler,
subscribeApiUrls,
subscribeDetailsHandler,
subscribeDialogOptionHandlers,
type SubscribeDialogOptions,
type SubscribeMediaType,
updateSubscribeHandler,
} from '@tests/support/msw/handlers/subscribe'
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { HttpResponse, http } from 'msw'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
confirm: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
}))
vi.mock('vue-toastification', () => ({
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
}))
vi.mock('@/composables/useConfirm', () => ({
useConfirm: () => mocks.confirm,
}))
interface DialogProps {
default?: boolean
subid?: number
type?: SubscribeMediaType
}
async function renderDialog(props: DialogProps, superUser = true) {
const events = {
close: vi.fn(),
remove: vi.fn(),
save: vi.fn(),
}
const result = await renderWithProviders(SubscribeEditDialog, {
initialState: {
user: {
superUser,
userName: superUser ? 'admin' : 'member',
},
},
props: {
modelValue: true,
...props,
onClose: events.close,
onRemove: events.remove,
onSave: events.save,
},
global: {
components: {
VDialogCloseBtn: DialogCloseBtn,
},
},
})
return { ...result, events }
}
function useDialogOptions(options: SubscribeDialogOptions = {}) {
server.use(...subscribeDialogOptionHandlers(options))
}
describe('SubscribeEditDialog', () => {
beforeEach(() => {
mocks.confirm.mockResolvedValue(true)
})
it('loads a TV subscription, normalizes flags, and exposes episode groups', async () => {
const record = createSubscribe({
best_version: 1,
best_version_full: 1,
id: 801,
name: '季度测试剧',
search_imdbid: 0,
season: 2,
tmdbid: 8010,
type: '电视剧',
})
const episodeGroupsRequested = vi.fn()
server.use(subscribeDetailsHandler(801, record))
useDialogOptions({
episodeGroups: [{ episode_count: 24, group_count: 2, id: 99, name: '官方特别排序' }],
onEpisodeGroups: episodeGroupsRequested,
tmdbId: 8010,
})
const user = userEvent.setup()
await renderDialog({ subid: 801 })
expect(await screen.findByText('季度测试剧 S02')).toBeInTheDocument()
expect(screen.getByLabelText('洗版')).toBeChecked()
expect(screen.getByLabelText('全集洗版')).toBeChecked()
expect(screen.getByLabelText('使用 ImdbID 搜索')).not.toBeChecked()
await waitFor(() => expect(episodeGroupsRequested).toHaveBeenCalledOnce())
await user.click(screen.getByRole('tab', { name: '进阶' }))
await user.click(screen.getByLabelText('指定剧集组'))
expect(await screen.findByText('官方特别排序')).toBeInTheDocument()
expect(screen.getByText('2 季 • 24 集')).toBeInTheDocument()
})
it('keeps movie titles free of season suffixes and skips episode groups', async () => {
const record = createSubscribe({ id: 802, name: '电影测试项', season: undefined, tmdbid: 8020, type: '电影' })
const episodeGroupsRequested = vi.fn()
server.use(subscribeDetailsHandler(802, record))
useDialogOptions({ onEpisodeGroups: episodeGroupsRequested, tmdbId: 8020 })
await renderDialog({ subid: 802 })
expect(await screen.findByText('电影测试项')).toBeInTheDocument()
expect(screen.queryByText(/电影测试项 S\d+/)).not.toBeInTheDocument()
expect(episodeGroupsRequested).not.toHaveBeenCalled()
})
it('shows enabled sites and stable downloader, directory, and rule options', async () => {
const activeSite = createSubscribeSite({ id: 1, is_active: true, name: '启用站点' })
const inactiveSite = createSubscribeSite({ id: 2, is_active: false, name: '停用站点' })
const requests = {
directories: vi.fn(),
downloaders: vi.fn(),
rules: vi.fn(),
sites: vi.fn(),
}
server.use(defaultSubscribeConfigHandler('电影', createSubscribe({ id: 0, type: '电影' })))
useDialogOptions({
directories: [
createSubscribeDirectory({ download_path: '/downloads', name: '目录一' }),
createSubscribeDirectory({ download_path: '/downloads', name: '目录二' }),
createSubscribeDirectory({ download_path: undefined, name: '空目录' }),
],
downloaders: [createSubscribeDownloader({ name: '下载器 A' })],
filterRuleGroups: [createSubscribeRuleGroup({ name: '高优先级' })],
onDirectories: requests.directories,
onDownloaders: requests.downloaders,
onFilterRuleGroups: requests.rules,
onSites: requests.sites,
sites: [activeSite, inactiveSite],
})
const user = userEvent.setup()
await renderDialog({ default: true, type: '电影' })
await waitFor(() => expect(requests.sites).toHaveBeenCalledOnce())
await waitFor(() => expect(requests.downloaders).toHaveBeenCalledOnce())
await waitFor(() => expect(requests.directories).toHaveBeenCalledOnce())
await waitFor(() => expect(requests.rules).toHaveBeenCalledOnce())
await user.click(screen.getByLabelText('订阅站点'))
expect(await screen.findByText('启用站点')).toBeInTheDocument()
expect(screen.queryByText('停用站点')).not.toBeInTheDocument()
await user.keyboard('{Escape}')
await user.click(screen.getByLabelText('下载器'))
expect(await screen.findByText('下载器 A')).toBeInTheDocument()
expect(screen.getAllByText('默认').length).toBeGreaterThanOrEqual(1)
await user.keyboard('{Escape}')
await user.click(screen.getByLabelText('保存路径'))
expect(await screen.findAllByText('/downloads')).toHaveLength(1)
expect(screen.queryByText('undefined')).not.toBeInTheDocument()
await user.keyboard('{Escape}')
await user.click(screen.getByRole('tab', { name: '进阶' }))
await user.click(screen.getByLabelText('优先级规则组'))
expect(await screen.findByText('高优先级')).toBeInTheDocument()
})
it('allows non-admin users to read public defaults but not private rules or save them', async () => {
const configRequested = vi.fn()
const rulesRequested = vi.fn()
const saved = vi.fn()
server.use(
defaultSubscribeConfigHandler('电视剧', createSubscribe({ id: 0, type: '电视剧' }), 200, configRequested),
saveDefaultSubscribeConfigHandler('电视剧', { success: true }, 200, saved),
)
useDialogOptions({ onFilterRuleGroups: rulesRequested })
const { events } = await renderDialog({ default: true, type: '电视剧' }, false)
await waitFor(() => expect(configRequested).toHaveBeenCalledOnce())
expect(rulesRequested).not.toHaveBeenCalled()
await fireEvent.click(screen.getByRole('button', { name: '保存' }))
expect(saved).not.toHaveBeenCalled()
expect(events.save).not.toHaveBeenCalled()
})
it.each(['电影', '电视剧'] as const)('loads and saves %s default configuration as an administrator', async type => {
const configRequested = vi.fn()
const saved = vi.fn()
server.use(
defaultSubscribeConfigHandler(type, createSubscribe({ id: 0, show_edit_dialog: false, type }), 200, configRequested),
saveDefaultSubscribeConfigHandler(type, { success: true }, 200, saved),
)
useDialogOptions()
const user = userEvent.setup()
const { events } = await renderDialog({ default: true, type })
await waitFor(() => expect(configRequested).toHaveBeenCalledOnce())
await waitFor(() => expect(screen.getByLabelText('订阅时编辑更多规则')).not.toBeChecked())
await user.click(screen.getByLabelText('订阅时编辑更多规则'))
await fireEvent.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(saved).toHaveBeenCalledOnce())
expect(saved.mock.calls[0][0]).toMatchObject({ show_edit_dialog: true, type })
expect(events.save).toHaveBeenCalledOnce()
expect(mocks.toastSuccess).toHaveBeenCalledWith(`${type}订阅默认规则保存成功`)
})
it('submits the complete TV editing form and exposes the close action', async () => {
const record = createSubscribe({
best_version: 1,
best_version_full: 0,
id: 809,
name: '完整表单测试剧',
search_imdbid: 0,
season: 1,
tmdbid: 8090,
type: '电视剧',
})
const updated = vi.fn()
server.use(subscribeDetailsHandler(809, record), updateSubscribeHandler({ success: true }, 200, updated))
useDialogOptions({
directories: [createSubscribeDirectory({ download_path: '/完整目录' })],
downloaders: [createSubscribeDownloader({ name: '完整下载器' })],
episodeGroups: [{ episode_count: 12, group_count: 1, id: 8091, name: '完整剧集组' }],
filterRuleGroups: [createSubscribeRuleGroup({ name: '完整规则组' })],
sites: [createSubscribeSite({ id: 8092, name: '完整站点' })],
tmdbId: 8090,
})
const user = userEvent.setup()
const { events } = await renderDialog({ subid: 809 })
await screen.findByText('完整表单测试剧 S01')
const chooseOption = async (label: string, option: string) => {
await user.click(screen.getByLabelText(label))
await user.click(await screen.findByText(option, {}, { timeout: 2_000 }))
}
await user.type(screen.getByLabelText('总集数'), '24')
await user.type(screen.getByLabelText('开始集数'), '2')
await chooseOption('质量', 'Remux')
await chooseOption('分辨率', '1080p')
await chooseOption('特效', 'HDR')
await chooseOption('订阅站点', '完整站点')
await user.keyboard('{Escape}')
await chooseOption('下载器', '完整下载器')
await chooseOption('保存路径', '/完整目录')
await user.click(screen.getByLabelText('全集洗版'))
await user.click(screen.getByLabelText('使用 ImdbID 搜索'))
await user.click(screen.getByRole('tab', { name: '进阶' }))
await user.type(screen.getByLabelText('包含(关键字、正则式)'), '国语')
await user.type(screen.getByLabelText('排除(关键字、正则式)'), '预告')
await chooseOption('优先级规则组', '完整规则组')
await user.keyboard('{Escape}')
await chooseOption('指定剧集组', '完整剧集组')
await chooseOption('指定季', '第 2 季')
await user.type(screen.getByLabelText('自定义类别'), '纪录片')
await user.type(screen.getByLabelText('自定义识别词'), '测试词 => 正式词')
const closeButton = document.querySelector<HTMLButtonElement>('.v-card-item button')
expect(closeButton).not.toBeNull()
await user.click(closeButton as HTMLButtonElement)
expect(events.close).toHaveBeenCalledOnce()
await user.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(updated).toHaveBeenCalledOnce())
expect(updated.mock.calls[0][0]).toMatchObject({
best_version_full: true,
custom_words: '测试词 => 正式词',
downloader: '完整下载器',
effect: '[\\s.]+HDR[\\s.]+|HDR10|HDR10\\+',
episode_group: 8091,
exclude: '预告',
filter_groups: ['完整规则组'],
include: '国语',
media_category: '纪录片',
quality: 'Remux',
resolution: '1080[pi]|x1080',
save_path: '/完整目录',
search_imdbid: true,
season: 2,
sites: [8092],
start_episode: '2',
total_episode: '24',
})
expect(events.save).toHaveBeenCalledWith(expect.objectContaining({ season: 2 }))
})
it.each([
['business failure', 200, { message: 'rejected', success: false }, '电影订阅默认规则保存失败:rejected!'],
['HTTP failure', 500, { message: 'server down', success: false }, '电影订阅默认规则保存失败:server down'],
])('keeps a default dialog open after a %s', async (_case, status, response, expectedMessage) => {
const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {})
server.use(
defaultSubscribeConfigHandler('电影', createSubscribe({ id: 0, type: '电影' })),
saveDefaultSubscribeConfigHandler('电影', response, status),
)
useDialogOptions()
const { events } = await renderDialog({ default: true, type: '电影' })
await screen.findByText('电影')
await fireEvent.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expectedMessage))
expect(events.save).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: '保存' })).toBeInTheDocument()
consoleLog.mockRestore()
})
it('updates an edited subscription and clears full-season mode when versioning is disabled', async () => {
const record = createSubscribe({
best_version: 1,
best_version_full: 1,
id: 803,
keyword: '旧关键词',
name: '编辑测试剧',
season: 1,
tmdbid: 8030,
type: '电视剧',
})
const updated = vi.fn()
server.use(subscribeDetailsHandler(803, record), updateSubscribeHandler({ success: true }, 200, updated))
useDialogOptions({ tmdbId: 8030 })
const user = userEvent.setup()
const { events } = await renderDialog({ subid: 803 })
await screen.findByText('编辑测试剧 S01')
const keyword = screen.getByLabelText('搜索关键词')
await user.clear(keyword)
await user.type(keyword, '新关键词')
await user.click(screen.getByLabelText('洗版'))
await waitFor(() => expect(screen.queryByLabelText('全集洗版')).not.toBeInTheDocument())
await user.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(updated).toHaveBeenCalledOnce())
expect(updated.mock.calls[0][0]).toMatchObject({
best_version: false,
best_version_full: false,
id: 803,
keyword: '新关键词',
})
expect(events.save).toHaveBeenCalledWith(expect.objectContaining({ id: 803, keyword: '新关键词' }))
expect(mocks.toastSuccess).toHaveBeenCalledWith('编辑测试剧 S01 更新成功!')
})
it.each([
['business failure', 200, { message: 'invalid', success: false }, '失败编辑项 更新失败:invalid'],
['HTTP failure', 500, { message: 'server down', success: false }, '失败编辑项 更新失败:server down'],
])('keeps an edit dialog usable after an update %s', async (_case, status, response, expectedMessage) => {
const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {})
const record = createSubscribe({ id: 804, name: '失败编辑项', tmdbid: 8040 })
server.use(subscribeDetailsHandler(804, record), updateSubscribeHandler(response, status))
useDialogOptions({ tmdbId: 8040 })
const { events } = await renderDialog({ subid: 804 })
await screen.findByText('失败编辑项')
await fireEvent.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expectedMessage))
expect(events.save).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: '保存' })).toBeInTheDocument()
consoleLog.mockRestore()
})
it('does not delete when confirmation is cancelled', async () => {
const record = createSubscribe({ id: 805, name: '保留订阅', tmdbid: 8050 })
const deleted = vi.fn()
server.use(subscribeDetailsHandler(805, record), deleteSubscribeByIdHandler(805, { success: true }, 200, deleted))
useDialogOptions({ tmdbId: 8050 })
mocks.confirm.mockResolvedValue(false)
const { events } = await renderDialog({ subid: 805 })
await screen.findByText('保留订阅')
await fireEvent.click(screen.getByRole('button', { name: '取消订阅' }))
expect(deleted).not.toHaveBeenCalled()
expect(events.remove).not.toHaveBeenCalled()
})
it('emits remove only after a successful deletion', async () => {
const record = createSubscribe({ id: 806, name: '删除订阅', tmdbid: 8060 })
const deleted = vi.fn()
server.use(subscribeDetailsHandler(806, record), deleteSubscribeByIdHandler(806, { success: true }, 200, deleted))
useDialogOptions({ tmdbId: 8060 })
const { events } = await renderDialog({ subid: 806 })
await screen.findByText('删除订阅')
await fireEvent.click(screen.getByRole('button', { name: '取消订阅' }))
await waitFor(() => expect(deleted).toHaveBeenCalledOnce())
expect(events.remove).toHaveBeenCalledOnce()
expect(mocks.toastSuccess).toHaveBeenCalledWith('删除订阅 已取消订阅!')
})
it.each([
['business failure', 200, { message: 'not allowed', success: false }, '删除失败项 取消订阅失败:not allowed'],
['HTTP failure', 500, { message: 'server down', success: false }, '删除失败项 取消订阅失败:server down'],
])('keeps the subscription after a delete %s', async (_case, status, response, expectedMessage) => {
const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {})
const record = createSubscribe({ id: 807, name: '删除失败项', tmdbid: 8070 })
server.use(subscribeDetailsHandler(807, record), deleteSubscribeByIdHandler(807, response, status))
useDialogOptions({ tmdbId: 8070 })
const { events } = await renderDialog({ subid: 807 })
await screen.findByText('删除失败项')
await fireEvent.click(screen.getByRole('button', { name: '取消订阅' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expectedMessage))
expect(events.remove).not.toHaveBeenCalled()
expect(screen.getByText('删除失败项')).toBeInTheDocument()
consoleLog.mockRestore()
})
it('remains editable when an auxiliary options request fails', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const record = createSubscribe({ id: 808, keyword: '仍可编辑', name: '部分失败项', tmdbid: 8080 })
const updated = vi.fn()
server.use(subscribeDetailsHandler(808, record), updateSubscribeHandler({ success: true }, 200, updated))
useDialogOptions({ tmdbId: 8080 })
server.use(
http.get(subscribeApiUrls.downloaders, () =>
HttpResponse.json({ message: 'unavailable', success: false }, { status: 500 }),
),
)
await renderDialog({ subid: 808 })
expect(await screen.findByDisplayValue('仍可编辑')).toBeInTheDocument()
await waitFor(() => expect(consoleError).toHaveBeenCalled())
await fireEvent.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(updated).toHaveBeenCalledOnce())
consoleError.mockRestore()
})
})
@@ -0,0 +1,574 @@
import type { MediaInfo, MediaSeason, Subscribe } from '@/api/types'
import {
getMediaSubscribeId,
getSubscribeMode,
type SeasonSubscribeModes,
type SubscribeMode,
useMediaSubscribe,
} from '@/composables/useMediaSubscribe'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import {
createSubscribe,
createSubscribeMovie,
createSubscribeTv,
} from '@tests/support/factories/subscribe'
import {
createSubscribeHandler,
defaultSubscribeConfigHandler,
deleteSubscribeByMediaHandler,
querySubscribeByMediaHandler,
updateSubscribeHandler,
} from '@tests/support/msw/handlers/subscribe'
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { defineComponent, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
cacheStatus: vi.fn(),
confirm: vi.fn(),
doneProgress: vi.fn(),
onEditRemove: vi.fn(),
openSharedDialog: vi.fn(),
startProgress: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
}))
vi.mock('vue-toastification', () => ({
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
}))
vi.mock('@/composables/useConfirm', () => ({
useConfirm: () => mocks.confirm,
}))
vi.mock('@/composables/useSharedDialog', () => ({
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
}))
vi.mock('@/api/nprogress', () => ({
configureNProgress: vi.fn(),
doneNProgress: mocks.doneProgress,
startNProgress: mocks.startProgress,
}))
vi.mock('@/utils/mediaStatusCache', () => ({
setCachedMediaSubscribeStatus: (...args: unknown[]) => mocks.cacheStatus(...args),
}))
interface MultiSeasonInput {
modes?: SubscribeMode | SeasonSubscribeModes
seasons?: MediaSeason[]
visible?: number[]
}
interface HarnessOptions {
actionSeason?: number | null
canSubscribe?: boolean
isExists?: boolean
isSubscribed?: boolean
media?: MediaInfo
modes?: SeasonSubscribeModes
multi?: MultiSeasonInput
seasonsMap?: Record<number, boolean>
subscribedSeasons?: number[]
useSeasonMap?: boolean
}
async function renderSubscribeHarness(options: HarnessOptions = {}) {
const media = options.media
const actionSeason = options.actionSeason ?? (media?.type === '电视剧' ? media.season ?? 1 : null)
const Harness = defineComponent({
name: 'MediaSubscribeHarness',
setup() {
const isSubscribed = ref(options.isSubscribed ?? false)
const seasonsSubscribed = ref<Record<number, boolean>>({ ...(options.seasonsMap ?? {}) })
const subscribedSeasons = ref([...(options.subscribedSeasons ?? [])])
const subscribedSeasonModes = ref<SeasonSubscribeModes>({ ...(options.modes ?? {}) })
const checkResult = ref('idle')
const actions = useMediaSubscribe({
canSubscribe: () => options.canSubscribe ?? true,
getSubscribeStatusKey: season => `status:${season ?? 'all'}`,
isExists: () => options.isExists ?? false,
isSubscribed,
media: () => media,
onEditRemove: mocks.onEditRemove,
primarySeason: () => media?.season ?? null,
seasonsSubscribed: options.useSeasonMap ? seasonsSubscribed : undefined,
subscribedSeasonModes,
subscribedSeasons,
})
async function check() {
try {
checkResult.value = (await actions.checkSubscribe(actionSeason)) ? 'subscribed' : 'missing'
} catch {
checkResult.value = 'error'
}
}
function alignSeasons() {
actions.subscribeSeasons(
options.multi?.seasons ?? [],
{},
'episode-group-1',
options.multi?.modes ?? 'normal',
options.multi?.visible ?? [],
)
}
return {
addBestFull: () => actions.addSubscribe(actionSeason, { best_version: 1, best_version_full: 1 }),
addNormal: () => actions.addSubscribe(actionSeason),
alignSeasons,
check,
checkResult,
handlePrimary: () => actions.handleSubscribe(),
handleSeason: () => actions.handleSubscribe(actionSeason, 'episode-group-entry'),
isSubscribed,
modes: subscribedSeasonModes,
openSeason: () => actions.openSubscribeSeasonDialog(actionSeason, 'episode-group-entry'),
remove: () => actions.removeSubscribe(actionSeason),
seasons: subscribedSeasons,
seasonsMap: seasonsSubscribed,
}
},
template: `
<button type="button" @click="handlePrimary">primary</button>
<button type="button" @click="handleSeason">season</button>
<button type="button" @click="addNormal">add-normal</button>
<button type="button" @click="addBestFull">add-best-full</button>
<button type="button" @click="remove">remove</button>
<button type="button" @click="check">check</button>
<button type="button" @click="openSeason">open-season</button>
<button type="button" @click="alignSeasons">align-seasons</button>
<output data-testid="subscribed">{{ String(isSubscribed) }}</output>
<output data-testid="seasons">{{ JSON.stringify(seasons) }}</output>
<output data-testid="season-map">{{ JSON.stringify(seasonsMap) }}</output>
<output data-testid="modes">{{ JSON.stringify(modes) }}</output>
<output data-testid="check-result">{{ checkResult }}</output>
`,
})
return renderWithProviders(Harness, {
initialState: {
user: {
superUser: false,
userName: 'tester',
},
},
})
}
function getDialogCall(index = 0) {
const [, props, events, options] = mocks.openSharedDialog.mock.calls[index] as [
unknown,
Record<string, unknown>,
Record<string, (...args: any[]) => unknown>,
Record<string, unknown>,
]
return { events, options, props }
}
describe('media subscribe identifiers and modes', () => {
it.each([
['TMDB before all fallback identifiers', { bangumi_id: '30', douban_id: '20', tmdb_id: 10 }, 'tmdb:10'],
['Douban before Bangumi and generic identifiers', { bangumi_id: '30', douban_id: '20', tmdb_id: undefined }, 'douban:20'],
['Bangumi before a generic identifier', { bangumi_id: '30', douban_id: undefined, tmdb_id: undefined }, 'bangumi:30'],
[
'generic identifiers when provider ids are absent',
{ bangumi_id: undefined, douban_id: undefined, media_id: 'abc', mediaid_prefix: 'custom', tmdb_id: undefined },
'custom:abc',
],
])('uses %s', (_case, overrides, expected) => {
expect(getMediaSubscribeId(createSubscribeMovie(overrides))).toBe(expected)
})
it.each([
[{ best_version: false, best_version_full: true }, 'normal'],
[{ best_version: 0, best_version_full: 1 }, 'normal'],
[{ best_version: '0', best_version_full: '1' }, 'normal'],
[{ best_version: true, best_version_full: false }, 'best_version'],
[{ best_version: 1, best_version_full: 0 }, 'best_version'],
[{ best_version: '1', best_version_full: '1' }, 'best_version_full'],
] as const)('normalizes compatible mode flags %#', (subscribe, expected) => {
expect(getSubscribeMode(subscribe)).toBe(expected)
})
})
describe('useMediaSubscribe entry flows', () => {
beforeEach(() => {
mocks.confirm.mockResolvedValue(true)
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
})
it('creates a normal movie subscription and synchronizes public state', async () => {
const media = createSubscribeMovie({ title: '普通电影', tmdb_id: 101, year: '2025' })
const created = vi.fn()
server.use(
createSubscribeHandler({ data: { id: 501 }, success: true }, 200, created),
defaultSubscribeConfigHandler('电影', { show_edit_dialog: false }),
)
await renderSubscribeHarness({ media })
await fireEvent.click(screen.getByRole('button', { name: 'primary' }))
await waitFor(() => expect(created).toHaveBeenCalledOnce())
await waitFor(() => expect(mocks.doneProgress).toHaveBeenCalledOnce())
expect(created).toHaveBeenCalledWith({
bangumiid: undefined,
doubanid: undefined,
episode_group: '',
mediaid: '',
name: '普通电影',
season: null,
tmdbid: 101,
type: '电影',
year: '2025',
})
expect(screen.getByTestId('subscribed')).toHaveTextContent('true')
expect(mocks.cacheStatus).toHaveBeenCalledWith('status:all', true)
expect(mocks.toastSuccess).toHaveBeenCalled()
expect(mocks.startProgress).toHaveBeenCalledOnce()
expect(mocks.doneProgress).toHaveBeenCalledOnce()
})
it('opens the mode chooser for an existing movie and creates the selected mode', async () => {
const media = createSubscribeMovie({ title: '已入库电影', tmdb_id: 102 })
const created = vi.fn()
server.use(
createSubscribeHandler({ data: { id: 502 }, success: true }, 200, created),
defaultSubscribeConfigHandler('电影', { show_edit_dialog: false }),
)
await renderSubscribeHarness({ isExists: true, media })
await fireEvent.click(screen.getByRole('button', { name: 'primary' }))
const modeDialog = getDialogCall()
expect(modeDialog.props).toMatchObject({ modes: ['normal', 'best_version'], type: '电影' })
modeDialog.events.choose('best_version')
await waitFor(() => expect(created).toHaveBeenCalledOnce())
await waitFor(() => expect(mocks.doneProgress).toHaveBeenCalledOnce())
expect(created.mock.calls[0][0]).toMatchObject({ best_version: 1, best_version_full: 0, season: null })
})
it('opens the TV season chooser with current state and the default mode', async () => {
const media = createSubscribeTv({ season: 2, title: '季选择剧集', tmdb_id: 103 })
server.use(defaultSubscribeConfigHandler('电视剧', { best_version: '1', best_version_full: '1' }))
await renderSubscribeHarness({
media,
modes: { 1: 'normal', 2: 'best_version' },
subscribedSeasons: [1, 2],
})
await fireEvent.click(screen.getByRole('button', { name: 'primary' }))
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
const dialog = getDialogCall()
expect(dialog.props).toMatchObject({
defaultSubscribeMode: 'best_version_full',
selectedSeason: undefined,
subscribedSeasonModes: { 1: 'normal', 2: 'best_version' },
subscribedSeasons: [1, 2],
})
expect(dialog.options).toEqual({ closeOn: ['close', 'subscribe'] })
})
it('opens the season chooser from an unsubscribed TV season entry', async () => {
const media = createSubscribeTv({ season: 2, title: '单季入口剧集', tmdb_id: 1031 })
server.use(defaultSubscribeConfigHandler('电视剧', { best_version: 0 }))
await renderSubscribeHarness({ actionSeason: 2, media })
await fireEvent.click(screen.getByRole('button', { name: 'season' }))
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
const dialog = getDialogCall()
expect(dialog.props).toMatchObject({
initialEpisodeGroup: 'episode-group-entry',
selectedSeason: 2,
})
})
it('cancels a subscribed movie from the primary entry', async () => {
const deleted = vi.fn()
server.use(deleteSubscribeByMediaHandler('tmdb:1032', { success: true }, 200, url => deleted(url)))
await renderSubscribeHarness({
isSubscribed: true,
media: createSubscribeMovie({ title: '主入口取消电影', tmdb_id: 1032 }),
})
await fireEvent.click(screen.getByRole('button', { name: 'primary' }))
await waitFor(() => expect(deleted).toHaveBeenCalledOnce())
expect((deleted.mock.calls[0][0] as URL).searchParams.has('season')).toBe(false)
expect(screen.getByTestId('subscribed')).toHaveTextContent('false')
expect(mocks.cacheStatus).toHaveBeenCalledWith('status:all', false)
})
it('cancels a subscribed TV season only after confirmation', async () => {
const media = createSubscribeTv({ season: 2, title: '取消季剧集', tmdb_id: 104 })
const deleted = vi.fn()
server.use(deleteSubscribeByMediaHandler('tmdb:104', { success: true }, 200, url => deleted(url)))
await renderSubscribeHarness({
isSubscribed: true,
media,
modes: { 1: 'normal', 2: 'best_version' },
seasonsMap: { 1: true, 2: true },
subscribedSeasons: [1, 2],
useSeasonMap: true,
})
mocks.confirm.mockResolvedValueOnce(false)
await fireEvent.click(screen.getByRole('button', { name: 'season' }))
expect(deleted).not.toHaveBeenCalled()
await fireEvent.click(screen.getByRole('button', { name: 'season' }))
await waitFor(() => expect(deleted).toHaveBeenCalledOnce())
expect((deleted.mock.calls[0][0] as URL).searchParams.get('season')).toBe('2')
expect(screen.getByTestId('season-map')).toHaveTextContent('"2":false')
expect(screen.getByTestId('modes')).not.toHaveTextContent('"2"')
expect(screen.getByTestId('subscribed')).toHaveTextContent('true')
expect(mocks.cacheStatus).toHaveBeenCalledWith('status:2', false)
})
it.each([
{
label: 'Douban',
media: createSubscribeTv({ douban_id: 'db-1', tmdb_id: undefined }),
mediaId: 'douban:db-1',
record: createSubscribe({ doubanid: 'db-1', season: 2, tmdbid: 0, type: '电视剧' }),
},
{
label: 'Bangumi',
media: createSubscribeTv({ bangumi_id: '42', tmdb_id: undefined }),
mediaId: 'bangumi:42',
record: createSubscribe({ bangumiid: 42 as unknown as string, season: 2, tmdbid: 0, type: '电视剧' }),
},
{
label: 'generic provider',
media: createSubscribeTv({ media_id: 'series-1', mediaid_prefix: 'custom', tmdb_id: undefined }),
mediaId: 'custom:series-1',
record: createSubscribe({ mediaid: 'custom:series-1', season: 2, tmdbid: 0, type: '电视剧' }),
},
])('queries $label subscriptions through the media endpoint', async ({ media, mediaId, record }) => {
const queried = vi.fn()
server.use(querySubscribeByMediaHandler(mediaId, record, 200, url => queried(url)))
await renderSubscribeHarness({ actionSeason: 2, media })
await fireEvent.click(screen.getByRole('button', { name: 'check' }))
await waitFor(() => expect(screen.getByTestId('check-result')).toHaveTextContent('subscribed'))
expect(queried).toHaveBeenCalledOnce()
expect((queried.mock.calls[0][0] as URL).searchParams.get('season')).toBe('2')
})
it('cancels a non-TMDB season through the media endpoint', async () => {
const media = createSubscribeTv({ douban_id: 'db-delete', tmdb_id: undefined })
const deleted = vi.fn()
server.use(deleteSubscribeByMediaHandler('douban:db-delete', { success: true }, 200, url => deleted(url)))
await renderSubscribeHarness({
actionSeason: 2,
isSubscribed: true,
media,
seasonsMap: { 2: true },
useSeasonMap: true,
})
await fireEvent.click(screen.getByRole('button', { name: 'remove' }))
await waitFor(() => expect(deleted).toHaveBeenCalledOnce())
expect((deleted.mock.calls[0][0] as URL).searchParams.get('season')).toBe('2')
expect(screen.getByTestId('season-map')).toHaveTextContent('"2":false')
})
it('aligns visible seasons while preserving hidden subscriptions', async () => {
const media = createSubscribeTv({ title: '多季剧集', tmdb_id: 105 })
const deleted = vi.fn()
const queried = vi.fn()
const updated = vi.fn()
const created = vi.fn()
server.use(
deleteSubscribeByMediaHandler('tmdb:105', { success: true }, 200, url => deleted(url)),
querySubscribeByMediaHandler(
'tmdb:105',
createSubscribe({ id: 605, season: 2, tmdbid: 105, type: '电视剧' }),
200,
url => queried(url),
),
updateSubscribeHandler({ success: true }, 200, updated),
createSubscribeHandler({ data: { id: 606 }, success: true }, 200, created),
)
await renderSubscribeHarness({
isSubscribed: true,
media,
modes: { 1: 'normal', 2: 'normal', 4: 'best_version' },
multi: {
modes: { 2: 'best_version', 3: 'best_version_full' },
seasons: [{ season_number: 2 }, { season_number: 3 }],
visible: [1, 2, 3],
},
subscribedSeasons: [1, 2, 4],
})
await fireEvent.click(screen.getByRole('button', { name: 'align-seasons' }))
await waitFor(() => expect(deleted).toHaveBeenCalledOnce())
await waitFor(() => expect(updated).toHaveBeenCalledOnce())
await waitFor(() => expect(created).toHaveBeenCalledOnce())
expect((deleted.mock.calls[0][0] as URL).searchParams.get('season')).toBe('1')
expect((queried.mock.calls[0][0] as URL).searchParams.get('season')).toBe('2')
expect(updated.mock.calls[0][0]).toMatchObject({ best_version: 1, best_version_full: 0, id: 605, season: 2 })
expect(created.mock.calls[0][0]).toMatchObject({
best_version: 1,
best_version_full: 1,
episode_group: 'episode-group-1',
season: 3,
})
await waitFor(() => expect(screen.getByTestId('seasons')).toHaveTextContent('[2,3,4]'))
expect(screen.getByTestId('modes')).toHaveTextContent('"2":"best_version"')
expect(screen.getByTestId('modes')).toHaveTextContent('"3":"best_version_full"')
expect(screen.getByTestId('modes')).toHaveTextContent('"4":"best_version"')
})
it('synchronizes the created season after edit save and remove events', async () => {
const media = createSubscribeTv({ season: 2, title: '编辑后同步', tmdb_id: 106 })
server.use(
createSubscribeHandler({ data: { id: 701 }, success: true }),
defaultSubscribeConfigHandler('电视剧', { show_edit_dialog: true }),
)
await renderSubscribeHarness({
isSubscribed: true,
media,
modes: { 1: 'normal' },
subscribedSeasons: [1],
})
await fireEvent.click(screen.getByRole('button', { name: 'add-best-full' }))
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
expect(screen.getByTestId('seasons')).toHaveTextContent('[1,2]')
const editDialog = getDialogCall()
expect(editDialog.props).toEqual({ subid: 701 })
editDialog.events.save(createSubscribe({ best_version: 1, best_version_full: 0, id: 701, season: 2, type: '电视剧' }))
await waitFor(() => expect(screen.getByTestId('modes')).toHaveTextContent('"2":"best_version"'))
editDialog.events.remove()
await waitFor(() => expect(screen.getByTestId('seasons')).toHaveTextContent('[1]'))
expect(screen.getByTestId('subscribed')).toHaveTextContent('true')
expect(screen.getByTestId('modes')).not.toHaveTextContent('"2"')
expect(mocks.cacheStatus).toHaveBeenLastCalledWith('status:2', false)
expect(mocks.onEditRemove).toHaveBeenCalledOnce()
})
it.each([
['business failure', 200, { message: 'duplicate', success: false }],
['HTTP failure', 500, { message: 'server down', success: false }],
])('keeps state unchanged when create returns a %s', async (_case, status, response) => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
server.use(createSubscribeHandler(response, status))
await renderSubscribeHarness({ media: createSubscribeMovie({ tmdb_id: 107 }) })
await fireEvent.click(screen.getByRole('button', { name: 'add-normal' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalled())
expect(screen.getByTestId('subscribed')).toHaveTextContent('false')
expect(mocks.cacheStatus).not.toHaveBeenCalled()
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
expect(mocks.doneProgress).toHaveBeenCalledOnce()
consoleError.mockRestore()
})
it.each([
['business failure', 200, { message: 'delete rejected', success: false }],
['HTTP failure', 500, { message: 'server down', success: false }],
])('keeps subscription state when removal returns a %s', async (_case, status, response) => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const deleted = vi.fn()
server.use(deleteSubscribeByMediaHandler('tmdb:108', response, status, url => deleted(url)))
await renderSubscribeHarness({
actionSeason: 2,
isSubscribed: true,
media: createSubscribeTv({ season: 2, tmdb_id: 108 }),
modes: { 1: 'normal', 2: 'best_version' },
seasonsMap: { 1: true, 2: true },
subscribedSeasons: [1, 2],
useSeasonMap: true,
})
await fireEvent.click(screen.getByRole('button', { name: 'remove' }))
await waitFor(() => expect(deleted).toHaveBeenCalledOnce())
await waitFor(() => expect(mocks.toastError).toHaveBeenCalled())
expect((deleted.mock.calls[0][0] as URL).searchParams.get('season')).toBe('2')
expect(screen.getByTestId('subscribed')).toHaveTextContent('true')
expect(screen.getByTestId('seasons')).toHaveTextContent('[1,2]')
expect(screen.getByTestId('season-map')).toHaveTextContent('"2":true')
expect(screen.getByTestId('modes')).toHaveTextContent('"2":"best_version"')
expect(mocks.cacheStatus).not.toHaveBeenCalled()
expect(mocks.doneProgress).toHaveBeenCalledOnce()
consoleError.mockRestore()
})
it.each([
['business failure', 200, { message: 'update rejected', success: false }],
['HTTP failure', 500, { message: 'server down', success: false }],
])('keeps the subscribed mode when an update returns a %s', async (_case, status, response) => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const media = createSubscribeTv({ title: '模式更新失败剧集', tmdb_id: 110 })
const updated = vi.fn()
server.use(
querySubscribeByMediaHandler(
'tmdb:110',
createSubscribe({ id: 710, season: 2, tmdbid: 110, type: '电视剧' }),
),
updateSubscribeHandler(response, status, updated),
)
await renderSubscribeHarness({
isSubscribed: true,
media,
modes: { 2: 'normal' },
multi: {
modes: { 2: 'best_version' },
seasons: [{ season_number: 2 }],
visible: [2],
},
subscribedSeasons: [2],
})
await fireEvent.click(screen.getByRole('button', { name: 'align-seasons' }))
await waitFor(() => expect(updated).toHaveBeenCalledOnce())
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce())
expect(screen.getByTestId('subscribed')).toHaveTextContent('true')
expect(screen.getByTestId('seasons')).toHaveTextContent('[2]')
expect(screen.getByTestId('modes')).toHaveTextContent('"2":"normal"')
expect(mocks.cacheStatus).not.toHaveBeenCalled()
expect(mocks.doneProgress).toHaveBeenCalledOnce()
consoleError.mockRestore()
})
it('maps a 404 query to missing and propagates other HTTP errors', async () => {
const media = createSubscribeMovie({ tmdb_id: 109 })
server.use(querySubscribeByMediaHandler('tmdb:109', {}, 404))
await renderSubscribeHarness({ media })
await fireEvent.click(screen.getByRole('button', { name: 'check' }))
await waitFor(() => expect(screen.getByTestId('check-result')).toHaveTextContent('missing'))
server.use(querySubscribeByMediaHandler('tmdb:109', {}, 500))
await fireEvent.click(screen.getByRole('button', { name: 'check' }))
await waitFor(() => expect(screen.getByTestId('check-result')).toHaveTextContent('error'))
})
it('does nothing when the current media is unavailable', async () => {
await renderSubscribeHarness()
await fireEvent.click(screen.getByRole('button', { name: 'primary' }))
await fireEvent.click(screen.getByRole('button', { name: 'add-normal' }))
await fireEvent.click(screen.getByRole('button', { name: 'open-season' }))
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
expect(mocks.startProgress).not.toHaveBeenCalled()
})
})
+59 -14
View File
@@ -92,6 +92,16 @@ function getModeName(t: ReturnType<typeof useI18n>['t'], mode: SubscribeMode) {
return t('dialog.subscribeMode.bestVersionFull') return t('dialog.subscribeMode.bestVersionFull')
} }
// 从变更请求异常中提取可展示消息,并为非标准错误提供稳定兜底。
function getRequestErrorMessage(error: unknown, fallback: string) {
if (typeof error === 'object' && error !== null) {
const responseMessage = (error as { response?: { data?: { message?: unknown } } }).response?.data?.message
if (typeof responseMessage === 'string' && responseMessage) return responseMessage
}
if (error instanceof Error && error.message) return error.message
return fallback
}
// 封装媒体卡片与详情页共用的订阅交互。 // 封装媒体卡片与详情页共用的订阅交互。
export function useMediaSubscribe(options: UseMediaSubscribeOptions) { export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
const { t } = useI18n() const { t } = useI18n()
@@ -155,17 +165,19 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
} }
// 打开已创建订阅的编辑弹窗。 // 打开已创建订阅的编辑弹窗。
function openSubscribeEditDialog(subid: number) { function openSubscribeEditDialog(subid: number, season: number | null, mode: SubscribeMode) {
openSharedDialog( openSharedDialog(
SubscribeEditDialog, SubscribeEditDialog,
{ subid }, { subid },
{ {
save: (subscribe?: Subscribe) => {
const savedSeason = currentMedia()?.type === '电影' ? null : (subscribe?.season ?? season)
if (savedSeason !== season) updateSubscribeStatus(season, false)
updateSubscribeStatus(savedSeason, true, subscribe ? getSubscribeMode(subscribe) : mode)
},
remove: () => { remove: () => {
if (options.onEditRemove) { updateSubscribeStatus(season, false)
options.onEditRemove() options.onEditRemove?.()
} else if (options.isSubscribed) {
options.isSubscribed.value = false
}
}, },
}, },
{ closeOn: ['close', 'save', 'remove'] }, { closeOn: ['close', 'save', 'remove'] },
@@ -270,16 +282,33 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
episode_group: episodeGroup.value, episode_group: episodeGroup.value,
}) })
if (result.success) updateSubscribeStatus(media.type === '电影' ? null : season, true, getSubscribeMode(payload)) const subscribeSeason = media.type === '电影' ? null : season
const subscribeMode = getSubscribeMode(payload)
if (result.success) updateSubscribeStatus(subscribeSeason, true, subscribeMode)
showSubscribeAddToast(result.success, media.title ?? '', season, result.message, payload.best_version ?? 0) showSubscribeAddToast(
result.success,
media.title ?? '',
season,
result.message ?? t('subscribe.requestFailed'),
payload.best_version ?? 0,
)
if (result.success && (addOptions.openEditDialog ?? true)) { if (result.success && (addOptions.openEditDialog ?? true)) {
const subscribeConfig = await queryDefaultSubscribeConfig() const subscribeConfig = await queryDefaultSubscribeConfig()
if (subscribeConfig?.show_edit_dialog) openSubscribeEditDialog(result.data.id) if (subscribeConfig?.show_edit_dialog && result.data?.id) {
openSubscribeEditDialog(result.data.id, subscribeSeason, subscribeMode)
}
} }
} catch (error) { } catch (error) {
console.error(error) console.error(error)
showSubscribeAddToast(
false,
media.title ?? '',
season,
getRequestErrorMessage(error, t('subscribe.requestFailed')),
payload.best_version ?? 0,
)
} finally { } finally {
doneNProgress() doneNProgress()
} }
@@ -297,6 +326,8 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
const media = currentMedia() const media = currentMedia()
if (!media) return if (!media) return
let title = media.title ?? ''
if (media.type !== '电影' && season !== null) title = `${title} ${formatSeason(season.toString())}`
startNProgress() startNProgress()
try { try {
@@ -305,17 +336,20 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
season: media.type === '电影' ? null : season, season: media.type === '电影' ? null : season,
}, },
}) })
let title = media.title ?? ''
if (media.type !== '电影' && season !== null) title = `${title} ${formatSeason(season.toString())}`
if (result.success) { if (result.success) {
updateSubscribeStatus(media.type === '电影' ? null : season, false) updateSubscribeStatus(media.type === '电影' ? null : season, false)
$toast.success(`${title} ${t('subscribe.cancelSuccess')}`) $toast.success(`${title} ${t('subscribe.cancelSuccess')}`)
} else { } else {
$toast.error(`${title} ${t('subscribe.cancelFailed', { message: result.message })}`) $toast.error(`${title} ${t('subscribe.cancelFailed', { message: result.message ?? t('subscribe.requestFailed') })}`)
} }
} catch (error) { } catch (error) {
console.error(error) console.error(error)
$toast.error(
`${title} ${t('subscribe.cancelFailed', {
message: getRequestErrorMessage(error, t('subscribe.requestFailed')),
})}`,
)
} finally { } finally {
doneNProgress() doneNProgress()
} }
@@ -361,6 +395,7 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
async function updateSubscribeMode(season: number, mode: SubscribeMode) { async function updateSubscribeMode(season: number, mode: SubscribeMode) {
const media = currentMedia() const media = currentMedia()
if (!media) return if (!media) return
const title = `${media.title ?? ''} ${formatSeason(season.toString())}`
startNProgress() startNProgress()
try { try {
@@ -375,16 +410,26 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
...subscribe, ...subscribe,
...payload, ...payload,
}) })
const title = `${media.title ?? ''} ${formatSeason(season.toString())}`
if (result.success) { if (result.success) {
updateSubscribeStatus(season, true, mode) updateSubscribeStatus(season, true, mode)
$toast.success(`${title} ${t('subscribe.modeUpdateSuccess', { mode: getModeName(t, mode) })}`) $toast.success(`${title} ${t('subscribe.modeUpdateSuccess', { mode: getModeName(t, mode) })}`)
} else { } else {
$toast.error(`${title} ${t('subscribe.addFailed', { name: getModeName(t, mode), message: result.message })}`) $toast.error(
`${title} ${t('subscribe.addFailed', {
name: getModeName(t, mode),
message: result.message ?? t('subscribe.requestFailed'),
})}`,
)
} }
} catch (error) { } catch (error) {
console.error(error) console.error(error)
$toast.error(
`${title} ${t('subscribe.addFailed', {
name: getModeName(t, mode),
message: getRequestErrorMessage(error, t('subscribe.requestFailed')),
})}`,
)
} finally { } finally {
doneNProgress() doneNProgress()
} }
+5
View File
@@ -1125,6 +1125,7 @@ export default {
cancelSuccess: 'Subscription cancelled!', cancelSuccess: 'Subscription cancelled!',
cancelFailed: 'Failed to cancel subscription: {message}!', cancelFailed: 'Failed to cancel subscription: {message}!',
notFound: 'Subscription not found!', notFound: 'Subscription not found!',
requestFailed: 'Request failed. Please try again later.',
filterSubscriptions: 'Filter Subscriptions', filterSubscriptions: 'Filter Subscriptions',
name: 'Name', name: 'Name',
searchShares: 'Search Subscription Shares', searchShares: 'Search Subscription Shares',
@@ -3168,6 +3169,10 @@ export default {
cancelSubscribe: 'Cancel Subscription', cancelSubscribe: 'Cancel Subscription',
save: 'Save', save: 'Save',
cancelSubscribeConfirm: 'Are you sure you want to cancel the subscription?', cancelSubscribeConfirm: 'Are you sure you want to cancel the subscription?',
updateSuccess: '{name} updated successfully!',
updateFailed: 'Failed to update {name}: {message}!',
defaultSaveSuccess: 'Default {type} subscription rules saved successfully.',
defaultSaveFailed: 'Failed to save default {type} subscription rules: {message}!',
}, },
subscribeFiles: { subscribeFiles: {
title: 'Subscription Files', title: 'Subscription Files',
+5
View File
@@ -1119,6 +1119,7 @@ export default {
cancelSuccess: '已取消订阅!', cancelSuccess: '已取消订阅!',
cancelFailed: '取消订阅失败:{message}', cancelFailed: '取消订阅失败:{message}',
notFound: '订阅不存在!', notFound: '订阅不存在!',
requestFailed: '请求失败,请稍后重试',
filterSubscriptions: '筛选订阅', filterSubscriptions: '筛选订阅',
name: '名称', name: '名称',
searchShares: '搜索订阅分享', searchShares: '搜索订阅分享',
@@ -3117,6 +3118,10 @@ export default {
cancelSubscribe: '取消订阅', cancelSubscribe: '取消订阅',
save: '保存', save: '保存',
cancelSubscribeConfirm: '是否确认取消订阅?', cancelSubscribeConfirm: '是否确认取消订阅?',
updateSuccess: '{name} 更新成功!',
updateFailed: '{name} 更新失败:{message}',
defaultSaveSuccess: '{type}订阅默认规则保存成功',
defaultSaveFailed: '{type}订阅默认规则保存失败:{message}',
}, },
subscribeFiles: { subscribeFiles: {
title: '订阅文件', title: '订阅文件',
+5
View File
@@ -1119,6 +1119,7 @@ export default {
cancelSuccess: '已取消訂閱!', cancelSuccess: '已取消訂閱!',
cancelFailed: '取消訂閱失敗:{message}', cancelFailed: '取消訂閱失敗:{message}',
notFound: '訂閱不存在!', notFound: '訂閱不存在!',
requestFailed: '請求失敗,請稍後重試',
filterSubscriptions: '篩選訂閱', filterSubscriptions: '篩選訂閱',
name: '名稱', name: '名稱',
searchShares: '搜索訂閱分享', searchShares: '搜索訂閱分享',
@@ -3116,6 +3117,10 @@ export default {
cancelSubscribe: '取消訂閱', cancelSubscribe: '取消訂閱',
save: '儲存', save: '儲存',
cancelSubscribeConfirm: '是否確認取消訂閱?', cancelSubscribeConfirm: '是否確認取消訂閱?',
updateSuccess: '{name} 更新成功!',
updateFailed: '{name} 更新失敗:{message}',
defaultSaveSuccess: '{type}訂閱默認規則儲存成功',
defaultSaveFailed: '{type}訂閱默認規則儲存失敗:{message}',
}, },
subscribeFiles: { subscribeFiles: {
title: '訂閱文件', title: '訂閱文件',
+36
View File
@@ -49,7 +49,13 @@ const MediaCardSlideViewStub = defineComponent({
}) })
interface SharedDialogEvents { interface SharedDialogEvents {
close: () => void
save: (payload?: { enabled?: Record<string, boolean> }) => Promise<void> save: (payload?: { enabled?: Record<string, boolean> }) => Promise<void>
'update:modelValue': (value: boolean) => void
}
interface SharedDialogProps {
valueGetter: (item: { title: string }) => string
} }
async function renderRecommend(options: { superUser?: boolean; discovery?: boolean } = {}) { async function renderRecommend(options: { superUser?: boolean; discovery?: boolean } = {}) {
@@ -179,6 +185,36 @@ describe('recommend page', () => {
expect(mocks.closeDialog).toHaveBeenCalledOnce() expect(mocks.closeDialog).toHaveBeenCalledOnce()
}) })
it('releases the shared settings controller through both close contracts', async () => {
const sourcesRequested = vi.fn()
const user = userEvent.setup()
localStorage.setItem('MP_RECOMMEND', JSON.stringify({ '流行趋势': true }))
server.use(recommendSourcesHandler([], 200, sourcesRequested))
await renderRecommend()
await waitFor(() => expect(sourcesRequested).toHaveBeenCalledOnce())
await waitFor(() => expect(document.querySelector('.compact-fab')).not.toBeNull())
const settingsButton = document.querySelector<HTMLButtonElement>('.compact-fab') as HTMLButtonElement
await user.click(settingsButton)
const dialogProps = mocks.openSharedDialog.mock.calls[0][1] as SharedDialogProps
const firstDialogEvents = mocks.openSharedDialog.mock.calls[0][2] as SharedDialogEvents
expect(dialogProps.valueGetter({ title: '流行趋势' })).toBe('流行趋势')
firstDialogEvents.close()
await user.click(settingsButton)
expect(mocks.closeDialog).not.toHaveBeenCalled()
const secondDialogEvents = mocks.openSharedDialog.mock.calls[1][2] as SharedDialogEvents
secondDialogEvents['update:modelValue'](true)
await user.click(settingsButton)
expect(mocks.closeDialog).toHaveBeenCalledOnce()
const thirdDialogEvents = mocks.openSharedDialog.mock.calls[2][2] as SharedDialogEvents
thirdDialogEvents['update:modelValue'](false)
await user.click(settingsButton)
expect(mocks.closeDialog).toHaveBeenCalledOnce()
})
it.each([ it.each([
{ discovery: false, superUser: false, visible: false }, { discovery: false, superUser: false, visible: false },
{ discovery: false, superUser: true, visible: true }, { discovery: false, superUser: true, visible: true },
+101
View File
@@ -0,0 +1,101 @@
import type {
DownloaderConf,
FilterRuleGroup,
MediaInfo,
Site,
Subscribe,
TransferDirectoryConf,
} from '@/api/types'
import { createMediaInfo } from './media'
let subscribeSeed = 1000
let siteSeed = 100
/** 构造满足前端订阅契约的最小记录。 */
export function createSubscribe(overrides: Partial<Subscribe> = {}): Subscribe {
subscribeSeed += 1
return {
best_version: 0,
best_version_full: 0,
current_priority: 0,
date: '2026-07-16',
downloader: '',
episode_group: '',
id: subscribeSeed,
last_update: '2026-07-16 12:00:00',
name: `测试订阅 ${subscribeSeed}`,
show_edit_dialog: false,
sites: [],
state: 'R',
tmdbid: subscribeSeed,
type: '电影',
username: 'tester',
year: '2026',
...overrides,
}
}
/** 构造电影媒体信息。 */
export function createSubscribeMovie(overrides: Partial<MediaInfo> = {}): MediaInfo {
return createMediaInfo({ type: '电影', ...overrides })
}
/** 构造电视剧媒体信息。 */
export function createSubscribeTv(overrides: Partial<MediaInfo> = {}): MediaInfo {
return createMediaInfo({
season: 1,
season_info: [
{ episode_count: 12, name: '第 1 季', season_number: 1 },
{ episode_count: 10, name: '第 2 季', season_number: 2 },
],
type: '电视剧',
...overrides,
})
}
/** 构造订阅站点选项。 */
export function createSubscribeSite(overrides: Partial<Site> = {}): Site {
siteSeed += 1
return {
domain: `site-${siteSeed}.example.com`,
downloader: '',
id: siteSeed,
is_active: true,
name: `测试站点 ${siteSeed}`,
url: `https://site-${siteSeed}.example.com`,
...overrides,
}
}
/** 构造下载器选项。 */
export function createSubscribeDownloader(overrides: Partial<DownloaderConf> = {}): DownloaderConf {
return {
config: {},
default: false,
enabled: true,
name: '测试下载器',
type: 'qbittorrent',
...overrides,
}
}
/** 构造下载目录配置。 */
export function createSubscribeDirectory(overrides: Partial<TransferDirectoryConf> = {}): TransferDirectoryConf {
return {
download_path: '/downloads',
name: '测试目录',
priority: 1,
storage: 'local',
transfer_type: 'link',
...overrides,
}
}
/** 构造订阅过滤规则组。 */
export function createSubscribeRuleGroup(overrides: Partial<FilterRuleGroup> = {}): FilterRuleGroup {
return {
name: '默认规则组',
rule_string: 'priority=1',
...overrides,
}
}
+181
View File
@@ -0,0 +1,181 @@
import type { DownloaderConf, FilterRuleGroup, Site, Subscribe, TransferDirectoryConf } from '@/api/types'
import { HttpResponse, http, type JsonBodyType, type RequestHandler } from 'msw'
const API_BASE_URL = 'http://localhost/api/v1/'
export type SubscribeMediaType = '电影' | '电视剧'
export interface SubscribeMutationResponse {
success: boolean
data?: Record<string, unknown>
message?: string
}
export const subscribeApiUrls = {
create: new URL('subscribe/', API_BASE_URL).href,
defaultConfig: (type: SubscribeMediaType, writable = false) =>
new URL(
`system/setting/${writable ? '' : 'public/'}${type === '电影' ? 'DefaultMovieSubscribeConfig' : 'DefaultTvSubscribeConfig'}`,
API_BASE_URL,
).href,
deleteById: (id: number) => new URL(`subscribe/${id}`, API_BASE_URL).href,
deleteByMedia: (mediaId: string) => new URL(`subscribe/media/${mediaId}`, API_BASE_URL).href,
details: (id: number) => new URL(`subscribe/${id}`, API_BASE_URL).href,
directories: new URL('system/setting/public/Directories', API_BASE_URL).href,
downloaders: new URL('download/clients', API_BASE_URL).href,
episodeGroups: (tmdbId: number) => new URL(`media/groups/${tmdbId}`, API_BASE_URL).href,
filterRuleGroups: new URL('system/setting/UserFilterRuleGroups', API_BASE_URL).href,
queryByMedia: (mediaId: string) => new URL(`subscribe/media/${mediaId}`, API_BASE_URL).href,
sites: new URL('site/rss', API_BASE_URL).href,
update: new URL('subscribe/', API_BASE_URL).href,
}
function jsonResponse(body: JsonBodyType, status: number) {
return HttpResponse.json(body, { status })
}
export function createSubscribeHandler(
response: SubscribeMutationResponse = { data: { id: 1 }, success: true },
status = 200,
onCreate: (payload: Record<string, unknown>) => void = () => {},
) {
return http.post(subscribeApiUrls.create, async ({ request }) => {
const payload = (await request.json()) as Record<string, unknown>
onCreate(payload)
return jsonResponse(response, status)
})
}
export function updateSubscribeHandler(
response: SubscribeMutationResponse = { success: true },
status = 200,
onUpdate: (payload: Record<string, unknown>) => void = () => {},
) {
return http.put(subscribeApiUrls.update, async ({ request }) => {
const payload = (await request.json()) as Record<string, unknown>
onUpdate(payload)
return jsonResponse(response, status)
})
}
export function querySubscribeByMediaHandler(
mediaId: string,
subscribe: Partial<Subscribe>,
status = 200,
onRequest: (url: URL) => void = () => {},
) {
return http.get(subscribeApiUrls.queryByMedia(mediaId), ({ request }) => {
onRequest(new URL(request.url))
return jsonResponse(subscribe as JsonBodyType, status)
})
}
export function deleteSubscribeByMediaHandler(
mediaId: string,
response: SubscribeMutationResponse = { success: true },
status = 200,
onRequest: (url: URL) => void = () => {},
) {
return http.delete(subscribeApiUrls.deleteByMedia(mediaId), ({ request }) => {
onRequest(new URL(request.url))
return jsonResponse(response, status)
})
}
export function subscribeDetailsHandler(id: number, subscribe: Subscribe, status = 200, onRequest: () => void = () => {}) {
return http.get(subscribeApiUrls.details(id), () => {
onRequest()
return jsonResponse(subscribe as unknown as JsonBodyType, status)
})
}
export function deleteSubscribeByIdHandler(
id: number,
response: SubscribeMutationResponse = { success: true },
status = 200,
onRequest: () => void = () => {},
) {
return http.delete(subscribeApiUrls.deleteById(id), () => {
onRequest()
return jsonResponse(response, status)
})
}
export function defaultSubscribeConfigHandler(
type: SubscribeMediaType,
config: Partial<Subscribe>,
status = 200,
onRequest: () => void = () => {},
) {
return http.get(subscribeApiUrls.defaultConfig(type), () => {
onRequest()
return jsonResponse({ data: { value: config }, success: status < 400 }, status)
})
}
export function saveDefaultSubscribeConfigHandler(
type: SubscribeMediaType,
response: SubscribeMutationResponse = { success: true },
status = 200,
onSave: (payload: Record<string, unknown>) => void = () => {},
) {
return http.post(subscribeApiUrls.defaultConfig(type, true), async ({ request }) => {
const payload = (await request.json()) as Record<string, unknown>
onSave(payload)
return jsonResponse(response, status)
})
}
export interface SubscribeDialogOptions {
directories?: TransferDirectoryConf[]
downloaders?: DownloaderConf[]
episodeGroups?: Record<string, unknown>[]
filterRuleGroups?: FilterRuleGroup[]
onDirectories?: () => void
onDownloaders?: () => void
onEpisodeGroups?: () => void
onFilterRuleGroups?: () => void
onSites?: () => void
sites?: Site[]
tmdbId?: number
}
/** 为编辑弹窗提供彼此独立、可按测试覆盖的选项接口。 */
export function subscribeDialogOptionHandlers(options: SubscribeDialogOptions = {}): RequestHandler[] {
const {
directories = [],
downloaders = [],
episodeGroups = [],
filterRuleGroups = [],
onDirectories = () => {},
onDownloaders = () => {},
onEpisodeGroups = () => {},
onFilterRuleGroups = () => {},
onSites = () => {},
sites = [],
tmdbId = 1,
} = options
return [
http.get(subscribeApiUrls.sites, () => {
onSites()
return jsonResponse(sites as unknown as JsonBodyType, 200)
}),
http.get(subscribeApiUrls.downloaders, () => {
onDownloaders()
return jsonResponse(downloaders as unknown as JsonBodyType, 200)
}),
http.get(subscribeApiUrls.directories, () => {
onDirectories()
return jsonResponse({ data: { value: directories }, success: true }, 200)
}),
http.get(subscribeApiUrls.filterRuleGroups, () => {
onFilterRuleGroups()
return jsonResponse({ data: { value: filterRuleGroups }, success: true }, 200)
}),
http.get(subscribeApiUrls.episodeGroups(tmdbId), () => {
onEpisodeGroups()
return jsonResponse(episodeGroups as unknown as JsonBodyType, 200)
}),
]
}
+48 -4
View File
@@ -276,15 +276,59 @@ export default defineConfig(({ mode }) => ({
'src/stores/auth.ts', 'src/stores/auth.ts',
'src/pages/recommend.vue', 'src/pages/recommend.vue',
'src/views/dashboard/MediaRecommend.vue', 'src/views/dashboard/MediaRecommend.vue',
'src/composables/useMediaSubscribe.ts',
'src/components/dialog/SubscribeEditDialog.vue',
], ],
provider: 'v8', provider: 'v8',
reporter: ['text', 'json-summary', 'html'], reporter: ['text', 'json-summary', 'html'],
reportsDirectory: 'coverage', reportsDirectory: 'coverage',
thresholds: { thresholds: {
branches: 75, branches: 80,
functions: 80, functions: 85,
lines: 80, lines: 85,
statements: 80, statements: 85,
'src/components/dialog/SubscribeEditDialog.vue': {
branches: 75,
functions: 80,
lines: 80,
statements: 80,
},
'src/composables/useMediaSubscribe.ts': {
branches: 75,
functions: 80,
lines: 80,
statements: 80,
},
'src/pages/recommend.vue': {
branches: 75,
functions: 80,
lines: 80,
statements: 80,
},
'src/stores/auth.ts': {
branches: 75,
functions: 80,
lines: 80,
statements: 80,
},
'src/utils/permission.ts': {
branches: 75,
functions: 80,
lines: 80,
statements: 80,
},
'src/utils/recommendSources.ts': {
branches: 75,
functions: 80,
lines: 80,
statements: 80,
},
'src/views/dashboard/MediaRecommend.vue': {
branches: 75,
functions: 80,
lines: 80,
statements: 80,
},
}, },
}, },
}, },