mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-06 16:16:42 +08:00
test(plugin): cover market settings lifecycle (#661)
This commit is contained in:
@@ -240,11 +240,6 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/components/dialog/PluginMarketSettingDialog.vue": {
|
|
||||||
"@typescript-eslint/no-explicit-any": {
|
|
||||||
"count": 3
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/components/dialog/RcloneConfigDialog.vue": {
|
"src/components/dialog/RcloneConfigDialog.vue": {
|
||||||
"@typescript-eslint/no-explicit-any": {
|
"@typescript-eslint/no-explicit-any": {
|
||||||
"count": 2
|
"count": 2
|
||||||
@@ -1051,4 +1046,4 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
|
import type { ApiResponse } from '@/api/types'
|
||||||
import draggable from 'vuedraggable'
|
import draggable from 'vuedraggable'
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
@@ -24,21 +25,42 @@ const repoText = ref('')
|
|||||||
const newRepoUrl = ref('')
|
const newRepoUrl = ref('')
|
||||||
const editingIndex = ref<number | null>(null)
|
const editingIndex = ref<number | null>(null)
|
||||||
const editingUrl = ref('')
|
const editingUrl = ref('')
|
||||||
|
const loadingRepos = ref(true)
|
||||||
|
const loadReposFailed = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
const syncingSources = ref(false)
|
const syncingSources = ref(false)
|
||||||
|
|
||||||
const emit = defineEmits(['save', 'close'])
|
const emit = defineEmits(['save', 'close', 'changed'])
|
||||||
|
|
||||||
const parsedTextRepos = computed(() => parseRepoInput(repoText.value))
|
const parsedTextRepos = computed(() => parseRepoInput(repoText.value))
|
||||||
const activeRepoCount = computed(() =>
|
const activeRepoCount = computed(() =>
|
||||||
editorMode.value === 'text' ? parsedTextRepos.value.repos.length : repoList.value.length,
|
editorMode.value === 'text' ? parsedTextRepos.value.repos.length : repoList.value.length,
|
||||||
)
|
)
|
||||||
const saveDisabled = computed(
|
const saveDisabled = computed(
|
||||||
() => activeRepoCount.value === 0 || (editorMode.value === 'text' && parsedTextRepos.value.invalidRepos.length > 0),
|
() =>
|
||||||
|
loadingRepos.value ||
|
||||||
|
loadReposFailed.value ||
|
||||||
|
saving.value ||
|
||||||
|
syncingSources.value ||
|
||||||
|
activeRepoCount.value === 0 ||
|
||||||
|
(editorMode.value === 'text' && parsedTextRepos.value.invalidRepos.length > 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
/** 判断仓库地址是否为可保存的 HTTP URL。 */
|
/** 判断仓库地址是否为可保存的 HTTP URL。 */
|
||||||
function isValidRepoUrl(url: string) {
|
function isValidRepoUrl(url: string) {
|
||||||
return /^https?:\/\//i.test(url)
|
if (/\s/.test(url)) return false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedUrl = new URL(url)
|
||||||
|
|
||||||
|
return (
|
||||||
|
['http:', 'https:'].includes(parsedUrl.protocol) &&
|
||||||
|
Boolean(parsedUrl.hostname) &&
|
||||||
|
!parsedUrl.hostname.includes('%')
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 将粘贴的仓库地址文本解析为有效、无效和重复地址列表。 */
|
/** 将粘贴的仓库地址文本解析为有效、无效和重复地址列表。 */
|
||||||
@@ -110,25 +132,36 @@ function switchEditorMode(mode: EditorMode | undefined) {
|
|||||||
|
|
||||||
/** 加载插件市场仓库配置。 */
|
/** 加载插件市场仓库配置。 */
|
||||||
async function queryMarketRepoSetting() {
|
async function queryMarketRepoSetting() {
|
||||||
|
loadingRepos.value = true
|
||||||
|
loadReposFailed.value = false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.get('system/setting/public/PLUGIN_MARKET')
|
const result = (await api.get('system/setting/public/PLUGIN_MARKET')) as unknown as ApiResponse<{
|
||||||
if (result && result.data && result.data.value) {
|
value?: string
|
||||||
repoList.value = parseRepoInput(result.data.value).repos
|
}>
|
||||||
syncTextFromList()
|
if (!result.success) throw new Error(result.message || '')
|
||||||
}
|
|
||||||
|
repoList.value = parseRepoInput(result.data?.value || '').repos
|
||||||
|
syncTextFromList()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
|
loadReposFailed.value = true
|
||||||
|
} finally {
|
||||||
|
loadingRepos.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 保存插件市场仓库配置。 */
|
/** 保存插件市场仓库配置。 */
|
||||||
async function saveHandle() {
|
async function saveHandle() {
|
||||||
|
if (saving.value) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const reposToSave = normalizeCurrentRepos()
|
const reposToSave = normalizeCurrentRepos()
|
||||||
if (!reposToSave) return
|
if (!reposToSave) return
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
const repoStringToSave = reposToSave.join(',')
|
const repoStringToSave = reposToSave.join(',')
|
||||||
const result: { [key: string]: any } = await api.post('system/setting/PLUGIN_MARKET', repoStringToSave)
|
const result = (await api.post('system/setting/PLUGIN_MARKET', repoStringToSave)) as unknown as ApiResponse<unknown>
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
$toast.success(t('dialog.pluginMarketSetting.saveSuccess'))
|
$toast.success(t('dialog.pluginMarketSetting.saveSuccess'))
|
||||||
@@ -136,6 +169,9 @@ async function saveHandle() {
|
|||||||
} else $toast.error(t('dialog.pluginMarketSetting.saveFailed', { message: result?.message }))
|
} else $toast.error(t('dialog.pluginMarketSetting.saveFailed', { message: result?.message }))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
|
$toast.error(t('dialog.pluginMarketSetting.saveFailed', { message: error instanceof Error ? error.message : '' }))
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,7 +179,12 @@ async function saveHandle() {
|
|||||||
async function syncPluginSources() {
|
async function syncPluginSources() {
|
||||||
try {
|
try {
|
||||||
syncingSources.value = true
|
syncingSources.value = true
|
||||||
const result: { [key: string]: any } = await api.post('system/setting/PLUGIN_MARKET/sync-wiki', {})
|
const result = (await api.post('system/setting/PLUGIN_MARKET/sync-wiki', {})) as unknown as ApiResponse<{
|
||||||
|
added_count?: number
|
||||||
|
repos?: string[]
|
||||||
|
total_count?: number
|
||||||
|
value?: string
|
||||||
|
}>
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const repos = Array.isArray(result.data?.repos)
|
const repos = Array.isArray(result.data?.repos)
|
||||||
@@ -151,6 +192,7 @@ async function syncPluginSources() {
|
|||||||
: parseRepoInput(result.data?.value || '').repos
|
: parseRepoInput(result.data?.value || '').repos
|
||||||
repoList.value = repos
|
repoList.value = repos
|
||||||
syncTextFromList()
|
syncTextFromList()
|
||||||
|
emit('changed')
|
||||||
$toast.success(
|
$toast.success(
|
||||||
t('dialog.pluginMarketSetting.syncSuccess', {
|
t('dialog.pluginMarketSetting.syncSuccess', {
|
||||||
added: result.data?.added_count ?? 0,
|
added: result.data?.added_count ?? 0,
|
||||||
@@ -292,7 +334,21 @@ onMounted(() => {
|
|||||||
</VCardItem>
|
</VCardItem>
|
||||||
<VDivider />
|
<VDivider />
|
||||||
<VCardText class="plugin-market-dialog-body pt-4">
|
<VCardText class="plugin-market-dialog-body pt-4">
|
||||||
<div class="plugin-market-toolbar">
|
<div v-if="loadingRepos" class="plugin-market-empty text-center text-medium-emphasis">
|
||||||
|
<VProgressCircular indeterminate color="primary" class="mb-2" />
|
||||||
|
<div>{{ t('common.loadingText') }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VAlert v-else-if="loadReposFailed" type="error" variant="tonal" class="plugin-market-load-error">
|
||||||
|
<div class="d-flex flex-wrap align-center justify-space-between ga-2">
|
||||||
|
<span>{{ t('common.serverConnectionFailed') }}</span>
|
||||||
|
<VBtn prepend-icon="mdi-refresh" size="small" variant="tonal" @click="queryMarketRepoSetting">
|
||||||
|
{{ t('common.retry') }}
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
|
</VAlert>
|
||||||
|
|
||||||
|
<div v-if="!loadingRepos && !loadReposFailed" class="plugin-market-toolbar">
|
||||||
<div class="plugin-market-toolbar-hint">
|
<div class="plugin-market-toolbar-hint">
|
||||||
<VIcon icon="mdi-information-outline" size="18" />
|
<VIcon icon="mdi-information-outline" size="18" />
|
||||||
<span>{{ t('dialog.pluginMarketSetting.repoCountHint', { count: activeRepoCount }) }}</span>
|
<span>{{ t('dialog.pluginMarketSetting.repoCountHint', { count: activeRepoCount }) }}</span>
|
||||||
@@ -333,7 +389,7 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="editorMode === 'list'" class="plugin-market-list-panel">
|
<div v-if="!loadingRepos && !loadReposFailed && editorMode === 'list'" class="plugin-market-list-panel">
|
||||||
<div class="plugin-market-input">
|
<div class="plugin-market-input">
|
||||||
<VTextField
|
<VTextField
|
||||||
v-model="newRepoUrl"
|
v-model="newRepoUrl"
|
||||||
@@ -441,7 +497,7 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="plugin-market-text-panel">
|
<div v-else-if="!loadingRepos && !loadReposFailed" class="plugin-market-text-panel">
|
||||||
<div class="plugin-market-textarea-field">
|
<div class="plugin-market-textarea-field">
|
||||||
<VIcon icon="mdi-text-box-edit-outline" class="plugin-market-textarea-icon" />
|
<VIcon icon="mdi-text-box-edit-outline" class="plugin-market-textarea-icon" />
|
||||||
<textarea
|
<textarea
|
||||||
@@ -484,7 +540,7 @@ onMounted(() => {
|
|||||||
variant="tonal"
|
variant="tonal"
|
||||||
prepend-icon="mdi-cloud-sync-outline"
|
prepend-icon="mdi-cloud-sync-outline"
|
||||||
:loading="syncingSources"
|
:loading="syncingSources"
|
||||||
:disabled="syncingSources"
|
:disabled="syncingSources || saving || loadingRepos || loadReposFailed"
|
||||||
@click="syncPluginSources"
|
@click="syncPluginSources"
|
||||||
>
|
>
|
||||||
{{ t('dialog.pluginMarketSetting.syncSources') }}
|
{{ t('dialog.pluginMarketSetting.syncSources') }}
|
||||||
@@ -496,6 +552,7 @@ onMounted(() => {
|
|||||||
@click="saveHandle"
|
@click="saveHandle"
|
||||||
prepend-icon="mdi-content-save-check"
|
prepend-icon="mdi-content-save-check"
|
||||||
class="px-5"
|
class="px-5"
|
||||||
|
:loading="saving"
|
||||||
:disabled="saveDisabled"
|
:disabled="saveDisabled"
|
||||||
>
|
>
|
||||||
{{ t('dialog.pluginMarketSetting.save') }}
|
{{ t('dialog.pluginMarketSetting.save') }}
|
||||||
|
|||||||
@@ -1,7 +1,113 @@
|
|||||||
|
import PluginMarketSettingDialog from '@/components/dialog/PluginMarketSettingDialog.vue'
|
||||||
|
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { renderWithProviders } from '@tests/support/render'
|
||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
import { cwd } from 'node:process'
|
import { cwd } from 'node:process'
|
||||||
import { resolve } from 'node:path'
|
import { resolve } from 'node:path'
|
||||||
import { describe, expect, it } from 'vitest'
|
import { defineComponent, h } from 'vue'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
apiGet: vi.fn(),
|
||||||
|
apiPost: vi.fn(),
|
||||||
|
toastError: vi.fn(),
|
||||||
|
toastSuccess: vi.fn(),
|
||||||
|
toastWarning: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api', () => ({
|
||||||
|
default: {
|
||||||
|
get: mocks.apiGet,
|
||||||
|
post: mocks.apiPost,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('vue-toastification', () => ({
|
||||||
|
useToast: () => ({
|
||||||
|
error: mocks.toastError,
|
||||||
|
success: mocks.toastSuccess,
|
||||||
|
warning: mocks.toastWarning,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('vuedraggable', async () => {
|
||||||
|
const { defineComponent, h } = await import('vue')
|
||||||
|
|
||||||
|
return {
|
||||||
|
default: defineComponent({
|
||||||
|
name: 'DraggableStub',
|
||||||
|
props: {
|
||||||
|
itemKey: { type: Function, required: true },
|
||||||
|
modelValue: { type: Array, required: true },
|
||||||
|
},
|
||||||
|
setup(props, { slots }) {
|
||||||
|
return () =>
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
props.modelValue.map((element, index) =>
|
||||||
|
h('div', { 'data-repo-key': props.itemKey(element) }, slots.item?.({ element, index })),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const DialogStub = defineComponent({
|
||||||
|
name: 'VDialog',
|
||||||
|
template: '<div role="dialog"><slot /></div>',
|
||||||
|
})
|
||||||
|
|
||||||
|
const CloseButtonStub = defineComponent({
|
||||||
|
name: 'VDialogCloseBtn',
|
||||||
|
emits: ['click'],
|
||||||
|
template: '<button type="button" @click="$emit(\'click\')">关闭</button>',
|
||||||
|
})
|
||||||
|
|
||||||
|
const IconButtonStub = defineComponent({
|
||||||
|
name: 'IconBtn',
|
||||||
|
props: { icon: String },
|
||||||
|
emits: ['click'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
return () => h('button', { 'aria-label': props.icon, onClick: () => emit('click'), type: 'button' })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function createDeferred<T>() {
|
||||||
|
let resolve!: (value: T) => void
|
||||||
|
let reject!: (reason?: unknown) => void
|
||||||
|
const promise = new Promise<T>((done, fail) => {
|
||||||
|
resolve = done
|
||||||
|
reject = fail
|
||||||
|
})
|
||||||
|
|
||||||
|
return { promise, reject, resolve }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderDialog() {
|
||||||
|
const changed = vi.fn()
|
||||||
|
const close = vi.fn()
|
||||||
|
const save = vi.fn()
|
||||||
|
const result = await renderWithProviders(PluginMarketSettingDialog, {
|
||||||
|
props: {
|
||||||
|
onChanged: changed,
|
||||||
|
onClose: close,
|
||||||
|
onSave: save,
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
components: {
|
||||||
|
VDialogCloseBtn: CloseButtonStub,
|
||||||
|
},
|
||||||
|
stubs: {
|
||||||
|
IconBtn: IconButtonStub,
|
||||||
|
VDialog: DialogStub,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ...result, changed, close, save }
|
||||||
|
}
|
||||||
|
|
||||||
const dialogSource = readFileSync(resolve(cwd(), 'src/components/dialog/PluginMarketSettingDialog.vue'), 'utf8')
|
const dialogSource = readFileSync(resolve(cwd(), 'src/components/dialog/PluginMarketSettingDialog.vue'), 'utf8')
|
||||||
|
|
||||||
@@ -15,6 +121,206 @@ function getStyleRule(selector: string) {
|
|||||||
return dialogSource.slice(ruleStart, ruleEnd)
|
return dialogSource.slice(ruleStart, ruleEnd)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe('PluginMarketSettingDialog behavior', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.apiGet.mockReset().mockResolvedValue({ data: { value: '' }, success: true })
|
||||||
|
mocks.apiPost.mockReset().mockResolvedValue({ success: true })
|
||||||
|
mocks.toastError.mockReset()
|
||||||
|
mocks.toastSuccess.mockReset()
|
||||||
|
mocks.toastWarning.mockReset()
|
||||||
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses configured repositories with stable deduplication and supports list edits', async () => {
|
||||||
|
const firstRepo = 'https://github.com/example/first'
|
||||||
|
const secondRepo = 'https://github.com/example/second.git'
|
||||||
|
mocks.apiGet.mockResolvedValueOnce({
|
||||||
|
data: { value: ` ${firstRepo},${secondRepo}\n${firstRepo} ` },
|
||||||
|
success: true,
|
||||||
|
})
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
await renderDialog()
|
||||||
|
|
||||||
|
expect(await screen.findByText('example/first')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('example/second')).toBeInTheDocument()
|
||||||
|
expect(screen.getAllByText(firstRepo)).toHaveLength(1)
|
||||||
|
|
||||||
|
await user.type(screen.getByPlaceholderText('输入插件仓库地址'), 'not-a-url')
|
||||||
|
await user.click(screen.getByRole('button', { name: '添加仓库' }))
|
||||||
|
expect(mocks.toastError).toHaveBeenCalledWith('请输入有效的URL地址')
|
||||||
|
|
||||||
|
await user.clear(screen.getByPlaceholderText('输入插件仓库地址'))
|
||||||
|
const NativeURL = URL
|
||||||
|
function BrowserLikeURL(input: string | URL, base?: string | URL) {
|
||||||
|
if (String(input) === 'https://not a valid') {
|
||||||
|
return { hostname: 'not%20a%20valid', protocol: 'https:' } as URL
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NativeURL(input, base)
|
||||||
|
}
|
||||||
|
vi.stubGlobal('URL', BrowserLikeURL)
|
||||||
|
await user.type(screen.getByPlaceholderText('输入插件仓库地址'), 'https://not a valid')
|
||||||
|
await user.click(screen.getByRole('button', { name: '添加仓库' }))
|
||||||
|
expect(mocks.toastError).toHaveBeenCalledTimes(2)
|
||||||
|
vi.stubGlobal('URL', NativeURL)
|
||||||
|
|
||||||
|
await user.clear(screen.getByPlaceholderText('输入插件仓库地址'))
|
||||||
|
await user.type(screen.getByPlaceholderText('输入插件仓库地址'), firstRepo)
|
||||||
|
await user.click(screen.getByRole('button', { name: '添加仓库' }))
|
||||||
|
expect(mocks.toastError).toHaveBeenCalledWith('该地址已存在')
|
||||||
|
|
||||||
|
await user.click(screen.getAllByRole('button', { name: 'mdi-pencil' })[1])
|
||||||
|
const editingInput = screen.getAllByRole('textbox')[1]
|
||||||
|
await user.type(editingInput, '/discarded')
|
||||||
|
await fireEvent.keyUp(editingInput, { key: 'Escape' })
|
||||||
|
expect(screen.queryByDisplayValue(`${secondRepo}/discarded`)).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
await user.click(screen.getAllByRole('button', { name: 'mdi-pencil' })[1])
|
||||||
|
const resumedEditingInput = screen.getAllByRole('textbox')[1]
|
||||||
|
await user.clear(resumedEditingInput)
|
||||||
|
await user.type(resumedEditingInput, 'https://git.example.com/team/renamed')
|
||||||
|
await fireEvent.keyUp(resumedEditingInput, { key: 'Enter' })
|
||||||
|
expect(screen.getAllByText('https://git.example.com/team/renamed')).toHaveLength(2)
|
||||||
|
|
||||||
|
await user.click(screen.getAllByRole('button', { name: 'mdi-delete' })[0])
|
||||||
|
expect(screen.queryByText(firstRepo)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a failed initial query distinct from an empty setting and retries in place', async () => {
|
||||||
|
mocks.apiGet
|
||||||
|
.mockRejectedValueOnce(new Error('load failed'))
|
||||||
|
.mockResolvedValueOnce({ data: { value: '' }, success: true })
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
await renderDialog()
|
||||||
|
|
||||||
|
expect(await screen.findByText('服务器连接失败')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('暂无插件仓库地址')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: '重试' }))
|
||||||
|
|
||||||
|
expect(await screen.findByText('暂无插件仓库地址')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('服务器连接失败')).not.toBeInTheDocument()
|
||||||
|
expect(mocks.apiGet).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('normalizes text input, rejects invalid entries and saves the JSON string payload', async () => {
|
||||||
|
mocks.apiGet.mockResolvedValueOnce({ data: { value: 'https://github.com/example/original' }, success: true })
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const { close, save } = await renderDialog()
|
||||||
|
|
||||||
|
await screen.findByText('example/original')
|
||||||
|
await user.click(screen.getByRole('tab', { name: '文本维护' }))
|
||||||
|
const textInput = screen.getByRole('textbox')
|
||||||
|
await user.clear(textInput)
|
||||||
|
await user.type(
|
||||||
|
textInput,
|
||||||
|
'https://github.com/example/a,invalid\nhttps://github.com/example/a,https://github.com/example/b',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByText('文本中有 1 个无效地址,请修正后保存。')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: '保存' })).toBeDisabled()
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('tab', { name: '列表维护' }))
|
||||||
|
expect(mocks.toastWarning).toHaveBeenCalledWith('已忽略 1 个无效地址')
|
||||||
|
await user.click(screen.getByRole('tab', { name: '文本维护' }))
|
||||||
|
|
||||||
|
const normalizedTextInput = screen.getByRole('textbox')
|
||||||
|
await user.clear(normalizedTextInput)
|
||||||
|
await user.type(
|
||||||
|
normalizedTextInput,
|
||||||
|
'https://github.com/example/a,https://github.com/example/a\nhttps://github.com/example/b',
|
||||||
|
)
|
||||||
|
expect(screen.getByText('重复地址会在保存时自动去重。')).toBeInTheDocument()
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: '保存' }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mocks.apiPost).toHaveBeenCalledWith(
|
||||||
|
'system/setting/PLUGIN_MARKET',
|
||||||
|
'https://github.com/example/a,https://github.com/example/b',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
expect(save).toHaveBeenCalledOnce()
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: '关闭' }))
|
||||||
|
expect(close).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('locks save while pending and reports both business and HTTP failures without closing', async () => {
|
||||||
|
mocks.apiGet.mockResolvedValue({ data: { value: 'https://github.com/example/repo' }, success: true })
|
||||||
|
const pendingSave = createDeferred<{ success: boolean; message?: string }>()
|
||||||
|
mocks.apiPost.mockReturnValueOnce(pendingSave.promise)
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const { save } = await renderDialog()
|
||||||
|
|
||||||
|
await screen.findByText('example/repo')
|
||||||
|
await user.click(screen.getByRole('button', { name: '保存' }))
|
||||||
|
expect(screen.getByRole('button', { name: '保存' })).toBeDisabled()
|
||||||
|
|
||||||
|
pendingSave.resolve({ success: false, message: 'rejected' })
|
||||||
|
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('插件仓库保存失败:rejected!'))
|
||||||
|
expect(save).not.toHaveBeenCalled()
|
||||||
|
expect(screen.getByRole('button', { name: '保存' })).toBeEnabled()
|
||||||
|
|
||||||
|
mocks.apiPost.mockRejectedValueOnce(new Error('network failed'))
|
||||||
|
await user.click(screen.getByRole('button', { name: '保存' }))
|
||||||
|
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('插件仓库保存失败:network failed!'))
|
||||||
|
expect(save).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('publishes a persistent change after source sync and always releases its loading state', async () => {
|
||||||
|
mocks.apiGet.mockResolvedValue({ data: { value: 'https://github.com/example/original' }, success: true })
|
||||||
|
const pendingSync = createDeferred<{
|
||||||
|
success: boolean
|
||||||
|
data: { added_count: number; repos: string[]; total_count: number }
|
||||||
|
}>()
|
||||||
|
mocks.apiPost.mockReturnValueOnce(pendingSync.promise)
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const { changed, close, save } = await renderDialog()
|
||||||
|
|
||||||
|
await screen.findByText('example/original')
|
||||||
|
await user.click(screen.getByRole('button', { name: '同步插件源' }))
|
||||||
|
expect(screen.getByRole('button', { name: '同步插件源' })).toBeDisabled()
|
||||||
|
|
||||||
|
pendingSync.resolve({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
added_count: 1,
|
||||||
|
repos: ['https://github.com/example/original', 'https://github.com/example/source'],
|
||||||
|
total_count: 2,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await screen.findByText('example/source')).toBeInTheDocument()
|
||||||
|
expect(changed).toHaveBeenCalledOnce()
|
||||||
|
expect(save).not.toHaveBeenCalled()
|
||||||
|
expect(close).not.toHaveBeenCalled()
|
||||||
|
expect(mocks.apiPost).toHaveBeenCalledWith('system/setting/PLUGIN_MARKET/sync-wiki', {})
|
||||||
|
expect(screen.getByRole('button', { name: '同步插件源' })).toBeEnabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the dialog open when source sync reports a business or HTTP failure', async () => {
|
||||||
|
mocks.apiGet.mockResolvedValue({ data: { value: 'https://github.com/example/original' }, success: true })
|
||||||
|
mocks.apiPost
|
||||||
|
.mockResolvedValueOnce({ success: false, message: 'source rejected' })
|
||||||
|
.mockRejectedValueOnce(new Error('source offline'))
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const { changed } = await renderDialog()
|
||||||
|
|
||||||
|
await screen.findByText('example/original')
|
||||||
|
await user.click(screen.getByRole('button', { name: '同步插件源' }))
|
||||||
|
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('同步插件源失败:source rejected!'))
|
||||||
|
expect(changed).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: '同步插件源' }))
|
||||||
|
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('同步插件源失败:source offline!'))
|
||||||
|
expect(changed).not.toHaveBeenCalled()
|
||||||
|
expect(screen.getByRole('button', { name: '同步插件源' })).toBeEnabled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('PluginMarketSettingDialog theme surfaces', () => {
|
describe('PluginMarketSettingDialog theme surfaces', () => {
|
||||||
it('uses shared theme tokens for the view switch and editor containers', () => {
|
it('uses shared theme tokens for the view switch and editor containers', () => {
|
||||||
const modeSwitchRule = getStyleRule('.plugin-market-mode-switch')
|
const modeSwitchRule = getStyleRule('.plugin-market-mode-switch')
|
||||||
|
|||||||
@@ -1315,6 +1315,7 @@ function openMarketSettingDialog() {
|
|||||||
PluginMarketSettingDialog,
|
PluginMarketSettingDialog,
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
|
changed: marketSettingDone,
|
||||||
save: marketSettingDone,
|
save: marketSettingDone,
|
||||||
},
|
},
|
||||||
{ closeOn: ['close', 'save'] },
|
{ closeOn: ['close', 'save'] },
|
||||||
|
|||||||
@@ -990,6 +990,12 @@ describe('PluginCardListView installed filtering and host callbacks', () => {
|
|||||||
getDialogEvents().save()
|
getDialogEvents().save()
|
||||||
await waitFor(() => expect(marketRequests).toBeGreaterThanOrEqual(3))
|
await waitFor(() => expect(marketRequests).toBeGreaterThanOrEqual(3))
|
||||||
|
|
||||||
|
const requestsAfterSave = marketRequests
|
||||||
|
getDynamicMenuItem('dialog.pluginMarketSetting.title').action()
|
||||||
|
expect(mocks.openSharedDialog.mock.calls.at(-1)?.[3]).toEqual({ closeOn: ['close', 'save'] })
|
||||||
|
getDialogEvents().changed()
|
||||||
|
await waitFor(() => expect(marketRequests).toBeGreaterThan(requestsAfterSave))
|
||||||
|
|
||||||
await fireEvent.click(screen.getByRole('button', { name: 'installed-Available' }))
|
await fireEvent.click(screen.getByRole('button', { name: 'installed-Available' }))
|
||||||
await waitFor(() => expect(sidebarStore.ensureSidebarNav).toHaveBeenCalledWith(true))
|
await waitFor(() => expect(sidebarStore.ensureSidebarNav).toHaveBeenCalledWith(true))
|
||||||
await waitForRequestsToFinish()
|
await waitForRequestsToFinish()
|
||||||
|
|||||||
@@ -387,6 +387,8 @@ describe('FullCalendarView', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('resets a stale mobile title filter after keep-alive refresh replaces the data', async () => {
|
it('resets a stale mobile title filter after keep-alive refresh replaces the data', async () => {
|
||||||
|
vi.useFakeTimers({ toFake: ['Date'] })
|
||||||
|
vi.setSystemTime(new Date('2026-08-10T12:00:00+08:00'))
|
||||||
setViewport(480)
|
setViewport(480)
|
||||||
const first = movieSubscribe(3601, '第一轮电影')
|
const first = movieSubscribe(3601, '第一轮电影')
|
||||||
const second = movieSubscribe(3602, '第二轮电影')
|
const second = movieSubscribe(3602, '第二轮电影')
|
||||||
|
|||||||
@@ -373,6 +373,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
|||||||
'src/components/cards/PluginCard.vue',
|
'src/components/cards/PluginCard.vue',
|
||||||
'src/components/cards/PluginAppCard.vue',
|
'src/components/cards/PluginAppCard.vue',
|
||||||
'src/components/dialog/PluginMarketDetailDialog.vue',
|
'src/components/dialog/PluginMarketDetailDialog.vue',
|
||||||
|
'src/components/dialog/PluginMarketSettingDialog.vue',
|
||||||
'src/components/dialog/PluginVersionHistoryDialog.vue',
|
'src/components/dialog/PluginVersionHistoryDialog.vue',
|
||||||
'src/components/slide/VirtualSlideView.vue',
|
'src/components/slide/VirtualSlideView.vue',
|
||||||
'src/views/discover/PersonCardSlideView.vue',
|
'src/views/discover/PersonCardSlideView.vue',
|
||||||
@@ -416,6 +417,12 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
|||||||
lines: 85,
|
lines: 85,
|
||||||
statements: 85,
|
statements: 85,
|
||||||
},
|
},
|
||||||
|
'src/components/dialog/PluginMarketSettingDialog.vue': {
|
||||||
|
branches: 85,
|
||||||
|
functions: 90,
|
||||||
|
lines: 90,
|
||||||
|
statements: 90,
|
||||||
|
},
|
||||||
'src/views/plugin/PluginCardListView.vue': {
|
'src/views/plugin/PluginCardListView.vue': {
|
||||||
branches: 75,
|
branches: 75,
|
||||||
functions: 80,
|
functions: 80,
|
||||||
|
|||||||
Reference in New Issue
Block a user