From 72e9b68923dfb2cbeefa8e4c6d9ff18c863de040 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 10 May 2026 04:59:19 +0900 Subject: [PATCH] =?UTF-8?q?feat(v031):=20VisionSection=20UI=20=E2=80=94=20?= =?UTF-8?q?dropdown=20+=20=EB=8B=A4=EC=8B=9C=20=EA=B0=90=EC=A7=80=20+=20?= =?UTF-8?q?=EB=A7=88=EC=A7=80=EB=A7=89=20=EA=B0=90=EC=A7=80=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../components/settings/AiProviderSection.tsx | 2 + .../components/settings/VisionSection.tsx | 81 +++++++++++++++++++ tests/unit/AiProviderSection.test.tsx | 6 +- tests/unit/App.test.tsx | 6 +- tests/unit/SettingsPage.test.tsx | 6 +- tests/unit/VisionSection.test.tsx | 75 +++++++++++++++++ 6 files changed, 173 insertions(+), 3 deletions(-) create mode 100644 src/renderer/inbox/components/settings/VisionSection.tsx create mode 100644 tests/unit/VisionSection.test.tsx diff --git a/src/renderer/inbox/components/settings/AiProviderSection.tsx b/src/renderer/inbox/components/settings/AiProviderSection.tsx index cd32e12..ed31a80 100644 --- a/src/renderer/inbox/components/settings/AiProviderSection.tsx +++ b/src/renderer/inbox/components/settings/AiProviderSection.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from 'react'; import { z } from 'zod'; import { inboxApi } from '../../api.js'; +import { VisionSection } from './VisionSection.js'; const endpointSchema = z.string().url(); @@ -192,6 +193,7 @@ export function AiProviderSection(): React.ReactElement { {recheckResult && (
{recheckResult}
)} + ); } diff --git a/src/renderer/inbox/components/settings/VisionSection.tsx b/src/renderer/inbox/components/settings/VisionSection.tsx new file mode 100644 index 0000000..2d728bc --- /dev/null +++ b/src/renderer/inbox/components/settings/VisionSection.tsx @@ -0,0 +1,81 @@ +import React, { useEffect, useState } from 'react'; +import { inboxApi } from '../../api.js'; + +export function VisionSection(): React.ReactElement { + const [models, setModels] = useState([]); + const [at, setAt] = useState(null); + const [selected, setSelected] = useState(null); + const [busy, setBusy] = useState<'select' | 'refresh' | null>(null); + const [feedback, setFeedback] = useState(null); + + async function load() { + const r = await inboxApi.getVisionModels(); + setModels(r.models); + setAt(r.at); + setSelected(r.selected); + } + + useEffect(() => { + void load(); + }, []); + + async function onSelect(value: string) { + const next = value === '' ? null : value; + setBusy('select'); + setFeedback(null); + await inboxApi.setVisionModel(next); + setSelected(next); + setBusy(null); + } + + async function onRefresh() { + setBusy('refresh'); + setFeedback(null); + const r = await inboxApi.refreshVisionCache(); + setBusy(null); + if (r.ok) { + await load(); + setFeedback(`감지 완료 (${r.models.length}개)`); + } else { + setFeedback(`감지 실패: ${r.reason}`); + } + } + + return ( +
+

이미지 분석 모델 (선택사항)

+
+ + +
+ {at !== null && ( +
+ 마지막 감지: {new Date(at).toLocaleString('ko-KR')} +
+ )} + {feedback !== null && ( +
{feedback}
+ )} + {models.length === 0 && ( +
+ 감지된 모델 없음. Ollama 에 vision 모델을 설치하고 "다시 감지" 클릭. +
+ )} +
+ ); +} diff --git a/tests/unit/AiProviderSection.test.tsx b/tests/unit/AiProviderSection.test.tsx index 4a9bea7..13d6618 100644 --- a/tests/unit/AiProviderSection.test.tsx +++ b/tests/unit/AiProviderSection.test.tsx @@ -11,7 +11,11 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({ getSettings: vi.fn(async () => ({ ai_enabled: true })), setAiEnabled: vi.fn(async () => ({ ok: true })), getDisabledCount: vi.fn(async () => 0), - enqueueDisabled: vi.fn(async () => ({ count: 0 })) + enqueueDisabled: vi.fn(async () => ({ count: 0 })), + // v0.3.1 Cut F — VisionSection 이 AiProviderSection 에 마운트되어 호출. + getVisionModels: vi.fn(async () => ({ models: [], at: null, selected: null })), + setVisionModel: vi.fn(async () => ({ ok: true as const })), + refreshVisionCache: vi.fn(async () => ({ ok: true as const, models: [] })) } })); diff --git a/tests/unit/App.test.tsx b/tests/unit/App.test.tsx index f49a065..0c269f7 100644 --- a/tests/unit/App.test.tsx +++ b/tests/unit/App.test.tsx @@ -62,7 +62,11 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({ setSyncAutoEnabled: vi.fn(async () => ({ ok: true as const })), setSyncIntervalMin: vi.fn(async () => ({ ok: true as const })), configureSync: vi.fn(async () => ({ ok: true as const })), - testSyncConnection: vi.fn(async () => ({ ok: true as const })) + testSyncConnection: vi.fn(async () => ({ ok: true as const })), + // v0.3.1 Cut F — VisionSection 이 AiProviderSection 에 마운트되어 호출. + getVisionModels: vi.fn(async () => ({ models: [], at: null, selected: null })), + setVisionModel: vi.fn(async () => ({ ok: true as const })), + refreshVisionCache: vi.fn(async () => ({ ok: true as const, models: [] })) } })); diff --git a/tests/unit/SettingsPage.test.tsx b/tests/unit/SettingsPage.test.tsx index 7405c46..ea8b313 100644 --- a/tests/unit/SettingsPage.test.tsx +++ b/tests/unit/SettingsPage.test.tsx @@ -52,7 +52,11 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({ setSyncAutoEnabled: vi.fn(async () => ({ ok: true as const })), setSyncIntervalMin: vi.fn(async () => ({ ok: true as const })), configureSync: vi.fn(async () => ({ ok: true as const })), - testSyncConnection: vi.fn(async () => ({ ok: true as const })) + testSyncConnection: vi.fn(async () => ({ ok: true as const })), + // v0.3.1 Cut F — VisionSection 이 AiProviderSection 에 마운트되어 호출. + getVisionModels: vi.fn(async () => ({ models: [], at: null, selected: null })), + setVisionModel: vi.fn(async () => ({ ok: true as const })), + refreshVisionCache: vi.fn(async () => ({ ok: true as const, models: [] })) } })); diff --git a/tests/unit/VisionSection.test.tsx b/tests/unit/VisionSection.test.tsx new file mode 100644 index 0000000..f610bca --- /dev/null +++ b/tests/unit/VisionSection.test.tsx @@ -0,0 +1,75 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import '@testing-library/jest-dom/vitest'; +import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'; +import React from 'react'; + +const { mockGet, mockSet, mockRefresh } = vi.hoisted(() => ({ + mockGet: vi.fn(), + mockSet: vi.fn(), + mockRefresh: vi.fn() +})); + +vi.mock('../../src/renderer/inbox/api.js', () => ({ + inboxApi: { + getVisionModels: mockGet, + setVisionModel: mockSet, + refreshVisionCache: mockRefresh + } +})); + +import { VisionSection } from '../../src/renderer/inbox/components/settings/VisionSection'; + +describe('VisionSection', () => { + beforeEach(() => { + vi.clearAllMocks(); + cleanup(); + mockGet.mockResolvedValue({ + models: ['gemma3:12b-vision', 'llava:13b'], + at: '2026-05-10T05:00:00Z', + selected: 'gemma3:12b-vision' + }); + mockSet.mockResolvedValue({ ok: true }); + mockRefresh.mockResolvedValue({ ok: true, models: ['gemma3:12b-vision', 'llava:13b'] }); + }); + + it('open 시 cache 로드 + dropdown 옵션 표시 + 선택된 모델 default', async () => { + render(); + await waitFor(() => { + expect(screen.getByLabelText('이미지 분석 모델')).toHaveValue('gemma3:12b-vision'); + }); + expect(screen.getByText('gemma3:12b-vision')).toBeInTheDocument(); + expect(screen.getByText('llava:13b')).toBeInTheDocument(); + expect(screen.getByText(/마지막 감지/)).toBeInTheDocument(); + }); + + it('dropdown 변경 → setVisionModel 호출', async () => { + render(); + await waitFor(() => screen.getByLabelText('이미지 분석 모델')); + fireEvent.change(screen.getByLabelText('이미지 분석 모델'), { target: { value: 'llava:13b' } }); + await waitFor(() => { + expect(mockSet).toHaveBeenCalledWith('llava:13b'); + }); + }); + + it('비활성 선택 → setVisionModel(null)', async () => { + render(); + await waitFor(() => screen.getByLabelText('이미지 분석 모델')); + fireEvent.change(screen.getByLabelText('이미지 분석 모델'), { target: { value: '' } }); + await waitFor(() => { + expect(mockSet).toHaveBeenCalledWith(null); + }); + }); + + it('다시 감지 클릭 → refreshVisionCache 호출 + 결과 표시', async () => { + render(); + await waitFor(() => screen.getByRole('button', { name: /다시 감지/ })); + fireEvent.click(screen.getByRole('button', { name: /다시 감지/ })); + await waitFor(() => { + expect(mockRefresh).toHaveBeenCalled(); + }); + await waitFor(() => { + expect(screen.getByText(/감지 완료/)).toBeInTheDocument(); + }); + }); +});