// tests/main/config-effects-overlay-redteam-r2-1.test.ts // - 설정 키별 부수효과는 어느 경로로 바뀌든(범용 config:set 포함) 한 번씩 적용된다 // - 제안 오버레이 표시 정책(프레젠터): show→update→hide 전이, 앵커 없음, 예외 격리, 매핑 단일화 import { describe, expect, it, vi } from 'vitest' import type { SuggestionState } from '@d3ro/core/input-intelligence' import { ConfigEffects, isEffectiveChange, type ConfigChange } from '../../src/main/config-effects' import { SuggestionOverlayPresenter, toSuggestionOverlayContent, toSuggestionOverlayShowPayload, type SuggestionOverlayView, } from '../../src/main/windows/suggestion-overlay-presenter' vi.mock('../../src/main/windows/WindowManager', () => ({ showSuggestionOverlay: vi.fn(), updateSuggestionOverlay: vi.fn(), hideSuggestionOverlay: vi.fn(), isSuggestionOverlayVisible: vi.fn(() => false), })) function bus() { const listeners = new Set<(change: ConfigChange) => void>() return { subscribe: (listener: (change: ConfigChange) => void) => { listeners.add(listener) return () => listeners.delete(listener) }, emit: (change: ConfigChange) => { for (const listener of [...listeners]) listener(change) }, size: () => listeners.size, } } describe('ConfigEffects', () => { it('범용 config:set 경로의 변경도 효과를 한 번 적용한다(자동 실행·테마)', () => { const b = bus() const effects = new ConfigEffects({ subscribe: b.subscribe }) const setLoginItem = vi.fn() const repaint = vi.fn() effects.register('autoLaunch', (value) => setLoginItem(value === true)) effects.register('theme', repaint) effects.start() b.emit({ key: 'autoLaunch', value: true, previousValue: false }) b.emit({ key: 'theme', value: 'nord', previousValue: 'dark' }) b.emit({ key: 'language', value: 'en', previousValue: 'ko' }) expect(setLoginItem).toHaveBeenCalledTimes(1) expect(setLoginItem).toHaveBeenCalledWith(true) expect(repaint).toHaveBeenCalledTimes(1) }) it('효과 안에서 같은 값을 다시 저장해도 재귀하지 않는다(AutoLaunchService.setEnabled 는 적용 후 configSet)', () => { const b = bus() const effects = new ConfigEffects({ subscribe: b.subscribe }) const apply = vi.fn((value: unknown) => { b.emit({ key: 'autoLaunch', value, previousValue: value }) }) effects.register('autoLaunch', apply) effects.start() b.emit({ key: 'autoLaunch', value: false, previousValue: true }) expect(apply).toHaveBeenCalledTimes(1) }) it('효과 하나가 던져도 다른 효과는 실행되고 오류를 알린다', () => { const b = bus() const onError = vi.fn() const effects = new ConfigEffects({ subscribe: b.subscribe, onError }) const second = vi.fn() effects.register('theme', () => { throw new Error('popup gone') }) effects.register('theme', second) effects.start() effects.start() // 멱등 b.emit({ key: 'theme', value: 'light', previousValue: 'dark' }) expect(second).toHaveBeenCalledTimes(1) expect(onError).toHaveBeenCalledWith('theme', expect.any(Error)) expect(b.size()).toBe(1) effects.dispose() expect(b.size()).toBe(0) }) it('isEffectiveChange: 원시값은 같으면 건너뛰고, 객체는 항상 적용한다', () => { expect(isEffectiveChange({ key: 'k', value: 1, previousValue: 1 })).toBe(false) expect(isEffectiveChange({ key: 'k', value: 'a', previousValue: 'b' })).toBe(true) expect(isEffectiveChange({ key: 'k', value: [1], previousValue: [1] })).toBe(true) }) }) function state(overrides: Partial = {}): SuggestionState { return { enabled: true, modelId: 'm', modelAvailable: true, visible: true, generating: false, warmingUp: false, partialText: null, candidates: [{ text: '안녕하세요', rank: 0 }], activeIndex: 0, targetTotal: 3, anchor: { x: 1, y: 2, width: 3, height: 4 }, anchorKind: 'caret', appName: 'Notepad', updatedAt: 0, lastSkipReason: null, requestsToday: 0, dailyBudget: 100, lastLatencyMs: null, triggerDelayMs: 600, minPrefixChars: 8, requestTimeoutMs: 1000, coolingDown: false, learnTypedText: false, telemetryEnabled: true, overlayInteractive: true, provenance: null, ...overrides, } as SuggestionState } function fakeView(visible = false) { const view = { visible, show: vi.fn(() => { view.visible = true }), update: vi.fn(), hide: vi.fn(() => { view.visible = false }), isVisible: () => view.visible, } return view } describe('SuggestionOverlayPresenter', () => { it('show → update → hide 로 전이하고, 텔레메트리 표시 플래그를 맞춘다', () => { const view = fakeView() const setActive = vi.fn() const presenter = new SuggestionOverlayPresenter({ view: view as SuggestionOverlayView, setPresentationActive: setActive, warn: vi.fn() }) presenter.onUpdated(state()) presenter.onUpdated(state({ generating: true })) presenter.onUpdated(state({ candidates: [] })) expect(view.show).toHaveBeenCalledTimes(1) expect(view.show).toHaveBeenCalledWith(expect.objectContaining({ anchorKind: 'caret', appName: 'Notepad' })) expect(view.update).toHaveBeenCalledTimes(1) expect(view.update.mock.calls[0][0]).not.toHaveProperty('appName') expect(view.hide).toHaveBeenCalledTimes(1) expect(setActive.mock.calls.map((c) => c[0])).toEqual([true, true, false]) }) it('앵커가 없으면 보여 줄 것이 있어도 숨긴다', () => { const view = fakeView(true) const presenter = new SuggestionOverlayPresenter({ view, setPresentationActive: vi.fn(), warn: vi.fn() }) presenter.onUpdated(state({ anchor: null })) expect(view.hide).toHaveBeenCalled() expect(view.show).not.toHaveBeenCalled() }) it('창 호출이 던져도 삼키고 경고만 남긴다 — cleared 는 플래그를 항상 내린다', () => { const view = fakeView() view.show.mockImplementation(() => { throw new Error('window destroyed') }) view.hide.mockImplementation(() => { throw new Error('window destroyed') }) const warn = vi.fn() const setActive = vi.fn() const presenter = new SuggestionOverlayPresenter({ view, setPresentationActive: setActive, warn }) expect(() => presenter.onUpdated(state())).not.toThrow() expect(() => presenter.onCleared()).not.toThrow() expect(warn).toHaveBeenCalledTimes(2) expect(setActive).toHaveBeenLastCalledWith(false) }) it('show payload 는 update payload(단일 매핑)에 배치 정보만 더한다', () => { const s = state({ partialText: '부분', warmingUp: true }) const { anchor, anchorKind, appName, ...content } = toSuggestionOverlayShowPayload(s) expect(content).toEqual(toSuggestionOverlayContent(s)) expect({ anchor, anchorKind, appName }).toEqual({ anchor: s.anchor, anchorKind: 'caret', appName: 'Notepad' }) }) })