fix(suggestion): place overlay in DIPs, size it to content, and harden suggestion sessions
This commit is contained in:
parent
75e053d72f
commit
9e5b94ced2
18 changed files with 2178 additions and 558 deletions
|
|
@ -324,8 +324,9 @@ describe('버그 6: 수락 직전 대상 창 확인', () => {
|
|||
const result = await service.accept()
|
||||
|
||||
expect(result).toEqual({ ok: true })
|
||||
expect(harness.insertText).toHaveBeenCalledWith('다음 단계도 확인하겠습니다.', 'clipboard')
|
||||
expect(fake.expectInsert).toHaveBeenCalledWith('다음 단계도 확인하겠습니다.')
|
||||
// 접두('…정리해서')와 후보 사이에 구분 공백을 붙여 넣는다(joinSuggestion).
|
||||
expect(harness.insertText).toHaveBeenCalledWith(' 다음 단계도 확인하겠습니다.', 'clipboard')
|
||||
expect(fake.expectInsert).toHaveBeenCalledWith(' 다음 단계도 확인하겠습니다.')
|
||||
expect(fake.recordAccepted).toHaveBeenCalledTimes(1)
|
||||
expect(fake.cancelInsert).not.toHaveBeenCalled()
|
||||
})
|
||||
|
|
|
|||
335
apps/desktop/tests/main/services/suggestion-redteam-r2-3.test.ts
Normal file
335
apps/desktop/tests/main/services/suggestion-redteam-r2-3.test.ts
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { PersonalPhrase } from '@d3ro/core/input-intelligence'
|
||||
|
||||
const config = vi.hoisted(() => ({
|
||||
suggestionEnabled: true as boolean,
|
||||
suggestionModelId: 'model-a' as string | null,
|
||||
llmModelId: 'model-a' as string | null,
|
||||
inputExcludedApps: [] as string[],
|
||||
suggestionTriggerDelayMs: 600,
|
||||
suggestionMinPrefixChars: 8,
|
||||
suggestionMaxRequestsPerMinute: 12,
|
||||
suggestionDailyBudget: 500,
|
||||
suggestionRequestTimeoutMs: 8000,
|
||||
inputLearnTypedText: false as boolean,
|
||||
inputTelemetryEnabled: false,
|
||||
suggestionOverlayInteractive: true,
|
||||
insertMethod: 'clipboard'
|
||||
}))
|
||||
const harness = vi.hoisted(() => ({
|
||||
isAvailable: vi.fn(() => true),
|
||||
streamGenerate: vi.fn(),
|
||||
insertText: vi.fn(async (text: string) => ({ success: true, method: 'clipboard', textLength: text.length })),
|
||||
phrases: [] as PersonalPhrase[]
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/ConfigService', () => ({
|
||||
configGet: vi.fn((key: keyof typeof config) => config[key]),
|
||||
configSet: vi.fn()
|
||||
}))
|
||||
vi.mock('../../../src/main/services/LoggerService', () => ({
|
||||
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() })
|
||||
}))
|
||||
vi.mock('../../../src/main/services/LocalLLMService', () => ({
|
||||
getLocalLLMService: () => ({ isAvailable: harness.isAvailable, streamGenerate: harness.streamGenerate })
|
||||
}))
|
||||
vi.mock('../../../src/main/services/InputTelemetryService', () => ({
|
||||
getInputTelemetryService: () => ({ listPhrases: () => harness.phrases })
|
||||
}))
|
||||
vi.mock('../../../src/main/services/PersonalGraphService', () => ({
|
||||
getPersonalGraphService: () => ({ retrieveContext: () => ({ continuations: [], related: [] }) })
|
||||
}))
|
||||
vi.mock('../../../src/main/services/TextInsertService', () => ({
|
||||
getTextInsertService: () => ({ insertText: harness.insertText })
|
||||
}))
|
||||
vi.mock('../../../src/main/services/modifier-state', () => ({ waitForModifiersReleased: async () => true }))
|
||||
vi.mock('../../../src/main/utils/win32-foreground', () => ({ getForegroundWindowInfo: () => null }))
|
||||
vi.mock('../../../src/main/db', () => ({ getDatabase: vi.fn() }))
|
||||
vi.mock('../../../src/main/db/schema', () => ({ suggestions: {} }))
|
||||
|
||||
import { SuggestionService, type SuggestionServiceDeps } from '../../../src/main/services/SuggestionService'
|
||||
import { SuggestionBudget } from '../../../src/main/services/suggestion/SuggestionBudget'
|
||||
import { ModelWarmTracker } from '../../../src/main/services/suggestion/ModelWarmTracker'
|
||||
import type { TypingContext } from '../../../src/main/services/InputTelemetryService'
|
||||
|
||||
const PREFIX = '오늘 회의에서 논의한 내용을 정리해서'
|
||||
const SUGGESTION = '다음 단계도 확인하겠습니다.'
|
||||
|
||||
function typingContext(overrides: Partial<TypingContext> = {}): TypingContext {
|
||||
const prefix = overrides.prefix ?? PREFIX
|
||||
return {
|
||||
prefix,
|
||||
fullText: prefix,
|
||||
caretOffset: prefix.length,
|
||||
anchor: { x: 100, y: 100, width: 2, height: 20 },
|
||||
anchorKind: 'caret',
|
||||
isPassword: false,
|
||||
isEditable: true,
|
||||
isComposing: false,
|
||||
hasSelection: false,
|
||||
available: true,
|
||||
appName: 'Slack.exe',
|
||||
windowTitle: 'general',
|
||||
windowHandle: 101,
|
||||
idleMs: 2000,
|
||||
capturedAt: Date.now(),
|
||||
editedSinceFocus: true,
|
||||
typedRecently: true,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
interface Fakes {
|
||||
deps: SuggestionServiceDeps
|
||||
record: ReturnType<typeof vi.fn>
|
||||
markAccepted: ReturnType<typeof vi.fn>
|
||||
expectInsert: ReturnType<typeof vi.fn>
|
||||
recordAccepted: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
function fakes(): Fakes {
|
||||
const record = vi.fn()
|
||||
const markAccepted = vi.fn()
|
||||
const expectInsert = vi.fn(() => vi.fn())
|
||||
const recordAccepted = vi.fn()
|
||||
return {
|
||||
record,
|
||||
markAccepted,
|
||||
expectInsert,
|
||||
recordAccepted,
|
||||
deps: {
|
||||
repository: { record, markAccepted, list: vi.fn(() => []) },
|
||||
budget: new SuggestionBudget(),
|
||||
warmth: new ModelWarmTracker(),
|
||||
foreground: { currentWindowHandle: () => 101 },
|
||||
learning: { expectProgrammaticInsert: expectInsert, recordAccepted }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function streamOf(text: string): AsyncGenerator<string> {
|
||||
return (async function* () {
|
||||
yield text
|
||||
})()
|
||||
}
|
||||
|
||||
/** 호출자가 풀어 줄 때까지 기다리는 스트림 — abort 되면 예외로 끝난다. */
|
||||
function gatedStream(signal: AbortSignal | undefined, gate: Promise<string>): AsyncGenerator<string> {
|
||||
return (async function* () {
|
||||
const text = await new Promise<string>((resolve, reject) => {
|
||||
signal?.addEventListener('abort', () => reject(new Error('aborted')), { once: true })
|
||||
void gate.then(resolve)
|
||||
})
|
||||
yield text
|
||||
})()
|
||||
}
|
||||
|
||||
interface Internal {
|
||||
_generate(prefix: string, context: TypingContext, maxCandidates: number, maxChars: number): Promise<void>
|
||||
_lastContext: TypingContext | null
|
||||
_inFlight: boolean
|
||||
_warmUpPromise: Promise<void> | null
|
||||
}
|
||||
|
||||
let services: SuggestionService[] = []
|
||||
function create(deps: SuggestionServiceDeps, warm = true): SuggestionService {
|
||||
if (warm) deps.warmth.noteWarm('model-a', Date.now() + 600_000)
|
||||
const service = new SuggestionService(deps)
|
||||
services.push(service)
|
||||
return service
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
config.suggestionEnabled = true
|
||||
config.inputLearnTypedText = false
|
||||
harness.isAvailable.mockReset()
|
||||
harness.isAvailable.mockReturnValue(true)
|
||||
harness.streamGenerate.mockReset()
|
||||
harness.insertText.mockClear()
|
||||
harness.phrases = []
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const service of services) service.dispose()
|
||||
services = []
|
||||
})
|
||||
|
||||
describe('진행 중 생성 — 요청 뒤 이어 치면 즉시 취소한다 (세션 규칙과 동일)', () => {
|
||||
it('12자 미만 성장도 진행 중 요청을 취소하고 예산을 환급한다', async () => {
|
||||
let signal: AbortSignal | undefined
|
||||
let release: (text: string) => void = () => undefined
|
||||
const gate = new Promise<string>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
harness.streamGenerate.mockImplementation((_text: string, options: { signal?: AbortSignal }) => {
|
||||
signal = options.signal
|
||||
return gatedStream(options.signal, gate)
|
||||
})
|
||||
const fake = fakes()
|
||||
const service = create(fake.deps)
|
||||
const internal = service as unknown as Internal
|
||||
|
||||
service.handleTypingContext(typingContext({ prefix: '회의 자료는 오늘' }))
|
||||
expect(internal._inFlight).toBe(true)
|
||||
|
||||
service.handleTypingContext(typingContext({ prefix: '회의 자료는 오늘 저녁까지', idleMs: 100 }))
|
||||
release(SUGGESTION)
|
||||
await Promise.resolve()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(signal?.aborted).toBe(true)
|
||||
expect(service.isVisible).toBe(false)
|
||||
expect(fake.deps.budget.minuteCount).toBe(0)
|
||||
expect(fake.record).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('결과 게시 직전 최신 문맥이 자랐으면 옛 접두용 결과를 게시하지 않는다', async () => {
|
||||
let release: (text: string) => void = () => undefined
|
||||
const gate = new Promise<string>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
harness.streamGenerate.mockImplementation((_text: string, options: { signal?: AbortSignal }) =>
|
||||
gatedStream(options.signal, gate)
|
||||
)
|
||||
const fake = fakes()
|
||||
const service = create(fake.deps)
|
||||
const internal = service as unknown as Internal
|
||||
const context = typingContext({ prefix: '회의 자료는 오늘' })
|
||||
|
||||
const generation = internal._generate('회의 자료는 오늘', context, 3, 160)
|
||||
// 취소 경로를 거치지 않고 최신 문맥만 바뀐 경우 (게시 가드만 검증)
|
||||
internal._lastContext = typingContext({ prefix: '회의 자료는 오늘 저녁까지' })
|
||||
release(SUGGESTION)
|
||||
await generation
|
||||
|
||||
expect(service.isVisible).toBe(false)
|
||||
expect(service.getState().lastSkipReason).toBe('stale')
|
||||
expect(fake.record).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('같은 접두(주기 스냅샷)면 그대로 게시한다', async () => {
|
||||
harness.streamGenerate.mockImplementation(() => streamOf(SUGGESTION))
|
||||
const fake = fakes()
|
||||
const service = create(fake.deps)
|
||||
const internal = service as unknown as Internal
|
||||
internal._lastContext = typingContext()
|
||||
await internal._generate(PREFIX, typingContext(), 3, 160)
|
||||
expect(service.isVisible).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('수락 — 구분 공백', () => {
|
||||
async function visibleModelSession(fake: Fakes): Promise<SuggestionService> {
|
||||
harness.streamGenerate.mockImplementation(() => streamOf(SUGGESTION))
|
||||
const service = create(fake.deps)
|
||||
await (service as unknown as Internal)._generate(PREFIX, typingContext(), 3, 160)
|
||||
expect(service.isVisible).toBe(true)
|
||||
return service
|
||||
}
|
||||
|
||||
it('단어 바로 뒤에서 멈췄으면 공백 하나를 앞에 붙여 삽입한다', async () => {
|
||||
const fake = fakes()
|
||||
const service = await visibleModelSession(fake)
|
||||
|
||||
expect(await service.accept()).toEqual({ ok: true })
|
||||
|
||||
expect(harness.insertText).toHaveBeenCalledWith(` ${SUGGESTION}`, 'clipboard')
|
||||
expect(fake.expectInsert).toHaveBeenCalledWith(` ${SUGGESTION}`)
|
||||
// 이력·학습은 후보 원문으로 — 이력 행을 찾을 수 있어야 한다.
|
||||
expect(fake.markAccepted).toHaveBeenCalledWith(SUGGESTION)
|
||||
expect(fake.recordAccepted).toHaveBeenCalledWith(SUGGESTION, expect.anything())
|
||||
})
|
||||
|
||||
it('영어 문장 끝 마침표 뒤에도 떼어 쓴다', async () => {
|
||||
harness.streamGenerate.mockImplementation(() => streamOf('Let me know if you have questions.'))
|
||||
const fake = fakes()
|
||||
const service = create(fake.deps)
|
||||
const context = typingContext({ prefix: 'I sent the report.' })
|
||||
await (service as unknown as Internal)._generate('I sent the report.', context, 3, 160)
|
||||
|
||||
await service.accept()
|
||||
expect(harness.insertText).toHaveBeenCalledWith(' Let me know if you have questions.', 'clipboard')
|
||||
})
|
||||
|
||||
it('제안 뒤 사용자가 이미 공백을 쳤으면(최신 문맥) 공백을 더하지 않는다', async () => {
|
||||
const fake = fakes()
|
||||
const service = await visibleModelSession(fake)
|
||||
service.handleTypingContext(typingContext({ prefix: `${PREFIX} `, idleMs: 100 }))
|
||||
expect(service.isVisible).toBe(true)
|
||||
|
||||
await service.accept()
|
||||
expect(harness.insertText).toHaveBeenCalledWith(SUGGESTION, 'clipboard')
|
||||
})
|
||||
|
||||
it('단어 중간에서 이어지는 로컬 기억 후보는 공백 없이 붙인다', async () => {
|
||||
harness.phrases = [
|
||||
{
|
||||
id: 'p1',
|
||||
phrase: 'Thank you for the update',
|
||||
count: 3,
|
||||
source: 'typed',
|
||||
appName: 'Slack.exe',
|
||||
lastUsedAt: Date.now(),
|
||||
createdAt: Date.now()
|
||||
}
|
||||
]
|
||||
const fake = fakes()
|
||||
// 쿨다운 중에는 로컬 기억만 게시한다.
|
||||
fake.deps.budget.noteFailure(Date.now())
|
||||
fake.deps.budget.noteFailure(Date.now())
|
||||
const service = create(fake.deps)
|
||||
service.handleTypingContext(typingContext({ prefix: 'Thank yo' }))
|
||||
expect(service.getState().candidates[0]?.text).toBe('u for the update')
|
||||
|
||||
await service.accept()
|
||||
expect(harness.insertText).toHaveBeenCalledWith('u for the update', 'clipboard')
|
||||
})
|
||||
})
|
||||
|
||||
describe('모델 서버가 없을 때 — 끝나지 않는 "준비 중" 을 띄우지 않는다', () => {
|
||||
it('준비 작업(워밍업)이 없으면 오버레이 없이 사유만 남긴다', () => {
|
||||
harness.isAvailable.mockReturnValue(false)
|
||||
const fake = fakes()
|
||||
const service = create(fake.deps)
|
||||
const updates: boolean[] = []
|
||||
service.on('updated', (state) => updates.push(state.warmingUp))
|
||||
|
||||
service.handleTypingContext(typingContext())
|
||||
|
||||
expect(service.getState()).toMatchObject({ warmingUp: false, lastSkipReason: 'model-unavailable' })
|
||||
expect(service.isPresentationActive).toBe(false)
|
||||
expect(updates).not.toContain(true)
|
||||
// 이어 쳐도 already-visible 로 굳지 않는다.
|
||||
service.handleTypingContext(typingContext({ prefix: `${PREFIX} 공유`, idleMs: 2000 }))
|
||||
expect(service.isPresentationActive).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('제안 이력 — 입력 원문은 학습 동의 규칙을 따른다', () => {
|
||||
async function publish(fake: Fakes, context: TypingContext): Promise<void> {
|
||||
harness.streamGenerate.mockImplementation(() => streamOf(SUGGESTION))
|
||||
const service = create(fake.deps)
|
||||
await (service as unknown as Internal)._generate(context.prefix, context, 3, 160)
|
||||
expect(service.isVisible).toBe(true)
|
||||
}
|
||||
|
||||
it('학습 동의가 없으면 접두 원문을 저장하지 않는다', async () => {
|
||||
const fake = fakes()
|
||||
await publish(fake, typingContext())
|
||||
expect(fake.record).toHaveBeenCalledWith(expect.objectContaining({ prefix: '', text: SUGGESTION, appName: 'Slack.exe' }))
|
||||
})
|
||||
|
||||
it('동의가 있어도 터미널(학습 제외 앱)의 명령 줄은 저장하지 않는다', async () => {
|
||||
config.inputLearnTypedText = true
|
||||
const fake = fakes()
|
||||
await publish(fake, typingContext({ prefix: 'mysql -u root -pS3cret prod', appName: 'WindowsTerminal.exe' }))
|
||||
expect(fake.record).toHaveBeenCalledWith(expect.objectContaining({ prefix: '' }))
|
||||
})
|
||||
|
||||
it('동의가 있고 일반 앱이면 접두를 저장한다', async () => {
|
||||
config.inputLearnTypedText = true
|
||||
const fake = fakes()
|
||||
await publish(fake, typingContext())
|
||||
expect(fake.record).toHaveBeenCalledWith(expect.objectContaining({ prefix: PREFIX }))
|
||||
})
|
||||
})
|
||||
382
apps/desktop/tests/main/windows/popup-redteam-r2-3.test.ts
Normal file
382
apps/desktop/tests/main/windows/popup-redteam-r2-3.test.ts
Normal file
|
|
@ -0,0 +1,382 @@
|
|||
// 팝업 창 배치·수명주기 회귀 (redteam r2-3)
|
||||
// - 제안 오버레이: UIA 물리 픽셀 앵커를 DIP 로 바꿔 배치한다 (150% 배율)
|
||||
// - 제안 오버레이: 창을 그려진 패널 높이로 줄여 투명 여백이 클릭을 삼키지 않는다
|
||||
// - 결과 팝업: 숨긴 창의 측정(rAF)이 오지 않아도 뜨고, 측정 리스너를 쌓지 않는다
|
||||
// - 팝업 팩토리: 측정/숨김 렌더가 필요한 팝업은 backgroundThrottling:false
|
||||
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type Listener = (...args: unknown[]) => void
|
||||
|
||||
interface FakeWebContents {
|
||||
listeners: Map<string, Listener[]>
|
||||
sent: Array<{ channel: string; data: unknown }>
|
||||
on: (event: string, listener: Listener) => void
|
||||
send: (channel: string, data: unknown) => void
|
||||
isDestroyed: () => boolean
|
||||
insertCSS: () => Promise<string>
|
||||
invalidate: () => void
|
||||
setWindowOpenHandler: () => void
|
||||
}
|
||||
|
||||
interface FakeWindow {
|
||||
options: Record<string, unknown>
|
||||
webContents: FakeWebContents
|
||||
bounds: Array<{ x: number; y: number; width: number; height: number }>
|
||||
visible: boolean
|
||||
showInactive: ReturnType<typeof vi.fn>
|
||||
setIgnoreMouseEvents: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
windows: [] as FakeWindow[],
|
||||
ipcOn: new Map<string, Listener[]>(),
|
||||
scale: 1.5
|
||||
}))
|
||||
|
||||
vi.unmock('../../../src/main/windows/WindowManager')
|
||||
|
||||
vi.mock('electron', () => {
|
||||
class FakeBrowserWindow {
|
||||
options: Record<string, unknown>
|
||||
bounds: Array<{ x: number; y: number; width: number; height: number }> = []
|
||||
visible = false
|
||||
destroyed = false
|
||||
windowListeners = new Map<string, Listener>()
|
||||
webContents: FakeWebContents = {
|
||||
listeners: new Map<string, Listener[]>(),
|
||||
sent: [],
|
||||
on(event: string, listener: Listener) {
|
||||
const list = this.listeners.get(event) ?? []
|
||||
list.push(listener)
|
||||
this.listeners.set(event, list)
|
||||
},
|
||||
send(channel: string, data: unknown) {
|
||||
this.sent.push({ channel, data })
|
||||
},
|
||||
isDestroyed: () => false,
|
||||
insertCSS: async () => '',
|
||||
invalidate: () => undefined,
|
||||
setWindowOpenHandler: () => undefined
|
||||
}
|
||||
showInactive = vi.fn(() => {
|
||||
this.visible = true
|
||||
})
|
||||
setIgnoreMouseEvents = vi.fn()
|
||||
constructor(options: Record<string, unknown>) {
|
||||
this.options = options
|
||||
state.windows.push(this as unknown as FakeWindow)
|
||||
}
|
||||
on(event: string, listener: Listener): this {
|
||||
this.windowListeners.set(event, listener)
|
||||
return this
|
||||
}
|
||||
setBounds(bounds: { x: number; y: number; width: number; height: number }): void {
|
||||
this.bounds.push(bounds)
|
||||
}
|
||||
isVisible(): boolean {
|
||||
return this.visible
|
||||
}
|
||||
isDestroyed(): boolean {
|
||||
return this.destroyed
|
||||
}
|
||||
hide(): void {
|
||||
this.visible = false
|
||||
}
|
||||
setAlwaysOnTop = vi.fn()
|
||||
moveTop = vi.fn()
|
||||
loadURL = vi.fn(async () => undefined)
|
||||
loadFile = vi.fn(async () => undefined)
|
||||
}
|
||||
|
||||
const scaled = (value: number): number => Math.round(value / state.scale)
|
||||
return {
|
||||
app: { quit: vi.fn(), getPath: vi.fn(() => '.'), on: vi.fn(), getVersion: vi.fn(() => '1.0.0'), isPackaged: false },
|
||||
BrowserWindow: Object.assign(FakeBrowserWindow, { getAllWindows: vi.fn(() => []) }),
|
||||
screen: {
|
||||
getPrimaryDisplay: vi.fn(() => ({
|
||||
workAreaSize: { width: 1280, height: 680 },
|
||||
workArea: { x: 0, y: 0, width: 1280, height: 680 }
|
||||
})),
|
||||
getAllDisplays: vi.fn(() => [{ workArea: { x: 0, y: 0, width: 1280, height: 680 } }]),
|
||||
getCursorScreenPoint: vi.fn(() => ({ x: 640, y: 400 })),
|
||||
// 150% 모니터 하나: 물리 1920x1080 → DIP 1280x720 (작업 영역 높이 680)
|
||||
getDisplayNearestPoint: vi.fn(() => ({ workArea: { x: 0, y: 0, width: 1280, height: 680 } })),
|
||||
screenToDipRect: vi.fn((_win: unknown, rect: { x: number; y: number; width: number; height: number }) => ({
|
||||
x: scaled(rect.x),
|
||||
y: scaled(rect.y),
|
||||
width: scaled(rect.width),
|
||||
height: scaled(rect.height)
|
||||
}))
|
||||
},
|
||||
ipcMain: {
|
||||
on: vi.fn((channel: string, listener: Listener) => {
|
||||
const list = state.ipcOn.get(channel) ?? []
|
||||
list.push(listener)
|
||||
state.ipcOn.set(channel, list)
|
||||
}),
|
||||
once: vi.fn(),
|
||||
handle: vi.fn(),
|
||||
removeHandler: vi.fn()
|
||||
},
|
||||
Menu: { setApplicationMenu: vi.fn(), buildFromTemplate: vi.fn() },
|
||||
clipboard: { writeText: vi.fn() },
|
||||
shell: { openExternal: vi.fn(async () => undefined) },
|
||||
safeStorage: { isEncryptionAvailable: vi.fn(() => false) },
|
||||
nativeImage: { createFromPath: vi.fn(), createEmpty: vi.fn() },
|
||||
Tray: vi.fn(),
|
||||
dialog: { showErrorBox: vi.fn() }
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@electron-toolkit/utils', () => ({ is: { dev: false } }))
|
||||
|
||||
const config: Record<string, unknown> = {
|
||||
theme: 'dark',
|
||||
language: 'ko',
|
||||
suggestionOverlayInteractive: true,
|
||||
keyBindings: {}
|
||||
}
|
||||
vi.mock('../../../src/main/services/ConfigService', () => ({
|
||||
configGet: (key: string) => config[key],
|
||||
configSet: (key: string, value: unknown) => {
|
||||
config[key] = value
|
||||
}
|
||||
}))
|
||||
|
||||
const originalPlatform = process.platform
|
||||
|
||||
beforeAll(() => {
|
||||
// 어댑터는 win32 에서만 screenToDipRect 를 쓴다 — CI(리눅스)에서도 같은 경로를 검증한다.
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform })
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
state.windows.length = 0
|
||||
state.ipcOn.clear()
|
||||
state.scale = 1.5
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
function finishLoad(win: FakeWindow): void {
|
||||
for (const listener of win.webContents.listeners.get('did-finish-load') ?? []) listener()
|
||||
}
|
||||
|
||||
function sentOn(win: FakeWindow, channel: string): unknown[] {
|
||||
return win.webContents.sent.filter((message) => message.channel === channel).map((message) => message.data)
|
||||
}
|
||||
|
||||
describe('제안 오버레이 — 물리 픽셀 앵커를 DIP 로 배치한다', () => {
|
||||
const content = { candidates: [], activeIndex: 0, generating: true, warmingUp: false, partialText: null }
|
||||
|
||||
it('150% 에서 캐럿 물리 (600,400) → DIP (400,267) 바로 아래, 창은 "생성 중" 한 줄 높이(54)', async () => {
|
||||
const wm = await import('../../../src/main/windows/WindowManager')
|
||||
wm.showSuggestionOverlay({
|
||||
...content,
|
||||
anchor: { x: 600, y: 400, width: 2, height: 30 },
|
||||
anchorKind: 'caret',
|
||||
appName: 'notepad.exe'
|
||||
})
|
||||
const win = state.windows[0]
|
||||
expect(win.bounds.at(-1)).toEqual({ x: 400, y: 267 + 20 + 6, width: 460, height: 54 })
|
||||
})
|
||||
|
||||
it('150% 에서 캐럿 물리 (1500,900) 은 작업 영역 구석에 박히지 않고 캐럿 위로 뒤집혀 캐럿 곁에 붙는다', async () => {
|
||||
const wm = await import('../../../src/main/windows/WindowManager')
|
||||
wm.showSuggestionOverlay({
|
||||
...content,
|
||||
anchor: { x: 1500, y: 900, width: 2, height: 30 },
|
||||
anchorKind: 'caret',
|
||||
appName: 'notepad.exe'
|
||||
})
|
||||
const bounds = state.windows[0].bounds.at(-1)
|
||||
// DIP 캐럿 (1000,600) — 창 아래 모서리가 캐럿 위 6px 에 붙는다
|
||||
expect(bounds).toEqual({ x: 820, y: 600 - 6 - 54, width: 460, height: 54 })
|
||||
})
|
||||
|
||||
it('후보가 도착하면 같은 자리에서 높이만 늘고, 창이 그려진 패널보다 크지 않다', async () => {
|
||||
const wm = await import('../../../src/main/windows/WindowManager')
|
||||
wm.showSuggestionOverlay({
|
||||
...content,
|
||||
anchor: { x: 600, y: 400, width: 2, height: 30 },
|
||||
anchorKind: 'caret',
|
||||
appName: 'notepad.exe'
|
||||
})
|
||||
const win = state.windows[0]
|
||||
win.visible = true
|
||||
wm.updateSuggestionOverlay({
|
||||
candidates: [{ text: '다음 문장', rank: 0 }],
|
||||
activeIndex: 0,
|
||||
generating: true,
|
||||
warmingUp: false,
|
||||
partialText: null
|
||||
})
|
||||
expect(win.bounds.at(-1)).toEqual({ x: 400, y: 293, width: 460, height: 116 })
|
||||
|
||||
// 같은 높이의 스트리밍 갱신은 창을 다시 옮기지 않는다
|
||||
const count = win.bounds.length
|
||||
wm.updateSuggestionOverlay({
|
||||
candidates: [{ text: '다음 문장', rank: 0 }],
|
||||
activeIndex: 0,
|
||||
generating: true,
|
||||
warmingUp: false,
|
||||
partialText: null
|
||||
})
|
||||
expect(win.bounds.length).toBe(count)
|
||||
})
|
||||
})
|
||||
|
||||
describe('결과 팝업 — 숨긴 창에서 측정이 오지 않아도 뜬다', () => {
|
||||
it('측정 리스너는 모듈에 하나뿐이고, 표시 호출마다 쌓이지 않는다', async () => {
|
||||
const wm = await import('../../../src/main/windows/WindowManager')
|
||||
const { IPC_CHANNELS } = await import('@d3ro/core/ipc-channels')
|
||||
wm.showResultPopup('첫 번째')
|
||||
wm.showResultPopup('두 번째')
|
||||
wm.showResultPopup('세 번째')
|
||||
expect(state.ipcOn.get(IPC_CHANNELS.POPUP_RESULT.MEASURED)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('측정 없이도 창을 먼저 띄우고, 한도가 지나면 내용을 보인다 (두 번째 표시 포함)', async () => {
|
||||
vi.useFakeTimers()
|
||||
const wm = await import('../../../src/main/windows/WindowManager')
|
||||
const { IPC_CHANNELS } = await import('@d3ro/core/ipc-channels')
|
||||
|
||||
wm.showResultPopup('받아쓴 텍스트')
|
||||
const win = state.windows[0]
|
||||
finishLoad(win)
|
||||
expect(win.visible).toBe(true)
|
||||
vi.advanceTimersByTime(400)
|
||||
expect(sentOn(win, IPC_CHANNELS.POPUP_RESULT.SHOW)).toHaveLength(1)
|
||||
|
||||
// 자동 닫힘 뒤 두 번째 실패 — 렌더러가 측정을 보내지 않아도 다시 뜬다.
|
||||
wm.hideResultPopup()
|
||||
expect(win.visible).toBe(false)
|
||||
wm.showResultPopup('다시 받아쓴 텍스트')
|
||||
expect(win.visible).toBe(true)
|
||||
vi.advanceTimersByTime(400)
|
||||
expect(sentOn(win, IPC_CHANNELS.POPUP_RESULT.SHOW)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('측정이 오면 그 크기로 커서 곁에 맞추고 바로 보인다', async () => {
|
||||
vi.useFakeTimers()
|
||||
const wm = await import('../../../src/main/windows/WindowManager')
|
||||
const { IPC_CHANNELS } = await import('@d3ro/core/ipc-channels')
|
||||
|
||||
wm.showResultPopup('받아쓴 텍스트')
|
||||
const win = state.windows[0]
|
||||
finishLoad(win)
|
||||
const measured = state.ipcOn.get(IPC_CHANNELS.POPUP_RESULT.MEASURED)?.[0]
|
||||
measured?.({ sender: win.webContents }, { width: 300, height: 120 })
|
||||
|
||||
// 커서 (640,400): 가로 가운데, 위 20px
|
||||
expect(win.bounds.at(-1)).toEqual({ x: 490, y: 260, width: 300, height: 120 })
|
||||
expect(sentOn(win, IPC_CHANNELS.POPUP_RESULT.SHOW)).toEqual([{ autoHideMs: 5000 }])
|
||||
// 한도가 지나도 두 번 보이지 않는다
|
||||
vi.advanceTimersByTime(400)
|
||||
expect(sentOn(win, IPC_CHANNELS.POPUP_RESULT.SHOW)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('다른 창이 보낸 측정은 무시한다', async () => {
|
||||
vi.useFakeTimers()
|
||||
const wm = await import('../../../src/main/windows/WindowManager')
|
||||
const { IPC_CHANNELS } = await import('@d3ro/core/ipc-channels')
|
||||
|
||||
wm.showResultPopup('받아쓴 텍스트')
|
||||
const win = state.windows[0]
|
||||
finishLoad(win)
|
||||
state.ipcOn.get(IPC_CHANNELS.POPUP_RESULT.MEASURED)?.[0]?.({ sender: {} }, { width: 300, height: 120 })
|
||||
expect(sentOn(win, IPC_CHANNELS.POPUP_RESULT.SHOW)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('팝업 팩토리 — 공통 옵션', () => {
|
||||
it('측정/숨김 렌더가 필요한 팝업은 backgroundThrottling:false, 항상 보이는 자막 창만 기존값', async () => {
|
||||
const wm = await import('../../../src/main/windows/WindowManager')
|
||||
wm.getResultPopupWindow()
|
||||
wm.getHistoryPopupWindow()
|
||||
wm.getCommandPopupWindow()
|
||||
wm.getRecordingTipWindow()
|
||||
wm.getSuggestionOverlayWindow()
|
||||
wm.getCaptionOverlayWindow()
|
||||
|
||||
const throttling = state.windows.map(
|
||||
(win) => (win.options.webPreferences as { backgroundThrottling?: boolean }).backgroundThrottling
|
||||
)
|
||||
expect(throttling).toEqual([false, false, false, false, false, true])
|
||||
for (const win of state.windows) {
|
||||
expect(win.options).toMatchObject({ show: false, frame: false, transparent: true, focusable: false, alwaysOnTop: true })
|
||||
expect(win.options.webPreferences).toMatchObject({ contextIsolation: true, nodeIntegration: false })
|
||||
}
|
||||
// 자막 창은 로드 전에 클릭 통과
|
||||
expect(state.windows[5].setIgnoreMouseEvents).toHaveBeenCalledWith(true, { forward: true })
|
||||
})
|
||||
|
||||
it('closed 이벤트면 창 참조를 지워 다음 호출에서 다시 만든다', async () => {
|
||||
const wm = await import('../../../src/main/windows/WindowManager')
|
||||
const first = wm.getResultPopupWindow() as unknown as { destroyed: boolean; windowListeners: Map<string, Listener> }
|
||||
first.destroyed = true
|
||||
first.windowListeners.get('closed')?.()
|
||||
wm.getResultPopupWindow()
|
||||
expect(state.windows).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('suggestionPanelHeight — style.css 고정 치수와 같은 규칙', () => {
|
||||
it('상태별 패널 높이', async () => {
|
||||
const { suggestionPanelHeight, SUGGESTION_OVERLAY_MAX_HEIGHT } = await import(
|
||||
'../../../src/main/windows/suggestion-overlay-geometry'
|
||||
)
|
||||
const base = { candidateCount: 0, activeIndex: 0, generating: false, warmingUp: false, hasPartialText: false }
|
||||
expect(SUGGESTION_OVERLAY_MAX_HEIGHT).toBe(208)
|
||||
expect(suggestionPanelHeight({ ...base, warmingUp: true })).toBe(54)
|
||||
expect(suggestionPanelHeight({ ...base, generating: true })).toBe(54)
|
||||
expect(suggestionPanelHeight({ ...base, generating: true, hasPartialText: true })).toBe(98)
|
||||
expect(suggestionPanelHeight({ ...base, candidateCount: 1, generating: true })).toBe(116)
|
||||
expect(suggestionPanelHeight({ ...base, candidateCount: 2 })).toBe(162)
|
||||
expect(suggestionPanelHeight({ ...base, candidateCount: 3 })).toBe(208)
|
||||
// 두 번째 페이지에 후보 1개
|
||||
expect(suggestionPanelHeight({ ...base, candidateCount: 4, activeIndex: 3 })).toBe(116)
|
||||
expect(suggestionPanelHeight({ ...base, candidateCount: 12, activeIndex: 5 })).toBe(208)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createElectronScreenGeometry — 좌표 공간 변환은 어댑터 한 곳에서', () => {
|
||||
function fakeScreen() {
|
||||
return {
|
||||
getCursorScreenPoint: vi.fn(() => ({ x: 10, y: 20 })),
|
||||
getDisplayNearestPoint: vi.fn(() => ({ workArea: { x: 0, y: 0, width: 1280, height: 680 } })),
|
||||
screenToDipRect: vi.fn((_win: unknown, rect: { x: number; y: number; width: number; height: number }) => ({
|
||||
x: rect.x / 1.5,
|
||||
y: rect.y / 1.5,
|
||||
width: rect.width / 1.5,
|
||||
height: rect.height / 1.5
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
it('win32 은 screenToDipRect 로 변환한다', async () => {
|
||||
const { createElectronScreenGeometry } = await import('../../../src/main/windows/screen-geometry')
|
||||
const screenApi = fakeScreen()
|
||||
const port = createElectronScreenGeometry(screenApi as never, 'win32')
|
||||
expect(port.toDip({ x: 600, y: 300, width: 3, height: 30 })).toEqual({ x: 400, y: 200, width: 2, height: 20 })
|
||||
expect(screenApi.screenToDipRect).toHaveBeenCalledWith(null, { x: 600, y: 300, width: 3, height: 30 })
|
||||
})
|
||||
|
||||
it('그 밖의 플랫폼은 항등 변환이다 (네이티브 좌표 = DIP)', async () => {
|
||||
const { createElectronScreenGeometry } = await import('../../../src/main/windows/screen-geometry')
|
||||
const screenApi = fakeScreen()
|
||||
const port = createElectronScreenGeometry(screenApi as never, 'darwin')
|
||||
expect(port.toDip({ x: 600, y: 300, width: 3, height: 30 })).toEqual({ x: 600, y: 300, width: 3, height: 30 })
|
||||
expect(screenApi.screenToDipRect).not.toHaveBeenCalled()
|
||||
expect(port.cursor()).toEqual({ x: 10, y: 20 })
|
||||
expect(port.workAreaNear(port.cursor())).toEqual({ x: 0, y: 0, width: 1280, height: 680 })
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue