refactor(web): align resource APIs and forms with the starter conventions

Centralize typed resource functions under common/apis and return complete JSON envelopes while preserving raw responses, bearer session isolation, and single-owner errors.

Keep settings state and queues inside the setup store. Use Element Plus forms and dialogs for login, settings, imports, moves, and images; preserve editor interactions and themes. Update callers, tests, generated declarations, and request import constraints together.
This commit is contained in:
eezd committed 2026-10-26 11:53:37 +08:00
1 parent cef0298c34
commit 6b537c03e0
114 files changed
+2876 -2217

No files matched your search

+176 -155
View File
@@ -1,36 +1,52 @@
import type { App } from 'vue'
import { preferencesApi } from '@@/apis/preferences'
import { trashApi } from '@@/apis/trash'
import * as fontsApi from '@@/apis/fonts'
import * as maintenanceApi from '@@/apis/maintenance'
import * as settingsApi from '@@/apis/settings'
import * as storageApi from '@@/apis/storage'
import * as themesApi from '@@/apis/themes'
import * as tokensApi from '@@/apis/tokens'
import * as trashApi from '@@/apis/trash'
import { confirmAction } from '@@/utils/confirm'
import { toasts } from '@@/utils/notifications'
import { flushPromises } from '@vue/test-utils'
import { CanceledError, isCancel } from 'axios'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick } from 'vue'
import { ApiError } from '@/http/axios'
import { settingsApi } from '@/pages/settings/apis'
import { pinia } from '@/pinia'
import { apiSuccess } from '../../../tests/api-response'
import { applyFontCatalog, customFonts } from '../../common/appearance/fonts'
import { allCommands, commandKeys, formatKeys, registerCommand } from '../../common/utils/commands'
import ShortcutSettings from '../../pages/settings/components/ShortcutSettings.vue'
import SettingsDialog from '../../pages/settings/index.vue'
import { searchSettings } from '../../pages/settings/settings-catalog'
import { ws } from '../../pages/workspace/workspace'
import { DEFAULTS, loadSettings, settings, SettingsAppearanceError, settingsSave, updateSettings } from './settings'
import { DEFAULTS, SettingsAppearanceError, useSettingsStore } from './settings'
const { loadSettings, settings, settingsSave, updateSettings } = useSettingsStore(pinia)
vi.mock('@@/utils/confirm', () => ({ confirmAction: vi.fn() }))
let app: App | undefined
let host: HTMLElement | undefined
const cleanup: (() => void)[] = []
beforeEach(() => {
vi.mocked(confirmAction).mockResolvedValue(true)
applyFontCatalog([])
Object.assign(settings, DEFAULTS, { colorMode: 'system', theme: 'default', cssSnippets: [], shortcuts: {} })
Object.assign(settingsSave, { pending: 0, result: 'idle', error: '' })
vi.spyOn(preferencesApi, 'patchSettings').mockResolvedValue({})
vi.spyOn(preferencesApi, 'settings').mockResolvedValue({})
vi.spyOn(preferencesApi, 'fonts').mockResolvedValue([])
vi.spyOn(settingsApi, 'maintenanceStatus').mockResolvedValue({ busy: false, operation: '', error: '', finishedAt: 0 })
vi.spyOn(preferencesApi, 'themes').mockResolvedValue([{ id: 'default', name: '默认', version: '1' }])
vi.spyOn(settingsApi, 'patchSettingsApi').mockResolvedValue(apiSuccess({}))
vi.spyOn(settingsApi, 'getSettingsApi').mockResolvedValue(apiSuccess({}))
vi.spyOn(fontsApi, 'getFontsApi').mockResolvedValue(apiSuccess({ fonts: [] }))
vi.spyOn(maintenanceApi, 'getMaintenanceStatusApi').mockResolvedValue(apiSuccess({ busy: false, operation: '', error: '', finishedAt: 0 }))
vi.spyOn(themesApi, 'getThemesApi').mockResolvedValue(apiSuccess({ themes: [{ id: 'default', name: '默认', version: '1' }] }))
})
afterEach(() => {
afterEach(async () => {
app?.unmount()
await flushPromises()
app = undefined
host?.remove()
cleanup.splice(0).forEach(fn => fn())
@@ -38,7 +54,7 @@ afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
function mount(component: typeof SettingsDialog | typeof ShortcutSettings) {
async function mount(component: typeof SettingsDialog | typeof ShortcutSettings) {
host = document.createElement('div')
document.body.append(host)
app = component === SettingsDialog
@@ -47,9 +63,10 @@ function mount(component: typeof SettingsDialog | typeof ShortcutSettings) {
}, 'prepareMaintenance': async () => {}, 'restoreNote': async () => {} })))
: createApp(component)
app.mount(host)
await flushPromises()
}
async function search(selector: string, query: string) {
const input = host!.querySelector<HTMLInputElement>(selector)!
const input = document.body.querySelector<HTMLInputElement>(selector)!
input.value = query
input.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
@@ -58,26 +75,27 @@ async function search(selector: string, query: string) {
describe('设置查找与重置', () => {
it('分类按约定排序,令牌归入账户与安全', async () => {
ws.settings = 'account'
vi.spyOn(settingsApi, 'tokens').mockResolvedValue([])
mount(SettingsDialog)
expect([...host!.querySelectorAll('.rm-settings-tab')].map(el => el.textContent)).toEqual(['外观', '编辑器', '快捷键', '插件', '存储空间', '回收站', '账户与安全', '关于'])
await vi.waitFor(() => expect(host!.querySelector('#rm-setting-tokens')).not.toBeNull())
vi.spyOn(tokensApi, 'getTokensApi').mockResolvedValue(apiSuccess({ tokens: [] }))
await mount(SettingsDialog)
expect([...document.body.querySelectorAll('.rm-settings-tab')].map(el => el.textContent)).toEqual(['外观', '编辑器', '快捷键', '插件', '存储空间', '回收站', '账户与安全', '关于'])
await vi.waitFor(() => expect(document.body.querySelector('#rm-setting-tokens')).not.toBeNull())
expect(searchSettings('API 令牌')).toEqual(expect.arrayContaining([expect.objectContaining({ id: 'tokens', section: 'account' })]))
})
it('编辑器字号在输入时立即生效,无需回车或失焦', async () => {
ws.settings = 'editor'
mount(SettingsDialog)
const input = host!.querySelector<HTMLInputElement>('#rm-font-size')!
await mount(SettingsDialog)
const input = document.body.querySelector<HTMLInputElement>('#rm-font-size')!
input.value = '24'
input.dispatchEvent(new Event('input', { bubbles: true }))
expect(settings.fontSize).toBe(24)
expect(document.documentElement.style.getPropertyValue('--rm-font-size')).toBe('24px')
await vi.waitFor(() => expect(preferencesApi.patchSettings).toHaveBeenCalledWith({ fontSize: 24 }))
await vi.waitFor(() => expect(settingsApi.patchSettingsApi).toHaveBeenCalledWith({ fontSize: 24 }))
input.value = ''
input.dispatchEvent(new Event('input', { bubbles: true }))
expect(settings.fontSize).toBe(24)
input.dispatchEvent(new Event('blur'))
await nextTick()
expect(input.value).toBe('24')
})
@@ -88,42 +106,42 @@ describe('设置查找与重置', () => {
expect(searchSettings('模板')).toEqual([])
expect(DEFAULTS).not.toHaveProperty('templatesFolder')
ws.settings = 'appearance'
mount(SettingsDialog)
expect(host!.textContent).not.toContain('日记')
expect(host!.querySelector('#rm-templates-folder')).toBeNull()
expect(host!.textContent).not.toContain('模板')
await mount(SettingsDialog)
expect(document.body.textContent).not.toContain('日记')
expect(document.body.querySelector('#rm-templates-folder')).toBeNull()
expect(document.body.textContent).not.toContain('模板')
})
it('界面与编辑器共用字体管理,删除预设同步恢复使用位置,Maple不可删除', async () => {
settings.uiFont = settings.textFont = settings.codeFont = 'serif'
ws.settings = 'appearance'
mount(SettingsDialog)
host!.querySelector<HTMLButtonElement>('#rm-ui-font')!.click()
await mount(SettingsDialog)
document.body.querySelector<HTMLButtonElement>('#rm-ui-font')!.click()
await nextTick()
expect(host!.querySelector('button[aria-label^="删除字体 Maple"]')).toBeNull()
host!.querySelector<HTMLButtonElement>('button[aria-label="删除字体 Noto Serif SC · 思源宋体"]')!.click()
expect(document.body.querySelector('button[aria-label^="删除字体 Maple"]')).toBeNull()
document.body.querySelector<HTMLButtonElement>('button[aria-label="删除字体 Noto Serif SC · 思源宋体"]')!.click()
await vi.waitFor(() => expect(settings.hiddenFonts).toContain('serif'))
expect(settings.uiFont).toBe('maple')
expect(settings.textFont).toBe('maple')
expect(settings.codeFont).toBe('maple')
const editor = [...host!.querySelectorAll<HTMLButtonElement>('.rm-settings-tab')].find(button => button.textContent?.trim() === '编辑器')!
const editor = [...document.body.querySelectorAll<HTMLButtonElement>('.rm-settings-tab')].find(button => button.textContent?.trim() === '编辑器')!
editor.click()
await nextTick()
host!.querySelector<HTMLButtonElement>('#rm-text-font')!.click()
document.body.querySelector<HTMLButtonElement>('#rm-text-font')!.click()
await nextTick()
expect(host!.querySelector('.rm-font-options')!.textContent).not.toContain('Noto Serif SC')
expect(host!.querySelector('.rm-font-options')!.textContent).toContain('导入字体')
expect(document.body.querySelector('.rm-font-options')!.textContent).not.toContain('Noto Serif SC')
expect(document.body.querySelector('.rm-font-options')!.textContent).toContain('导入字体')
})
it('先装载字体资源再恢复服务器上的自定义字体,字体删除后恢复Maple', async () => {
const font = { id: `custom-${'a'.repeat(64)}`, name: '导入字体', format: 'woff2' as const }
vi.mocked(preferencesApi.fonts).mockResolvedValue([font])
vi.mocked(preferencesApi.settings).mockResolvedValue({ uiFont: font.id, textFont: font.id, codeFont: font.id })
vi.mocked(fontsApi.getFontsApi).mockResolvedValue(apiSuccess({ fonts: [font] }))
vi.mocked(settingsApi.getSettingsApi).mockResolvedValue(apiSuccess({ uiFont: font.id, textFont: font.id, codeFont: font.id }))
await loadSettings()
expect(settings.uiFont).toBe(font.id)
expect(document.querySelector('#rm-custom-fonts')!.textContent).toContain(`/api/v1/resources/test-fonts/fonts/${font.id}/file`)
expect(document.documentElement.style.getPropertyValue('--rm-font-text')).toContain(font.id)
vi.mocked(preferencesApi.fonts).mockResolvedValue([])
vi.mocked(fontsApi.getFontsApi).mockResolvedValue(apiSuccess({ fonts: [] }))
await loadSettings()
expect(settings.uiFont).toBe('maple')
expect(settings.textFont).toBe('maple')
@@ -131,35 +149,35 @@ describe('设置查找与重置', () => {
})
it('字体保存失败后重新读取原选择并显示错误', async () => {
vi.mocked(preferencesApi.patchSettings).mockRejectedValueOnce(new ApiError(500, '字体设置写入失败'))
vi.mocked(settingsApi.patchSettingsApi).mockRejectedValueOnce(new ApiError(500, '字体设置写入失败'))
ws.settings = 'appearance'
mount(SettingsDialog)
host!.querySelector<HTMLButtonElement>('#rm-ui-font')!.click()
await mount(SettingsDialog)
document.body.querySelector<HTMLButtonElement>('#rm-ui-font')!.click()
await nextTick()
const serif = [...host!.querySelectorAll<HTMLButtonElement>('.rm-font-choice')].find(button => button.textContent?.trim() === 'Noto Serif SC · 思源宋体')!
const serif = [...document.body.querySelectorAll<HTMLButtonElement>('.el-select-dropdown__item')].find(button => button.textContent?.trim() === 'Noto Serif SC · 思源宋体')!
serif.click()
await vi.waitFor(() => expect(host!.querySelector('[role="alert"]')!.textContent).toContain('字体设置写入失败'))
await vi.waitFor(() => expect(document.body.querySelector('[role="alert"]')!.textContent).toContain('字体设置写入失败'))
expect(settings.uiFont).toBe('maple')
expect(host!.querySelector<HTMLButtonElement>('#rm-ui-font')!.textContent).toContain('Maple')
expect(document.body.querySelector('#rm-ui-font')!.closest('.el-select')!.textContent).toContain('Maple')
})
it('删除字体失败保留原字体和设置,允许重试', async () => {
const font = { id: `custom-${'b'.repeat(64)}`, name: '不可删除测试', format: 'woff2' as const }
applyFontCatalog([font])
settings.uiFont = font.id
vi.spyOn(preferencesApi, 'deleteFont').mockRejectedValueOnce(new ApiError(500, '字体删除失败'))
vi.spyOn(fontsApi, 'deleteFontApi').mockRejectedValueOnce(new ApiError(500, '字体删除失败'))
ws.settings = 'appearance'
mount(SettingsDialog)
host!.querySelector<HTMLButtonElement>('#rm-ui-font')!.click()
await mount(SettingsDialog)
document.body.querySelector<HTMLButtonElement>('#rm-ui-font')!.click()
await nextTick()
host!.querySelector<HTMLButtonElement>('button[aria-label="删除字体 不可删除测试"]')!.click()
await vi.waitFor(() => expect(host!.querySelector('[role="alert"]')!.textContent).toContain('字体删除失败'))
document.body.querySelector<HTMLButtonElement>('button[aria-label="删除字体 不可删除测试"]')!.click()
await vi.waitFor(() => expect(document.body.querySelector('[role="alert"]')!.textContent).toContain('字体删除失败'))
expect(settings.uiFont).toBe(font.id)
expect(host!.querySelector<HTMLButtonElement>('#rm-ui-font')!.disabled).toBe(false)
expect(document.body.querySelector<HTMLButtonElement>('#rm-ui-font')!.disabled).toBe(false)
})
it('忽略隐藏Maple的外部设置,不允许移除默认字体', async () => {
vi.mocked(preferencesApi.settings).mockResolvedValue({ hiddenFonts: ['maple', 'serif'], uiFont: 'serif' })
vi.mocked(settingsApi.getSettingsApi).mockResolvedValue(apiSuccess({ hiddenFonts: ['maple', 'serif'], uiFont: 'serif' }))
await loadSettings()
expect(settings.hiddenFonts).toEqual(['serif'])
expect(settings.uiFont).toBe('maple')
@@ -169,9 +187,9 @@ describe('设置查找与重置', () => {
expect(allCommands().map(command => command.id)).not.toContain('note.share')
expect(allCommands().map(command => command.id)).not.toContain('app.shares')
ws.settings = 'appearance'
mount(SettingsDialog)
expect(host!.querySelectorAll('.rm-settings-tab')).toHaveLength(8)
expect(host!.textContent).not.toContain('分享')
await mount(SettingsDialog)
expect(document.body.querySelectorAll('.rm-settings-tab')).toHaveLength(8)
expect(document.body.textContent).not.toContain('分享')
})
it('按名称、关键词及多词匹配,只索引静态说明', () => {
@@ -184,13 +202,13 @@ describe('设置查找与重置', () => {
it('从搜索结果跳转设置行,并展开所在折叠区', async () => {
ws.settings = 'editor'
mount(SettingsDialog)
await mount(SettingsDialog)
await search('input[aria-label="搜索设置"]', 'CSS 高级')
host!.querySelector<HTMLButtonElement>('.rm-settings-result')!.click()
document.body.querySelector<HTMLButtonElement>('.rm-settings-result')!.click()
await vi.waitFor(() => expect(document.activeElement?.id).toBe('rm-setting-css-snippets'))
expect(ws.settings).toBe('appearance')
expect(host!.querySelector<HTMLDetailsElement>('.rm-appearance-advanced')!.open).toBe(true)
expect(host!.querySelector<HTMLInputElement>('input[aria-label="搜索设置"]')!.value).toBe('')
expect(document.body.querySelector<HTMLDetailsElement>('.rm-appearance-advanced')!.open).toBe(true)
expect(document.body.querySelector<HTMLInputElement>('input[aria-label="搜索设置"]')!.value).toBe('')
})
it('单项重置只提交对应键,不影响另一类字号,并可重新读取', async () => {
@@ -198,17 +216,17 @@ describe('设置查找与重置', () => {
settings.fontSize = 24
settings.lineHeight = 2
ws.settings = 'appearance'
mount(SettingsDialog)
host!.querySelector<HTMLButtonElement>('button[aria-label="重置界面字号"]')!.click()
await vi.waitFor(() => expect(preferencesApi.patchSettings).toHaveBeenCalledWith({ uiFontSize: DEFAULTS.uiFontSize }))
await mount(SettingsDialog)
document.body.querySelector<HTMLButtonElement>('button[aria-label="重置界面字号"]')!.click()
await vi.waitFor(() => expect(settingsApi.patchSettingsApi).toHaveBeenCalledWith({ uiFontSize: DEFAULTS.uiFontSize }))
expect(settings.fontSize).toBe(24)
const editor = [...host!.querySelectorAll<HTMLButtonElement>('.rm-settings-tab')].find(e => e.textContent?.trim() === '编辑器')!
const editor = [...document.body.querySelectorAll<HTMLButtonElement>('.rm-settings-tab')].find(e => e.textContent?.trim() === '编辑器')!
editor.click()
await nextTick()
host!.querySelector<HTMLButtonElement>('button[aria-label="重置正文行高"]')!.click()
await vi.waitFor(() => expect(preferencesApi.patchSettings).toHaveBeenLastCalledWith({ lineHeight: DEFAULTS.lineHeight }))
document.body.querySelector<HTMLButtonElement>('button[aria-label="重置正文行高"]')!.click()
await vi.waitFor(() => expect(settingsApi.patchSettingsApi).toHaveBeenLastCalledWith({ lineHeight: DEFAULTS.lineHeight }))
expect(settings.fontSize).toBe(24)
vi.mocked(preferencesApi.settings).mockResolvedValue({ uiFontSize: DEFAULTS.uiFontSize, fontSize: 24, lineHeight: DEFAULTS.lineHeight })
vi.mocked(settingsApi.getSettingsApi).mockResolvedValue(apiSuccess({ uiFontSize: DEFAULTS.uiFontSize, fontSize: 24, lineHeight: DEFAULTS.lineHeight }))
await loadSettings()
expect(settings.fontSize).toBe(24)
expect(settings.lineHeight).toBe(DEFAULTS.lineHeight)
@@ -223,16 +241,16 @@ describe('设置同步顺序', () => {
const pending = new Promise<void>((resolve) => {
release = resolve
})
vi.mocked(preferencesApi.settings).mockImplementationOnce(async () => {
vi.mocked(settingsApi.getSettingsApi).mockImplementationOnce(async () => {
if (delayed === 'settings') await pending
return oldSettings
return apiSuccess(await (oldSettings))
})
vi.mocked(preferencesApi.fonts).mockImplementationOnce(async () => {
vi.mocked(fontsApi.getFontsApi).mockImplementationOnce(async () => {
if (delayed === 'fonts') await pending
return [font]
return apiSuccess({ fonts: await ([font]) })
})
const oldLoad = loadSettings()
vi.mocked(preferencesApi.settings).mockResolvedValue({ uiFont: 'maple', textFont: 'maple', codeFont: 'maple', fontSize: 24 })
vi.mocked(settingsApi.getSettingsApi).mockResolvedValue(apiSuccess({ uiFont: 'maple', textFont: 'maple', codeFont: 'maple', fontSize: 24 }))
await loadSettings()
const cache = localStorage.getItem('rawmark.settings')
release()
@@ -250,13 +268,13 @@ describe('设置同步顺序', () => {
const pending = new Promise<void>((resolve) => {
release = resolve
})
vi.mocked(preferencesApi.settings).mockImplementationOnce(async () => {
vi.mocked(settingsApi.getSettingsApi).mockImplementationOnce(async () => {
if (delayed === 'settings') await pending
return { fontSize: 16, trashRetentionDays: 30 }
return apiSuccess(await ({ fontSize: 16, trashRetentionDays: 30 }))
})
vi.mocked(preferencesApi.fonts).mockImplementationOnce(async () => {
vi.mocked(fontsApi.getFontsApi).mockImplementationOnce(async () => {
if (delayed === 'fonts') await pending
return []
return apiSuccess({ fonts: await ([]) })
})
const oldLoad = loadSettings()
await updateSettings({ fontSize: 24, trashRetentionDays: 0 })
@@ -277,49 +295,49 @@ describe('设置同步顺序', () => {
const pendingSecond = new Promise<Record<string, unknown>>((resolve) => {
finishSecond = resolve
})
vi.mocked(preferencesApi.patchSettings)
.mockImplementationOnce(() => pendingFirst)
.mockImplementationOnce(() => pendingSecond)
vi.mocked(preferencesApi.settings).mockResolvedValue({ fontSize: 24, trashRetentionDays: 0 })
vi.mocked(settingsApi.patchSettingsApi)
.mockImplementationOnce(() => (pendingFirst).then(value => apiSuccess(value)))
.mockImplementationOnce(() => pendingSecond.then(value => apiSuccess(value)))
vi.mocked(settingsApi.getSettingsApi).mockResolvedValue(apiSuccess({ fontSize: 24, trashRetentionDays: 0 }))
const first = updateSettings({ fontSize: 24 })
const second = updateSettings({ trashRetentionDays: 0 })
const sync = loadSettings()
try {
await vi.waitFor(() => expect(preferencesApi.patchSettings).toHaveBeenCalledTimes(1))
expect(preferencesApi.settings).not.toHaveBeenCalled()
await vi.waitFor(() => expect(settingsApi.patchSettingsApi).toHaveBeenCalledTimes(1))
expect(settingsApi.getSettingsApi).not.toHaveBeenCalled()
finishFirst({})
await vi.waitFor(() => expect(preferencesApi.patchSettings).toHaveBeenCalledTimes(2))
expect(preferencesApi.settings).not.toHaveBeenCalled()
await vi.waitFor(() => expect(settingsApi.patchSettingsApi).toHaveBeenCalledTimes(2))
expect(settingsApi.getSettingsApi).not.toHaveBeenCalled()
} finally {
finishFirst({})
finishSecond({})
await Promise.all([first, second, sync])
}
expect(preferencesApi.settings).toHaveBeenCalledTimes(1)
expect(settingsApi.getSettingsApi).toHaveBeenCalledTimes(1)
expect(settings).toMatchObject({ fontSize: 24, trashRetentionDays: 0 })
})
it('等待保存中的读取被后续本地修改作废,不回写旧设置', async () => {
let finish!: (value: Record<string, unknown>) => void
vi.mocked(preferencesApi.patchSettings).mockImplementationOnce(() => new Promise((resolve) => {
vi.mocked(settingsApi.patchSettingsApi).mockImplementationOnce(() => (new Promise<Awaited<ReturnType<typeof settingsApi.patchSettingsApi>>['data']>((resolve) => {
finish = resolve
}))
})).then(value => apiSuccess(value)))
const first = updateSettings({ fontSize: 22 })
const sync = loadSettings()
const second = updateSettings({ fontSize: 24 })
await vi.waitFor(() => expect(preferencesApi.patchSettings).toHaveBeenCalledTimes(1))
await vi.waitFor(() => expect(settingsApi.patchSettingsApi).toHaveBeenCalledTimes(1))
finish({})
await Promise.all([first, second, sync])
expect(preferencesApi.settings).not.toHaveBeenCalled()
expect(settingsApi.getSettingsApi).not.toHaveBeenCalled()
expect(settings.fontSize).toBe(24)
})
it('等待中的保存失败后仍可读取服务端真值,保留原保存错误', async () => {
let fail!: (cause: Error) => void
vi.mocked(preferencesApi.patchSettings).mockImplementationOnce(() => new Promise((_resolve, reject) => {
vi.mocked(settingsApi.patchSettingsApi).mockImplementationOnce(() => (new Promise<Awaited<ReturnType<typeof settingsApi.patchSettingsApi>>['data']>((_resolve, reject) => {
fail = reject
}))
vi.mocked(preferencesApi.settings).mockResolvedValue({ fontSize: 18 })
})).then(value => apiSuccess(value)))
vi.mocked(settingsApi.getSettingsApi).mockResolvedValue(apiSuccess({ fontSize: 18 }))
const cause = new ApiError(500, '保存未完成')
const write = updateSettings({ fontSize: 24 }).catch(error => error)
const sync = loadSettings()
@@ -333,17 +351,17 @@ describe('设置同步顺序', () => {
it('最新同步失败仍报告原错误,迟到旧响应不会覆盖当前状态,后续可重新同步', async () => {
let release!: (value: Record<string, unknown>) => void
vi.mocked(preferencesApi.settings).mockImplementationOnce(() => new Promise((resolve) => {
vi.mocked(settingsApi.getSettingsApi).mockImplementationOnce(() => (new Promise<Awaited<ReturnType<typeof settingsApi.getSettingsApi>>['data']>((resolve) => {
release = resolve
}))
})).then(value => apiSuccess(value)))
const oldLoad = loadSettings()
const cause = new ApiError(503, '字体列表暂时不可用')
vi.mocked(preferencesApi.fonts).mockRejectedValueOnce(cause)
vi.mocked(fontsApi.getFontsApi).mockRejectedValueOnce(cause)
await expect(loadSettings()).rejects.toBe(cause)
release({ fontSize: 18 })
await oldLoad
expect(settings.fontSize).toBe(DEFAULTS.fontSize)
vi.mocked(preferencesApi.settings).mockResolvedValue({ fontSize: 24 })
vi.mocked(settingsApi.getSettingsApi).mockResolvedValue(apiSuccess({ fontSize: 24 }))
await loadSettings()
expect(settings.fontSize).toBe(24)
})
@@ -352,22 +370,22 @@ describe('设置同步顺序', () => {
describe('保存反馈', () => {
it('所有排队请求完成之前保留保存中,不提前发送后续请求', async () => {
let finish!: (value: Record<string, unknown>) => void
vi.mocked(preferencesApi.patchSettings).mockImplementationOnce(() => new Promise((resolve) => {
vi.mocked(settingsApi.patchSettingsApi).mockImplementationOnce(() => (new Promise<Awaited<ReturnType<typeof settingsApi.patchSettingsApi>>['data']>((resolve) => {
finish = resolve
}))
})).then(value => apiSuccess(value)))
const first = updateSettings({ uiFontSize: 18 })
const second = updateSettings({ fontSize: 22 })
expect(settingsSave.pending).toBe(2)
await vi.waitFor(() => expect(preferencesApi.patchSettings).toHaveBeenCalledTimes(1))
await vi.waitFor(() => expect(settingsApi.patchSettingsApi).toHaveBeenCalledTimes(1))
expect(settingsSave.result).toBe('idle')
finish({})
await Promise.all([first, second])
expect(preferencesApi.patchSettings).toHaveBeenNthCalledWith(2, { fontSize: 22 })
expect(settingsApi.patchSettingsApi).toHaveBeenNthCalledWith(2, { fontSize: 22 })
expect(settingsSave).toMatchObject({ pending: 0, result: 'saved', error: '' })
})
it('失败可见,重新读取不冒充保存成功,后续保存仍可执行', async () => {
vi.mocked(preferencesApi.patchSettings).mockRejectedValueOnce(new ApiError(500, '写入失败'))
vi.mocked(settingsApi.patchSettingsApi).mockRejectedValueOnce(new ApiError(500, '写入失败'))
await expect(updateSettings({ uiFontSize: 18 })).rejects.toThrow('写入失败')
expect(settingsSave).toMatchObject({ pending: 0, result: 'error', error: '写入失败' })
await loadSettings()
@@ -378,7 +396,7 @@ describe('保存反馈', () => {
it('写入成功但主题加载失败,保留保存结果和原始错误', async () => {
const cause = new ApiError(503, '主题暂时不可用')
vi.spyOn(preferencesApi, 'theme').mockRejectedValueOnce(cause)
vi.spyOn(themesApi, 'getThemeApi').mockRejectedValueOnce(cause)
const failure = await updateSettings({ theme: 'unavailable' }).catch(error => error)
expect(failure).toBeInstanceOf(SettingsAppearanceError)
expect(failure.cause).toBe(cause)
@@ -387,42 +405,42 @@ describe('保存反馈', () => {
})
it('取消的主题读取保留取消语义,不伪装成主题应用失败', async () => {
vi.spyOn(preferencesApi, 'theme').mockRejectedValueOnce(new CanceledError('会话已变化'))
vi.spyOn(themesApi, 'getThemeApi').mockRejectedValueOnce(new CanceledError('会话已变化'))
const failure = await updateSettings({ theme: 'cancelled-theme' }).catch(error => error)
expect(isCancel(failure)).toBe(true)
expect(failure).not.toBeInstanceOf(SettingsAppearanceError)
})
it('失效会话的设置保存取消后不弹错误或启动新会话的回退读取', async () => {
vi.mocked(preferencesApi.patchSettings).mockRejectedValueOnce(new CanceledError('会话已变化'))
vi.mocked(settingsApi.patchSettingsApi).mockRejectedValueOnce(new CanceledError('会话已变化'))
ws.settings = 'editor'
mount(SettingsDialog)
await mount(SettingsDialog)
toasts.splice(0)
const input = host!.querySelector<HTMLInputElement>('#rm-font-size')!
const input = document.body.querySelector<HTMLInputElement>('#rm-font-size')!
input.value = '24'
input.dispatchEvent(new Event('input', { bubbles: true }))
await vi.waitFor(() => expect(settingsSave.pending).toBe(0))
expect(toasts).toHaveLength(0)
expect(preferencesApi.settings).not.toHaveBeenCalled()
expect(settingsApi.getSettingsApi).not.toHaveBeenCalled()
})
it('cSS 已写入但主题应用失败时结束编辑,不把成功的写入留作重试', async () => {
settings.theme = 'broken-theme'
vi.spyOn(preferencesApi, 'theme').mockRejectedValue(new ApiError(503, '主题暂时不可用'))
vi.spyOn(themesApi, 'getThemeApi').mockRejectedValue(new ApiError(503, '主题暂时不可用'))
ws.settings = 'appearance'
mount(SettingsDialog)
host!.querySelector<HTMLDetailsElement>('.rm-appearance-advanced')!.open = true
const add = [...host!.querySelectorAll<HTMLButtonElement>('button')].find(e => e.textContent?.trim() === '新建片段')!
await mount(SettingsDialog)
document.body.querySelector<HTMLDetailsElement>('.rm-appearance-advanced')!.open = true
const add = [...document.body.querySelectorAll<HTMLButtonElement>('button')].find(e => e.textContent?.trim() === '新建片段')!
add.click()
await nextTick()
await search('input[aria-label="片段名称"]', '主题失败时的片段')
const css = host!.querySelector<HTMLTextAreaElement>('textarea')!
const css = document.body.querySelector<HTMLTextAreaElement>('textarea')!
css.value = ':root { --test-marker: 1; }'
css.dispatchEvent(new Event('input', { bubbles: true }))
host!.querySelector<HTMLFormElement>('.rm-snippet-form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
await vi.waitFor(() => expect(host!.querySelector('.rm-snippet-form')).toBeNull())
document.body.querySelector<HTMLFormElement>('.rm-snippet-form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
await vi.waitFor(() => expect(document.body.querySelector('.rm-snippet-form')).toBeNull())
expect(settings.cssSnippets).toHaveLength(1)
expect(preferencesApi.patchSettings).toHaveBeenCalledTimes(1)
expect(settingsApi.patchSettingsApi).toHaveBeenCalledTimes(1)
expect(settingsSave.result).toBe('saved')
})
})
@@ -431,24 +449,24 @@ describe('快捷键查找', () => {
it('过滤列表后仍检查隐藏命令的冲突,可按 ID 和组合键查找', async () => {
const occupied = { id: 'plugin:settings-test:hidden', title: '隐藏占用命令', keys: ['Mod+Alt+Shift+9'], run() {} }
cleanup.push(registerCommand({ id: 'settings-test.target', title: '审查定位命令', run() {} }), registerCommand(occupied))
mount(ShortcutSettings)
await mount(ShortcutSettings)
await search('input[aria-label="搜索快捷键"]', '审查定位命令')
expect(host!.querySelectorAll('.rm-shortcut-row')).toHaveLength(1)
const add = [...host!.querySelectorAll<HTMLButtonElement>('.rm-shortcut-row button')].find(e => e.textContent?.trim() === '添加')!
expect(document.body.querySelectorAll('.rm-shortcut-row')).toHaveLength(1)
const add = [...document.body.querySelectorAll<HTMLButtonElement>('.rm-shortcut-row button')].find(e => e.textContent?.trim() === '添加')!
add.click()
await nextTick()
host!.querySelector('[data-shortcut-recorder]')!.dispatchEvent(new KeyboardEvent('keydown', { key: '9', code: 'Digit9', ctrlKey: true, altKey: true, shiftKey: true, bubbles: true, cancelable: true }))
document.body.querySelector('[data-shortcut-recorder]')!.dispatchEvent(new KeyboardEvent('keydown', { key: '9', code: 'Digit9', ctrlKey: true, altKey: true, shiftKey: true, bubbles: true, cancelable: true }))
await nextTick()
expect(host!.querySelector('[role="alert"]')!.textContent).toContain('隐藏占用命令')
expect(preferencesApi.patchSettings).not.toHaveBeenCalled()
expect(document.body.querySelector('[role="alert"]')!.textContent).toContain('隐藏占用命令')
expect(settingsApi.patchSettingsApi).not.toHaveBeenCalled()
await search('input[aria-label="搜索快捷键"]', 'plugin:settings-test:hidden')
expect(host!.querySelectorAll('.rm-shortcut-row')).toHaveLength(1)
expect(host!.querySelector('[data-shortcut-recorder]')).toBeNull()
expect(document.body.querySelectorAll('.rm-shortcut-row')).toHaveLength(1)
expect(document.body.querySelector('[data-shortcut-recorder]')).toBeNull()
await search('input[aria-label="搜索快捷键"]', formatKeys(commandKeys(occupied)[0]))
expect(host!.textContent).toContain('隐藏占用命令')
expect(document.body.textContent).toContain('隐藏占用命令')
await search('input[aria-label="搜索快捷键"]', '不存在的命令')
expect(host!.querySelectorAll('.rm-shortcut-row')).toHaveLength(0)
expect(host!.textContent).toContain('没有匹配的快捷键')
expect(document.body.querySelectorAll('.rm-shortcut-row')).toHaveLength(0)
expect(document.body.textContent).toContain('没有匹配的快捷键')
})
})
@@ -456,86 +474,89 @@ describe('应用存储与回收站', () => {
const usage = { totalSize: 1024, categories: [{ id: 'vault' as const, size: 1024 }], builtinFontsSize: 0, customFontsSize: 0, assetsSize: 512, trashSize: 256, calculatedAt: 1700000000000 }
it('进入页面自动统计,防重复请求,刷新失败保留上次结果并展示错误', async () => {
let resolve!: (value: typeof usage) => void
const storage = vi.spyOn(settingsApi, 'storage').mockImplementationOnce(() => new Promise((done) => {
const storage = vi.spyOn(storageApi, 'getStorageApi').mockImplementationOnce(() => (new Promise<Awaited<ReturnType<typeof storageApi.getStorageApi>>['data']>((done) => {
resolve = done
}))
})).then(value => apiSuccess(value)))
ws.settings = 'storage'
mount(SettingsDialog)
await mount(SettingsDialog)
expect(storage).toHaveBeenCalledTimes(1)
const button = host!.querySelector<HTMLButtonElement>('#rm-setting-storage button')!
const button = document.body.querySelector<HTMLButtonElement>('#rm-setting-storage button')!
button.click()
await nextTick()
expect(button.disabled).toBe(true)
button.click()
expect(storage).toHaveBeenCalledTimes(1)
resolve(usage)
await vi.waitFor(() => expect(host!.textContent).toContain('1 KiB'))
expect(host!.textContent).toContain('附件')
await vi.waitFor(() => expect(document.body.textContent).toContain('1 KiB'))
expect(document.body.textContent).toContain('附件')
storage.mockRejectedValueOnce(new Error('磁盘读取错误'))
button.click()
await vi.waitFor(() => expect(host!.querySelector('[role="alert"]')?.textContent).toContain('磁盘读取错误'))
expect(host!.textContent).toContain('1 KiB')
await vi.waitFor(() => expect(document.body.querySelector('[role="alert"]')?.textContent).toContain('磁盘读取错误'))
expect(document.body.textContent).toContain('1 KiB')
})
it('维护重置取消时不发送请求,确认时等待保存并保留真实失败', async () => {
vi.spyOn(settingsApi, 'storage').mockResolvedValue(usage)
const reset = vi.spyOn(settingsApi, 'resetSettings').mockRejectedValue(new Error('backup disk full'))
vi.stubGlobal('confirm', vi.fn().mockReturnValueOnce(false).mockReturnValue(true))
ws.settings = 'storage'; mount(SettingsDialog)
const button = host!.querySelector<HTMLButtonElement>('#rm-setting-settings-reset button')!
button.click(); expect(reset).not.toHaveBeenCalled()
vi.spyOn(storageApi, 'getStorageApi').mockResolvedValue(apiSuccess(usage))
const reset = vi.spyOn(maintenanceApi, 'resetSettingsApi').mockRejectedValue(new Error('backup disk full'))
vi.mocked(confirmAction).mockResolvedValueOnce(false).mockResolvedValue(true)
ws.settings = 'storage'; await mount(SettingsDialog)
const button = document.body.querySelector<HTMLButtonElement>('#rm-setting-settings-reset button')!
button.click(); await flushPromises(); expect(reset).not.toHaveBeenCalled()
button.click()
await vi.waitFor(() => expect(host!.textContent).toContain('backup disk full'))
await vi.waitFor(() => expect(document.body.textContent).toContain('backup disk full'))
expect(reset).toHaveBeenCalledTimes(1)
expect(button.disabled).toBe(false)
})
it('清空必须确认;防止重复操作,部分失败后重读并保留错误详情', async () => {
const item = { id: 'one', path: 'broken.md', dir: false, deletedAt: Date.now() }
vi.spyOn(trashApi, 'trash').mockResolvedValue([item])
vi.spyOn(trashApi, 'getTrashApi').mockResolvedValue(apiSuccess({ items: [item] }))
let finish!: (value: { deleted: string[], failed: { id: string, error: string }[] }) => void
const clear = vi.spyOn(trashApi, 'clearTrash').mockImplementation(() => new Promise((done) => {
const clear = vi.spyOn(trashApi, 'clearTrashApi').mockImplementation(() => (new Promise<Awaited<ReturnType<typeof trashApi.clearTrashApi>>['data']>((done) => {
finish = done
}))
const confirm = vi.fn().mockReturnValueOnce(false).mockReturnValue(true)
vi.stubGlobal('confirm', confirm)
})).then(value => apiSuccess(value)))
const confirm = vi.mocked(confirmAction).mockResolvedValueOnce(false).mockResolvedValue(true)
ws.settings = 'trash'
mount(SettingsDialog)
await vi.waitFor(() => expect(host!.textContent).toContain('broken'))
const button = host!.querySelector<HTMLButtonElement>('#rm-setting-trash-clear button')!
await mount(SettingsDialog)
await vi.waitFor(() => expect(document.body.textContent).toContain('broken'))
const button = document.body.querySelector<HTMLButtonElement>('#rm-setting-trash-clear button')!
button.click()
await flushPromises()
expect(clear).not.toHaveBeenCalled()
button.click()
await nextTick()
expect(confirm).toHaveBeenCalledTimes(2)
expect(button.disabled).toBe(true)
expect([...host!.querySelectorAll<HTMLButtonElement>('.rm-trash-list button')].every(b => b.disabled)).toBe(true)
await vi.waitFor(() => expect(button.disabled).toBe(true))
expect([...document.body.querySelectorAll<HTMLButtonElement>('.el-table button')].every(b => b.disabled)).toBe(true)
button.click()
expect(clear).toHaveBeenCalledTimes(1)
finish({ deleted: [], failed: [{ id: item.id, error: '元数据损坏' }] })
await vi.waitFor(() => expect(host!.querySelector('[role="alert"]')?.textContent).toContain('元数据损坏'))
expect(trashApi.trash).toHaveBeenCalledTimes(2)
await vi.waitFor(() => expect(document.body.querySelector('[role="alert"]')?.textContent).toContain('元数据损坏'))
expect(trashApi.getTrashApi).toHaveBeenCalledTimes(2)
expect(button.disabled).toBe(false)
})
it('保留天数支持0,拒绝负数和小数,不提交无效值', async () => {
vi.spyOn(trashApi, 'trash').mockResolvedValue([])
vi.spyOn(trashApi, 'getTrashApi').mockResolvedValue(apiSuccess({ items: [] }))
ws.settings = 'trash'
mount(SettingsDialog)
const input = host!.querySelector<HTMLInputElement>('#rm-trash-retention')!
await mount(SettingsDialog)
const input = document.body.querySelector<HTMLInputElement>('#rm-trash-retention')!
expect(input.value).toBe('30')
for (const value of ['-1', '1.5', '']) {
input.value = value
input.dispatchEvent(new Event('input', { bubbles: true }))
input.dispatchEvent(new Event('change', { bubbles: true }))
expect(preferencesApi.patchSettings).not.toHaveBeenCalled()
await nextTick()
expect(settingsApi.patchSettingsApi).not.toHaveBeenCalled()
expect(input.value).toBe('30')
}
input.value = '0'
input.dispatchEvent(new Event('input', { bubbles: true }))
input.dispatchEvent(new Event('change', { bubbles: true }))
await vi.waitFor(() => expect(preferencesApi.patchSettings).toHaveBeenCalledWith({ trashRetentionDays: 0 }))
await vi.waitFor(() => expect(settingsApi.patchSettingsApi).toHaveBeenCalledWith({ trashRetentionDays: 0 }))
expect(settings.trashRetentionDays).toBe(0)
})
})
vi.mock('@@/apis/auth/resources', () => ({
vi.mock('@@/utils/resource-tickets', () => ({
prepareResources: async () => {},
imageResourceRoot: () => '/api/v1/resources/test-images/images/',
fontResourceRoot: () => '/api/v1/resources/test-fonts/fonts/',
@@ -547,9 +568,9 @@ it('换会话后旧设置队列不得使用新Token提交,也不得覆盖新
const { useUserStore } = await import('./user')
const user = useUserStore(pinia)
let finish!: (value: Record<string, unknown>) => void
const patch = vi.mocked(preferencesApi.patchSettings).mockImplementationOnce(() => new Promise((resolve) => {
const patch = vi.mocked(settingsApi.patchSettingsApi).mockImplementationOnce(() => (new Promise<Awaited<ReturnType<typeof settingsApi.patchSettingsApi>>['data']>((resolve) => {
finish = resolve
}))
})).then(value => apiSuccess(value)))
const first = updateSettings({ fontSize: 21 }).catch(error => error)
const queued = updateSettings({ fontSize: 22 }).catch(error => error)
await vi.waitFor(() => expect(finish).toBeTypeOf('function'))