mirror of
https://github.com/purocean/yn.git
synced 2026-08-31 01:55:43 +08:00
test: push coverage past ninety-two percent
This commit is contained in:
@@ -487,6 +487,41 @@ describe('main app entry', () => {
|
||||
expect(win.destroy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('covers alternate window restore and action branches', async () => {
|
||||
const electron = await import('electron')
|
||||
mocks.store.set('window.state', { x: 5, y: 5, width: 900, height: 500, maximized: true })
|
||||
await loadApp()
|
||||
mocks.appEvents.ready()
|
||||
let win = mocks.browserWindowInstances[0]
|
||||
|
||||
expect(win.maximize).toHaveBeenCalled()
|
||||
|
||||
await mocks.actions['show-open-dialog']({ properties: ['openFile'] })
|
||||
expect(electron.dialog.showOpenDialog).toHaveBeenCalledWith(win, { properties: ['openFile'] })
|
||||
expect(mocks.actions['get-main-widow']()).toBe(win)
|
||||
expect(mocks.actions['get-url-mode']()).toBe('scheme')
|
||||
mocks.actions['set-url-mode']('prod')
|
||||
expect(mocks.actions['get-url-mode']()).toBe('prod')
|
||||
mocks.actions['open-in-browser']()
|
||||
expect(mocks.shellOpenExternal).toHaveBeenCalledWith('url:prod:4555:8066')
|
||||
|
||||
win.events['enter-full-screen']()
|
||||
mocks.actions['toggle-fullscreen']()
|
||||
expect(win.setFullScreen).toHaveBeenCalledWith(false)
|
||||
win.events['leave-full-screen']()
|
||||
|
||||
win.events.closed()
|
||||
expect(() => mocks.actions['show-main-window']()).not.toThrow()
|
||||
win = mocks.browserWindowInstances[1]
|
||||
expect(win).toBeTruthy()
|
||||
|
||||
vi.resetModules()
|
||||
mocks.store.set('window.state', { x: 10, y: 10, width: -1, height: 500, maximized: false })
|
||||
await loadApp()
|
||||
mocks.appEvents.ready()
|
||||
expect(mocks.browserWindowInstances[2].setBounds).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('handles open-file, second-instance, and open-url branches', async () => {
|
||||
process.argv = ['/electron', '/app/app.js', 'argv-doc.md']
|
||||
await loadApp()
|
||||
|
||||
@@ -931,4 +931,91 @@ describe('server index module', () => {
|
||||
loadSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test('handles websocket pty lifecycle, input, resize, and process cleanup', async () => {
|
||||
const Module = (await import('node:module')).default as any
|
||||
const originalLoad = Module._load
|
||||
const ptyExitHandlers: Function[] = []
|
||||
let dataHandler: Function | undefined
|
||||
const ptyProcess = {
|
||||
kill: vi.fn(),
|
||||
onData: vi.fn((handler: Function) => {
|
||||
dataHandler = handler
|
||||
}),
|
||||
onExit: vi.fn((handler: Function) => {
|
||||
ptyExitHandlers.push(handler)
|
||||
}),
|
||||
resize: vi.fn(),
|
||||
write: vi.fn(),
|
||||
}
|
||||
const pty = {
|
||||
spawn: vi.fn(() => ptyProcess),
|
||||
}
|
||||
const processOn = vi.spyOn(process, 'on')
|
||||
const processOff = vi.spyOn(process, 'off')
|
||||
const loadSpy = vi.spyOn(Module, '_load').mockImplementation((request: string, parent: any, isMain: boolean) => {
|
||||
if (request === 'http') {
|
||||
return { createServer: mocks.httpCreateServer }
|
||||
}
|
||||
if (request === 'socket.io') {
|
||||
return mocks.socketIo
|
||||
}
|
||||
if (request === 'node-pty') {
|
||||
return pty
|
||||
}
|
||||
return originalLoad.call(Module, request, parent, isMain)
|
||||
})
|
||||
mocks.disableServer = false
|
||||
|
||||
try {
|
||||
const { default: server } = await loadServer()
|
||||
server(3999)
|
||||
|
||||
const socketHandlers: Record<string, Function> = {}
|
||||
const localSocket = {
|
||||
client: { conn: { remoteAddress: '127.0.0.1' } },
|
||||
disconnect: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
handshake: { query: { cwd: '/repo', env: '{"TERM":"xterm"}' } },
|
||||
on: vi.fn((event: string, handler: Function) => {
|
||||
socketHandlers[event] = handler
|
||||
})
|
||||
}
|
||||
mocks.socketEmitter.emit('connection', localSocket)
|
||||
|
||||
expect(pty.spawn).toHaveBeenCalledWith('/bin/zsh', [], expect.objectContaining({
|
||||
cols: 80,
|
||||
cwd: '/repo',
|
||||
env: expect.objectContaining({ TERM: 'xterm' }),
|
||||
rows: 24,
|
||||
}))
|
||||
expect(processOn).toHaveBeenCalledWith('exit', expect.any(Function))
|
||||
|
||||
dataHandler?.('hello')
|
||||
expect(localSocket.emit).toHaveBeenCalledWith('output', 'hello')
|
||||
|
||||
socketHandlers.input('\u001b]51;A/tmp\n')
|
||||
expect(mocks.shellTransformCdCommand).toHaveBeenCalledWith('\u001b]51;A/tmp\n')
|
||||
expect(ptyProcess.write).toHaveBeenLastCalledWith('cd:\u001b]51;A/tmp\n')
|
||||
|
||||
socketHandlers.input('echo ok\n')
|
||||
expect(ptyProcess.write).toHaveBeenLastCalledWith('echo ok\n')
|
||||
|
||||
socketHandlers.resize([100, 40])
|
||||
expect(ptyProcess.resize).toHaveBeenCalledWith(100, 40)
|
||||
|
||||
ptyExitHandlers[0]()
|
||||
expect(localSocket.disconnect).toHaveBeenCalled()
|
||||
expect(processOff).toHaveBeenCalledWith('exit', expect.any(Function))
|
||||
|
||||
const killPromise = socketHandlers.disconnect()
|
||||
ptyExitHandlers.at(-1)!()
|
||||
await killPromise
|
||||
expect(ptyProcess.kill).toHaveBeenCalled()
|
||||
} finally {
|
||||
loadSpy.mockRestore()
|
||||
processOn.mockRestore()
|
||||
processOff.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
const mocks = vi.hoisted(() => ({
|
||||
use: vi.fn(),
|
||||
mount: vi.fn(),
|
||||
createApp: vi.fn(() => ({
|
||||
use: mocks.use,
|
||||
mount: mocks.mount,
|
||||
})),
|
||||
plugins: {
|
||||
directives: { name: 'directives' },
|
||||
toast: { name: 'toast' },
|
||||
modal: { name: 'modal' },
|
||||
contextmenu: { name: 'contextmenu' },
|
||||
quickFilter: { name: 'quickFilter' },
|
||||
fixedFloat: { name: 'fixedFloat' },
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('vue', () => ({
|
||||
createApp: mocks.createApp,
|
||||
}))
|
||||
|
||||
vi.mock('@fe/others/demo', () => ({}))
|
||||
vi.mock('@fe/Main.vue', () => ({ default: { name: 'Main' } }))
|
||||
vi.mock('@fe/directives', () => ({ default: mocks.plugins.directives }))
|
||||
vi.mock('@fe/support/ui/toast', () => ({ default: mocks.plugins.toast }))
|
||||
vi.mock('@fe/support/ui/modal', () => ({ default: mocks.plugins.modal }))
|
||||
vi.mock('@fe/support/ui/context-menu', () => ({ default: mocks.plugins.contextmenu }))
|
||||
vi.mock('@fe/support/ui/quick-filter', () => ({ default: mocks.plugins.quickFilter }))
|
||||
vi.mock('@fe/support/ui/fixed-float', () => ({ default: mocks.plugins.fixedFloat }))
|
||||
|
||||
describe('renderer entry', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
mocks.createApp.mockClear()
|
||||
mocks.use.mockClear()
|
||||
mocks.mount.mockClear()
|
||||
})
|
||||
|
||||
test('creates the Vue app, installs shared UI plugins, and mounts it', async () => {
|
||||
await import('../index')
|
||||
|
||||
expect(mocks.createApp).toHaveBeenCalledWith({ name: 'Main' })
|
||||
expect(mocks.use.mock.calls.map(([plugin]) => plugin)).toEqual([
|
||||
mocks.plugins.directives,
|
||||
mocks.plugins.toast,
|
||||
mocks.plugins.modal,
|
||||
mocks.plugins.contextmenu,
|
||||
mocks.plugins.quickFilter,
|
||||
mocks.plugins.fixedFloat,
|
||||
])
|
||||
expect(mocks.mount).toHaveBeenCalledWith('#app')
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { nextTick } from 'vue'
|
||||
import { shallowMount } from '@vue/test-utils'
|
||||
import { flushPromises, shallowMount } from '@vue/test-utils'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
actions: new Map<string, Function>(),
|
||||
@@ -12,9 +12,14 @@ const mocks = vi.hoisted(() => ({
|
||||
commentHistoryVersion: vi.fn(),
|
||||
deleteHistoryVersion: vi.fn(),
|
||||
setValue: vi.fn(),
|
||||
inputPassword: vi.fn(),
|
||||
decrypt: vi.fn(),
|
||||
modalInput: vi.fn(),
|
||||
modalConfirm: vi.fn(),
|
||||
modalAlert: vi.fn(),
|
||||
toastShow: vi.fn(),
|
||||
showPremium: vi.fn(),
|
||||
purchased: true,
|
||||
registerHook: vi.fn(),
|
||||
removeHook: vi.fn(),
|
||||
createEditor: vi.fn(),
|
||||
@@ -63,14 +68,14 @@ vi.mock('@fe/services/document', () => ({
|
||||
isSameFile: (a: any, b: any) => a?.repo === b?.repo && a?.path === b?.path,
|
||||
}))
|
||||
|
||||
vi.mock('@fe/services/base', () => ({ inputPassword: vi.fn() }))
|
||||
vi.mock('@fe/services/base', () => ({ inputPassword: mocks.inputPassword }))
|
||||
|
||||
vi.mock('@fe/services/i18n', () => ({
|
||||
useI18n: () => ({ t: (key: string, ...args: string[]) => args.length ? `${key}:${args.join(':')}` : key }),
|
||||
}))
|
||||
|
||||
vi.mock('@fe/support/ui/modal', () => ({
|
||||
useModal: () => ({ input: mocks.modalInput, confirm: mocks.modalConfirm, alert: vi.fn() }),
|
||||
useModal: () => ({ input: mocks.modalInput, confirm: mocks.modalConfirm, alert: mocks.modalAlert }),
|
||||
}))
|
||||
|
||||
vi.mock('@fe/support/ui/toast', () => ({
|
||||
@@ -81,11 +86,11 @@ vi.mock('@fe/utils', () => ({
|
||||
getLogger: () => ({ debug: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('@fe/utils/crypto', () => ({ decrypt: vi.fn() }))
|
||||
vi.mock('@fe/utils/crypto', () => ({ decrypt: mocks.decrypt }))
|
||||
|
||||
vi.mock('@fe/others/premium', () => ({
|
||||
getPurchased: () => true,
|
||||
showPremium: vi.fn(),
|
||||
getPurchased: () => mocks.purchased,
|
||||
showPremium: mocks.showPremium,
|
||||
}))
|
||||
|
||||
vi.mock('@fe/support/store', () => ({
|
||||
@@ -133,9 +138,16 @@ beforeEach(() => {
|
||||
mocks.commentHistoryVersion.mockResolvedValue(undefined)
|
||||
mocks.deleteHistoryVersion.mockResolvedValue(undefined)
|
||||
mocks.setValue.mockReset()
|
||||
mocks.inputPassword.mockReset()
|
||||
mocks.inputPassword.mockResolvedValue('password')
|
||||
mocks.decrypt.mockReset()
|
||||
mocks.decrypt.mockReturnValue({ content: 'decrypted history' })
|
||||
mocks.modalInput.mockReset()
|
||||
mocks.modalConfirm.mockReset()
|
||||
mocks.modalAlert.mockReset()
|
||||
mocks.toastShow.mockReset()
|
||||
mocks.showPremium.mockReset()
|
||||
mocks.purchased = true
|
||||
mocks.registerHook.mockReset()
|
||||
mocks.removeHook.mockReset()
|
||||
mocks.createEditor.mockReturnValue(editorMock())
|
||||
@@ -211,4 +223,59 @@ describe('DocHistory', () => {
|
||||
await (wrapper.vm as any).clearVersions()
|
||||
expect(mocks.deleteHistoryVersion).toHaveBeenCalledWith(mocks.storeState.currentFile, '--all--')
|
||||
})
|
||||
|
||||
test('handles premium gating, encrypted content, alerts, and cleanup hooks', async () => {
|
||||
mocks.fetchHistoryList.mockResolvedValue({
|
||||
size: 4096,
|
||||
list: [
|
||||
{ name: '2024-01-04 03-04-05.encrypted.md', comment: '' },
|
||||
],
|
||||
})
|
||||
mocks.fetchHistoryContent.mockResolvedValue('cipher text')
|
||||
|
||||
const wrapper = shallowMount(DocHistory, {
|
||||
global: {
|
||||
mocks: { $t: (key: string) => key },
|
||||
stubs: {
|
||||
XMask: { props: ['show'], template: '<div v-if="show" class="mask-stub"><slot /></div>' },
|
||||
GroupTabs: {
|
||||
props: ['modelValue', 'tabs'],
|
||||
emits: ['update:modelValue'],
|
||||
template: '<div class="group-tabs-stub"><button v-for="tab in tabs" :key="tab.value" @click="$emit(\'update:modelValue\', tab.value)">{{tab.label}}</button></div>',
|
||||
},
|
||||
SvgIcon: { emits: ['click'], template: '<i class="svg-icon" @click="$emit(\'click\', $event)" />' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
mocks.storeState.currentContent = 'x'.repeat(102401)
|
||||
mocks.actions.get('doc.show-history')?.()
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
expect(mocks.modalAlert).toHaveBeenCalledWith(expect.objectContaining({
|
||||
title: 'doc-history.content-too-long-alert.title',
|
||||
}))
|
||||
expect(mocks.inputPassword).toHaveBeenCalledWith('document.password-open', 'History Version', true)
|
||||
expect(mocks.decrypt).toHaveBeenCalledWith('cipher text', 'password')
|
||||
expect((wrapper.vm as any).content).toBe('decrypted history')
|
||||
|
||||
mocks.purchased = false
|
||||
await (wrapper.vm as any).markVersion((wrapper.vm as any).versions[0])
|
||||
expect(mocks.toastShow).toHaveBeenCalledWith('warning', 'premium.need-purchase:Mark')
|
||||
expect(mocks.showPremium).toHaveBeenCalled()
|
||||
|
||||
mocks.decrypt.mockImplementationOnce(() => {
|
||||
throw new Error('bad password')
|
||||
})
|
||||
;(wrapper.vm as any).currentVersion = undefined
|
||||
await nextTick()
|
||||
;(wrapper.vm as any).choose((wrapper.vm as any).versions[0])
|
||||
await flushPromises()
|
||||
expect((wrapper.vm as any).content).toBe('document.wrong-password')
|
||||
|
||||
wrapper.unmount()
|
||||
expect(mocks.removeHook).toHaveBeenCalledWith('GLOBAL_RESIZE', expect.any(Function))
|
||||
expect(mocks.actions.has('doc.show-history')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,8 @@ const mocks = vi.hoisted(() => ({
|
||||
toggleExportPanel: vi.fn((val: boolean) => { mocks.storeState.showExport = val }),
|
||||
downloadContent: vi.fn(),
|
||||
sleep: vi.fn(() => Promise.resolve()),
|
||||
isElectron: false,
|
||||
flagDemo: false,
|
||||
}))
|
||||
|
||||
vi.mock('@share/misc', () => ({
|
||||
@@ -24,11 +26,11 @@ vi.mock('@fe/support/store', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@fe/support/env', () => ({
|
||||
isElectron: false,
|
||||
get isElectron () { return mocks.isElectron },
|
||||
}))
|
||||
|
||||
vi.mock('@fe/support/args', () => ({
|
||||
FLAG_DEMO: false,
|
||||
get FLAG_DEMO () { return mocks.flagDemo },
|
||||
}))
|
||||
|
||||
vi.mock('@fe/support/ui/toast', () => ({
|
||||
@@ -84,9 +86,12 @@ beforeEach(() => {
|
||||
mocks.printCurrentDocument.mockReset()
|
||||
mocks.printCurrentDocument.mockResolvedValue(undefined)
|
||||
mocks.printCurrentDocumentToPDF.mockReset()
|
||||
mocks.printCurrentDocumentToPDF.mockResolvedValue(new Blob(['pdf']))
|
||||
mocks.toggleExportPanel.mockClear()
|
||||
mocks.downloadContent.mockReset()
|
||||
mocks.sleep.mockClear()
|
||||
mocks.isElectron = false
|
||||
mocks.flagDemo = false
|
||||
})
|
||||
|
||||
describe('ExportPanel', () => {
|
||||
@@ -136,4 +141,46 @@ describe('ExportPanel', () => {
|
||||
await nextTick()
|
||||
expect(convert.localHtmlOptions.includeStyle).toBe(false)
|
||||
})
|
||||
|
||||
test('prints pdf through electron with clamped scale and downloads buffer', async () => {
|
||||
mocks.isElectron = true
|
||||
const wrapper = mountExportPanel()
|
||||
const convert = (wrapper.vm as any).convert
|
||||
convert.pdfOptions.scaleFactor = 500
|
||||
convert.pdfOptions.landscape = 'true'
|
||||
convert.pdfOptions.pageSize = 'Letter'
|
||||
convert.pdfOptions.printBackground = false
|
||||
|
||||
await (wrapper.vm as any).ok()
|
||||
await flushPromises()
|
||||
|
||||
expect(mocks.printCurrentDocumentToPDF).toHaveBeenCalledWith({
|
||||
pageSize: 'Letter',
|
||||
printBackground: false,
|
||||
generateDocumentOutline: true,
|
||||
landscape: true,
|
||||
scale: 2,
|
||||
})
|
||||
expect(mocks.downloadContent).toHaveBeenCalledWith('note.pdf', expect.any(Blob), 'application/pdf')
|
||||
})
|
||||
|
||||
test('handles demo, missing content, and conversion errors without downloading', async () => {
|
||||
const wrapper = mountExportPanel()
|
||||
|
||||
mocks.storeState.currentFile.content = ''
|
||||
await (wrapper.vm as any).ok()
|
||||
expect(mocks.toggleExportPanel).not.toHaveBeenCalled()
|
||||
|
||||
mocks.storeState.currentFile.content = '# Note'
|
||||
mocks.flagDemo = true
|
||||
;(wrapper.vm as any).convert.toType = 'html'
|
||||
await (wrapper.vm as any).ok()
|
||||
expect(mocks.toastShow).toHaveBeenCalledWith('warning', 'demo-tips')
|
||||
expect(mocks.convertCurrentDocument).not.toHaveBeenCalled()
|
||||
|
||||
mocks.flagDemo = false
|
||||
mocks.convertCurrentDocument.mockRejectedValueOnce(new Error('convert failed'))
|
||||
await expect((wrapper.vm as any).ok()).rejects.toThrow('convert failed')
|
||||
expect(mocks.toastShow).toHaveBeenCalledWith('warning', 'convert failed')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,6 +22,7 @@ const mocks = vi.hoisted(() => ({
|
||||
fileTabsTapActionBtns: vi.fn(),
|
||||
fileTabsRemoveActionBtnTapper: vi.fn(),
|
||||
fileTabsRefreshActionBtns: vi.fn(),
|
||||
lastIframe: undefined as any,
|
||||
xtermInit: vi.fn(),
|
||||
xtermInput: vi.fn(),
|
||||
downloadContent: vi.fn(),
|
||||
@@ -86,6 +87,7 @@ vi.mock('@fe/support/embed', () => ({
|
||||
}),
|
||||
},
|
||||
} as any
|
||||
mocks.lastIframe = iframe
|
||||
this.onLoad(iframe)
|
||||
},
|
||||
template: '<iframe class="iframe-stub" />',
|
||||
@@ -203,6 +205,7 @@ beforeEach(() => {
|
||||
mocks.fileTabsTapActionBtns.mockReset()
|
||||
mocks.fileTabsRemoveActionBtnTapper.mockReset()
|
||||
mocks.fileTabsRefreshActionBtns.mockReset()
|
||||
mocks.lastIframe = undefined
|
||||
mocks.xtermInit.mockReset()
|
||||
mocks.xtermInput.mockReset()
|
||||
mocks.downloadContent.mockReset()
|
||||
@@ -290,6 +293,44 @@ describe('DefaultPreviewer', () => {
|
||||
expect(mocks.hooks.has('GLOBAL_RESIZE')).toBe(false)
|
||||
expect(mocks.fileTabsRemoveActionBtnTapper).toHaveBeenCalledWith(expect.any(Function))
|
||||
})
|
||||
|
||||
test('handles iframe scroll, beforeunload recovery, resize CSS variable, and empty tab action state', async () => {
|
||||
vi.useFakeTimers()
|
||||
const wrapper = mount(DefaultPreviewer, {
|
||||
global: {
|
||||
mocks: { $t: (key: string) => key },
|
||||
stubs: { teleport: true },
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
mocks.lastIframe.onscroll({ target: { documentElement: { scrollTop: 42 } } })
|
||||
await nextTick()
|
||||
expect(mocks.triggerHook).toHaveBeenCalledWith('VIEW_SCROLL', expect.any(Object))
|
||||
expect(wrapper.find('.scroll-to-top').classes()).not.toContain('hide')
|
||||
|
||||
Object.defineProperty(wrapper.find('.default-previewer').element, 'clientHeight', {
|
||||
value: 640,
|
||||
configurable: true,
|
||||
})
|
||||
mocks.hooks.get('GLOBAL_RESIZE')?.()
|
||||
await nextTick()
|
||||
expect(mocks.lastIframe.contentDocument.documentElement.style.getPropertyValue('--previewer-height')).toBe('640px')
|
||||
|
||||
const tapper = mocks.fileTabsTapActionBtns.mock.calls[0][0]
|
||||
mocks.storeState.currentFile = undefined
|
||||
const btns: any[] = []
|
||||
tapper(btns)
|
||||
expect(btns).toEqual([])
|
||||
|
||||
mocks.lastIframe.onbeforeunload({})
|
||||
await nextTick()
|
||||
expect(wrapper.find('.iframe-stub').exists()).toBe(false)
|
||||
vi.advanceTimersByTime(3000)
|
||||
expect(mocks.toastShow).toHaveBeenCalledWith('warning', 'IFrame Error!')
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ExportPanel extra branches', () => {
|
||||
|
||||
@@ -165,6 +165,45 @@ describe('renderer directives', () => {
|
||||
expect(textarea.value).toBe('second')
|
||||
})
|
||||
|
||||
test('up-down-history ignores guarded keys and trims stored history length', () => {
|
||||
const directive = installOne(upDownHistory, 'up-down-history')
|
||||
const textarea = document.createElement('textarea')
|
||||
const inputListener = vi.fn()
|
||||
vi.spyOn(window, 'getComputedStyle').mockReturnValue({
|
||||
lineHeight: '20px',
|
||||
paddingTop: '0px',
|
||||
paddingBottom: '0px',
|
||||
} as CSSStyleDeclaration)
|
||||
Object.defineProperty(textarea, 'clientHeight', { value: 40, configurable: true })
|
||||
textarea.addEventListener('input', inputListener)
|
||||
directive.mounted(textarea, { value: { maxLength: 1 } })
|
||||
|
||||
textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }))
|
||||
textarea.value = 'ignored'
|
||||
textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', isComposing: true, bubbles: true }))
|
||||
textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', ctrlKey: true, bubbles: true }))
|
||||
|
||||
textarea.value = 'first'
|
||||
textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
|
||||
textarea.value = 'second'
|
||||
textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
|
||||
|
||||
textarea.value = 'middle'
|
||||
textarea.setSelectionRange(1, 1)
|
||||
textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true, cancelable: true }))
|
||||
expect(textarea.value).toBe('middle')
|
||||
|
||||
Object.defineProperty(textarea, 'clientHeight', { value: 10, configurable: true })
|
||||
textarea.setSelectionRange(0, 0)
|
||||
textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true, cancelable: true }))
|
||||
expect(textarea.value).toBe('second')
|
||||
|
||||
textarea.setSelectionRange(textarea.value.length, textarea.value.length)
|
||||
textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }))
|
||||
expect(textarea.value).toBe('')
|
||||
expect(inputListener).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('fixed-float manages focus, blur, self-click, and escape close reasons', () => {
|
||||
const directive = installOne(fixedFloat, 'fixed-float')
|
||||
const el = document.createElement('div')
|
||||
@@ -193,6 +232,25 @@ describe('renderer directives', () => {
|
||||
expect(onClose).toHaveBeenLastCalledWith('esc')
|
||||
})
|
||||
|
||||
test('fixed-float ignores invalid or disabled bindings and honors autofocus opt-out', () => {
|
||||
const directive = installOne(fixedFloat, 'fixed-float')
|
||||
const invalid = document.createElement('div')
|
||||
const disabled = document.createElement('div')
|
||||
const noFocus = document.createElement('div')
|
||||
vi.spyOn(invalid, 'focus')
|
||||
vi.spyOn(disabled, 'focus')
|
||||
vi.spyOn(noFocus, 'focus')
|
||||
|
||||
directive.mounted(invalid, { value: undefined })
|
||||
directive.mounted(disabled, { value: { disable: true, onClose: vi.fn() } })
|
||||
directive.mounted(noFocus, { value: { disableAutoFocus: true, onClose: vi.fn() } })
|
||||
|
||||
expect(invalid.focus).not.toHaveBeenCalled()
|
||||
expect(disabled.focus).not.toHaveBeenCalled()
|
||||
expect(noFocus.tabIndex).toBe(0)
|
||||
expect(noFocus.focus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('auto-z-index bumps layers and unregisters escape handler', () => {
|
||||
const directive = installOne(autoZIndex, 'auto-z-index')
|
||||
const lower = document.createElement('div')
|
||||
|
||||
@@ -281,4 +281,51 @@ describe('editor-paste plugin', () => {
|
||||
expect(mocks.insert).not.toHaveBeenCalled()
|
||||
expect(mocks.refreshTree).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
test('status menu actions paste clipboard image as base64 and html as markdown', async () => {
|
||||
const ctx = createCtx()
|
||||
editorPaste.register(ctx)
|
||||
await ctx.editor.whenEditorReady.mock.results[0].value
|
||||
mocks.fileToBase64URL.mockResolvedValue('data:image/png;base64,bWVudQ==')
|
||||
|
||||
ctx.base.readFromClipboard.mockImplementationOnce((fn: Function) => {
|
||||
fn('image/png', async () => new Blob(['menu image'], { type: 'image/png' }))
|
||||
})
|
||||
ctx._statusMenus['status-bar-insert'].list[0].onClick()
|
||||
|
||||
await vi.waitFor(() => expect(mocks.insert).toHaveBeenCalledWith('\n'))
|
||||
await vi.waitFor(() => expect(mocks.editor.focus).toHaveBeenCalled())
|
||||
|
||||
mocks.insert.mockClear()
|
||||
mocks.editor.focus.mockClear()
|
||||
ctx.base.readFromClipboard.mockImplementationOnce((fn: Function) => {
|
||||
fn('text/html', async () => new Blob(['<h1>Clip</h1><p>Body</p>'], { type: 'text/html' }))
|
||||
})
|
||||
ctx._statusMenus['status-bar-insert'].list[1].onClick()
|
||||
|
||||
await vi.waitFor(() => expect(mocks.insert).toHaveBeenCalledWith(expect.stringContaining('# Clip')))
|
||||
expect(mocks.insert).toHaveBeenCalledWith(expect.stringContaining('Body'))
|
||||
expect(mocks.editor.focus).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('paste listener exits when editor is unfocused or clipboard data is missing', () => {
|
||||
const ctx = createCtx()
|
||||
editorPaste.register(ctx)
|
||||
mocks.editor.hasTextFocus.mockReturnValueOnce(false)
|
||||
|
||||
pasteListener({
|
||||
clipboardData: { items: [{ type: 'image/png', getAsFile: vi.fn() }] },
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
})
|
||||
pasteListener({
|
||||
clipboardData: null,
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
})
|
||||
|
||||
expect(mocks.insert).not.toHaveBeenCalled()
|
||||
expect(mocks.upload).not.toHaveBeenCalled()
|
||||
expect(mocks.editor.getSelections).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -140,4 +140,47 @@ describe('history-stack plugin', () => {
|
||||
expect(menus['status-bar-navigation'].list.map(item => item.disabled)).toEqual([true, true])
|
||||
expect(schema.navigation.items.map(item => item.disabled)).toEqual([true, true])
|
||||
})
|
||||
|
||||
test('records skipped same-document positions and ignores history-stack sourced switches', async () => {
|
||||
const ctx = createCtx()
|
||||
historyStack.register(ctx)
|
||||
const positionA = { viewScrollTop: 30, editorScrollTop: 40 }
|
||||
const positionB = { viewScrollTop: 50, editorScrollTop: 60 }
|
||||
|
||||
ctx.hooks.get('DOC_SWITCH_SKIPPED')[0]({ doc: docA, opts: { position: positionA } })
|
||||
ctx.hooks.get('DOC_SWITCH_SKIPPED')[0]({ doc: docA, opts: { source: 'history-stack', position: positionB } })
|
||||
ctx.hooks.get('DOC_SWITCH_SKIPPED')[0]({ doc: docB, opts: { position: positionB } })
|
||||
ctx.actions.get('plugin.document-history-stack.back')()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(ctx.doc.switchDoc).toHaveBeenCalledWith(docA, { source: 'history-stack', position: positionA })
|
||||
|
||||
ctx.doc.switchDoc.mockClear()
|
||||
ctx.hooks.get('DOC_SWITCHED')[0]({ doc: docB, opts: { source: 'history-stack' } })
|
||||
ctx.actions.get('plugin.document-history-stack.forward')()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(ctx.doc.switchDoc).toHaveBeenCalledWith(docB, { source: 'history-stack', position: positionB })
|
||||
})
|
||||
|
||||
test('removes deleted, moved, and failed documents from the navigation stack', () => {
|
||||
const ctx = createCtx()
|
||||
historyStack.register(ctx)
|
||||
|
||||
ctx.hooks.get('DOC_SWITCHED')[0]({ doc: docA, opts: {} })
|
||||
ctx.hooks.get('DOC_SWITCHED')[0]({ doc: docB, opts: {} })
|
||||
ctx.hooks.get('DOC_DELETED')[0]({ doc: docA })
|
||||
|
||||
const menusAfterDelete = { 'status-bar-navigation': { list: [] as any[] } }
|
||||
ctx.hooks.get('STARTUP')[0]()
|
||||
ctx.statusBar.tapMenus.mock.calls.at(-1)[0](menusAfterDelete)
|
||||
expect(menusAfterDelete['status-bar-navigation'].list.map(item => item.disabled)).toEqual([true, true])
|
||||
|
||||
ctx.hooks.get('DOC_MOVED')[0]({ oldDoc: docB })
|
||||
ctx.hooks.get('DOC_SWITCH_FAILED')[0]({ doc: docA })
|
||||
|
||||
const schema = { navigation: { items: [] as any[] } }
|
||||
ctx.workbench.ControlCenter.tapSchema.mock.calls[0][0](schema)
|
||||
expect(schema.navigation.items.map(item => item.disabled)).toEqual([true, true])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,7 +16,11 @@ vi.mock('@fe/core/hook', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@fe/support/embed', () => ({
|
||||
IFrame: { name: 'IFrame' },
|
||||
IFrame: {
|
||||
name: 'IFrame',
|
||||
props: ['html', 'debounce', 'globalStyle', 'iframeProps', 'onLoad'],
|
||||
template: '<iframe />',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@fe/utils', () => ({
|
||||
@@ -24,6 +28,7 @@ vi.mock('@fe/utils', () => ({
|
||||
}))
|
||||
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import markdownApplet from '../markdown-applet'
|
||||
|
||||
function createCtx (md = new MarkdownIt()) {
|
||||
@@ -106,6 +111,56 @@ describe('markdown-applet plugin', () => {
|
||||
expect(vnode.props.html).toContain('<input>')
|
||||
})
|
||||
|
||||
test('applet component toggles with render hooks and initializes the iframe', async () => {
|
||||
const ctx = createCtx()
|
||||
markdownApplet.register(ctx)
|
||||
const fence = ctx._md.renderer.rules.fence
|
||||
const vnode = fence([
|
||||
{
|
||||
info: 'html',
|
||||
content: '<!-- --applet-- -->\n<script>window.loaded = true</script>',
|
||||
meta: { attrs: { sandbox: 'allow-scripts' } },
|
||||
},
|
||||
], 0, {}, { safeMode: false }, {}) as any
|
||||
const wrapper = mount(vnode.type, { props: vnode.props })
|
||||
|
||||
expect(wrapper.findComponent({ name: 'IFrame' }).exists()).toBe(false)
|
||||
|
||||
mocks.hooks.get('VIEW_RENDERED')![0]()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const iframe = document.createElement('iframe')
|
||||
const contentWindow = { init: vi.fn(), resize: vi.fn() }
|
||||
Object.defineProperty(iframe, 'contentWindow', {
|
||||
value: contentWindow,
|
||||
configurable: true,
|
||||
})
|
||||
const frame = wrapper.findComponent({ name: 'IFrame' })
|
||||
expect(frame.props()).toMatchObject({
|
||||
debounce: 1000,
|
||||
globalStyle: true,
|
||||
iframeProps: {
|
||||
class: 'applet-iframe',
|
||||
height: '20px',
|
||||
sandbox: 'allow-scripts',
|
||||
},
|
||||
})
|
||||
|
||||
frame.props('onLoad')(iframe)
|
||||
expect(contentWindow).toMatchObject({ appletId: 'applet-hash123-0' })
|
||||
expect(contentWindow.init).toHaveBeenCalled()
|
||||
expect(contentWindow.resize).toHaveBeenCalled()
|
||||
|
||||
mocks.hooks.get('DOC_SWITCHED')![0]()
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.findComponent({ name: 'IFrame' }).exists()).toBe(false)
|
||||
|
||||
wrapper.unmount()
|
||||
expect(mocks.removeHook).toHaveBeenCalledWith('DOC_SWITCHED', expect.any(Function))
|
||||
expect(mocks.removeHook).toHaveBeenCalledWith('VIEW_BEFORE_REFRESH', expect.any(Function))
|
||||
expect(mocks.removeHook).toHaveBeenCalledWith('VIEW_RENDERED', expect.any(Function))
|
||||
})
|
||||
|
||||
test('falls back to the default fence renderer for non applet, non-html, or safe-mode fences', () => {
|
||||
const ctx = createCtx()
|
||||
markdownApplet.register(ctx)
|
||||
|
||||
@@ -55,6 +55,15 @@ describe('markdown-html plugin', () => {
|
||||
expect(spanOpen?.attrs).toEqual([['data-id', 'ok']])
|
||||
})
|
||||
|
||||
test('rejects script tags, incomplete comments, and unsupported safe-mode tags', () => {
|
||||
const md = new MarkdownIt({ html: true })
|
||||
markdownHtml.register(createCtx(md))
|
||||
|
||||
expect(md.parse('<script>alert(1)</script>', {}).some(token => token.tag === 'script')).toBe(false)
|
||||
expect(md.parse('<!-- missing close', {}).some(token => token.type === 'comment')).toBe(false)
|
||||
expect(md.parse('<custom>x</custom>', { safeMode: true }).some(token => token.type === 'html_open')).toBe(false)
|
||||
})
|
||||
|
||||
test('hides complete multi-line html comments', () => {
|
||||
const md = new MarkdownIt({ html: true })
|
||||
markdownHtml.register(createCtx(md))
|
||||
@@ -95,4 +104,13 @@ describe('markdown-html plugin', () => {
|
||||
const indented = md.parse(' <div>code</div>', {})
|
||||
expect(indented.some(token => token.type === 'html_open')).toBe(false)
|
||||
})
|
||||
|
||||
test('rejects incomplete multi-line blocks and blocks with interrupted indentation', () => {
|
||||
const md = new MarkdownIt({ html: true })
|
||||
markdownHtml.register(createCtx(md))
|
||||
|
||||
expect(md.parse('<div\nclass="note"', {}).some(token => token.type === 'html_end')).toBe(false)
|
||||
expect(md.parse('<div>\n\n</div>', {}).some(token => token.type === 'html_end')).toBe(true)
|
||||
expect(md.parse('<div\n class="note"\n</div>', {}).some(token => token.type === 'html_end')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -34,6 +34,15 @@ describe('markdown-imsize plugin', () => {
|
||||
expect(html).toContain('<img src="tall.png" alt="tall" height="240">')
|
||||
})
|
||||
|
||||
test('keeps title attributes and trailing whitespace around image sizes', () => {
|
||||
const md = new MarkdownIt()
|
||||
markdownImsize.register(createCtx(md))
|
||||
|
||||
const html = md.render('')
|
||||
|
||||
expect(html).toContain('<img src="photo.png" alt="alt" title="Caption" width="120" height="80">')
|
||||
})
|
||||
|
||||
test('falls back to normal image parsing when size syntax is invalid', () => {
|
||||
const md = new MarkdownIt()
|
||||
markdownImsize.register(createCtx(md))
|
||||
@@ -45,6 +54,16 @@ describe('markdown-imsize plugin', () => {
|
||||
expect(html).not.toContain('height=')
|
||||
})
|
||||
|
||||
test('rejects malformed links before mutating parser position', () => {
|
||||
const md = new MarkdownIt()
|
||||
markdownImsize.register(createCtx(md))
|
||||
|
||||
expect(md.render('')).toContain('<p></p>')
|
||||
expect(md.render(' =120x80)')).toContain('<p> =120x80)</p>')
|
||||
expect(md.render('')).toContain('<img src="" alt="alt">')
|
||||
expect(md.render('![alt')).toContain('<p>![alt</p>')
|
||||
})
|
||||
|
||||
test('keeps reference image behavior while installing the custom rule', () => {
|
||||
const md = new MarkdownIt()
|
||||
markdownImsize.register(createCtx(md))
|
||||
@@ -53,4 +72,13 @@ describe('markdown-imsize plugin', () => {
|
||||
|
||||
expect(html).toContain('<img src="/img.png" alt="Alt" title="Title">')
|
||||
})
|
||||
|
||||
test('supports collapsed and shortcut reference image labels', () => {
|
||||
const md = new MarkdownIt()
|
||||
markdownImsize.register(createCtx(md))
|
||||
|
||||
expect(md.render('![Alt][]\n\n[Alt]: /collapsed.png')).toContain('<img src="/collapsed.png" alt="Alt">')
|
||||
expect(md.render('![Alt]\n\n[Alt]: /shortcut.png')).toContain('<img src="/shortcut.png" alt="Alt">')
|
||||
expect(md.render('![Missing][ref]')).toContain('<p>![Missing][ref]</p>')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -169,6 +169,73 @@ describe('markdown-macro plugin', () => {
|
||||
expect(env.source).toBe('ADA')
|
||||
})
|
||||
|
||||
test('skips macro transform when disabled or in safe mode', () => {
|
||||
const md = new MarkdownIt()
|
||||
const ctx = createCtx(md)
|
||||
markdownMacro.register(ctx)
|
||||
const disabledEnv: any = { attributes: { enableMacro: false }, source: 'before' }
|
||||
const safeEnv: any = { attributes: { enableMacro: true }, safeMode: true, source: 'before' }
|
||||
|
||||
md.parse('[= 1 + 1 =]', disabledEnv)
|
||||
md.parse('[= 1 + 1 =]', safeEnv)
|
||||
|
||||
expect(disabledEnv.source).toBe('before')
|
||||
expect(safeEnv.source).toBe('before')
|
||||
expect(ctx.renderer.getRenderCache).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('shares exported vars and sequence counters across synchronous macros', () => {
|
||||
const md = new MarkdownIt()
|
||||
const ctx = createCtx(md)
|
||||
markdownMacro.register(ctx)
|
||||
const env: any = {
|
||||
attributes: { enableMacro: true },
|
||||
file: { type: 'file', repo: 'main', name: 'note.md', path: '/repo/note.md' },
|
||||
}
|
||||
|
||||
md.parse('[= $seq("n") =],[= $seq("n") =],[= $export("name", "Ada") =][= name =]', env)
|
||||
|
||||
expect(env.source).toBe('n1,n2,Ada')
|
||||
})
|
||||
|
||||
test('caches include errors for non-markdown paths without reading files', async () => {
|
||||
const md = new MarkdownIt()
|
||||
const ctx = createCtx(md)
|
||||
markdownMacro.register(ctx)
|
||||
const env: any = {
|
||||
attributes: { enableMacro: true },
|
||||
file: { type: 'file', repo: 'main', name: 'note.md', path: '/repo/note.md' },
|
||||
}
|
||||
|
||||
md.parse('[= await $include("child.txt") =]', env)
|
||||
expect(env.source).toBe('running...')
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const retryEnv: any = {
|
||||
attributes: { enableMacro: true },
|
||||
file: { type: 'file', repo: 'main', name: 'note.md', path: '/repo/note.md' },
|
||||
}
|
||||
md.parse('[= await $include("child.txt") =]', retryEnv)
|
||||
expect(retryEnv.source).toBe('Error: $include path is not a plain file')
|
||||
})
|
||||
expect(mocks.readFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('reports after-macro errors through toast and keeps transformed body', () => {
|
||||
const md = new MarkdownIt()
|
||||
const ctx = createCtx(md)
|
||||
markdownMacro.register(ctx)
|
||||
const env: any = {
|
||||
attributes: { enableMacro: true },
|
||||
file: { type: 'file', repo: 'main', name: 'note.md', path: '/repo/note.md' },
|
||||
}
|
||||
|
||||
md.parse('[= $afterMacro(() => { throw new Error("bad hook") }) =]body', env)
|
||||
|
||||
expect(env.source).toBe('body')
|
||||
expect(mocks.toastShow).toHaveBeenCalledWith('warning', '[$afterMacro]: Error: bad hook')
|
||||
})
|
||||
|
||||
test('resolves included markdown with front matter vars through the render cache', async () => {
|
||||
const md = new MarkdownIt()
|
||||
const ctx = createCtx(md)
|
||||
|
||||
@@ -97,6 +97,21 @@ describe('markdown-toc plugin', () => {
|
||||
log.mockRestore()
|
||||
})
|
||||
|
||||
test('handles inline toc markers and empty heading sets', () => {
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => undefined)
|
||||
const md = new MarkdownIt()
|
||||
markdownToc.register(createCtx(md))
|
||||
|
||||
expect(md.render('plain [toc] text')).toContain('[object Object]')
|
||||
|
||||
const tokens = md.parse('[toc]\n\nparagraph only', {})
|
||||
const tocToken = tokens.flatMap(token => token.children || []).find(token => token.type === 'toc_body')!
|
||||
const vnode = md.renderer.rules.toc_body!([tocToken] as any, 0, md.options, {}, md.renderer as any) as any
|
||||
|
||||
expect(vnode.props.innerHTML).toContain('<ul></ul>')
|
||||
log.mockRestore()
|
||||
})
|
||||
|
||||
test('renders full toc chunks and preserves existing heading attrs', () => {
|
||||
const md = new MarkdownIt()
|
||||
markdownToc.register(createCtx(md))
|
||||
|
||||
@@ -556,6 +556,27 @@ describe('document service pure helpers', () => {
|
||||
await expect(createDir({ repo: 'main', content: undefined }, { ...fileDoc, type: 'dir', path: '/folder' })).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test('lets the create-file panel update doc type before building the path', async () => {
|
||||
const customType = {
|
||||
id: 'vector',
|
||||
displayName: 'Vector',
|
||||
extension: ['.svg'],
|
||||
plain: true,
|
||||
buildNewContent: (filename: string) => `<svg data-name="${filename}" />`,
|
||||
}
|
||||
modalMocks.input.mockImplementationOnce((opts: any) => {
|
||||
const vnode = opts.component()
|
||||
expect(vnode.props.currentPath).toBe('/folder')
|
||||
vnode.props.onUpdateDocType(customType)
|
||||
return 'icon'
|
||||
})
|
||||
|
||||
const doc = await createDoc({ repo: 'main' }, { ...fileDoc, type: 'dir', path: '/folder' })
|
||||
|
||||
expect(doc).toMatchObject({ path: '/folder/icon.svg', name: 'icon.svg' })
|
||||
expect(apiMocks.writeFile).toHaveBeenLastCalledWith(doc, '<svg data-name="icon.svg" />', false)
|
||||
})
|
||||
|
||||
test('handles create-doc builder errors, inline base64 payloads, and encrypted creation', async () => {
|
||||
const category: DocCategory = {
|
||||
category: 'custom-docs',
|
||||
@@ -601,6 +622,34 @@ describe('document service pure helpers', () => {
|
||||
expect(apiMocks.writeFile).toHaveBeenLastCalledWith(encrypted, 'encrypted:secret', false)
|
||||
})
|
||||
|
||||
test('runs custom save-confirm actions and tolerates force switching after save errors', async () => {
|
||||
const doc = { ...fileDoc, status: 'loaded' as const }
|
||||
storeMock.state.currentFile = doc
|
||||
storeMock.state.currentContent = 'changed'
|
||||
storeMock.getters.isSaved.value = false
|
||||
settingMocks.values.set('auto-save', 0)
|
||||
modalMocks.confirm.mockImplementationOnce((opts: any) => {
|
||||
opts.action.children[0].props.onClick()
|
||||
return new Promise(() => undefined)
|
||||
})
|
||||
|
||||
await ensureCurrentFileSaved()
|
||||
|
||||
expect(apiMocks.writeFile).toHaveBeenCalledWith(doc, 'changed')
|
||||
|
||||
storeMock.state.currentFile = { ...fileDoc, status: 'loaded' as const }
|
||||
storeMock.state.currentContent = 'still changed'
|
||||
storeMock.getters.isSaved.value = false
|
||||
modalMocks.confirm.mockResolvedValueOnce(false)
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
|
||||
await switchDoc({ ...fileDoc, path: '/dir/forced.md', name: 'forced.md' }, { force: true })
|
||||
|
||||
expect(consoleError).toHaveBeenCalledWith(expect.any(Error))
|
||||
expect(storeMock.state.currentFile).toMatchObject({ path: '/dir/forced.md', status: 'loaded' })
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
|
||||
test('covers guarded document operations and error branches', async () => {
|
||||
await expect(duplicateDoc({ ...fileDoc, type: 'dir' as any })).rejects.toThrow('Invalid document type')
|
||||
modalMocks.input.mockResolvedValueOnce('')
|
||||
|
||||
@@ -16,6 +16,9 @@ vi.mock('@fe/utils/storage', () => storageMocks)
|
||||
|
||||
vi.mock('@fe/core/hook', () => hookMocks)
|
||||
|
||||
import { defineComponent, h, nextTick } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
describe('renderer i18n service', () => {
|
||||
beforeEach(() => {
|
||||
storageMocks.values.clear()
|
||||
@@ -107,4 +110,27 @@ describe('renderer i18n service', () => {
|
||||
|
||||
expect(() => i18n.useI18n()).toThrow('VM Error')
|
||||
})
|
||||
|
||||
test('installs component translator and unregisters language hook on unmount', async () => {
|
||||
const i18n = await import('@fe/services/i18n')
|
||||
i18n.setLanguage('en')
|
||||
const component = defineComponent({
|
||||
setup () {
|
||||
const { $t } = i18n.useI18n()
|
||||
return () => h('span', $t.value('app.quit' as any))
|
||||
},
|
||||
})
|
||||
|
||||
const wrapper = mount(component)
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.text()).toBe(i18n.t('app.quit' as any))
|
||||
expect((wrapper.vm as any).$t('app.quit')).toBe(i18n.t('app.quit' as any))
|
||||
expect(hookMocks.registerHook).toHaveBeenCalledWith('I18N_CHANGE_LANGUAGE', expect.any(Function))
|
||||
|
||||
hookMocks.registerHook.mock.calls[0][1]()
|
||||
wrapper.unmount()
|
||||
|
||||
expect(hookMocks.removeHook).toHaveBeenCalledWith('I18N_CHANGE_LANGUAGE', expect.any(Function))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({
|
||||
fetchTree: vi.fn(),
|
||||
toastShow: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
watchCallback: undefined as any,
|
||||
}))
|
||||
|
||||
vi.mock('vue', async importOriginal => ({
|
||||
@@ -44,7 +45,9 @@ vi.mock('@fe/core/ioc', () => ({
|
||||
vi.mock('@fe/support/store', () => ({
|
||||
default: {
|
||||
state: mocks.state,
|
||||
watch: vi.fn(),
|
||||
watch: vi.fn((_getter: Function, callback: Function) => {
|
||||
mocks.watchCallback = callback
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -142,3 +145,13 @@ test('does not fetch without a current repo and reveals current node via action'
|
||||
expect(mocks.warn).toHaveBeenCalledWith('No repo')
|
||||
expect(mocks.reveal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('refreshes and reveals when tree sort changes', async () => {
|
||||
mocks.state.currentRepo = { name: 'notes' }
|
||||
mocks.fetchTree.mockResolvedValue([{ name: '/', children: [] }])
|
||||
|
||||
await mocks.watchCallback()
|
||||
|
||||
expect(mocks.fetchTree).toHaveBeenCalledWith('notes', { by: 'serial', order: 'asc' })
|
||||
expect(mocks.reveal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user