// 팝업 창 배치·수명주기 회귀 (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 sent: Array<{ channel: string; data: unknown }> on: (event: string, listener: Listener) => void send: (channel: string, data: unknown) => void isDestroyed: () => boolean insertCSS: () => Promise executeJavaScript: () => Promise invalidate: () => void setWindowOpenHandler: () => void } interface FakeWindow { options: Record webContents: FakeWebContents bounds: Array<{ x: number; y: number; width: number; height: number }> visible: boolean showInactive: ReturnType setIgnoreMouseEvents: ReturnType } const state = vi.hoisted(() => ({ windows: [] as FakeWindow[], ipcOn: new Map(), scale: 1.5 })) vi.unmock('../../../src/main/windows/WindowManager') vi.mock('electron', () => { class FakeBrowserWindow { options: Record bounds: Array<{ x: number; y: number; width: number; height: number }> = [] visible = false destroyed = false windowListeners = new Map() webContents: FakeWebContents = { listeners: new Map(), 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 () => '', executeJavaScript: async () => undefined, invalidate: () => undefined, setWindowOpenHandler: () => undefined } showInactive = vi.fn(() => { this.visible = true }) setIgnoreMouseEvents = vi.fn() constructor(options: Record) { 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 { // 팝업 테마: 시스템 테마 해석(2026-09-28) nativeTheme: { shouldUseDarkColors: true, on: () => undefined, removeListener: () => undefined }, 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 = { 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 } 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 }) }) })