d3ro-voice/apps/desktop/tests/main/services/input-telemetry-redteam-r1-1.test.ts

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)
})
})