335 lines
13 KiB
TypeScript
335 lines
13 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import type { PersonalPhrase } from '@d3ro/core/input-intelligence'
|
|
|
|
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 as boolean,
|
|
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 })),
|
|
phrases: [] as PersonalPhrase[]
|
|
}))
|
|
|
|
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: () => harness.phrases })
|
|
}))
|
|
vi.mock('../../../src/main/services/PersonalGraphService', () => ({
|
|
getPersonalGraphService: () => ({ retrieveContext: () => ({ 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 PREFIX = '오늘 회의에서 논의한 내용을 정리해서'
|
|
const SUGGESTION = '다음 단계도 확인하겠습니다.'
|
|
|
|
function typingContext(overrides: Partial<TypingContext> = {}): TypingContext {
|
|
const prefix = overrides.prefix ?? PREFIX
|
|
return {
|
|
prefix,
|
|
fullText: prefix,
|
|
caretOffset: prefix.length,
|
|
anchor: { x: 100, y: 100, width: 2, height: 20 },
|
|
anchorKind: 'caret',
|
|
isPassword: false,
|
|
isEditable: true,
|
|
isComposing: false,
|
|
hasSelection: false,
|
|
available: true,
|
|
appName: 'Slack.exe',
|
|
windowTitle: 'general',
|
|
windowHandle: 101,
|
|
idleMs: 2000,
|
|
capturedAt: Date.now(),
|
|
editedSinceFocus: true,
|
|
typedRecently: true,
|
|
...overrides
|
|
}
|
|
}
|
|
|
|
interface Fakes {
|
|
deps: SuggestionServiceDeps
|
|
record: ReturnType<typeof vi.fn>
|
|
markAccepted: ReturnType<typeof vi.fn>
|
|
expectInsert: ReturnType<typeof vi.fn>
|
|
recordAccepted: ReturnType<typeof vi.fn>
|
|
}
|
|
|
|
function fakes(): Fakes {
|
|
const record = vi.fn()
|
|
const markAccepted = vi.fn()
|
|
const expectInsert = vi.fn(() => vi.fn())
|
|
const recordAccepted = vi.fn()
|
|
return {
|
|
record,
|
|
markAccepted,
|
|
expectInsert,
|
|
recordAccepted,
|
|
deps: {
|
|
repository: { record, markAccepted, list: vi.fn(() => []) },
|
|
budget: new SuggestionBudget(),
|
|
warmth: new ModelWarmTracker(),
|
|
foreground: { currentWindowHandle: () => 101 },
|
|
learning: { expectProgrammaticInsert: expectInsert, recordAccepted }
|
|
}
|
|
}
|
|
}
|
|
|
|
function streamOf(text: string): AsyncGenerator<string> {
|
|
return (async function* () {
|
|
yield text
|
|
})()
|
|
}
|
|
|
|
/** 호출자가 풀어 줄 때까지 기다리는 스트림 — abort 되면 예외로 끝난다. */
|
|
function gatedStream(signal: AbortSignal | undefined, gate: Promise<string>): AsyncGenerator<string> {
|
|
return (async function* () {
|
|
const text = await new Promise<string>((resolve, reject) => {
|
|
signal?.addEventListener('abort', () => reject(new Error('aborted')), { once: true })
|
|
void gate.then(resolve)
|
|
})
|
|
yield text
|
|
})()
|
|
}
|
|
|
|
interface Internal {
|
|
_generate(prefix: string, context: TypingContext, maxCandidates: number, maxChars: number): Promise<void>
|
|
_lastContext: TypingContext | null
|
|
_inFlight: boolean
|
|
_warmUpPromise: Promise<void> | null
|
|
}
|
|
|
|
let services: SuggestionService[] = []
|
|
function create(deps: SuggestionServiceDeps, warm = true): SuggestionService {
|
|
if (warm) deps.warmth.noteWarm('model-a', Date.now() + 600_000)
|
|
const service = new SuggestionService(deps)
|
|
services.push(service)
|
|
return service
|
|
}
|
|
|
|
beforeEach(() => {
|
|
config.suggestionEnabled = true
|
|
config.inputLearnTypedText = false
|
|
harness.isAvailable.mockReset()
|
|
harness.isAvailable.mockReturnValue(true)
|
|
harness.streamGenerate.mockReset()
|
|
harness.insertText.mockClear()
|
|
harness.phrases = []
|
|
})
|
|
|
|
afterEach(() => {
|
|
for (const service of services) service.dispose()
|
|
services = []
|
|
})
|
|
|
|
describe('진행 중 생성 — 요청 뒤 이어 치면 즉시 취소한다 (세션 규칙과 동일)', () => {
|
|
it('12자 미만 성장도 진행 중 요청을 취소하고 예산을 환급한다', async () => {
|
|
let signal: AbortSignal | undefined
|
|
let release: (text: string) => void = () => undefined
|
|
const gate = new Promise<string>((resolve) => {
|
|
release = resolve
|
|
})
|
|
harness.streamGenerate.mockImplementation((_text: string, options: { signal?: AbortSignal }) => {
|
|
signal = options.signal
|
|
return gatedStream(options.signal, gate)
|
|
})
|
|
const fake = fakes()
|
|
const service = create(fake.deps)
|
|
const internal = service as unknown as Internal
|
|
|
|
service.handleTypingContext(typingContext({ prefix: '회의 자료는 오늘' }))
|
|
expect(internal._inFlight).toBe(true)
|
|
|
|
service.handleTypingContext(typingContext({ prefix: '회의 자료는 오늘 저녁까지', idleMs: 100 }))
|
|
release(SUGGESTION)
|
|
await Promise.resolve()
|
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
|
|
expect(signal?.aborted).toBe(true)
|
|
expect(service.isVisible).toBe(false)
|
|
expect(fake.deps.budget.minuteCount).toBe(0)
|
|
expect(fake.record).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('결과 게시 직전 최신 문맥이 자랐으면 옛 접두용 결과를 게시하지 않는다', async () => {
|
|
let release: (text: string) => void = () => undefined
|
|
const gate = new Promise<string>((resolve) => {
|
|
release = resolve
|
|
})
|
|
harness.streamGenerate.mockImplementation((_text: string, options: { signal?: AbortSignal }) =>
|
|
gatedStream(options.signal, gate)
|
|
)
|
|
const fake = fakes()
|
|
const service = create(fake.deps)
|
|
const internal = service as unknown as Internal
|
|
const context = typingContext({ prefix: '회의 자료는 오늘' })
|
|
|
|
const generation = internal._generate('회의 자료는 오늘', context, 3, 160)
|
|
// 취소 경로를 거치지 않고 최신 문맥만 바뀐 경우 (게시 가드만 검증)
|
|
internal._lastContext = typingContext({ prefix: '회의 자료는 오늘 저녁까지' })
|
|
release(SUGGESTION)
|
|
await generation
|
|
|
|
expect(service.isVisible).toBe(false)
|
|
expect(service.getState().lastSkipReason).toBe('stale')
|
|
expect(fake.record).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('같은 접두(주기 스냅샷)면 그대로 게시한다', async () => {
|
|
harness.streamGenerate.mockImplementation(() => streamOf(SUGGESTION))
|
|
const fake = fakes()
|
|
const service = create(fake.deps)
|
|
const internal = service as unknown as Internal
|
|
internal._lastContext = typingContext()
|
|
await internal._generate(PREFIX, typingContext(), 3, 160)
|
|
expect(service.isVisible).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('수락 — 구분 공백', () => {
|
|
async function visibleModelSession(fake: Fakes): Promise<SuggestionService> {
|
|
harness.streamGenerate.mockImplementation(() => streamOf(SUGGESTION))
|
|
const service = create(fake.deps)
|
|
await (service as unknown as Internal)._generate(PREFIX, typingContext(), 3, 160)
|
|
expect(service.isVisible).toBe(true)
|
|
return service
|
|
}
|
|
|
|
it('단어 바로 뒤에서 멈췄으면 공백 하나를 앞에 붙여 삽입한다', async () => {
|
|
const fake = fakes()
|
|
const service = await visibleModelSession(fake)
|
|
|
|
expect(await service.accept()).toEqual({ ok: true })
|
|
|
|
expect(harness.insertText).toHaveBeenCalledWith(` ${SUGGESTION}`, 'clipboard')
|
|
expect(fake.expectInsert).toHaveBeenCalledWith(` ${SUGGESTION}`)
|
|
// 이력·학습은 후보 원문으로 — 이력 행을 찾을 수 있어야 한다.
|
|
expect(fake.markAccepted).toHaveBeenCalledWith(SUGGESTION)
|
|
expect(fake.recordAccepted).toHaveBeenCalledWith(SUGGESTION, expect.anything())
|
|
})
|
|
|
|
it('영어 문장 끝 마침표 뒤에도 떼어 쓴다', async () => {
|
|
harness.streamGenerate.mockImplementation(() => streamOf('Let me know if you have questions.'))
|
|
const fake = fakes()
|
|
const service = create(fake.deps)
|
|
const context = typingContext({ prefix: 'I sent the report.' })
|
|
await (service as unknown as Internal)._generate('I sent the report.', context, 3, 160)
|
|
|
|
await service.accept()
|
|
expect(harness.insertText).toHaveBeenCalledWith(' Let me know if you have questions.', 'clipboard')
|
|
})
|
|
|
|
it('제안 뒤 사용자가 이미 공백을 쳤으면(최신 문맥) 공백을 더하지 않는다', async () => {
|
|
const fake = fakes()
|
|
const service = await visibleModelSession(fake)
|
|
service.handleTypingContext(typingContext({ prefix: `${PREFIX} `, idleMs: 100 }))
|
|
expect(service.isVisible).toBe(true)
|
|
|
|
await service.accept()
|
|
expect(harness.insertText).toHaveBeenCalledWith(SUGGESTION, 'clipboard')
|
|
})
|
|
|
|
it('단어 중간에서 이어지는 로컬 기억 후보는 공백 없이 붙인다', async () => {
|
|
harness.phrases = [
|
|
{
|
|
id: 'p1',
|
|
phrase: 'Thank you for the update',
|
|
count: 3,
|
|
source: 'typed',
|
|
appName: 'Slack.exe',
|
|
lastUsedAt: Date.now(),
|
|
createdAt: Date.now()
|
|
}
|
|
]
|
|
const fake = fakes()
|
|
// 쿨다운 중에는 로컬 기억만 게시한다.
|
|
fake.deps.budget.noteFailure(Date.now())
|
|
fake.deps.budget.noteFailure(Date.now())
|
|
const service = create(fake.deps)
|
|
service.handleTypingContext(typingContext({ prefix: 'Thank yo' }))
|
|
expect(service.getState().candidates[0]?.text).toBe('u for the update')
|
|
|
|
await service.accept()
|
|
expect(harness.insertText).toHaveBeenCalledWith('u for the update', 'clipboard')
|
|
})
|
|
})
|
|
|
|
describe('모델 서버가 없을 때 — 끝나지 않는 "준비 중" 을 띄우지 않는다', () => {
|
|
it('준비 작업(워밍업)이 없으면 오버레이 없이 사유만 남긴다', () => {
|
|
harness.isAvailable.mockReturnValue(false)
|
|
const fake = fakes()
|
|
const service = create(fake.deps)
|
|
const updates: boolean[] = []
|
|
service.on('updated', (state) => updates.push(state.warmingUp))
|
|
|
|
service.handleTypingContext(typingContext())
|
|
|
|
expect(service.getState()).toMatchObject({ warmingUp: false, lastSkipReason: 'model-unavailable' })
|
|
expect(service.isPresentationActive).toBe(false)
|
|
expect(updates).not.toContain(true)
|
|
// 이어 쳐도 already-visible 로 굳지 않는다.
|
|
service.handleTypingContext(typingContext({ prefix: `${PREFIX} 공유`, idleMs: 2000 }))
|
|
expect(service.isPresentationActive).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('제안 이력 — 입력 원문은 학습 동의 규칙을 따른다', () => {
|
|
async function publish(fake: Fakes, context: TypingContext): Promise<void> {
|
|
harness.streamGenerate.mockImplementation(() => streamOf(SUGGESTION))
|
|
const service = create(fake.deps)
|
|
await (service as unknown as Internal)._generate(context.prefix, context, 3, 160)
|
|
expect(service.isVisible).toBe(true)
|
|
}
|
|
|
|
it('학습 동의가 없으면 접두 원문을 저장하지 않는다', async () => {
|
|
const fake = fakes()
|
|
await publish(fake, typingContext())
|
|
expect(fake.record).toHaveBeenCalledWith(expect.objectContaining({ prefix: '', text: SUGGESTION, appName: 'Slack.exe' }))
|
|
})
|
|
|
|
it('동의가 있어도 터미널(학습 제외 앱)의 명령 줄은 저장하지 않는다', async () => {
|
|
config.inputLearnTypedText = true
|
|
const fake = fakes()
|
|
await publish(fake, typingContext({ prefix: 'mysql -u root -pS3cret prod', appName: 'WindowsTerminal.exe' }))
|
|
expect(fake.record).toHaveBeenCalledWith(expect.objectContaining({ prefix: '' }))
|
|
})
|
|
|
|
it('동의가 있고 일반 앱이면 접두를 저장한다', async () => {
|
|
config.inputLearnTypedText = true
|
|
const fake = fakes()
|
|
await publish(fake, typingContext())
|
|
expect(fake.record).toHaveBeenCalledWith(expect.objectContaining({ prefix: PREFIX }))
|
|
})
|
|
})
|