refactor(suggestion): drive suggestion decisions from one core step and split SuggestionService collaborators
This commit is contained in:
parent
9aa7302944
commit
190b6db284
13 changed files with 1994 additions and 496 deletions
|
|
@ -0,0 +1,181 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { createTestDb } from '../../helpers/createTestDb'
|
||||
import { personalPhrases, phraseEdges, typingSamples } from '../../../src/main/db/schema'
|
||||
|
||||
const holder = vi.hoisted(() => ({
|
||||
db: null as unknown,
|
||||
config: new Map<string, unknown>()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({ screen: { getPrimaryDisplay: () => ({ scaleFactor: 1 }) } }))
|
||||
vi.mock('uiohook-napi', () => ({ uIOhook: { on: vi.fn(), removeListener: vi.fn() } }))
|
||||
vi.mock('../../../src/main/db', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../../src/main/db')>()),
|
||||
getDatabase: () => holder.db
|
||||
}))
|
||||
vi.mock('../../../src/main/services/ConfigService', () => ({
|
||||
configGet: (key: string) => holder.config.get(key),
|
||||
configSet: (key: string, value: unknown) => holder.config.set(key, value)
|
||||
}))
|
||||
vi.mock('../../../src/main/services/LoggerService', () => ({
|
||||
getLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() })
|
||||
}))
|
||||
vi.mock('../../../src/main/services/global-input-hook', () => ({ acquireGlobalInputHook: () => () => undefined }))
|
||||
vi.mock('../../../src/main/services/KeyBindingService', () => ({ uiohookCodeToVk: () => null }))
|
||||
vi.mock('../../../src/main/services/UiaContextService', () => ({
|
||||
getUiaContextService: () => ({ isAvailable: () => false, lastReason: 'test', lastSuccessAt: 0 })
|
||||
}))
|
||||
vi.mock('../../../src/main/utils/win32-foreground', () => ({ getForegroundWindowInfo: () => null }))
|
||||
|
||||
import {
|
||||
getInputTelemetryService,
|
||||
resetInputTelemetryServiceForTests
|
||||
} from '../../../src/main/services/InputTelemetryService'
|
||||
import {
|
||||
getPersonalGraphService,
|
||||
resetPersonalGraphServiceForTests
|
||||
} from '../../../src/main/services/PersonalGraphService'
|
||||
|
||||
let testDb: ReturnType<typeof createTestDb>
|
||||
|
||||
interface TelemetryInternal {
|
||||
_foreground: { appName: string | null; windowTitle: string | null; hwnd: number | null }
|
||||
_applyTypedDelta(snapshot: { windowTitle: string | null; controlName?: string; controlType?: string; text: string }): void
|
||||
}
|
||||
|
||||
function snapshot(text: string) {
|
||||
return { windowTitle: '채널', controlName: '메시지', controlType: 'Edit', text }
|
||||
}
|
||||
|
||||
function typedSamples(): string[] {
|
||||
return testDb.db
|
||||
.select()
|
||||
.from(typingSamples)
|
||||
.all()
|
||||
.filter((row) => row.source === 'typed')
|
||||
.map((row) => row.text)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
testDb = createTestDb()
|
||||
holder.db = testDb.db
|
||||
holder.config.clear()
|
||||
holder.config.set('inputLearnTypedText', true)
|
||||
holder.config.set('inputExcludedApps', [])
|
||||
resetInputTelemetryServiceForTests()
|
||||
resetPersonalGraphServiceForTests()
|
||||
const telemetry = getInputTelemetryService() as unknown as TelemetryInternal
|
||||
telemetry._foreground.appName = 'Slack.exe'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
testDb.close()
|
||||
})
|
||||
|
||||
describe('버그 3: 프로그램이 삽입한 텍스트를 타이핑으로 다시 학습하지 않는다', () => {
|
||||
const ACCEPTED = '다음 회의는 내일 오후 세 시에 하겠습니다'
|
||||
|
||||
it('예고된 제안 수락 삽입은 diff 에서 typed 로 학습하지 않는다', () => {
|
||||
const telemetry = getInputTelemetryService()
|
||||
const internal = telemetry as unknown as TelemetryInternal
|
||||
internal._applyTypedDelta(snapshot('안녕하세요 여러분 '))
|
||||
|
||||
telemetry.expectProgrammaticInsert(ACCEPTED)
|
||||
internal._applyTypedDelta(snapshot(`안녕하세요 여러분 ${ACCEPTED}`))
|
||||
|
||||
expect(typedSamples()).toEqual([])
|
||||
})
|
||||
|
||||
it('삽입 앞뒤로 직접 친 부분만 학습한다', () => {
|
||||
const telemetry = getInputTelemetryService()
|
||||
const internal = telemetry as unknown as TelemetryInternal
|
||||
internal._applyTypedDelta(snapshot('시작 '))
|
||||
|
||||
telemetry.expectProgrammaticInsert(ACCEPTED)
|
||||
internal._applyTypedDelta(snapshot(`시작 ${ACCEPTED} 확인 부탁드립니다`))
|
||||
|
||||
expect(typedSamples()).toEqual(['확인 부탁드립니다'])
|
||||
})
|
||||
|
||||
it('예고는 한 번만 소비된다 — 같은 문장을 나중에 직접 치면 학습한다', () => {
|
||||
const telemetry = getInputTelemetryService()
|
||||
const internal = telemetry as unknown as TelemetryInternal
|
||||
internal._applyTypedDelta(snapshot(''))
|
||||
internal._applyTypedDelta(snapshot('첫 줄 '))
|
||||
telemetry.expectProgrammaticInsert(ACCEPTED)
|
||||
internal._applyTypedDelta(snapshot(`첫 줄 ${ACCEPTED}`))
|
||||
internal._applyTypedDelta(snapshot(`첫 줄 ${ACCEPTED}\n${ACCEPTED}`))
|
||||
|
||||
expect(typedSamples()).toContain(ACCEPTED)
|
||||
})
|
||||
|
||||
it('삽입이 실패해 예고를 취소하면 이후 직접 친 같은 글은 학습한다', () => {
|
||||
const telemetry = getInputTelemetryService()
|
||||
const internal = telemetry as unknown as TelemetryInternal
|
||||
internal._applyTypedDelta(snapshot('앞 '))
|
||||
const cancel = telemetry.expectProgrammaticInsert(ACCEPTED)
|
||||
cancel()
|
||||
internal._applyTypedDelta(snapshot(`앞 ${ACCEPTED}`))
|
||||
|
||||
expect(typedSamples()).toEqual([ACCEPTED])
|
||||
})
|
||||
|
||||
it('키보드 방식으로 치는 도중 잡힌 앞부분도 학습하지 않고 나머지를 기다린다', () => {
|
||||
const telemetry = getInputTelemetryService()
|
||||
const internal = telemetry as unknown as TelemetryInternal
|
||||
internal._applyTypedDelta(snapshot(''))
|
||||
internal._applyTypedDelta(snapshot('x '))
|
||||
telemetry.expectProgrammaticInsert(ACCEPTED)
|
||||
internal._applyTypedDelta(snapshot(`x ${ACCEPTED.slice(0, 8)}`))
|
||||
internal._applyTypedDelta(snapshot(`x ${ACCEPTED}`))
|
||||
|
||||
expect(typedSamples()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('버그 9: 문구 삭제는 그 문장의 그래프 엣지도 지운다', () => {
|
||||
const ANCHOR = '오늘 회의 결과를 공유드립니다'
|
||||
const PRIVATE = '계좌 비밀번호는 따로 보내드리겠습니다'
|
||||
|
||||
it('삭제한 문장이 follows 이웃·통계·조회에 남지 않는다', () => {
|
||||
const graph = getPersonalGraphService()
|
||||
graph.indexText(`${ANCHOR}. ${PRIVATE}.`, { source: 'typed', appName: 'Slack.exe' })
|
||||
expect(graph.retrieveContext('오늘 회의 결과를').related).toContain(PRIVATE)
|
||||
|
||||
const row = testDb.db
|
||||
.select()
|
||||
.from(personalPhrases)
|
||||
.where(eq(personalPhrases.phrase, PRIVATE))
|
||||
.get()
|
||||
expect(row).toBeDefined()
|
||||
|
||||
expect(getInputTelemetryService().deletePhrase(row?.id ?? '')).toBe(true)
|
||||
|
||||
const edges = testDb.db.select().from(phraseEdges).all()
|
||||
expect(edges.filter((edge) => edge.fromPhrase === PRIVATE || edge.toPhrase === PRIVATE)).toEqual([])
|
||||
expect(graph.retrieveContext('오늘 회의 결과를').related).not.toContain(PRIVATE)
|
||||
expect(graph.getStats().topEdges.some((edge) => edge.to === PRIVATE || edge.from === PRIVATE)).toBe(false)
|
||||
expect(graph.query('오늘 회의').neighbors.some((neighbor) => neighbor.text === PRIVATE)).toBe(false)
|
||||
})
|
||||
|
||||
it('없는 id 삭제는 false 이고 다른 엣지를 건드리지 않는다', () => {
|
||||
const graph = getPersonalGraphService()
|
||||
graph.indexText(`${ANCHOR}. ${PRIVATE}.`, { source: 'typed', appName: 'Slack.exe' })
|
||||
const before = testDb.db.select().from(phraseEdges).all().length
|
||||
|
||||
expect(getInputTelemetryService().deletePhrase('missing')).toBe(false)
|
||||
expect(testDb.db.select().from(phraseEdges).all()).toHaveLength(before)
|
||||
})
|
||||
|
||||
it('removePhrase 는 양방향 엣지를 모두 지우고 지운 수를 돌려준다', () => {
|
||||
const graph = getPersonalGraphService()
|
||||
graph.indexText(`${ANCHOR}. ${PRIVATE}. 확인 후 다시 연락드리겠습니다.`, { source: 'typed' })
|
||||
|
||||
const removed = graph.removePhrase(PRIVATE)
|
||||
|
||||
expect(removed).toBeGreaterThanOrEqual(2)
|
||||
const remaining = testDb.db.select().from(phraseEdges).all()
|
||||
expect(remaining.every((edge) => edge.fromPhrase !== PRIVATE && edge.toPhrase !== PRIVATE)).toBe(true)
|
||||
})
|
||||
})
|
||||
415
apps/desktop/tests/main/services/suggestion-redteam-r1-1.test.ts
Normal file
415
apps/desktop/tests/main/services/suggestion-redteam-r1-1.test.ts
Normal file
|
|
@ -0,0 +1,415 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const config = vi.hoisted(() => ({
|
||||
suggestionEnabled: true as boolean,
|
||||
suggestionModelId: 'model-a' as string | null,
|
||||
llmModelId: 'model-a' as string | null,
|
||||
inputExcludedApps: [] as string[],
|
||||
suggestionTriggerDelayMs: 600,
|
||||
suggestionMinPrefixChars: 8,
|
||||
suggestionMaxRequestsPerMinute: 12,
|
||||
suggestionDailyBudget: 500,
|
||||
suggestionRequestTimeoutMs: 8000,
|
||||
inputLearnTypedText: false,
|
||||
inputTelemetryEnabled: false,
|
||||
suggestionOverlayInteractive: true,
|
||||
insertMethod: 'clipboard'
|
||||
}))
|
||||
const harness = vi.hoisted(() => ({
|
||||
isAvailable: vi.fn(() => true),
|
||||
streamGenerate: vi.fn(),
|
||||
insertText: vi.fn(async (text: string) => ({ success: true, method: 'clipboard', textLength: text.length })),
|
||||
continuations: [] as string[]
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/ConfigService', () => ({
|
||||
configGet: vi.fn((key: keyof typeof config) => config[key]),
|
||||
configSet: vi.fn()
|
||||
}))
|
||||
vi.mock('../../../src/main/services/LoggerService', () => ({
|
||||
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() })
|
||||
}))
|
||||
vi.mock('../../../src/main/services/LocalLLMService', () => ({
|
||||
getLocalLLMService: () => ({ isAvailable: harness.isAvailable, streamGenerate: harness.streamGenerate })
|
||||
}))
|
||||
vi.mock('../../../src/main/services/InputTelemetryService', () => ({
|
||||
getInputTelemetryService: () => ({ listPhrases: () => [] })
|
||||
}))
|
||||
vi.mock('../../../src/main/services/PersonalGraphService', () => ({
|
||||
getPersonalGraphService: () => ({
|
||||
retrieveContext: () => ({ continuations: harness.continuations, related: [] })
|
||||
})
|
||||
}))
|
||||
vi.mock('../../../src/main/services/TextInsertService', () => ({
|
||||
getTextInsertService: () => ({ insertText: harness.insertText })
|
||||
}))
|
||||
vi.mock('../../../src/main/services/modifier-state', () => ({ waitForModifiersReleased: async () => true }))
|
||||
vi.mock('../../../src/main/utils/win32-foreground', () => ({ getForegroundWindowInfo: () => null }))
|
||||
vi.mock('../../../src/main/db', () => ({ getDatabase: vi.fn() }))
|
||||
vi.mock('../../../src/main/db/schema', () => ({ suggestions: {} }))
|
||||
|
||||
import { SuggestionService, type SuggestionServiceDeps } from '../../../src/main/services/SuggestionService'
|
||||
import { SuggestionBudget } from '../../../src/main/services/suggestion/SuggestionBudget'
|
||||
import { ModelWarmTracker } from '../../../src/main/services/suggestion/ModelWarmTracker'
|
||||
import type { TypingContext } from '../../../src/main/services/InputTelemetryService'
|
||||
|
||||
const SHORT_PREFIX = '오늘 회의에서 논의한 내용을 정리해서'
|
||||
const LONG_PREFIX = `${'가나다라 '.repeat(100)}${SHORT_PREFIX}`
|
||||
|
||||
function typingContext(overrides: Partial<TypingContext> = {}): TypingContext {
|
||||
const prefix = overrides.prefix ?? SHORT_PREFIX
|
||||
return {
|
||||
prefix,
|
||||
fullText: prefix,
|
||||
caretOffset: prefix.length,
|
||||
anchor: null,
|
||||
anchorKind: null,
|
||||
isPassword: false,
|
||||
isEditable: true,
|
||||
isComposing: false,
|
||||
hasSelection: false,
|
||||
available: true,
|
||||
appName: 'chrome.exe',
|
||||
windowTitle: 'Gmail',
|
||||
windowHandle: 101,
|
||||
idleMs: 2000,
|
||||
capturedAt: Date.now(),
|
||||
editedSinceFocus: true,
|
||||
typedRecently: true,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
interface Fakes {
|
||||
deps: SuggestionServiceDeps
|
||||
record: ReturnType<typeof vi.fn>
|
||||
foregroundHandle: { value: number | null }
|
||||
expectInsert: ReturnType<typeof vi.fn>
|
||||
cancelInsert: ReturnType<typeof vi.fn>
|
||||
recordAccepted: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
function fakes(): Fakes {
|
||||
const record = vi.fn()
|
||||
const foregroundHandle = { value: 101 as number | null }
|
||||
const cancelInsert = vi.fn()
|
||||
const expectInsert = vi.fn(() => cancelInsert)
|
||||
const recordAccepted = vi.fn()
|
||||
return {
|
||||
record,
|
||||
foregroundHandle,
|
||||
expectInsert,
|
||||
cancelInsert,
|
||||
recordAccepted,
|
||||
deps: {
|
||||
repository: { record, markAccepted: vi.fn(), list: vi.fn(() => []) },
|
||||
budget: new SuggestionBudget(),
|
||||
warmth: new ModelWarmTracker(),
|
||||
foreground: { currentWindowHandle: () => foregroundHandle.value },
|
||||
learning: { expectProgrammaticInsert: expectInsert, recordAccepted }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function streamOf(text: string) {
|
||||
return (async function* () {
|
||||
yield text
|
||||
})()
|
||||
}
|
||||
|
||||
let services: SuggestionService[] = []
|
||||
function create(deps: SuggestionServiceDeps): SuggestionService {
|
||||
const service = new SuggestionService(deps)
|
||||
services.push(service)
|
||||
return service
|
||||
}
|
||||
|
||||
interface Internal {
|
||||
_generate(prefix: string, context: TypingContext, maxCandidates: number, maxChars: number): Promise<void>
|
||||
_warmUpPromise: Promise<void> | null
|
||||
_lastSkipReason: string | null
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
config.suggestionEnabled = true
|
||||
config.suggestionModelId = 'model-a'
|
||||
config.llmModelId = 'model-a'
|
||||
harness.isAvailable.mockReset()
|
||||
harness.isAvailable.mockReturnValue(true)
|
||||
harness.streamGenerate.mockReset()
|
||||
harness.insertText.mockClear()
|
||||
harness.continuations = []
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const service of services) service.dispose()
|
||||
services = []
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('버그 1: 400자 넘는 접두에서 세션이 stale 로 닫히지 않는다', () => {
|
||||
it('같은 긴 접두의 다음 스냅샷에서도 세션이 유지되고 재생성하지 않는다', async () => {
|
||||
harness.streamGenerate.mockImplementation(() => streamOf('다음 단계도 확인하겠습니다.'))
|
||||
const { deps } = fakes()
|
||||
deps.warmth.noteWarm('model-a', Date.now() + 600_000)
|
||||
const service = create(deps)
|
||||
const internal = service as unknown as Internal
|
||||
const context = typingContext({ prefix: LONG_PREFIX })
|
||||
expect(LONG_PREFIX.length).toBeGreaterThan(400)
|
||||
|
||||
await internal._generate(LONG_PREFIX, context, 3, 160)
|
||||
expect(service.isVisible).toBe(true)
|
||||
|
||||
service.handleTypingContext(context)
|
||||
|
||||
expect(service.isVisible).toBe(true)
|
||||
expect(service.getState().lastSkipReason).not.toBe('stale')
|
||||
// 세션이 유지되므로 새 첫 요청(예산 소비)이 일어나지 않는다.
|
||||
expect(deps.budget.minuteCount).toBe(1)
|
||||
})
|
||||
|
||||
it('긴 접두에서도 이어 치면 세션을 끝낸다', async () => {
|
||||
harness.streamGenerate.mockImplementation(() => streamOf('다음 단계도 확인하겠습니다.'))
|
||||
const { deps } = fakes()
|
||||
deps.warmth.noteWarm('model-a', Date.now() + 600_000)
|
||||
const service = create(deps)
|
||||
await (service as unknown as Internal)._generate(LONG_PREFIX, typingContext({ prefix: LONG_PREFIX }), 3, 160)
|
||||
|
||||
service.handleTypingContext(typingContext({ prefix: `${LONG_PREFIX} 그리고`, idleMs: 100 }))
|
||||
|
||||
expect(service.isVisible).toBe(false)
|
||||
expect(service.getState().lastSkipReason).toBe('stale')
|
||||
})
|
||||
})
|
||||
|
||||
describe('버그 2: 쿨다운 중 표시된 로컬 기억 세션', () => {
|
||||
function coolingService(): { service: SuggestionService; fake: Fakes } {
|
||||
const fake = fakes()
|
||||
fake.deps.budget.noteFailure(Date.now())
|
||||
fake.deps.budget.noteFailure(Date.now())
|
||||
expect(fake.deps.budget.isCoolingDown(Date.now())).toBe(true)
|
||||
harness.continuations = ['첫 번째 이어쓰기 문장입니다', '두 번째 이어쓰기 문장입니다']
|
||||
const service = create(fake.deps)
|
||||
service.handleTypingContext(typingContext())
|
||||
expect(service.getState()).toMatchObject({ visible: true, provenance: { mode: 'local-memory' } })
|
||||
expect(fake.record).toHaveBeenCalledTimes(1)
|
||||
return { service, fake }
|
||||
}
|
||||
|
||||
it('다음 후보 단축키(키 입력 직후 스냅샷)로 세션이 닫히지 않는다', () => {
|
||||
const { service } = coolingService()
|
||||
service.next()
|
||||
service.handleTypingContext(typingContext({ idleMs: 100 }))
|
||||
expect(service.getState()).toMatchObject({ visible: true, activeIndex: 1 })
|
||||
})
|
||||
|
||||
it('멈춘 상태의 주기 스냅샷이 선택을 초기화하거나 이력을 중복 기록하지 않는다', () => {
|
||||
const { service, fake } = coolingService()
|
||||
service.next()
|
||||
service.handleTypingContext(typingContext({ idleMs: 2000 }))
|
||||
service.handleTypingContext(typingContext({ idleMs: 2800 }))
|
||||
expect(service.getState().activeIndex).toBe(1)
|
||||
expect(fake.record).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('타이핑이 끊긴 뒤(not-typing)에도 읽는 중인 세션을 닫지 않는다', () => {
|
||||
const { service } = coolingService()
|
||||
service.handleTypingContext(typingContext({ typedRecently: false, idleMs: 9000 }))
|
||||
expect(service.isVisible).toBe(true)
|
||||
})
|
||||
|
||||
it('비밀번호 칸으로 옮기면 쿨다운 중에도 닫는다', () => {
|
||||
const { service } = coolingService()
|
||||
service.handleTypingContext(typingContext({ isPassword: true }))
|
||||
expect(service.isVisible).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('버그 4: 모델 변경 뒤 콜드 모델로 바로 생성하지 않는다', () => {
|
||||
it('이전 모델이 warm 이어도 새 모델은 워밍업부터 한다', async () => {
|
||||
harness.streamGenerate.mockImplementation(() => streamOf('ok'))
|
||||
const { deps } = fakes()
|
||||
deps.warmth.noteWarm('model-a', Date.now() + 600_000)
|
||||
const service = create(deps)
|
||||
const internal = service as unknown as Internal
|
||||
const generateSpy = vi.spyOn(internal, '_generate')
|
||||
|
||||
config.suggestionModelId = 'model-b'
|
||||
service.handleTypingContext(typingContext())
|
||||
|
||||
expect(generateSpy).not.toHaveBeenCalled()
|
||||
expect(harness.streamGenerate).toHaveBeenCalledWith('hi', expect.objectContaining({ model: 'model-b', maxTokens: 1 }))
|
||||
expect(service.getState().lastSkipReason).toBe('model-unavailable')
|
||||
await internal._warmUpPromise
|
||||
expect(deps.warmth.isWarm('model-b', Date.now())).toBe(true)
|
||||
})
|
||||
|
||||
it('handleModelChanged 는 새 콜드 모델을 미리 워밍업한다', async () => {
|
||||
harness.streamGenerate.mockImplementation(() => streamOf('ok'))
|
||||
const { deps } = fakes()
|
||||
deps.warmth.noteWarm('model-a', Date.now() + 600_000)
|
||||
const service = create(deps)
|
||||
|
||||
service.handleModelChanged()
|
||||
expect(harness.streamGenerate).not.toHaveBeenCalled()
|
||||
|
||||
config.suggestionModelId = 'model-b'
|
||||
service.handleModelChanged()
|
||||
await (service as unknown as Internal)._warmUpPromise
|
||||
expect(harness.streamGenerate).toHaveBeenCalledTimes(1)
|
||||
expect(harness.streamGenerate).toHaveBeenCalledWith('hi', expect.objectContaining({ model: 'model-b' }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('버그 5: 실패한 워밍업은 실패로 센다', () => {
|
||||
it('워밍업 실패 뒤 다음 멈춤에서 워밍업을 바로 다시 쏘지 않고, 두 번째 실패로 쿨다운에 들어간다', async () => {
|
||||
vi.useFakeTimers()
|
||||
harness.streamGenerate.mockImplementation(() =>
|
||||
(async function* () {
|
||||
throw new Error('Ollama responded with 404')
|
||||
})()
|
||||
)
|
||||
const { deps } = fakes()
|
||||
const service = create(deps)
|
||||
const internal = service as unknown as Internal
|
||||
|
||||
service.handleTypingContext(typingContext())
|
||||
await internal._warmUpPromise
|
||||
expect(harness.streamGenerate).toHaveBeenCalledTimes(1)
|
||||
expect(deps.budget.consecutiveFailures).toBe(1)
|
||||
expect(service.getState().lastSkipReason).toBe('generation-failed')
|
||||
|
||||
// 800ms 뒤 샘플 — 실패 직후라 워밍업을 다시 쏘지 않는다.
|
||||
vi.advanceTimersByTime(800)
|
||||
service.handleTypingContext(typingContext())
|
||||
expect(harness.streamGenerate).toHaveBeenCalledTimes(1)
|
||||
expect(service.getState()).toMatchObject({ warmingUp: false, lastSkipReason: 'generation-failed' })
|
||||
|
||||
// 물러서기 시간이 지나면 한 번 더 시도하고, 그 실패로 쿨다운에 들어간다.
|
||||
vi.advanceTimersByTime(5000)
|
||||
service.handleTypingContext(typingContext())
|
||||
await internal._warmUpPromise
|
||||
expect(harness.streamGenerate).toHaveBeenCalledTimes(2)
|
||||
expect(deps.budget.isCoolingDown(Date.now())).toBe(true)
|
||||
expect(service.getState().coolingDown).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('버그 6: 수락 직전 대상 창 확인', () => {
|
||||
async function visibleService(fake: Fakes): Promise<SuggestionService> {
|
||||
harness.streamGenerate.mockImplementation(() => streamOf('다음 단계도 확인하겠습니다.'))
|
||||
fake.deps.warmth.noteWarm('model-a', Date.now() + 600_000)
|
||||
const service = create(fake.deps)
|
||||
await (service as unknown as Internal)._generate(SHORT_PREFIX, typingContext({ windowHandle: 101 }), 3, 160)
|
||||
expect(service.isVisible).toBe(true)
|
||||
return service
|
||||
}
|
||||
|
||||
it('포커스가 다른 창으로 옮겨 갔으면 붙여넣지 않는다', async () => {
|
||||
const fake = fakes()
|
||||
const service = await visibleService(fake)
|
||||
fake.foregroundHandle.value = 202
|
||||
|
||||
const result = await service.accept()
|
||||
|
||||
expect(result).toEqual({ ok: false, reason: 'stale' })
|
||||
expect(harness.insertText).not.toHaveBeenCalled()
|
||||
expect(fake.expectInsert).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('같은 창이면 삽입하고, 삽입 예고와 수락 학습을 한 번씩 알린다', async () => {
|
||||
const fake = fakes()
|
||||
const service = await visibleService(fake)
|
||||
|
||||
const result = await service.accept()
|
||||
|
||||
expect(result).toEqual({ ok: true })
|
||||
expect(harness.insertText).toHaveBeenCalledWith('다음 단계도 확인하겠습니다.', 'clipboard')
|
||||
expect(fake.expectInsert).toHaveBeenCalledWith('다음 단계도 확인하겠습니다.')
|
||||
expect(fake.recordAccepted).toHaveBeenCalledTimes(1)
|
||||
expect(fake.cancelInsert).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('삽입이 실패하면 삽입 예고를 취소한다', async () => {
|
||||
const fake = fakes()
|
||||
const service = await visibleService(fake)
|
||||
harness.insertText.mockResolvedValueOnce({ success: false, method: 'clipboard', textLength: 0 })
|
||||
|
||||
const result = await service.accept()
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
expect(fake.cancelInsert).toHaveBeenCalledTimes(1)
|
||||
expect(fake.recordAccepted).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('창을 알 수 없으면(포그라운드 조회 실패) 기존처럼 삽입한다', async () => {
|
||||
const fake = fakes()
|
||||
const service = await visibleService(fake)
|
||||
fake.foregroundHandle.value = null
|
||||
|
||||
expect(await service.accept()).toEqual({ ok: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('SuggestionBudget', () => {
|
||||
it('consume 은 창을 먼저 굴린다 — 오래 쉰 뒤의 요청도 예산에 잡힌다', () => {
|
||||
const budget = new SuggestionBudget()
|
||||
const t0 = new Date(2026, 8, 28, 10, 0, 0).getTime()
|
||||
budget.consume(t0)
|
||||
const later = t0 + 120_000
|
||||
budget.consume(later)
|
||||
// 직후의 상태 조회(창 굴리기)가 방금 소비를 지우면 안 된다.
|
||||
expect(budget.snapshot(later + 10)).toMatchObject({ requestsThisMinute: 1, requestsToday: 2 })
|
||||
})
|
||||
|
||||
it('창이 바뀐 뒤의 환급은 새 창의 카운트를 깎지 않는다', () => {
|
||||
const budget = new SuggestionBudget()
|
||||
const t0 = new Date(2026, 8, 28, 10, 0, 0).getTime()
|
||||
budget.consume(t0)
|
||||
budget.consume(t0 + 61_000)
|
||||
budget.snapshot(t0 + 61_000)
|
||||
budget.consume(t0 + 62_000)
|
||||
budget.refund()
|
||||
expect(budget.snapshot(t0 + 62_000)).toMatchObject({ requestsThisMinute: 1 })
|
||||
expect(budget.lastRequestAt).toBe(t0 + 61_000)
|
||||
|
||||
const other = new SuggestionBudget()
|
||||
other.consume(t0)
|
||||
other.snapshot(t0 + 61_000)
|
||||
other.refund()
|
||||
expect(other.minuteCount).toBe(0)
|
||||
expect(other.dayCount).toBe(0)
|
||||
})
|
||||
|
||||
it('연속 실패 임계에서 쿨다운, 성공이면 초기화, relaxInterval 은 최소 간격만 푼다', () => {
|
||||
const budget = new SuggestionBudget({ failureCooldownThreshold: 2, failureCooldownMs: 60_000 })
|
||||
expect(budget.noteFailure(1000)).toBe(false)
|
||||
expect(budget.noteFailure(2000)).toBe(true)
|
||||
expect(budget.isCoolingDown(61_999)).toBe(true)
|
||||
expect(budget.isCoolingDown(62_000)).toBe(false)
|
||||
budget.noteSuccess()
|
||||
expect(budget.isCoolingDown(3000)).toBe(false)
|
||||
|
||||
budget.consume(100_000)
|
||||
budget.relaxInterval(101_000, 5000)
|
||||
expect(budget.snapshot(101_000)).toMatchObject({ sinceLastRequestMs: 5000, requestsThisMinute: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('ModelWarmTracker', () => {
|
||||
it('온기와 워밍업 실패를 모델별로 기억한다', () => {
|
||||
const tracker = new ModelWarmTracker()
|
||||
tracker.noteWarm('a', 10_000)
|
||||
expect(tracker.isWarm('a', 9_999)).toBe(true)
|
||||
expect(tracker.isWarm('a', 10_000)).toBe(false)
|
||||
expect(tracker.isWarm('b', 5_000)).toBe(false)
|
||||
expect(tracker.isWarm(null, 5_000)).toBe(false)
|
||||
|
||||
tracker.noteWarmUpFailure('b', 1_000)
|
||||
expect(tracker.isBackingOff('b', 5_999, 5_000)).toBe(true)
|
||||
expect(tracker.isBackingOff('b', 6_000, 5_000)).toBe(false)
|
||||
expect(tracker.isBackingOff('a', 2_000, 5_000)).toBe(false)
|
||||
tracker.noteWarm('b', 20_000)
|
||||
expect(tracker.isBackingOff('b', 2_000, 5_000)).toBe(false)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue