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