diff --git a/test/renderer/components/HTMLArtifact.test.ts b/test/renderer/components/HTMLArtifact.test.ts new file mode 100644 index 000000000..8ebbec375 --- /dev/null +++ b/test/renderer/components/HTMLArtifact.test.ts @@ -0,0 +1,26 @@ +import { mount } from '@vue/test-utils' +import { describe, it, expect } from 'vitest' +import HTMLArtifact from '@/components/artifacts/HTMLArtifact.vue' + +describe('HTMLArtifact', () => { + it('applies correct classes and styles for mobile viewport', () => { + const wrapper = mount(HTMLArtifact, { + props: { + block: { content: 'Hello', artifact: { type: 'text/html', title: 'doc' } }, + isPreview: true, + viewportSize: 'mobile' + }, + attachTo: document.body + }) + + const iframe = wrapper.find('iframe') + expect(iframe.exists()).toBe(true) + const cls = iframe.attributes('class') || '' + expect(cls).toContain('html-iframe-wrapper') + expect(cls).toContain('border') + + const style = iframe.attributes('style') || '' + expect(style).toContain('width: 375px') + expect(style).toContain('height: 667px') + }) +}) \ No newline at end of file diff --git a/test/renderer/components/MessageActionButtons.test.ts b/test/renderer/components/MessageActionButtons.test.ts new file mode 100644 index 000000000..0cfbf8bd1 --- /dev/null +++ b/test/renderer/components/MessageActionButtons.test.ts @@ -0,0 +1,16 @@ +import { mount } from '@vue/test-utils' +import { describe, it, expect } from 'vitest' +import MessageActionButtons from '@/components/message/MessageActionButtons.vue' + +describe('MessageActionButtons', () => { + it('emits events on clicks', async () => { + const wrapper = mount(MessageActionButtons, { + props: { showCleanButton: true, showScrollButton: true } + }) + await wrapper.find('[key="new-chat"]').trigger('click') + await wrapper.find('[key="scroll-bottom"]').trigger('click') + + expect(wrapper.emitted().clean).toBeTruthy() + expect(wrapper.emitted()['scroll-to-bottom']).toBeTruthy() + }) +}) \ No newline at end of file diff --git a/test/renderer/composables/useArtifactContext.test.ts b/test/renderer/composables/useArtifactContext.test.ts new file mode 100644 index 000000000..8fc8e9718 --- /dev/null +++ b/test/renderer/composables/useArtifactContext.test.ts @@ -0,0 +1,24 @@ +import { ref } from 'vue' +import { describe, it, expect } from 'vitest' +import { useArtifactContext } from '@/composables/useArtifactContext' + +describe('useArtifactContext', () => { + it('builds a stable context key from thread/message/artifact', () => { + const art = ref({ id: 'art-1' }) + const threadId = ref('t-1') + const messageId = ref('m-1') + const { componentKey, activeArtifactContext } = useArtifactContext(art, threadId, messageId) + + expect(activeArtifactContext.value).toBe('t-1:m-1:art-1') + const prevKey = componentKey.value + + // same artifact id but different message produces new key + messageId.value = 'm-2' + expect(activeArtifactContext.value).toBe('t-1:m-2:art-1') + expect(componentKey.value).toBeGreaterThan(prevKey) + + // null artifact resets key + art.value = null + expect(activeArtifactContext.value).toBeNull() + }) +}) \ No newline at end of file diff --git a/test/renderer/composables/useArtifactExport.test.ts b/test/renderer/composables/useArtifactExport.test.ts new file mode 100644 index 000000000..7016c96af --- /dev/null +++ b/test/renderer/composables/useArtifactExport.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { useArtifactExport } from '@/composables/useArtifactExport' + +vi.mock('mermaid', () => ({ + default: {}, + render: vi.fn().mockResolvedValue({ svg: '' }) +})) + +// jsdom helpers for blob download +beforeEach(() => { + // @ts-ignore + global.URL.createObjectURL = vi.fn(() => 'blob://x') + // @ts-ignore + global.URL.revokeObjectURL = vi.fn() + vi.spyOn(document.body, 'appendChild') + vi.spyOn(document.body, 'removeChild') +}) + +const mkArtifact = (type: string, content: string, title='artifact') => ({ type, content, title } as any) + +describe('useArtifactExport', () => { + it('exports mermaid as SVG via download', async () => { + const capture = vi.fn() + const api = useArtifactExport(capture) + await api.exportSVG(mkArtifact('application/vnd.ant.mermaid','graph TD; A-->B;','m1')) + expect(document.body.appendChild).toHaveBeenCalled() + }) + + it('throws for invalid svg content', async () => { + const api = useArtifactExport(vi.fn()) + await expect(api.exportSVG(mkArtifact('image/svg+xml','NOT_SVG','bad'))) + .rejects.toBeTruthy() + }) + + it('exports code and copies content', async () => { + const api = useArtifactExport(vi.fn()) + // export code should trigger download + await api.exportCode(mkArtifact('text/markdown','# hello','readme')) + expect(document.body.appendChild).toHaveBeenCalled() + + // copy + Object.assign(navigator, { clipboard: { writeText: vi.fn().mockResolvedValue(void 0) } }) + await api.copyContent(mkArtifact('application/vnd.ant.code','hello world')) + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('hello world') + }) + + it('copyAsImage delegates to captureAndCopy with proper config', async () => { + const capture = vi.fn().mockResolvedValue(true) + const api = useArtifactExport(capture) + const ok = await api.copyAsImage(mkArtifact('text/plain','content'), { + isDark: false, + version: '1.0.0', + texts: { brand: 'DeepChat', tip: 'tip' } + }) + expect(ok).toBe(true) + expect(capture).toHaveBeenCalled() + }) +}) \ No newline at end of file diff --git a/test/renderer/composables/useArtifactViewMode.test.ts b/test/renderer/composables/useArtifactViewMode.test.ts new file mode 100644 index 000000000..9197687cd --- /dev/null +++ b/test/renderer/composables/useArtifactViewMode.test.ts @@ -0,0 +1,35 @@ +import { ref } from 'vue' +import { describe, it, expect } from 'vitest' +import { useArtifactViewMode } from '@/composables/useArtifactViewMode' + +const mkArtifact = (id: string, type: string, status: 'loaded'|'loading'|'error'='loaded') => ({ + id, type, status +}) as any + +describe('useArtifactViewMode', () => { + it('auto-previews for certain types and reacts to changes', () => { + const artifact = ref(mkArtifact('a1','application/vnd.ant.mermaid')) + const { isPreview, setPreview } = useArtifactViewMode(artifact) + expect(isPreview.value).toBe(true) + + // user override sticks + setPreview(false) + expect(isPreview.value).toBe(false) + + // new artifact resets preference and recomputes + artifact.value = mkArtifact('a2','image/svg+xml') + expect(isPreview.value).toBe(true) + + // non-preview types default to code view + artifact.value = mkArtifact('a3','text/markdown') + expect(isPreview.value).toBe(false) + }) + + it('depends on status: not preview until loaded', () => { + const artifact = ref(mkArtifact('b1','image/svg+xml','loading')) + const vm = useArtifactViewMode(artifact) + expect(vm.isPreview.value).toBe(false) + artifact.value = mkArtifact('b1','image/svg+xml','loaded') + expect(vm.isPreview.value).toBe(true) + }) +}) \ No newline at end of file diff --git a/test/renderer/composables/useDragAndDrop.test.ts b/test/renderer/composables/useDragAndDrop.test.ts new file mode 100644 index 000000000..6a680d6bf --- /dev/null +++ b/test/renderer/composables/useDragAndDrop.test.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from 'vitest' +import { useDragAndDrop } from '@/components/prompt-input/composables/useDragAndDrop' + +describe('useDragAndDrop', () => { + it('tracks drag state with proper counters and timers', () => { + const api = useDragAndDrop() + const evt = { dataTransfer: { types: ['Files'] } } as any as DragEvent + api.handleDragEnter(evt) + expect(api.isDragging.value).toBe(true) + api.handleDragOver() + api.handleDragLeave() + // we cannot await timer here; call reset directly + api.resetDragState() + expect(api.isDragging.value).toBe(false) + }) +}) \ No newline at end of file diff --git a/test/renderer/composables/useInputHistory.test.ts b/test/renderer/composables/useInputHistory.test.ts new file mode 100644 index 000000000..922ababbb --- /dev/null +++ b/test/renderer/composables/useInputHistory.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, vi } from 'vitest' +import { useInputHistory } from '@/components/prompt-input/composables/useInputHistory' + +// mock search history module with a simple ring buffer +let entries = ['alpha','beta','gamma'] +let idx = entries.length +vi.mock('@/lib/searchHistory', () => ({ + searchHistory: { + addSearch: (v: string) => { entries.push(v); idx = entries.length }, + resetIndex: () => { idx = entries.length }, + getPrevious: () => { if (idx>0) { idx--; return entries[idx] } return null }, + getNext: () => { if (idx ({ + commands: { + setContent: vi.fn() + }, + view: { + updateState: vi.fn() + } +}) as any + +describe('useInputHistory', () => { + it('manages placeholder and confirms fill', () => { + const t = (k: string) => k + const ed = fakeEditor() + const api = useInputHistory(ed, t) + + expect(api.dynamicPlaceholder.value).toBe('chat.input.placeholder') + api.setHistoryPlaceholder('recent text') + expect(api.dynamicPlaceholder.value.includes('recent text')).toBe(true) + + const ok = api.confirmHistoryPlaceholder() + expect(ok).toBe(true) + expect(ed.commands.setContent).toHaveBeenCalledWith('recent text') + }) + + it('navigates entries with arrows only when empty', () => { + const t = (k: string) => k + const ed = fakeEditor() + const api = useInputHistory(ed, t) + + let handled = api.handleArrowKey('up','') + expect(handled).toBe(true) + handled = api.handleArrowKey('down','') + expect(handled).toBe(true) + + // content not empty -> do nothing + expect(api.handleArrowKey('up','has text')).toBe(false) + }) +}) \ No newline at end of file diff --git a/test/renderer/composables/useMessageCapture.test.ts b/test/renderer/composables/useMessageCapture.test.ts new file mode 100644 index 000000000..8a5b0fc9f --- /dev/null +++ b/test/renderer/composables/useMessageCapture.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { useMessageCapture } from '@/composables/message/useMessageCapture' + +vi.mock('@/composables/usePageCapture', () => ({ + usePageCapture: () => ({ + isCapturing: { value: false }, + captureAndCopy: vi.fn().mockResolvedValue(true) + }) +})) +vi.mock('@/composables/usePresenter', () => ({ + usePresenter: (name: string) => { + if (name === 'devicePresenter') { + return { getAppVersion: vi.fn().mockResolvedValue('1.0.0') } + } + return {} + } +})) +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ t: (k: string) => k }) +})) + +describe('useMessageCapture', () => { + beforeEach(() => { + // container + const container = document.createElement('div') + container.className = 'message-list-container' + document.body.appendChild(container) + }) + + it('captures assistant and user block area', async () => { + const api = useMessageCapture() + + // prepare elements + const user = document.createElement('div') + user.setAttribute('data-message-id','u1') + const asst = document.createElement('div') + asst.setAttribute('data-message-id','a1') + document.body.appendChild(user) + document.body.appendChild(asst) + + const ok = await api.captureMessage({ + messageId: 'a1', + parentId: 'u1', + modelInfo: { model_name: 'm', model_provider: 'p' } + }) + expect(ok).toBe(true) + + document.body.removeChild(user) + document.body.removeChild(asst) + }) + + it('captures from top to current', async () => { + const api = useMessageCapture() + const first = document.createElement('div') + first.setAttribute('data-message-id','x-first') + const current = document.createElement('div') + current.setAttribute('data-message-id','x-current') + document.body.appendChild(first) + document.body.appendChild(current) + + const ok = await api.captureMessage({ messageId: 'x-current', fromTop: true }) + expect(ok).toBe(true) + + document.body.removeChild(first) + document.body.removeChild(current) + }) +}) \ No newline at end of file diff --git a/test/renderer/composables/useMessageScroll.test.ts b/test/renderer/composables/useMessageScroll.test.ts new file mode 100644 index 000000000..58e4dee58 --- /dev/null +++ b/test/renderer/composables/useMessageScroll.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { useMessageScroll } from '@/composables/message/useMessageScroll' + +class IO { + cb: any + constructor(cb: any) { this.cb = cb } + observe() { this.cb([{ isIntersecting: false }]) } + unobserve() {} + disconnect() {} +} +beforeEach(() => { + // @ts-ignore + global.IntersectionObserver = IO as any +}) + +describe('useMessageScroll', () => { + it('updates scroll info and threshold, supports scrolling to bottom/message', async () => { + const api = useMessageScroll() + const container = document.createElement('div') + const anchor = document.createElement('div') + Object.defineProperty(container, 'clientHeight', { value: 500 }) + Object.defineProperty(container, 'scrollHeight', { value: 2000, configurable: true }) + Object.defineProperty(container, 'scrollTop', { + get() { return this._st || 0 }, + set(v) { this._st = v } + }) + + api.messagesContainer.value = container as any + api.scrollAnchor.value = anchor as any + + api.setupScrollObserver() + expect(api.aboveThreshold.value).toBe(true) + + api.scrollToBottom(false) + // allow nextTick chain to run + await Promise.resolve() + expect((container as any)._st).toBe(1500) + + // add a target to scroll to + const msg = document.createElement('div') + msg.setAttribute('data-message-id','m-1') + document.body.appendChild(msg) + api.scrollToMessage('m-1') + await Promise.resolve() + document.body.removeChild(msg) + }) +}) \ No newline at end of file diff --git a/test/renderer/composables/useModelCapabilities.test.ts b/test/renderer/composables/useModelCapabilities.test.ts new file mode 100644 index 000000000..1871b1413 --- /dev/null +++ b/test/renderer/composables/useModelCapabilities.test.ts @@ -0,0 +1,30 @@ +import { ref } from 'vue' +import { describe, it, expect } from 'vitest' +import { useModelCapabilities } from '@/composables/useModelCapabilities' + +describe('useModelCapabilities', () => { + it('fetches capabilities and resets when ids missing', async () => { + const providerId = ref('openai') + const modelId = ref('gpt-4') + const mockPresenter: any = { + supportsReasoningCapability: vi.fn().mockResolvedValue(true), + getThinkingBudgetRange: vi.fn().mockResolvedValue({ min: 100, max: 200 }), + supportsSearchCapability: vi.fn().mockResolvedValue(true), + getSearchDefaults: vi.fn().mockResolvedValue({ default: true, forced: false, strategy: 'turbo' }) + } + + const api = useModelCapabilities({ providerId, modelId, configPresenter: mockPresenter }) + // initial immediate fetch occurs + await Promise.resolve() + expect(api.supportsReasoning.value).toBe(true) + expect(api.budgetRange.value?.max).toBe(200) + expect(api.supportsSearch.value).toBe(true) + expect(api.searchDefaults.value?.strategy).toBe('turbo') + + // reset path + providerId.value = undefined + await Promise.resolve() + expect(api.supportsReasoning.value).toBeNull() + expect(api.budgetRange.value).toBeNull() + }) +}) \ No newline at end of file diff --git a/test/renderer/composables/useModelTypeDetection.test.ts b/test/renderer/composables/useModelTypeDetection.test.ts new file mode 100644 index 000000000..683c269d3 --- /dev/null +++ b/test/renderer/composables/useModelTypeDetection.test.ts @@ -0,0 +1,25 @@ +import { ref } from 'vue' +import { describe, it, expect } from 'vitest' +import { useModelTypeDetection } from '@/composables/useModelTypeDetection' + +vi.mock('@/stores/settings', () => ({ + useSettingsStore: () => ({ + getModelConfig: vi.fn().mockResolvedValue({ reasoning: true }) + }) +})) + +describe('useModelTypeDetection', () => { + it('detects provider/model type and loads reasoning flag', async () => { + const modelId = ref('gpt-5-pro') + const providerId = ref('gemini') + const modelType = ref<'chat'|'imageGeneration'|'embedding'|'rerank'|undefined>('imageGeneration') + + const api = useModelTypeDetection({ modelId, providerId, modelType }) + expect(api.isImageGenerationModel.value).toBe(true) + expect(api.isGPT5Model.value).toBe(true) + expect(api.isGeminiProvider.value).toBe(true) + + await Promise.resolve() + expect(api.modelReasoning.value).toBe(true) + }) +}) \ No newline at end of file diff --git a/test/renderer/composables/useSearchConfig.test.ts b/test/renderer/composables/useSearchConfig.test.ts new file mode 100644 index 000000000..63bf787f9 --- /dev/null +++ b/test/renderer/composables/useSearchConfig.test.ts @@ -0,0 +1,25 @@ +import { ref } from 'vue' +import { describe, it, expect } from 'vitest' +import { useSearchConfig } from '@/composables/useSearchConfig' + +describe('useSearchConfig', () => { + it('computes visibility and option availability from capabilities', () => { + const supportsSearch = ref(true) + const searchDefaults = ref({ forced: true as boolean|undefined, strategy: 'turbo' as 'turbo'|'max'|undefined }) + + const api = useSearchConfig({ supportsSearch, searchDefaults }) + expect(api.showSearchConfig.value).toBe(true) + expect(api.hasForcedSearchOption.value).toBe(true) + expect(api.hasSearchStrategyOption.value).toBe(true) + + // When not supported + supportsSearch.value = null + expect(api.showSearchConfig.value).toBe(false) + + // Missing defaults + supportsSearch.value = true + searchDefaults.value = {} + expect(api.hasForcedSearchOption.value).toBe(false) + expect(api.hasSearchStrategyOption.value).toBe(false) + }) +}) \ No newline at end of file diff --git a/test/renderer/composables/useThinkingBudget.test.ts b/test/renderer/composables/useThinkingBudget.test.ts new file mode 100644 index 000000000..de139c7b7 --- /dev/null +++ b/test/renderer/composables/useThinkingBudget.test.ts @@ -0,0 +1,55 @@ +import { ref, computed } from 'vue' +import { describe, it, expect } from 'vitest' +import { useThinkingBudget } from '@/composables/useThinkingBudget' + +// mock i18n -> return the key so we can assert on it +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ t: (k: string, _p?: any) => k }) +})) + +describe('useThinkingBudget', () => { + it('computes showThinkingBudget only when reasoning supported and range provided', () => { + const thinkingBudget = ref(undefined) + const budgetRange = ref<{min?: number; max?: number; default?: number} | null>({ min: 256, max: 4096 }) + const modelReasoning = ref(true) + const supportsReasoning = ref(true) + const isGeminiProvider = computed(() => false) + + const api = useThinkingBudget({ thinkingBudget, budgetRange, modelReasoning, supportsReasoning, isGeminiProvider }) + expect(api.showThinkingBudget.value).toBe(true) + + supportsReasoning.value = null + expect(api.showThinkingBudget.value).toBe(false) + + supportsReasoning.value = true + budgetRange.value = null + expect(api.showThinkingBudget.value).toBe(false) + }) + + it('validates range and returns translation keys; allows -1 for Gemini', () => { + const thinkingBudget = ref(128) + const budgetRange = ref<{min?: number; max?: number; default?: number} | null>({ min: 256, max: 1024 }) + const modelReasoning = ref(true) + const supportsReasoning = ref(true) + const isGeminiProvider = computed(() => false) + + const api = useThinkingBudget({ thinkingBudget, budgetRange, modelReasoning, supportsReasoning, isGeminiProvider }) + expect(api.validationError.value).toBe('settings.model.modelConfig.thinkingBudget.validation.minValue') + + thinkingBudget.value = 2048 + expect(api.validationError.value).toBe('settings.model.modelConfig.thinkingBudget.validation.maxValue') + + thinkingBudget.value = 512 + expect(api.validationError.value).toBe('') + + // Gemini special case + const gemApi = useThinkingBudget({ + thinkingBudget: ref(-1), + budgetRange, + modelReasoning, + supportsReasoning, + isGeminiProvider: computed(() => true) + }) + expect(gemApi.validationError.value).toBe('') + }) +}) \ No newline at end of file diff --git a/test/renderer/composables/useViewportSize.test.ts b/test/renderer/composables/useViewportSize.test.ts new file mode 100644 index 000000000..64a49f454 --- /dev/null +++ b/test/renderer/composables/useViewportSize.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'vitest' +import { useViewportSize } from '@/composables/useViewportSize' + +describe('useViewportSize', () => { + it('manages size and returns fixed dimensions for tablet/mobile', () => { + const api = useViewportSize() + expect(api.viewportSize.value).toBe('desktop') + expect(api.getDimensions()).toBeNull() + + api.setViewportSize('tablet') + expect(api.viewportSize.value).toBe('tablet') + expect(api.getDimensions()).toEqual({ width: api.TABLET_WIDTH, height: api.TABLET_HEIGHT }) + + api.setViewportSize('mobile') + expect(api.getDimensions()).toEqual({ width: api.MOBILE_WIDTH, height: api.MOBILE_HEIGHT }) + }) +}) \ No newline at end of file