fix(desktop): harden session, meeting, caption and LLM lifecycles; route LLM calls through the gateway
This commit is contained in:
parent
3a46437f28
commit
ddc78546f0
62 changed files with 4786 additions and 648 deletions
|
|
@ -0,0 +1,194 @@
|
|||
// 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' })
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue