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