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)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue