194 lines
7 KiB
TypeScript
194 lines
7 KiB
TypeScript
// 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> = {}): 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' })
|
|
})
|
|
})
|