181 lines
7.5 KiB
TypeScript
181 lines
7.5 KiB
TypeScript
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)
|
|
})
|
|
})
|