release: ship v1.6.0 with paged suggestions and a cleaner phrase memory
Next-sentence suggestions now arrive one at a time up to twelve, shown three per page with Ctrl+Alt+Up/Down to move, Left/Right to page, Enter to accept and Esc to close; old default bindings migrate and the panel guide follows the live bindings. The overlay is redesigned, stays put while candidates stream and sits outside the input box when no caret is reported. The personal phrase memory stops learning from terminals, code editors and the coding-agent hub, ignores symbol-heavy lines and empty-field placeholders, and prunes existing entries that break those rules. Fixes suggestion keys starting dictation, installs stuck on a pre-1.5.0 speech engine without the focus endpoint, Ollama runner windows flashing while typing, the speech engine starting twice, and cold-model timeouts. Live captions can be dragged to a remembered position and show a waiting notice until the first line arrives. Bumps the product version to 1.6.0 (Android/iOS build 1060000).
This commit is contained in:
parent
856e375f3e
commit
2fe20fa7b5
71 changed files with 2469 additions and 366 deletions
|
|
@ -48,14 +48,134 @@ describe('ConfigService suggestion tuning migration', () => {
|
|||
)
|
||||
await initConfigService()
|
||||
|
||||
expect(configGet('suggestionTuningRevision')).toBe(4)
|
||||
expect(configGet('suggestionTuningRevision')).toBe(5)
|
||||
expect(configGet('suggestionTriggerDelayMs')).toBe(600)
|
||||
expect(configGet('suggestionMaxRequestsPerMinute')).toBe(6)
|
||||
expect(configGet('suggestionMinPrefixChars')).toBe(17)
|
||||
expect(configGet('suggestionDailyBudget')).toBe(777)
|
||||
expect(configGet('suggestionRequestTimeoutMs')).toBe(23000)
|
||||
// 옛 기본값이 아닌 커스터마이즈(Ctrl+A)는 revision 5 마이그레이션도 건드리지 않는다.
|
||||
expect(configGet('keyBindings')['suggestion-accept']).toEqual(savedBindings['suggestion-accept'])
|
||||
|
||||
resetInMemoryConfig()
|
||||
})
|
||||
|
||||
function makeTestStore<T extends Record<string, unknown>>(persisted: Partial<T>) {
|
||||
return class TestStore {
|
||||
store: T
|
||||
|
||||
constructor(options: { defaults: T }) {
|
||||
this.store = { ...options.defaults, ...persisted } as T
|
||||
}
|
||||
|
||||
get<K extends keyof T>(key: K): T[K] {
|
||||
return this.store[key]
|
||||
}
|
||||
|
||||
set<K extends keyof T>(key: K, value: T[K]): void {
|
||||
this.store[key] = value
|
||||
}
|
||||
|
||||
delete(key: string): void {
|
||||
delete this.store[key as keyof T]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('revision 5: 정확히 옛 기본값(accept=Ctrl+Alt+→, dismiss=Ctrl+Alt+←)이면 새 기본값으로 옮기고 페이지 이동을 그 자리에 채운다', async () => {
|
||||
const OLD_ACCEPT = [{ device: 'keyboard' as const, code: 39, ctrl: true, alt: true, shift: false, meta: false }]
|
||||
const OLD_NEXT = [{ device: 'keyboard' as const, code: 40, ctrl: true, alt: true, shift: false, meta: false }]
|
||||
const OLD_PREV = [{ device: 'keyboard' as const, code: 38, ctrl: true, alt: true, shift: false, meta: false }]
|
||||
const OLD_DISMISS = [{ device: 'keyboard' as const, code: 37, ctrl: true, alt: true, shift: false, meta: false }]
|
||||
|
||||
vi.doMock('electron-store', () => ({
|
||||
default: makeTestStore({
|
||||
suggestionTuningRevision: 4,
|
||||
keyBindings: {
|
||||
'suggestion-accept': OLD_ACCEPT,
|
||||
'suggestion-next': OLD_NEXT,
|
||||
'suggestion-prev': OLD_PREV,
|
||||
'suggestion-dismiss': OLD_DISMISS
|
||||
}
|
||||
})
|
||||
}))
|
||||
const { configGet, initConfigService, resetInMemoryConfig } = await import(
|
||||
'../../../src/main/services/ConfigService'
|
||||
)
|
||||
await initConfigService()
|
||||
|
||||
const bindings = configGet('keyBindings')
|
||||
expect(bindings['suggestion-accept']).toEqual([
|
||||
{ device: 'keyboard', code: 0x0d, ctrl: true, alt: true, shift: false, meta: false }
|
||||
])
|
||||
expect(bindings['suggestion-dismiss']).toEqual([
|
||||
{ device: 'keyboard', code: 0x08, ctrl: true, alt: true, shift: false, meta: false }
|
||||
])
|
||||
// 옮기지 않는 액션은 그대로 남는다.
|
||||
expect(bindings['suggestion-next']).toEqual(OLD_NEXT)
|
||||
expect(bindings['suggestion-prev']).toEqual(OLD_PREV)
|
||||
// accept/dismiss 가 비켜난 자리를 새 페이지 이동 액션이 충돌 없이 채운다.
|
||||
expect(bindings['suggestion-page-next']).toEqual([
|
||||
{ device: 'keyboard', code: 0x27, ctrl: true, alt: true, shift: false, meta: false }
|
||||
])
|
||||
expect(bindings['suggestion-page-prev']).toEqual([
|
||||
{ device: 'keyboard', code: 0x25, ctrl: true, alt: true, shift: false, meta: false }
|
||||
])
|
||||
expect(configGet('suggestionTuningRevision')).toBe(5)
|
||||
|
||||
resetInMemoryConfig()
|
||||
})
|
||||
|
||||
it('revision 5: 옛 기본값이 아닌 커스터마이즈는 절대 건드리지 않는다', async () => {
|
||||
const CUSTOM_ACCEPT = [{ device: 'keyboard' as const, code: 0x41, ctrl: true, alt: false, shift: false, meta: false }]
|
||||
const CUSTOM_DISMISS = [{ device: 'keyboard' as const, code: 0x44, ctrl: true, alt: false, shift: false, meta: false }]
|
||||
|
||||
vi.doMock('electron-store', () => ({
|
||||
default: makeTestStore({
|
||||
suggestionTuningRevision: 4,
|
||||
keyBindings: {
|
||||
'suggestion-accept': CUSTOM_ACCEPT,
|
||||
'suggestion-dismiss': CUSTOM_DISMISS
|
||||
}
|
||||
})
|
||||
}))
|
||||
const { configGet, initConfigService, resetInMemoryConfig } = await import(
|
||||
'../../../src/main/services/ConfigService'
|
||||
)
|
||||
await initConfigService()
|
||||
|
||||
const bindings = configGet('keyBindings')
|
||||
expect(bindings['suggestion-accept']).toEqual(CUSTOM_ACCEPT)
|
||||
expect(bindings['suggestion-dismiss']).toEqual(CUSTOM_DISMISS)
|
||||
|
||||
resetInMemoryConfig()
|
||||
})
|
||||
|
||||
it('revision 5: 새 페이지 이동 기본값이 다른 액션과 충돌하면 바인딩 없이 둔다', async () => {
|
||||
// suggestion-next 를 페이지-다음의 새 기본값(Ctrl+Alt+→)과 똑같이 커스터마이즈해 충돌을 만든다.
|
||||
const CONFLICTING_NEXT = [{ device: 'keyboard' as const, code: 0x27, ctrl: true, alt: true, shift: false, meta: false }]
|
||||
|
||||
vi.doMock('electron-store', () => ({
|
||||
default: makeTestStore({
|
||||
suggestionTuningRevision: 4,
|
||||
keyBindings: {
|
||||
'suggestion-next': CONFLICTING_NEXT
|
||||
}
|
||||
})
|
||||
}))
|
||||
const { configGet, initConfigService, resetInMemoryConfig } = await import(
|
||||
'../../../src/main/services/ConfigService'
|
||||
)
|
||||
await initConfigService()
|
||||
|
||||
const bindings = configGet('keyBindings')
|
||||
expect(bindings['suggestion-page-next']).toEqual([])
|
||||
// 충돌이 없는 page-prev 는 정상적으로 기본값을 받는다.
|
||||
expect(bindings['suggestion-page-prev']).toEqual([
|
||||
{ device: 'keyboard', code: 0x25, ctrl: true, alt: true, shift: false, meta: false }
|
||||
])
|
||||
expect(bindings['suggestion-next']).toEqual(CONFLICTING_NEXT)
|
||||
|
||||
resetInMemoryConfig()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ const context = {
|
|||
fullText: PREFIX,
|
||||
caretOffset: PREFIX.length,
|
||||
anchor: null,
|
||||
anchorKind: null,
|
||||
isPassword: false,
|
||||
isEditable: true,
|
||||
isComposing: false,
|
||||
|
|
@ -55,7 +56,9 @@ const context = {
|
|||
appName: 'notepad.exe',
|
||||
windowTitle: 'notes',
|
||||
idleMs: 1000,
|
||||
capturedAt: Date.now()
|
||||
capturedAt: Date.now(),
|
||||
editedSinceFocus: true,
|
||||
typedRecently: true
|
||||
}
|
||||
|
||||
interface InternalSuggestionService {
|
||||
|
|
@ -112,7 +115,7 @@ afterEach(async () => {
|
|||
})
|
||||
|
||||
describe('SuggestionService warm-up', () => {
|
||||
it('동시 warm-up 호출을 하나의 1토큰 요청으로 합치고 2분만 유지한다', async () => {
|
||||
it('동시 warm-up 호출을 하나의 1토큰 요청으로 합치고 10분간 유지한다', async () => {
|
||||
localLlm.streamGenerate.mockImplementation(async function* () {
|
||||
yield 'ok'
|
||||
})
|
||||
|
|
@ -126,11 +129,63 @@ describe('SuggestionService warm-up', () => {
|
|||
expect(localLlm.streamGenerate).toHaveBeenCalledTimes(1)
|
||||
expect(localLlm.streamGenerate).toHaveBeenCalledWith(
|
||||
'hi',
|
||||
expect.objectContaining({ maxTokens: 1, keepAlive: '2m' })
|
||||
expect.objectContaining({ maxTokens: 1, keepAlive: '10m' })
|
||||
)
|
||||
expect(warmingStates).toEqual([true, false])
|
||||
})
|
||||
|
||||
it('콜드 모델(워밍업 전)에서는 요청 결정이어도 생성 대신 워밍업을 트리거한다', async () => {
|
||||
localLlm.streamGenerate.mockImplementation(async function* () {
|
||||
yield 'ok'
|
||||
})
|
||||
const { getSuggestionService } = await import('../../../src/main/services/SuggestionService')
|
||||
const service = getSuggestionService()
|
||||
const internal = service as unknown as InternalSuggestionService & {
|
||||
_warmUpPromise: Promise<void> | null
|
||||
}
|
||||
const generateSpy = vi.spyOn(internal, '_generate')
|
||||
|
||||
service.handleTypingContext({ ...context, idleMs: 1000 })
|
||||
|
||||
expect(generateSpy).not.toHaveBeenCalled()
|
||||
expect(localLlm.streamGenerate).toHaveBeenCalledWith('hi', expect.objectContaining({ maxTokens: 1 }))
|
||||
const state = service.getState()
|
||||
expect(state.warmingUp).toBe(true)
|
||||
expect(state.lastSkipReason).toBe('model-unavailable')
|
||||
|
||||
await internal._warmUpPromise
|
||||
})
|
||||
|
||||
it('dismiss(stale)는 진행 중인 워밍업을 취소하지 않는다', async () => {
|
||||
let resolveChunk: (() => void) | null = null
|
||||
localLlm.streamGenerate.mockImplementation((_text: string, options: { signal?: AbortSignal }) => {
|
||||
return (async function* () {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
resolveChunk = resolve
|
||||
options.signal?.addEventListener('abort', () => reject(new Error('aborted')), { once: true })
|
||||
})
|
||||
yield 'ok'
|
||||
})()
|
||||
})
|
||||
const { getSuggestionService } = await import('../../../src/main/services/SuggestionService')
|
||||
const service = getSuggestionService()
|
||||
const internal = service as unknown as InternalSuggestionService & {
|
||||
_warmUpAbort: AbortController | null
|
||||
}
|
||||
|
||||
const warmUpPromise = service.warmUp()
|
||||
await Promise.resolve()
|
||||
expect(internal._warmUpAbort?.signal.aborted).toBe(false)
|
||||
|
||||
service.dismiss('stale')
|
||||
|
||||
expect(internal._warmUpAbort?.signal.aborted).toBe(false)
|
||||
resolveChunk?.()
|
||||
await warmUpPromise
|
||||
|
||||
expect(localLlm.streamGenerate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('비활성화하면 가용성 재시도 타이머와 warm-up을 취소한다', async () => {
|
||||
vi.useFakeTimers()
|
||||
localLlm.isAvailable.mockReturnValue(false)
|
||||
|
|
@ -224,10 +279,13 @@ describe('SuggestionService warm-up', () => {
|
|||
await (service as unknown as InternalSuggestionService)._generate(PREFIX, context, 3, 240)
|
||||
|
||||
const published = states.find((state) => state.candidates.length > 0)
|
||||
expect(published).toMatchObject({ generating: false, partialText: null })
|
||||
// partialText 는 후보 공개와 함께 비워지지만, generating 은 계속 true 로 남는다 —
|
||||
// 첫 후보 공개 뒤 채우기 루프가 백그라운드에서 나머지(최대 12개)를 마저
|
||||
// 청하는 중이라는 신호다(설계).
|
||||
expect(published).toMatchObject({ generating: true, partialText: null })
|
||||
})
|
||||
|
||||
it('stale 취소는 실패 쿨다운을 올리거나 즉시 재시작하지 않는다', async () => {
|
||||
it('stale 취소는 실패 쿨다운을 올리거나 즉시 재시작하지 않고, 보여줄 후보가 없으면 오버레이를 지운다', async () => {
|
||||
localLlm.streamGenerate.mockImplementation((_text: string, options: { signal?: AbortSignal }) =>
|
||||
waitForAbort(options.signal)
|
||||
)
|
||||
|
|
@ -245,7 +303,81 @@ describe('SuggestionService warm-up', () => {
|
|||
expect(internal._consecutiveFailures).toBe(0)
|
||||
expect(internal._cooldownUntil).toBe(0)
|
||||
expect(localLlm.streamGenerate).toHaveBeenCalledTimes(1)
|
||||
expect(cleared).toEqual([])
|
||||
// 스피너만 뜬 채로 남지 않도록, 후보가 없는 stale 취소는 오버레이를 닫는다.
|
||||
expect(cleared).toEqual(['stale'])
|
||||
})
|
||||
|
||||
it('stale 취소는 속도 제한 예산을 환급해 다음 요청이 rate-limited 되지 않는다', async () => {
|
||||
localLlm.streamGenerate.mockImplementation((_text: string, options: { signal?: AbortSignal }) =>
|
||||
waitForAbort(options.signal)
|
||||
)
|
||||
const { getSuggestionService } = await import('../../../src/main/services/SuggestionService')
|
||||
const service = getSuggestionService()
|
||||
const internal = service as unknown as InternalSuggestionService & {
|
||||
_minuteCount: number
|
||||
_dayCount: number
|
||||
_prevRequestAt: number
|
||||
_lastSkipReason: string | null
|
||||
}
|
||||
|
||||
// 분당 카운터 창을 먼저 굳힌다 — 그렇지 않으면 이 인스턴스의 첫 getState() 호출
|
||||
// (_generate 내부의 emit('updated', ...) 에서 일어난다) 이 창을 "지금" 으로
|
||||
// 다시 잡으며 방금 늘린 카운트를 0으로 되돌린다 (실제 흐름에선 handleTypingContext
|
||||
// 가 먼저 창을 굳혀 두므로 일어나지 않는 순서 문제).
|
||||
service.getState()
|
||||
|
||||
const generation = internal._generate(PREFIX, context, 3, 240)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(internal._minuteCount).toBe(1)
|
||||
const requestedAt = internal._lastRequestAt
|
||||
expect(requestedAt).toBeGreaterThan(0)
|
||||
|
||||
internal._abortStaleGeneration('완전히 다른 문맥으로 바뀐 입력입니다')
|
||||
await generation
|
||||
|
||||
// 취소된 요청이 쓴 예산이 되돌아간다.
|
||||
expect(internal._minuteCount).toBe(0)
|
||||
expect(internal._lastRequestAt).toBe(internal._prevRequestAt)
|
||||
expect(internal._lastRequestAt).toBeLessThan(requestedAt)
|
||||
|
||||
// 되돌아간 예산으로 바로 다음 요청은 rate-limited 로 막히지 않는다.
|
||||
service.handleTypingContext({ ...context, idleMs: 1000 })
|
||||
expect(internal._lastSkipReason).not.toBe('rate-limited')
|
||||
})
|
||||
|
||||
it('pageNext는 다음 페이지 첫 항목으로, 후보가 없는 페이지면 그대로 둔다', async () => {
|
||||
const { getSuggestionService } = await import('../../../src/main/services/SuggestionService')
|
||||
const service = getSuggestionService()
|
||||
const internal = service as unknown as { _candidates: Array<{ text: string; rank: number }> }
|
||||
internal._candidates = Array.from({ length: 5 }, (_v, i) => ({ text: `후보${i}`, rank: i }))
|
||||
|
||||
expect(service.getState().activeIndex).toBe(0)
|
||||
service.pageNext()
|
||||
expect(service.getState().activeIndex).toBe(3)
|
||||
service.pageNext()
|
||||
// 5개뿐이라 다음 페이지가 없다 — 그대로 둔다.
|
||||
expect(service.getState().activeIndex).toBe(3)
|
||||
service.pagePrev()
|
||||
expect(service.getState().activeIndex).toBe(0)
|
||||
service.pagePrev()
|
||||
expect(service.getState().activeIndex).toBe(0)
|
||||
})
|
||||
|
||||
it('next/previous는 페이지와 무관하게 전체 후보를 가로질러 순환한다', async () => {
|
||||
const { getSuggestionService } = await import('../../../src/main/services/SuggestionService')
|
||||
const service = getSuggestionService()
|
||||
const internal = service as unknown as { _candidates: Array<{ text: string; rank: number }> }
|
||||
internal._candidates = Array.from({ length: 4 }, (_v, i) => ({ text: `후보${i}`, rank: i }))
|
||||
|
||||
service.next()
|
||||
service.next()
|
||||
service.next()
|
||||
expect(service.getState().activeIndex).toBe(3)
|
||||
service.next()
|
||||
expect(service.getState().activeIndex).toBe(0)
|
||||
service.previous()
|
||||
expect(service.getState().activeIndex).toBe(3)
|
||||
})
|
||||
|
||||
it('watchdog는 실제 요청 signal을 abort하고 세대를 무효화한다', async () => {
|
||||
|
|
@ -272,3 +404,165 @@ describe('SuggestionService warm-up', () => {
|
|||
expect(cleared).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('SuggestionService 세션 채우기 (최대 12개, 순차 · 페이지)', () => {
|
||||
it('첫 요청은 1개만 청하고, 성공하면 채우기 루프가 하나씩 최대 12개까지 채운다', async () => {
|
||||
// 접두/확장 중복 판정 때문에 숫자 접미사(1, 10, 11…)는 서로를 중복으로 오판한다
|
||||
// ("이어지는 문장 1" 이 "이어지는 문장 10" 의 접두이므로) — 서로소인 글자를 쓴다.
|
||||
const LETTERS = 'ABCDEFGHIJKL'
|
||||
let call = 0
|
||||
localLlm.streamGenerate.mockImplementation(async function* () {
|
||||
const letter = LETTERS[call % LETTERS.length]
|
||||
call += 1
|
||||
yield `이어지는 문장 ${letter}`
|
||||
})
|
||||
const { getSuggestionService } = await import('../../../src/main/services/SuggestionService')
|
||||
const service = getSuggestionService()
|
||||
const internal = service as unknown as InternalSuggestionService
|
||||
|
||||
await internal._generate(PREFIX, context, 3, 240)
|
||||
// 세션의 첫 요청은 정확히 1개만 청한다 (한꺼번에 여러 개를 청하면 느리다 — 사용자 요청).
|
||||
expect(localLlm.streamGenerate).toHaveBeenCalledWith(
|
||||
expect.stringContaining('1개'),
|
||||
expect.anything()
|
||||
)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(service.getState().candidates).toHaveLength(12)
|
||||
})
|
||||
expect(service.getState().generating).toBe(false)
|
||||
expect(service.getState().targetTotal).toBe(12)
|
||||
expect(localLlm.streamGenerate).toHaveBeenCalledTimes(12)
|
||||
})
|
||||
|
||||
it('연속 2번 새 후보가 없으면(전부 중복) 채우기를 멈춘다', async () => {
|
||||
localLlm.streamGenerate.mockImplementation(async function* () {
|
||||
yield '같은 문장입니다.'
|
||||
})
|
||||
const { getSuggestionService } = await import('../../../src/main/services/SuggestionService')
|
||||
const service = getSuggestionService()
|
||||
const internal = service as unknown as InternalSuggestionService
|
||||
|
||||
await internal._generate(PREFIX, context, 3, 240)
|
||||
await vi.waitFor(() => {
|
||||
expect(service.getState().generating).toBe(false)
|
||||
})
|
||||
|
||||
expect(service.getState().candidates).toHaveLength(1)
|
||||
// 첫 요청 1번 + 중복으로 끝난 채우기 시도 2번 = 3번.
|
||||
expect(localLlm.streamGenerate).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('중복(완전 일치·접두/확장)은 건너뛰고 고유한 후보만 덧붙인다', async () => {
|
||||
const sequence = ['같은 문장입니다.', '같은 문장입니다.', '다른 문장입니다.']
|
||||
let call = 0
|
||||
localLlm.streamGenerate.mockImplementation(async function* () {
|
||||
const text = sequence[Math.min(call, sequence.length - 1)]
|
||||
call += 1
|
||||
yield text
|
||||
})
|
||||
const { getSuggestionService } = await import('../../../src/main/services/SuggestionService')
|
||||
const service = getSuggestionService()
|
||||
const internal = service as unknown as InternalSuggestionService
|
||||
|
||||
await internal._generate(PREFIX, context, 3, 240)
|
||||
await vi.waitFor(() => {
|
||||
expect(service.getState().generating).toBe(false)
|
||||
})
|
||||
|
||||
expect(service.getState().candidates.map((c) => c.text)).toEqual([
|
||||
'같은 문장입니다.',
|
||||
'다른 문장입니다.'
|
||||
])
|
||||
})
|
||||
|
||||
it('dismiss는 진행 중인 채우기 요청을 취소하고 루프를 멈춘다', async () => {
|
||||
let fillSignal: AbortSignal | undefined
|
||||
let firstServed = false
|
||||
localLlm.streamGenerate.mockImplementation((_text: string, options: { signal?: AbortSignal }) => {
|
||||
if (!firstServed) {
|
||||
firstServed = true
|
||||
return (async function* () {
|
||||
yield '첫 후보입니다.'
|
||||
})()
|
||||
}
|
||||
fillSignal = options.signal
|
||||
return waitForAbort(options.signal)
|
||||
})
|
||||
const { getSuggestionService } = await import('../../../src/main/services/SuggestionService')
|
||||
const service = getSuggestionService()
|
||||
const internal = service as unknown as InternalSuggestionService
|
||||
|
||||
await internal._generate(PREFIX, context, 3, 240)
|
||||
await vi.waitFor(() => expect(fillSignal).toBeDefined())
|
||||
|
||||
expect(fillSignal?.aborted).toBe(false)
|
||||
service.dismiss('dismissed')
|
||||
|
||||
expect(fillSignal?.aborted).toBe(true)
|
||||
expect(service.getState().candidates).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('세션의 첫 요청만 분당/일일 예산을 쓴다 — 채우기 요청은 쓰지 않는다', async () => {
|
||||
const LETTERS = 'ABCDEFGHIJKL'
|
||||
let call = 0
|
||||
localLlm.streamGenerate.mockImplementation(async function* () {
|
||||
const letter = LETTERS[call % LETTERS.length]
|
||||
call += 1
|
||||
yield `문장 ${letter}`
|
||||
})
|
||||
const { getSuggestionService } = await import('../../../src/main/services/SuggestionService')
|
||||
const service = getSuggestionService()
|
||||
const internal = service as unknown as InternalSuggestionService & {
|
||||
_minuteCount: number
|
||||
_dayCount: number
|
||||
}
|
||||
// 분당 카운터 창을 먼저 굳힌다 (다른 테스트와 같은 이유 — 위 주석 참조).
|
||||
service.getState()
|
||||
|
||||
await internal._generate(PREFIX, context, 3, 240)
|
||||
await vi.waitFor(() => expect(service.getState().candidates).toHaveLength(12))
|
||||
|
||||
expect(internal._minuteCount).toBe(1)
|
||||
expect(internal._dayCount).toBe(1)
|
||||
})
|
||||
|
||||
it('세션이 떠 있는 동안 접두가 자라면(다음 문장 시작) 즉시 세션을 끝낸다', async () => {
|
||||
localLlm.streamGenerate.mockImplementation(async function* () {
|
||||
yield '첫 후보입니다.'
|
||||
})
|
||||
const { getSuggestionService } = await import('../../../src/main/services/SuggestionService')
|
||||
const service = getSuggestionService()
|
||||
const internal = service as unknown as InternalSuggestionService
|
||||
const cleared: string[] = []
|
||||
service.on('cleared', ({ reason }) => cleared.push(reason))
|
||||
|
||||
await internal._generate(PREFIX, context, 3, 240)
|
||||
await vi.waitFor(() => expect(service.getState().visible).toBe(true))
|
||||
|
||||
service.handleTypingContext({ ...context, prefix: `${PREFIX} 추가로 입력했습니다`, idleMs: 1000 })
|
||||
|
||||
expect(service.getState().visible).toBe(false)
|
||||
expect(cleared).toContain('stale')
|
||||
})
|
||||
|
||||
it('세션이 떠 있는 동안 마지막 글자만 IME 조합으로 바뀌면 세션을 유지한다', async () => {
|
||||
localLlm.streamGenerate.mockImplementation(async function* () {
|
||||
yield '첫 후보입니다.'
|
||||
})
|
||||
const { getSuggestionService } = await import('../../../src/main/services/SuggestionService')
|
||||
const service = getSuggestionService()
|
||||
const internal = service as unknown as InternalSuggestionService
|
||||
const cleared: string[] = []
|
||||
service.on('cleared', ({ reason }) => cleared.push(reason))
|
||||
|
||||
await internal._generate(PREFIX, context, 3, 240)
|
||||
await vi.waitFor(() => expect(service.getState().visible).toBe(true))
|
||||
|
||||
const mutatedLastChar = `${PREFIX.slice(0, -1)}요`
|
||||
service.handleTypingContext({ ...context, prefix: mutatedLastChar, idleMs: 1000 })
|
||||
|
||||
expect(service.getState().visible).toBe(true)
|
||||
expect(cleared).not.toContain('stale')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -262,6 +262,7 @@ function typingContext(overrides: Partial<Parameters<ReturnType<typeof getSugges
|
|||
fullText: '오늘 회의 결과를',
|
||||
caretOffset: 9,
|
||||
anchor: { x: 1, y: 1, width: 1, height: 1 },
|
||||
anchorKind: 'caret' as const,
|
||||
isPassword: false,
|
||||
isEditable: true,
|
||||
isComposing: false,
|
||||
|
|
@ -271,6 +272,8 @@ function typingContext(overrides: Partial<Parameters<ReturnType<typeof getSugges
|
|||
windowTitle: '회의록',
|
||||
idleMs: 300,
|
||||
capturedAt: now,
|
||||
editedSinceFocus: true,
|
||||
typedRecently: true,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
|
@ -466,14 +469,18 @@ describe('입력 플로우 서비스', () => {
|
|||
fullText: '오늘 회의 결과를',
|
||||
caretOffset: 9,
|
||||
anchor: { x: 1, y: 1, width: 1, height: 1 },
|
||||
anchorKind: 'caret',
|
||||
isPassword: false,
|
||||
isEditable: true,
|
||||
isComposing: false,
|
||||
hasSelection: false,
|
||||
available: true,
|
||||
appName: 'Notion.exe',
|
||||
windowTitle: '회의록',
|
||||
idleMs: 300,
|
||||
capturedAt: now
|
||||
capturedAt: now,
|
||||
editedSinceFocus: true,
|
||||
typedRecently: true
|
||||
})
|
||||
|
||||
expect(getSuggestionService().getState()).toMatchObject({
|
||||
|
|
@ -566,9 +573,9 @@ describe('입력 플로우 서비스', () => {
|
|||
service._publishLocalMemory(
|
||||
'오늘 회의 결과를',
|
||||
{
|
||||
prefix: '오늘 회의 결과를', fullText: '', caretOffset: null, anchor: null, isPassword: false,
|
||||
prefix: '오늘 회의 결과를', fullText: '', caretOffset: null, anchor: null, anchorKind: null, isPassword: false,
|
||||
isEditable: true, isComposing: false, hasSelection: false, available: true, appName: 'Notion.exe', windowTitle: null,
|
||||
idleMs: 300, capturedAt: now
|
||||
idleMs: 300, capturedAt: now, editedSinceFocus: true, typedRecently: true
|
||||
},
|
||||
3,
|
||||
160,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
decideSuggestionRefresh,
|
||||
emptyActivityBucket,
|
||||
endsSentence,
|
||||
extendsPrefix,
|
||||
extractPhrases,
|
||||
isAppExcluded,
|
||||
isWordBoundaryKey,
|
||||
|
|
@ -37,9 +38,57 @@ import {
|
|||
type PersonalPhrase,
|
||||
type SuggestionPolicyInput
|
||||
} from '@d3ro/core/input-intelligence'
|
||||
import {
|
||||
LEARNING_EXCLUDED_APPS,
|
||||
TERMINAL_APPS,
|
||||
isAppExcluded,
|
||||
isLearnablePhrase,
|
||||
withoutPlaceholderText,
|
||||
emptyFocusSnapshot
|
||||
} from '@d3ro/core/input-intelligence'
|
||||
|
||||
const NO_MODS = { ctrl: false, alt: false, shift: false, meta: false }
|
||||
|
||||
describe('개인 코퍼스 학습 규칙', () => {
|
||||
it('실측된 터미널 상태줄·타임스탬프는 문장으로 치지 않는다', () => {
|
||||
for (const junk of [
|
||||
'◑ OPUS 5',
|
||||
'00 ◷9',
|
||||
'5 medium │ CTX ▕░░░░░░░░░░░░▏ 0% 0K/1M',
|
||||
'0 tokens ─────────────',
|
||||
'5 분 5',
|
||||
'7 분 12'
|
||||
]) {
|
||||
expect(isLearnablePhrase(junk), junk).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('일상 문장은 언어와 무관하게 통과한다', () => {
|
||||
for (const prose of ['하람이랑 열심히 놀고 있어요', 'Thank you', '달빛에 비치는 캐릭터', 'api 목록에도 안']) {
|
||||
expect(isLearnablePhrase(prose), prose).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('터미널·에디터·코딩 에이전트는 학습에서 빠지고, 터미널만 제안에서도 빠진다', () => {
|
||||
expect(isAppExcluded('WindowsTerminal.exe', LEARNING_EXCLUDED_APPS)).toBe(true)
|
||||
expect(isAppExcluded('Agent Switchboard.exe', LEARNING_EXCLUDED_APPS)).toBe(true)
|
||||
expect(isAppExcluded('Code.exe', LEARNING_EXCLUDED_APPS)).toBe(true)
|
||||
expect(isAppExcluded('KakaoTalk.exe', LEARNING_EXCLUDED_APPS)).toBe(false)
|
||||
expect(isAppExcluded('WindowsTerminal.exe', TERMINAL_APPS)).toBe(true)
|
||||
expect(isAppExcluded('Agent Switchboard.exe', TERMINAL_APPS)).toBe(false)
|
||||
})
|
||||
|
||||
it('입력창 이름과 같은 텍스트(안내 문구)는 빈 칸으로 본다', () => {
|
||||
const base = { ...emptyFocusSnapshot('-', 0), available: true, isEditable: true, caretOffset: 6 }
|
||||
const placeholder = withoutPlaceholderText({ ...base, controlName: '메시지 입력', text: '메시지 입력' })
|
||||
expect(placeholder.text).toBe('')
|
||||
expect(placeholder.caretOffset).toBeNull()
|
||||
|
||||
const typed = withoutPlaceholderText({ ...base, controlName: '메시지 입력', text: '안녕하세요' })
|
||||
expect(typed.text).toBe('안녕하세요')
|
||||
})
|
||||
})
|
||||
|
||||
describe('classifyKeyStroke', () => {
|
||||
it('문자/숫자/기능 키를 종류로 나눈다', () => {
|
||||
expect(classifyKeyStroke(0x41, NO_MODS)).toBe('letter') // A
|
||||
|
|
@ -155,6 +204,8 @@ describe('decideSuggestion', () => {
|
|||
isEditable: true,
|
||||
appName: 'chrome.exe',
|
||||
excludedApps: [],
|
||||
editedSinceFocus: true,
|
||||
typedRecently: true,
|
||||
prefix: '오늘 회의에서 논의한 내용을 정리해서',
|
||||
idleMs: SUGGESTION_DEFAULTS.triggerDelayMs + 50,
|
||||
triggerDelayMs: SUGGESTION_DEFAULTS.triggerDelayMs,
|
||||
|
|
@ -258,6 +309,25 @@ describe('decideSuggestion', () => {
|
|||
reason: 'already-visible'
|
||||
})
|
||||
})
|
||||
|
||||
it('포커스만 옮겨 왔을 뿐(편집 없음) 이면 지운다 (마우스 클릭만으로 옛 텍스트가 제안되던 문제)', () => {
|
||||
// 실측: YouTube 검색창(이미 19자 옛 검색어가 있는)을 클릭만 했는데 제안이 떴다.
|
||||
expect(decideSuggestion(policy({ editedSinceFocus: false }))).toEqual({
|
||||
action: 'clear',
|
||||
reason: 'not-typing'
|
||||
})
|
||||
})
|
||||
|
||||
it('편집은 했지만 최근에 실제로 타이핑한 적이 없으면 지운다', () => {
|
||||
expect(decideSuggestion(policy({ typedRecently: false }))).toEqual({
|
||||
action: 'clear',
|
||||
reason: 'not-typing'
|
||||
})
|
||||
})
|
||||
|
||||
it('편집도 했고 최근 타이핑도 있으면 통과한다', () => {
|
||||
expect(decideSuggestion(policy({ editedSinceFocus: true, typedRecently: true })).action).toBe('request')
|
||||
})
|
||||
})
|
||||
|
||||
describe('표시 중 제안 재생성 정책', () => {
|
||||
|
|
@ -274,6 +344,31 @@ describe('표시 중 제안 재생성 정책', () => {
|
|||
it('생성 접두의 앞부분이 바뀌면 stale 로 처리한다', () => {
|
||||
expect(decideSuggestionRefresh('회의 결과를 공유합니다', '프로젝트 결과를 공유합니다')).toBe('stale')
|
||||
})
|
||||
|
||||
it('IME 조합으로 마지막 글자만 바뀌면 stale 이 아니라 keep 이다', () => {
|
||||
// 생성 시점엔 "하" 로 끝났는데, 조합이 이어져 지금은 "한" 으로 끝난 경우.
|
||||
expect(decideSuggestionRefresh('회의록을 정리하고 공유하려고 하', '회의록을 정리하고 공유하려고 한')).toBe(
|
||||
'keep'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('extendsPrefix — IME 마지막 글자 조합 보정', () => {
|
||||
it('마지막 글자만 조합 중 문자로 바뀌어도 연속 확장으로 본다', () => {
|
||||
expect(extendsPrefix('회의록을 정리하고 공유하려고 하', '회의록을 정리하고 공유하려고 한')).toBe(true)
|
||||
})
|
||||
|
||||
it('마지막 글자를 빼도 접두가 다르면 확장이 아니다', () => {
|
||||
expect(extendsPrefix('회의 결과를 공유합니다', '프로젝트 결과를 공유합니다')).toBe(false)
|
||||
})
|
||||
|
||||
it('완전히 같은 접두는 확장이다', () => {
|
||||
expect(extendsPrefix('회의 결과를 공유합니다', '회의 결과를 공유합니다')).toBe(true)
|
||||
})
|
||||
|
||||
it('접두 뒤로 자란 경우도 확장이다', () => {
|
||||
expect(extendsPrefix('회의 결과를 공유합니다', '회의 결과를 공유합니다 내일')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isAppExcluded', () => {
|
||||
|
|
@ -336,14 +431,21 @@ describe('anchorFloatingPanel', () => {
|
|||
const workArea = { x: 0, y: 0, width: 1920, height: 1080 }
|
||||
const size = { width: 460, height: 96 }
|
||||
|
||||
it('케어 아래에 붙인다', () => {
|
||||
const position = anchorFloatingPanel({ x: 400, y: 300, width: 2, height: 20 }, { x: 0, y: 0 }, size, workArea)
|
||||
it('케어 앵커는 아래에 붙인다', () => {
|
||||
const position = anchorFloatingPanel(
|
||||
{ x: 400, y: 300, width: 2, height: 20 },
|
||||
'caret',
|
||||
{ x: 0, y: 0 },
|
||||
size,
|
||||
workArea
|
||||
)
|
||||
expect(position).toEqual({ x: 400, y: 326 })
|
||||
})
|
||||
|
||||
it('아래 공간이 없으면 위로 뒤집는다', () => {
|
||||
it('케어 앵커는 아래 공간이 없으면 위로 뒤집는다', () => {
|
||||
const position = anchorFloatingPanel(
|
||||
{ x: 400, y: 1000, width: 2, height: 20 },
|
||||
'caret',
|
||||
{ x: 0, y: 0 },
|
||||
size,
|
||||
workArea
|
||||
|
|
@ -351,9 +453,10 @@ describe('anchorFloatingPanel', () => {
|
|||
expect(position.y).toBe(1000 - 6 - 96)
|
||||
})
|
||||
|
||||
it('작업영역 밖으로 나가지 않는다', () => {
|
||||
it('케어 앵커는 작업영역 밖으로 나가지 않는다', () => {
|
||||
const position = anchorFloatingPanel(
|
||||
{ x: 1900, y: 10, width: 2, height: 20 },
|
||||
'caret',
|
||||
{ x: 0, y: 0 },
|
||||
size,
|
||||
workArea
|
||||
|
|
@ -363,9 +466,44 @@ describe('anchorFloatingPanel', () => {
|
|||
})
|
||||
|
||||
it('앵커가 없으면 커서를 쓴다', () => {
|
||||
const position = anchorFloatingPanel(null, { x: 200, y: 500 }, size, workArea)
|
||||
const position = anchorFloatingPanel(null, null, { x: 200, y: 500 }, size, workArea)
|
||||
expect(position).toEqual({ x: 200, y: 506 })
|
||||
})
|
||||
|
||||
describe('요소 앵커 (케어렛을 못 얻어 elementRect 로 폴백한 경우)', () => {
|
||||
it('아래에 맞으면 요소 바깥 아래에 붙인다', () => {
|
||||
const element = { x: 400, y: 300, width: 300, height: 40 }
|
||||
const position = anchorFloatingPanel(element, 'element', { x: 0, y: 0 }, size, workArea)
|
||||
expect(position).toEqual({ x: 400, y: 300 + 40 + 6 })
|
||||
})
|
||||
|
||||
it('아래가 안 맞고 위가 맞으면 요소 바깥 위에 붙인다', () => {
|
||||
const element = { x: 400, y: 1080 - 50, width: 300, height: 40 }
|
||||
const position = anchorFloatingPanel(element, 'element', { x: 0, y: 0 }, size, workArea)
|
||||
expect(position).toEqual({ x: 400, y: element.y - 6 - size.height })
|
||||
})
|
||||
|
||||
it('위아래 다 안 맞으면 오른쪽 바깥에 붙인다', () => {
|
||||
const element = { x: 0, y: 0, width: 1400, height: 1076 }
|
||||
const position = anchorFloatingPanel(element, 'element', { x: 0, y: 0 }, size, workArea)
|
||||
expect(position).toEqual({ x: element.x + element.width + 6, y: element.y })
|
||||
})
|
||||
|
||||
it('위아래오른쪽 다 안 맞으면 왼쪽 바깥에 붙인다', () => {
|
||||
const element = { x: 1820, y: 0, width: 100, height: 1080 }
|
||||
const position = anchorFloatingPanel(element, 'element', { x: 0, y: 0 }, size, workArea)
|
||||
expect(position).toEqual({ x: element.x - 6 - size.width, y: element.y })
|
||||
})
|
||||
|
||||
it('네 방향 다 안 맞으면 요소 안쪽 우하단 모서리로 물러난다', () => {
|
||||
const element = { x: 0, y: 0, width: 1920, height: 1080 }
|
||||
const position = anchorFloatingPanel(element, 'element', { x: 0, y: 0 }, size, workArea)
|
||||
expect(position).toEqual({
|
||||
x: element.x + element.width - size.width - 6,
|
||||
y: element.y + element.height - size.height - 6
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('마우스 이동', () => {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
DEFAULT_TARGET_LANGUAGE,
|
||||
BASE_SYSTEM_PROMPTS,
|
||||
SUGGESTION_NO_THINK_PREFIX,
|
||||
SUGGESTION_SYSTEM_PROMPT,
|
||||
} from '../../../src/main/services/llm-prompts'
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -212,4 +213,38 @@ describe('buildSuggestionPrompt', () => {
|
|||
expect(systemPrompt).toContain('400')
|
||||
expect(text).toContain('5')
|
||||
})
|
||||
|
||||
it('비서처럼 되묻거나 도와주겠다고 하지 말라는 규칙이 시스템 프롬프트에 있고 사용자 텍스트에는 없다', () => {
|
||||
// 실측: 모델이 "혹시 이 영상 내용에 대해 궁금한 점이 있으신가요?" 처럼
|
||||
// 사용자에게 되묻는 비서형 응답을 낸 회귀를 막는다.
|
||||
const { systemPrompt, text } = buildSuggestionPrompt({ prefix: '오늘 회의에서 논의한 내용을 정리해서' })
|
||||
|
||||
expect(SUGGESTION_SYSTEM_PROMPT).toMatch(/비서가 아닙니다/)
|
||||
expect(SUGGESTION_SYSTEM_PROMPT).toMatch(/질문하지 말고/)
|
||||
expect(SUGGESTION_SYSTEM_PROMPT).toMatch(/검색어나 폼 입력/)
|
||||
expect(systemPrompt).toContain('비서가 아닙니다')
|
||||
expect(systemPrompt).toContain('질문하지 말고')
|
||||
expect(systemPrompt).toContain('검색어나 폼 입력')
|
||||
|
||||
expect(text).not.toContain('비서가 아닙니다')
|
||||
expect(text).not.toContain('질문하지 말고')
|
||||
expect(text).not.toContain('검색어나 폼 입력')
|
||||
})
|
||||
|
||||
it('avoidCandidates는 데이터 섹션으로만 들어가고 이미 나온 후보를 모두 나열한다', () => {
|
||||
const { systemPrompt, text } = buildSuggestionPrompt({
|
||||
prefix: '오늘 회의에서 논의한 내용을 정리해서',
|
||||
avoidCandidates: ['공유드리겠습니다.', '전달드리겠습니다.']
|
||||
})
|
||||
|
||||
expect(text).toContain('공유드리겠습니다.')
|
||||
expect(text).toContain('전달드리겠습니다.')
|
||||
expect(text).not.toContain('규칙:')
|
||||
expect(systemPrompt).not.toContain('공유드리겠습니다.')
|
||||
})
|
||||
|
||||
it('avoidCandidates가 없으면 해당 섹션이 아예 없다', () => {
|
||||
const { text } = buildSuggestionPrompt({ prefix: 'abc' })
|
||||
expect(text).not.toContain('이미 제안한 문장')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
41
apps/desktop/tests/main/suggestion-overlay-policy.test.ts
Normal file
41
apps/desktop/tests/main/suggestion-overlay-policy.test.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
// tests/main/suggestion-overlay-policy.test.ts
|
||||
// bootstrap.ts 의 suggestion.on('updated') 배선이 쓰는 표시 전략 순수 함수 테스트.
|
||||
// 스트리밍 중 매 청크마다 show(=setBounds+present) 를 다시 부르면 X 클릭이 가로채이고
|
||||
// 패널이 튀던 문제(실측)를 막는 결정을 검증한다.
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { decideSuggestionOverlayAction, shouldDismissOnEscape } from '../../src/main/suggestion-overlay-policy'
|
||||
|
||||
describe('decideSuggestionOverlayAction', () => {
|
||||
it('보여줄 것이 없으면 오버레이가 떠 있든 아니든 hide', () => {
|
||||
expect(decideSuggestionOverlayAction(false, false)).toBe('hide')
|
||||
expect(decideSuggestionOverlayAction(true, false)).toBe('hide')
|
||||
})
|
||||
|
||||
it('아직 떠 있지 않으면 show (위치를 새로 계산)', () => {
|
||||
expect(decideSuggestionOverlayAction(false, true)).toBe('show')
|
||||
})
|
||||
|
||||
it('이미 떠 있으면 update (재배치/재present 없이 내용만)', () => {
|
||||
expect(decideSuggestionOverlayAction(true, true)).toBe('update')
|
||||
})
|
||||
})
|
||||
|
||||
const NO_MODS = { ctrl: false, alt: false, shift: false, meta: false }
|
||||
|
||||
describe('shouldDismissOnEscape', () => {
|
||||
it('아무것도 안 떠 있으면 평범한 Esc 도 아무 일도 하지 않는다', () => {
|
||||
expect(shouldDismissOnEscape(false, NO_MODS)).toBe(false)
|
||||
})
|
||||
|
||||
it('떠 있고 수정자가 없으면 닫는다', () => {
|
||||
expect(shouldDismissOnEscape(true, NO_MODS)).toBe(true)
|
||||
})
|
||||
|
||||
it('떠 있어도 수정자가 있으면(Ctrl+Esc 등) 반응하지 않는다', () => {
|
||||
expect(shouldDismissOnEscape(true, { ...NO_MODS, ctrl: true })).toBe(false)
|
||||
expect(shouldDismissOnEscape(true, { ...NO_MODS, alt: true })).toBe(false)
|
||||
expect(shouldDismissOnEscape(true, { ...NO_MODS, shift: true })).toBe(false)
|
||||
expect(shouldDismissOnEscape(true, { ...NO_MODS, meta: true })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -75,7 +75,7 @@ describe('paths (packaged)', () => {
|
|||
configurable: true,
|
||||
})
|
||||
|
||||
expect(() => getSidecarCommand()).toThrowError(/사이드카를 찾을 수 없습니다/)
|
||||
expect(() => getSidecarCommand()).toThrowError(/로컬 음성 엔진이 아직 설치되지 않았습니다/)
|
||||
})
|
||||
|
||||
it('uses the packaged sidecar executable when present', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue