mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-08-01 16:43:37 +08:00
8a3ea62b taught dragging to clamp against the mascot rather than the whole window, so a saved edge position deliberately leaves the transparent padding off-screen. Recreating the window re-clamps that position against the whole window again, and the reposition-on-restore step that undoes it was gated on darwin -- so on Windows and Linux the mascot walked inwards by the padding width on every hide/show, not just on restart. The gate only existed because visibleDragRegion used to be darwin-only; the region now arrives everywhere, so the repositioning runs everywhere. e24d59fe restated the recorded window size on every drag tick to stop setPosition from growing the window, but sampled that size from getBounds(). Chromium converts window rects between physical pixels and DIP with ceil in both directions, so the sampled value has already been rounded up once and restating it rounds up again: stable within a drag, a pixel per drag across them. The window is created non-resizable at the nominal size, so restating the constants is idempotent instead -- and heals a window that already drifted. Both call sites now go through movePetWindow. petWindowContentExtent fell back to the nominal constants when getContentBounds returned an empty rect, which silently reinstates the exact clamp it exists to avoid. Fall through to the live window box first. The fake window in the tests returned one object for both getBounds and getContentBounds, and modelled setBounds as inherently size-neutral -- so neither "clamp to the content box" nor "size-neutral drag" was actually proven: reverting either left all 33 tests green. It now models the ceil/ceil DIP round trip and carries a distinct content box, and covers the previously untested paths: cross-platform restore, multi-region shapes, the region normalization bounds, and the getContentBounds fallbacks.
947 lines
34 KiB
TypeScript
947 lines
34 KiB
TypeScript
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import path from 'node:path'
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
import {
|
|
PET_WINDOW_HEIGHT,
|
|
PET_WINDOW_WIDTH,
|
|
PetWindowController,
|
|
type PetWindowPosition,
|
|
clampPetWindowPosition,
|
|
getPetWindowBounds,
|
|
petWindowStatePath,
|
|
petWindowOptions,
|
|
readPetWindowPosition,
|
|
writePetWindowPosition,
|
|
} from './petWindow'
|
|
|
|
const desktopRoot = existsSync(path.resolve(process.cwd(), 'electron', 'main.ts'))
|
|
? process.cwd()
|
|
: path.resolve(process.cwd(), 'desktop')
|
|
const mainSource = readFileSync(path.join(desktopRoot, 'electron', 'main.ts'), 'utf8')
|
|
.replace(/\r\n/g, '\n')
|
|
|
|
type FakeWindowOptions = {
|
|
/**
|
|
* Display scale factor. Chromium converts window rects between physical
|
|
* pixels and DIP with ceil in *both* directions (ScreenWin::ScreenToDIPRect
|
|
* and DIPToScreenRect), so on a fractional scale every getBounds ->
|
|
* setBounds round trip grows the window by a pixel. Modelling that is what
|
|
* makes the size-neutrality assertions mean anything; the default of 1 keeps
|
|
* the arithmetic exact for every test that does not care.
|
|
*/
|
|
scaleFactor?: number
|
|
/**
|
|
* Client area, when it differs from the window box. Renderer regions are
|
|
* measured against this, not against getBounds().
|
|
*/
|
|
contentSize?: { width: number; height: number }
|
|
/** Drop getContentBounds entirely, to exercise the fallback. */
|
|
withoutContentBounds?: boolean
|
|
}
|
|
|
|
function createFakeWindow(
|
|
initialBounds = {
|
|
x: 100,
|
|
y: 100,
|
|
width: PET_WINDOW_WIDTH,
|
|
height: PET_WINDOW_HEIGHT,
|
|
},
|
|
{
|
|
scaleFactor = 1,
|
|
contentSize,
|
|
withoutContentBounds = false,
|
|
}: FakeWindowOptions = {},
|
|
) {
|
|
const handlers = new Map<string, () => void>()
|
|
let visible = false
|
|
let destroyed = false
|
|
const toPhysical = (value: number) => Math.ceil(value * scaleFactor)
|
|
const toDip = (value: number) => Math.ceil(value / scaleFactor)
|
|
let physical = {
|
|
x: initialBounds.x,
|
|
y: initialBounds.y,
|
|
width: toPhysical(initialBounds.width),
|
|
height: toPhysical(initialBounds.height),
|
|
}
|
|
const readBounds = () => ({
|
|
x: physical.x,
|
|
y: physical.y,
|
|
width: toDip(physical.width),
|
|
height: toDip(physical.height),
|
|
})
|
|
const setBounds = vi.fn((next: Partial<typeof physical>) => {
|
|
const merged = { ...readBounds(), ...next }
|
|
physical = {
|
|
x: merged.x,
|
|
y: merged.y,
|
|
width: toPhysical(merged.width),
|
|
height: toPhysical(merged.height),
|
|
}
|
|
})
|
|
|
|
return {
|
|
handlers,
|
|
isDestroyed: vi.fn(() => destroyed),
|
|
isVisible: vi.fn(() => visible),
|
|
showInactive: vi.fn(() => {
|
|
visible = true
|
|
}),
|
|
hide: vi.fn(() => {
|
|
visible = false
|
|
}),
|
|
destroy: vi.fn(() => {
|
|
destroyed = true
|
|
}),
|
|
setAlwaysOnTop: vi.fn(),
|
|
setVisibleOnAllWorkspaces: vi.fn(),
|
|
setIgnoreMouseEvents: vi.fn(),
|
|
setShape: vi.fn(),
|
|
getBounds: vi.fn(readBounds),
|
|
...(withoutContentBounds ? {} : {
|
|
getContentBounds: vi.fn(() => ({ ...readBounds(), ...contentSize })),
|
|
}),
|
|
setBounds,
|
|
// Electron implements NativeWindow::SetPosition as
|
|
// SetBounds(gfx::Rect(position, GetSize())) — the size makes a lossy round
|
|
// trip through DIP on every call.
|
|
setPosition: vi.fn((x: number, y: number) => {
|
|
const { width, height } = readBounds()
|
|
setBounds({ x, y, width, height })
|
|
}),
|
|
on: vi.fn((event: string, handler: () => void) => {
|
|
handlers.set(event, handler)
|
|
}),
|
|
}
|
|
}
|
|
|
|
// Dragging moves the window with setBounds so the size is restated every tick.
|
|
function lastDragBounds(window: ReturnType<typeof createFakeWindow>) {
|
|
return window.setBounds.mock.calls.at(-1)?.[0]
|
|
}
|
|
|
|
function draggedTo(position: PetWindowPosition) {
|
|
return { ...position, width: PET_WINDOW_WIDTH, height: PET_WINDOW_HEIGHT }
|
|
}
|
|
|
|
describe('Electron pet window service', () => {
|
|
it('places fixed companion bounds inside the current display work area', () => {
|
|
expect(getPetWindowBounds({ x: 1440, y: 25, width: 1920, height: 1055 })).toEqual({
|
|
x: 1440 + 1920 - PET_WINDOW_WIDTH - 24,
|
|
y: 25 + 1055 - PET_WINDOW_HEIGHT - 24,
|
|
width: PET_WINDOW_WIDTH,
|
|
height: PET_WINDOW_HEIGHT,
|
|
})
|
|
})
|
|
|
|
it('restores and clamps a saved position into its visible display work area', () => {
|
|
const workArea = { x: -900, y: 25, width: 900, height: 700 }
|
|
expect(clampPetWindowPosition({ x: -1_200, y: 900 }, workArea)).toEqual({
|
|
x: -900,
|
|
y: 325,
|
|
})
|
|
expect(getPetWindowBounds(workArea, { x: -1_200, y: 900 })).toEqual({
|
|
x: -900,
|
|
y: 325,
|
|
width: PET_WINDOW_WIDTH,
|
|
height: PET_WINDOW_HEIGHT,
|
|
})
|
|
})
|
|
|
|
it('persists position only in the app-owned cc-haha config root', () => {
|
|
const root = mkdtempSync(path.join(tmpdir(), 'cc-haha-pet-position-'))
|
|
const configDir = path.join(root, 'portable')
|
|
const env = {
|
|
CLAUDE_CONFIG_DIR: configDir,
|
|
CODEX_HOME: path.join(root, 'codex-must-not-be-used'),
|
|
}
|
|
try {
|
|
expect(petWindowStatePath(env, root)).toBe(path.join(
|
|
configDir,
|
|
'cc-haha',
|
|
'pet-window.json',
|
|
))
|
|
writePetWindowPosition({ x: -420.4, y: 85.7 }, env, root)
|
|
expect(readPetWindowPosition(env, root)).toEqual({ x: -420, y: 86 })
|
|
expect(existsSync(path.join(root, 'codex-must-not-be-used'))).toBe(false)
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('creates a transparent frameless sandboxed always-on-top window', () => {
|
|
const macOptions = petWindowOptions(
|
|
{ x: 20, y: 30, width: PET_WINDOW_WIDTH, height: PET_WINDOW_HEIGHT },
|
|
'/app/electron-dist/preload.cjs',
|
|
'darwin',
|
|
)
|
|
expect(macOptions).toMatchObject({
|
|
x: 20,
|
|
y: 30,
|
|
width: PET_WINDOW_WIDTH,
|
|
height: PET_WINDOW_HEIGHT,
|
|
alwaysOnTop: true,
|
|
backgroundColor: '#00000000',
|
|
frame: false,
|
|
fullscreenable: false,
|
|
hasShadow: false,
|
|
resizable: false,
|
|
show: false,
|
|
transparent: true,
|
|
type: 'panel',
|
|
webPreferences: {
|
|
contextIsolation: true,
|
|
nodeIntegration: false,
|
|
partition: 'cc-haha-pet',
|
|
preload: '/app/electron-dist/preload.cjs',
|
|
sandbox: true,
|
|
},
|
|
})
|
|
expect(macOptions).not.toHaveProperty('skipTaskbar')
|
|
|
|
const windowsOptions = petWindowOptions(
|
|
{ x: 20, y: 30, width: PET_WINDOW_WIDTH, height: PET_WINDOW_HEIGHT },
|
|
'/app/electron-dist/preload.cjs',
|
|
'win32',
|
|
)
|
|
expect(windowsOptions.type).toBeUndefined()
|
|
expect(windowsOptions.skipTaskbar).toBe(true)
|
|
})
|
|
|
|
it('loads the dedicated renderer mode with pet-scoped server auth configured', () => {
|
|
expect(mainSource).toContain("loadRendererEntry(window, { petWindow: '1' })")
|
|
expect(mainSource).toContain('window.webContents.session.webRequest')
|
|
expect(mainSource).toContain('resolvePetServerAccess')
|
|
expect(mainSource).toContain('token: runtime.getPetAccessToken()')
|
|
})
|
|
|
|
it('validates the exact bounded spritesheet bytes without reopening a path', () => {
|
|
expect(mainSource).toContain('nativeImage.createFromBuffer(data).getSize()')
|
|
expect(mainSource).not.toContain('nativeImage.createFromPath')
|
|
})
|
|
|
|
it('focuses the main app before emitting the named session navigation event', () => {
|
|
expect(mainSource).toContain('showMainWindow(mainWindow, app)')
|
|
expect(mainSource).toContain(
|
|
'mainWindow?.webContents.send(ELECTRON_EVENT_CHANNELS.petNavigateSession, sessionId)',
|
|
)
|
|
})
|
|
|
|
it('lets only the owned pet window focus the main desktop window', () => {
|
|
expect(mainSource).toContain(
|
|
'registerHandler(ELECTRON_IPC_CHANNELS.petsFocusMainWindow, (event)',
|
|
)
|
|
expect(mainSource).toContain(
|
|
'if (!getPetWindowController().owns(currentWindow(event)))',
|
|
)
|
|
expect(mainSource).toContain('showMainWindow(mainWindow, app)')
|
|
})
|
|
|
|
it('routes the native context menu through the sender-owned pet controller', () => {
|
|
expect(mainSource).toContain(
|
|
'registerHandler(ELECTRON_IPC_CHANNELS.petsShowContextMenu, (event, payload)',
|
|
)
|
|
expect(mainSource).toContain(
|
|
'getPetWindowController().showContextMenu(\n currentWindow(event),',
|
|
)
|
|
expect(mainSource).toContain('closeLabel.trim(),\n Menu,')
|
|
})
|
|
|
|
it('routes drag coordinates through the sender-owned pet controller', () => {
|
|
expect(mainSource).toContain(
|
|
'getCursorScreenPoint: () => screen.getCursorScreenPoint()',
|
|
)
|
|
expect(mainSource).toContain(
|
|
'registerHandler(ELECTRON_IPC_CHANNELS.petsDragWindow, (event, payload)',
|
|
)
|
|
expect(mainSource).toContain(
|
|
'getPetWindowController().dragWindow(\n currentWindow(event),',
|
|
)
|
|
})
|
|
|
|
it('notifies the main renderer whenever native pet visibility changes', () => {
|
|
expect(mainSource).toContain(
|
|
'mainWindow?.webContents.send(ELECTRON_EVENT_CHANNELS.petVisibilityChanged, true)',
|
|
)
|
|
expect(mainSource).toContain(
|
|
'mainWindow?.webContents.send(ELECTRON_EVENT_CHANNELS.petVisibilityChanged, false)',
|
|
)
|
|
})
|
|
|
|
it('creates and loads only one window across concurrent show calls', async () => {
|
|
const window = createFakeWindow()
|
|
const createWindow = vi.fn(() => window)
|
|
let finishLoad: (() => void) | undefined
|
|
const load = vi.fn(() => new Promise<void>((resolve) => {
|
|
finishLoad = resolve
|
|
}))
|
|
const controller = new PetWindowController({
|
|
createWindow: createWindow as never,
|
|
getCurrentWorkArea: () => ({ x: 0, y: 0, width: 1440, height: 900 }),
|
|
load,
|
|
platform: 'darwin',
|
|
preloadPath: '/app/electron-dist/preload.cjs',
|
|
})
|
|
|
|
const firstShow = controller.show()
|
|
const secondShow = controller.show()
|
|
finishLoad?.()
|
|
await Promise.all([firstShow, secondShow])
|
|
|
|
expect(createWindow).toHaveBeenCalledTimes(1)
|
|
expect(load).toHaveBeenCalledTimes(1)
|
|
expect(window.showInactive).toHaveBeenCalledTimes(1)
|
|
expect(window.setAlwaysOnTop).toHaveBeenCalledWith(true, 'floating')
|
|
expect(window.setVisibleOnAllWorkspaces).toHaveBeenCalledWith(true, {
|
|
skipTransformProcessType: true,
|
|
visibleOnFullScreen: true,
|
|
})
|
|
expect(window.setIgnoreMouseEvents).toHaveBeenCalledWith(true, { forward: true })
|
|
})
|
|
|
|
it('restores the saved companion position before creating the window', async () => {
|
|
const window = createFakeWindow()
|
|
const createWindow = vi.fn(() => window)
|
|
const getWorkAreaForPoint = vi.fn(() => ({ x: -900, y: 25, width: 900, height: 700 }))
|
|
const controller = new PetWindowController({
|
|
createWindow: createWindow as never,
|
|
getCurrentWorkArea: () => ({ x: 0, y: 25, width: 1440, height: 875 }),
|
|
getWorkAreaForPoint,
|
|
load: vi.fn().mockResolvedValue(undefined),
|
|
platform: 'darwin',
|
|
preloadPath: '/app/electron-dist/preload.cjs',
|
|
readPosition: () => ({ x: -600, y: 80 }),
|
|
})
|
|
|
|
await controller.show()
|
|
|
|
expect(getWorkAreaForPoint).toHaveBeenCalledWith({
|
|
x: -600 + Math.floor(PET_WINDOW_WIDTH / 2),
|
|
y: 80 + Math.floor(PET_WINDOW_HEIGHT / 2),
|
|
})
|
|
expect(createWindow).toHaveBeenCalledWith(expect.objectContaining({
|
|
x: -600,
|
|
y: 80,
|
|
width: PET_WINDOW_WIDTH,
|
|
height: PET_WINDOW_HEIGHT,
|
|
}))
|
|
})
|
|
|
|
it('keeps the pointer anchor, clamps dragging to the pointer display, and saves on release', async () => {
|
|
const petWindow = createFakeWindow({
|
|
x: 100,
|
|
y: 120,
|
|
width: PET_WINDOW_WIDTH,
|
|
height: PET_WINDOW_HEIGHT,
|
|
})
|
|
const writePosition = vi.fn()
|
|
const getWorkAreaForPoint = vi.fn(() => ({ x: 0, y: 25, width: 800, height: 575 }))
|
|
const controller = new PetWindowController({
|
|
createWindow: vi.fn(() => petWindow) as never,
|
|
getCurrentWorkArea: () => ({ x: 0, y: 25, width: 800, height: 575 }),
|
|
getWorkAreaForPoint,
|
|
load: vi.fn().mockResolvedValue(undefined),
|
|
platform: 'darwin',
|
|
preloadPath: '/app/electron-dist/preload.cjs',
|
|
writePosition,
|
|
})
|
|
await controller.show()
|
|
|
|
controller.dragWindow(petWindow as never, { phase: 'start', x: 150, y: 180 })
|
|
controller.dragWindow(petWindow as never, { phase: 'move', x: 190, y: 220 })
|
|
expect(lastDragBounds(petWindow)).toEqual(draggedTo({ x: 140, y: 160 }))
|
|
|
|
controller.dragWindow(petWindow as never, { phase: 'end', x: 1_000, y: 900 })
|
|
expect(getWorkAreaForPoint).toHaveBeenLastCalledWith({ x: 1_000, y: 900 })
|
|
expect(lastDragBounds(petWindow)).toEqual(draggedTo({
|
|
x: 800 - PET_WINDOW_WIDTH,
|
|
y: 25 + 575 - PET_WINDOW_HEIGHT,
|
|
}))
|
|
expect(writePosition).toHaveBeenCalledOnce()
|
|
expect(writePosition).toHaveBeenCalledWith({
|
|
x: 800 - PET_WINDOW_WIDTH,
|
|
y: 25 + 575 - PET_WINDOW_HEIGHT,
|
|
})
|
|
})
|
|
|
|
it.each([
|
|
['darwin', 'left', { x: -100, y: 220 }, { x: -136, y: 160 }],
|
|
['darwin', 'right', { x: 1_000, y: 220 }, { x: 552, y: 160 }],
|
|
['win32', 'left', { x: -100, y: 220 }, { x: -136, y: 160 }],
|
|
['win32', 'right', { x: 1_000, y: 220 }, { x: 552, y: 160 }],
|
|
] as const)(
|
|
'lets the %s mascot reach the %s display edge through transparent window padding',
|
|
async (platform, _edge, pointerEnd, expectedPosition) => {
|
|
const petWindow = createFakeWindow({
|
|
x: 100,
|
|
y: 120,
|
|
width: PET_WINDOW_WIDTH,
|
|
height: PET_WINDOW_HEIGHT,
|
|
})
|
|
const controller = new PetWindowController({
|
|
createWindow: vi.fn(() => petWindow) as never,
|
|
getCurrentWorkArea: () => ({ x: 0, y: 25, width: 800, height: 575 }),
|
|
getWorkAreaForPoint: () => ({ x: 0, y: 25, width: 800, height: 575 }),
|
|
load: vi.fn().mockResolvedValue(undefined),
|
|
platform,
|
|
preloadPath: '/app/electron-dist/preload.cjs',
|
|
})
|
|
await controller.show()
|
|
controller.setInteractiveRegions(petWindow as never, [
|
|
{ x: 136, y: 240, width: 112, height: 128 },
|
|
])
|
|
|
|
controller.dragWindow(petWindow as never, { phase: 'start', x: 150, y: 180 })
|
|
controller.dragWindow(petWindow as never, { phase: 'end', ...pointerEnd })
|
|
|
|
expect(lastDragBounds(petWindow)).toEqual(draggedTo(expectedPosition))
|
|
},
|
|
)
|
|
|
|
it('clamps dragging against the mascot measured in the real content box', async () => {
|
|
// The renderer measures the mascot against the live viewport, so a content
|
|
// area taller than the nominal height puts the mascot below the constant.
|
|
// Clamping the drag region to the constant would trim the mascot's bottom
|
|
// and stop it short of the work area floor.
|
|
const contentHeight = PET_WINDOW_HEIGHT + 40
|
|
const petWindow = createFakeWindow(
|
|
{ x: 100, y: 120, width: PET_WINDOW_WIDTH, height: PET_WINDOW_HEIGHT },
|
|
{ contentSize: { width: PET_WINDOW_WIDTH, height: contentHeight } },
|
|
)
|
|
const controller = new PetWindowController({
|
|
createWindow: vi.fn(() => petWindow) as never,
|
|
getCurrentWorkArea: () => ({ x: 0, y: 25, width: 800, height: 575 }),
|
|
getWorkAreaForPoint: () => ({ x: 0, y: 25, width: 800, height: 575 }),
|
|
load: vi.fn().mockResolvedValue(undefined),
|
|
platform: 'win32',
|
|
preloadPath: '/app/electron-dist/preload.cjs',
|
|
})
|
|
await controller.show()
|
|
controller.setInteractiveRegions(petWindow as never, [
|
|
{ x: 136, y: contentHeight - 160, width: 112, height: 128 },
|
|
])
|
|
|
|
controller.dragWindow(petWindow as never, { phase: 'start', x: 150, y: 180 })
|
|
controller.dragWindow(petWindow as never, { phase: 'end', x: 150, y: 2_000 })
|
|
|
|
expect(lastDragBounds(petWindow)).toEqual({
|
|
x: 100,
|
|
// Mascot bottom (y + 128) lands exactly on the work area floor. Clamping
|
|
// the region to the window box instead of the content box would place it
|
|
// 40px short of the floor.
|
|
y: 25 + 575 - (contentHeight - 160) - 128,
|
|
width: PET_WINDOW_WIDTH,
|
|
height: PET_WINDOW_HEIGHT,
|
|
})
|
|
})
|
|
|
|
it.each(['darwin', 'win32', 'linux'] as const)(
|
|
'restores a %s edge position once the renderer reports the mascot region',
|
|
async (platform) => {
|
|
// Dragging clamps against the mascot, so a saved edge position puts the
|
|
// window's transparent padding off-screen. Recreating the window (every
|
|
// hide/show, and every restart) re-clamps that position against the whole
|
|
// window, which walks the mascot inwards by the padding width unless the
|
|
// reported region moves it back.
|
|
let petWindow: ReturnType<typeof createFakeWindow> | undefined
|
|
const createWindow = vi.fn((bounds) => {
|
|
petWindow = createFakeWindow(bounds as {
|
|
x: number
|
|
y: number
|
|
width: number
|
|
height: number
|
|
})
|
|
return petWindow
|
|
})
|
|
const controller = new PetWindowController({
|
|
createWindow: createWindow as never,
|
|
getCurrentWorkArea: () => ({ x: 0, y: 25, width: 800, height: 575 }),
|
|
getWorkAreaForPoint: () => ({ x: 0, y: 25, width: 800, height: 575 }),
|
|
load: vi.fn().mockResolvedValue(undefined),
|
|
platform,
|
|
preloadPath: '/app/electron-dist/preload.cjs',
|
|
readPosition: () => ({ x: -136, y: 160 }),
|
|
})
|
|
|
|
await controller.show()
|
|
expect(createWindow).toHaveBeenCalledWith(expect.objectContaining({ x: 0, y: 160 }))
|
|
controller.setInteractiveRegions(petWindow as never, [
|
|
{ x: 136, y: 240, width: 112, height: 128 },
|
|
])
|
|
|
|
expect(petWindow?.getBounds()).toEqual({
|
|
x: -136,
|
|
y: 160,
|
|
width: PET_WINDOW_WIDTH,
|
|
height: PET_WINDOW_HEIGHT,
|
|
})
|
|
},
|
|
)
|
|
|
|
it('tracks the native cursor at 60 Hz without renderer move payloads', async () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
const petWindow = createFakeWindow({
|
|
x: 100,
|
|
y: 120,
|
|
width: PET_WINDOW_WIDTH,
|
|
height: PET_WINDOW_HEIGHT,
|
|
})
|
|
let cursor = { x: 150, y: 180 }
|
|
const writePosition = vi.fn()
|
|
const controller = new PetWindowController({
|
|
createWindow: vi.fn(() => petWindow) as never,
|
|
getCursorScreenPoint: () => cursor,
|
|
getCurrentWorkArea: () => ({ x: 0, y: 25, width: 1_200, height: 775 }),
|
|
getWorkAreaForPoint: () => ({ x: 0, y: 25, width: 1_200, height: 775 }),
|
|
load: vi.fn().mockResolvedValue(undefined),
|
|
platform: 'darwin',
|
|
preloadPath: '/app/electron-dist/preload.cjs',
|
|
writePosition,
|
|
})
|
|
await controller.show()
|
|
|
|
controller.dragWindow(petWindow as never, { phase: 'start', x: 150, y: 180 })
|
|
cursor = { x: 203, y: 227 }
|
|
vi.advanceTimersByTime(16)
|
|
|
|
expect(lastDragBounds(petWindow)).toEqual(draggedTo({ x: 153, y: 167 }))
|
|
expect(writePosition).not.toHaveBeenCalled()
|
|
|
|
controller.dragWindow(petWindow as never, { phase: 'end', x: 203, y: 227 })
|
|
expect(writePosition).toHaveBeenCalledOnce()
|
|
expect(writePosition).toHaveBeenCalledWith({ x: 153, y: 167 })
|
|
expect(vi.getTimerCount()).toBe(0)
|
|
|
|
const setBoundsCalls = petWindow.setBounds.mock.calls.length
|
|
cursor = { x: 260, y: 280 }
|
|
vi.advanceTimersByTime(32)
|
|
expect(petWindow.setBounds).toHaveBeenCalledTimes(setBoundsCalls)
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it.each(['hide', 'closed', 'dispose'] as const)(
|
|
'stops native cursor tracking and persists the final position on %s',
|
|
async (action) => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
const petWindow = createFakeWindow({
|
|
x: 100,
|
|
y: 120,
|
|
width: PET_WINDOW_WIDTH,
|
|
height: PET_WINDOW_HEIGHT,
|
|
})
|
|
let cursor = { x: 150, y: 180 }
|
|
const writePosition = vi.fn()
|
|
const controller = new PetWindowController({
|
|
createWindow: vi.fn(() => petWindow) as never,
|
|
getCursorScreenPoint: () => cursor,
|
|
getCurrentWorkArea: () => ({ x: 0, y: 25, width: 1_200, height: 775 }),
|
|
load: vi.fn().mockResolvedValue(undefined),
|
|
platform: 'darwin',
|
|
preloadPath: '/app/electron-dist/preload.cjs',
|
|
writePosition,
|
|
})
|
|
await controller.show()
|
|
|
|
controller.dragWindow(petWindow as never, { phase: 'start', x: 150, y: 180 })
|
|
expect(vi.getTimerCount()).toBe(1)
|
|
cursor = { x: 180, y: 200 }
|
|
vi.advanceTimersByTime(16)
|
|
expect(lastDragBounds(petWindow)).toEqual(draggedTo({ x: 130, y: 140 }))
|
|
|
|
if (action === 'hide') controller.hide()
|
|
if (action === 'closed') petWindow.handlers.get('closed')?.()
|
|
if (action === 'dispose') controller.dispose()
|
|
|
|
expect(vi.getTimerCount()).toBe(0)
|
|
expect(writePosition).toHaveBeenCalledOnce()
|
|
expect(writePosition).toHaveBeenCalledWith({ x: 130, y: 140 })
|
|
const setBoundsCalls = petWindow.setBounds.mock.calls.length
|
|
cursor = { x: 260, y: 280 }
|
|
vi.advanceTimersByTime(32)
|
|
expect(petWindow.setBounds).toHaveBeenCalledTimes(setBoundsCalls)
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
},
|
|
)
|
|
|
|
it('rejects drag coordinates and drag senders outside the owned pet window', async () => {
|
|
const petWindow = createFakeWindow()
|
|
const otherWindow = createFakeWindow()
|
|
const controller = new PetWindowController({
|
|
createWindow: vi.fn(() => petWindow) as never,
|
|
getCurrentWorkArea: () => ({ x: 0, y: 0, width: 1440, height: 900 }),
|
|
load: vi.fn().mockResolvedValue(undefined),
|
|
platform: 'darwin',
|
|
preloadPath: '/app/electron-dist/preload.cjs',
|
|
})
|
|
await controller.show()
|
|
|
|
expect(() => controller.dragWindow(otherWindow as never, {
|
|
phase: 'start',
|
|
x: 10,
|
|
y: 10,
|
|
})).toThrow('does not own')
|
|
expect(() => controller.dragWindow(petWindow as never, {
|
|
phase: 'start',
|
|
x: Number.POSITIVE_INFINITY,
|
|
y: 10,
|
|
})).toThrow('finite screen coordinates')
|
|
expect(() => controller.dragWindow(petWindow as never, {
|
|
phase: 'move',
|
|
x: 10,
|
|
y: 10,
|
|
})).toThrow('has not started')
|
|
})
|
|
|
|
it('destroys a hidden companion so its renderer releases observers', async () => {
|
|
const firstWindow = createFakeWindow()
|
|
const secondWindow = createFakeWindow()
|
|
const createWindow = vi.fn()
|
|
.mockReturnValueOnce(firstWindow)
|
|
.mockReturnValueOnce(secondWindow)
|
|
const controller = new PetWindowController({
|
|
createWindow: createWindow as never,
|
|
getCurrentWorkArea: () => ({ x: 0, y: 0, width: 1440, height: 900 }),
|
|
load: vi.fn().mockResolvedValue(undefined),
|
|
platform: 'linux',
|
|
preloadPath: '/app/electron-dist/preload.cjs',
|
|
})
|
|
|
|
await controller.show()
|
|
controller.hide()
|
|
await controller.show()
|
|
expect(createWindow).toHaveBeenCalledTimes(2)
|
|
expect(firstWindow.destroy).toHaveBeenCalledTimes(1)
|
|
expect(secondWindow.showInactive).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('keeps the shaped Windows pet topmost and rejects IPC from another window', async () => {
|
|
const petWindow = createFakeWindow()
|
|
const otherWindow = createFakeWindow()
|
|
const controller = new PetWindowController({
|
|
createWindow: vi.fn(() => petWindow) as never,
|
|
getCurrentWorkArea: () => ({ x: 0, y: 0, width: 1440, height: 900 }),
|
|
load: vi.fn().mockResolvedValue(undefined),
|
|
platform: 'win32',
|
|
preloadPath: '/app/electron-dist/preload.cjs',
|
|
})
|
|
|
|
await controller.show()
|
|
controller.setInteractiveRegions(petWindow as never, [
|
|
{ x: 100, y: 220, width: 144, height: 170 },
|
|
])
|
|
controller.setIgnoreMouseEvents(petWindow as never, true)
|
|
|
|
expect(petWindow.setShape).toHaveBeenLastCalledWith([
|
|
{ x: 88, y: 208, width: 168, height: 192 },
|
|
])
|
|
expect(petWindow.setAlwaysOnTop).toHaveBeenCalledWith(true)
|
|
expect(petWindow.setAlwaysOnTop).toHaveBeenLastCalledWith(true)
|
|
expect(petWindow.setIgnoreMouseEvents).toHaveBeenCalledTimes(1)
|
|
expect(() => controller.setInteractiveRegions(otherWindow as never, [
|
|
{ x: 0, y: 0, width: 10, height: 10 },
|
|
])).toThrow('does not own')
|
|
})
|
|
|
|
it('shapes the Windows pet against the real content size, not the nominal height', async () => {
|
|
// A Windows content area can end up taller than the nominal height (DPI
|
|
// rounding, invisible frame). The mascot sits flush with the viewport
|
|
// bottom, so clamping the shape to the constant would slice its legs off.
|
|
const contentHeight = PET_WINDOW_HEIGHT + 40
|
|
const petWindow = createFakeWindow(
|
|
{ x: 100, y: 120, width: PET_WINDOW_WIDTH, height: PET_WINDOW_HEIGHT },
|
|
{ contentSize: { width: PET_WINDOW_WIDTH, height: contentHeight } },
|
|
)
|
|
const controller = new PetWindowController({
|
|
createWindow: vi.fn(() => petWindow) as never,
|
|
getCurrentWorkArea: () => ({ x: 0, y: 0, width: 1440, height: 900 }),
|
|
load: vi.fn().mockResolvedValue(undefined),
|
|
platform: 'win32',
|
|
preloadPath: '/app/electron-dist/preload.cjs',
|
|
})
|
|
|
|
await controller.show()
|
|
controller.setInteractiveRegions(petWindow as never, [
|
|
{ x: 144, y: contentHeight - 114, width: 96, height: 104 },
|
|
])
|
|
|
|
expect(petWindow.setShape).toHaveBeenLastCalledWith([
|
|
{ x: 132, y: contentHeight - 126, width: 120, height: 126 },
|
|
])
|
|
})
|
|
|
|
it('shapes every reported region, not just the mascot', async () => {
|
|
// The task badge sits above the mascot and carries its own rect; dropping
|
|
// the tail of the list would make it unclickable.
|
|
const petWindow = createFakeWindow()
|
|
const controller = new PetWindowController({
|
|
createWindow: vi.fn(() => petWindow) as never,
|
|
getCurrentWorkArea: () => ({ x: 0, y: 0, width: 1440, height: 900 }),
|
|
load: vi.fn().mockResolvedValue(undefined),
|
|
platform: 'win32',
|
|
preloadPath: '/app/electron-dist/preload.cjs',
|
|
})
|
|
|
|
await controller.show()
|
|
controller.setInteractiveRegions(petWindow as never, [
|
|
{ x: 144, y: 280, width: 96, height: 104 },
|
|
{ x: 250, y: 40, width: 24, height: 24 },
|
|
])
|
|
|
|
expect(petWindow.setShape).toHaveBeenLastCalledWith([
|
|
{ x: 132, y: 268, width: 120, height: 128 },
|
|
{ x: 238, y: 28, width: 48, height: 48 },
|
|
])
|
|
})
|
|
|
|
it.each([
|
|
[
|
|
'clamps a region reported past the content box',
|
|
{ x: PET_WINDOW_WIDTH + 50, y: PET_WINDOW_HEIGHT + 50, width: 40, height: 40 },
|
|
{
|
|
x: PET_WINDOW_WIDTH - 1,
|
|
y: PET_WINDOW_HEIGHT - 1,
|
|
width: 1,
|
|
height: 1,
|
|
},
|
|
],
|
|
[
|
|
'keeps a degenerate region at least one pixel wide',
|
|
{ x: 100, y: 100, width: 0, height: 0 },
|
|
{ x: 88, y: 88, width: 24, height: 24 },
|
|
],
|
|
[
|
|
'clamps a negative region back into the content box',
|
|
{ x: -100, y: -100, width: 40, height: 40 },
|
|
{ x: 0, y: 0, width: 1, height: 1 },
|
|
],
|
|
] as const)('%s', async (_name, region, expected) => {
|
|
// setShape rejects rectangles outside the window or without positive
|
|
// extent, so normalization has to hold that invariant for any input the
|
|
// renderer can produce.
|
|
const petWindow = createFakeWindow()
|
|
const controller = new PetWindowController({
|
|
createWindow: vi.fn(() => petWindow) as never,
|
|
getCurrentWorkArea: () => ({ x: 0, y: 0, width: 1440, height: 900 }),
|
|
load: vi.fn().mockResolvedValue(undefined),
|
|
platform: 'win32',
|
|
preloadPath: '/app/electron-dist/preload.cjs',
|
|
})
|
|
|
|
await controller.show()
|
|
controller.setInteractiveRegions(petWindow as never, [region])
|
|
|
|
expect(petWindow.setShape).toHaveBeenLastCalledWith([expected])
|
|
})
|
|
|
|
it.each([
|
|
['getContentBounds is unavailable', { withoutContentBounds: true }],
|
|
['the content view has no extent yet', {
|
|
contentSize: { width: 0, height: 0 },
|
|
}],
|
|
] as const)('falls back to the window box when %s', async (_name, options) => {
|
|
// Falling back to the nominal constants would silently reinstate the very
|
|
// clamp this code exists to avoid, so the live window box is the fallback.
|
|
const windowHeight = PET_WINDOW_HEIGHT + 40
|
|
const petWindow = createFakeWindow(
|
|
{ x: 100, y: 120, width: PET_WINDOW_WIDTH, height: windowHeight },
|
|
options,
|
|
)
|
|
const controller = new PetWindowController({
|
|
createWindow: vi.fn(() => petWindow) as never,
|
|
getCurrentWorkArea: () => ({ x: 0, y: 0, width: 1440, height: 900 }),
|
|
load: vi.fn().mockResolvedValue(undefined),
|
|
platform: 'win32',
|
|
preloadPath: '/app/electron-dist/preload.cjs',
|
|
})
|
|
|
|
await controller.show()
|
|
controller.setInteractiveRegions(petWindow as never, [
|
|
{ x: 144, y: windowHeight - 114, width: 96, height: 104 },
|
|
])
|
|
|
|
expect(petWindow.setShape).toHaveBeenLastCalledWith([
|
|
{ x: 132, y: windowHeight - 126, width: 120, height: 126 },
|
|
])
|
|
})
|
|
|
|
it('keeps the window size fixed across drag ticks so DIP rounding cannot grow it', async () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
const petWindow = createFakeWindow(
|
|
{ x: 100, y: 120, width: PET_WINDOW_WIDTH, height: PET_WINDOW_HEIGHT },
|
|
{ scaleFactor: 1.15 },
|
|
)
|
|
let cursor = { x: 150, y: 180 }
|
|
const controller = new PetWindowController({
|
|
createWindow: vi.fn(() => petWindow) as never,
|
|
getCursorScreenPoint: () => cursor,
|
|
getCurrentWorkArea: () => ({ x: 0, y: 0, width: 1_600, height: 1_000 }),
|
|
getWorkAreaForPoint: () => ({ x: 0, y: 0, width: 1_600, height: 1_000 }),
|
|
load: vi.fn().mockResolvedValue(undefined),
|
|
platform: 'win32',
|
|
preloadPath: '/app/electron-dist/preload.cjs',
|
|
})
|
|
await controller.show()
|
|
// Chromium reports a fractionally scaled window back a pixel larger than
|
|
// it was created; that rounding is the engine's, not ours. What has to
|
|
// hold is that dragging never adds to it.
|
|
const { width, height } = petWindow.getBounds()
|
|
|
|
controller.dragWindow(petWindow as never, { phase: 'start', x: 150, y: 180 })
|
|
for (let tick = 0; tick < 60; tick += 1) {
|
|
cursor = { x: cursor.x + 2, y: cursor.y + 2 }
|
|
vi.advanceTimersByTime(16)
|
|
}
|
|
controller.dragWindow(petWindow as never, { phase: 'end', ...cursor })
|
|
|
|
// Without a position assertion this test would also pass for a drag tick
|
|
// that moves nothing at all.
|
|
expect(petWindow.getBounds()).toEqual({ x: 220, y: 240, width, height })
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('stays size-neutral across repeated drags on a fractionally scaled display', async () => {
|
|
// Restating a size that was itself read back through the DIP round trip
|
|
// re-applies the ceil, so the window grows a pixel per drag even though any
|
|
// single drag looks stable. The recorded size has to come from the nominal
|
|
// constants the window was created with.
|
|
vi.useFakeTimers()
|
|
try {
|
|
const petWindow = createFakeWindow(
|
|
{ x: 100, y: 120, width: PET_WINDOW_WIDTH, height: PET_WINDOW_HEIGHT },
|
|
{ scaleFactor: 1.15 },
|
|
)
|
|
let cursor = { x: 150, y: 180 }
|
|
const controller = new PetWindowController({
|
|
createWindow: vi.fn(() => petWindow) as never,
|
|
getCursorScreenPoint: () => cursor,
|
|
getCurrentWorkArea: () => ({ x: 0, y: 0, width: 1_600, height: 1_000 }),
|
|
getWorkAreaForPoint: () => ({ x: 0, y: 0, width: 1_600, height: 1_000 }),
|
|
load: vi.fn().mockResolvedValue(undefined),
|
|
platform: 'win32',
|
|
preloadPath: '/app/electron-dist/preload.cjs',
|
|
})
|
|
await controller.show()
|
|
const { width, height } = petWindow.getBounds()
|
|
|
|
for (let drag = 0; drag < 40; drag += 1) {
|
|
controller.dragWindow(petWindow as never, { phase: 'start', ...cursor })
|
|
cursor = { x: cursor.x + 4, y: cursor.y + 4 }
|
|
vi.advanceTimersByTime(16)
|
|
controller.dragWindow(petWindow as never, { phase: 'end', ...cursor })
|
|
}
|
|
|
|
expect(petWindow.getBounds()).toMatchObject({ width, height })
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('returns whether the native pet context menu close item was selected', async () => {
|
|
const petWindow = createFakeWindow()
|
|
const controller = new PetWindowController({
|
|
createWindow: vi.fn(() => petWindow) as never,
|
|
getCurrentWorkArea: () => ({ x: 0, y: 0, width: 1440, height: 900 }),
|
|
load: vi.fn().mockResolvedValue(undefined),
|
|
platform: 'darwin',
|
|
preloadPath: '/app/electron-dist/preload.cjs',
|
|
})
|
|
await controller.show()
|
|
|
|
let clickClose: (() => void) | undefined
|
|
let dismissMenu: (() => void) | undefined
|
|
const popup = vi.fn((options: { callback: () => void }) => {
|
|
dismissMenu = options.callback
|
|
})
|
|
const menuFactory = {
|
|
buildFromTemplate: vi.fn((template: Array<{ click?: () => void }>) => {
|
|
clickClose = template[0]?.click
|
|
return { popup }
|
|
}),
|
|
}
|
|
|
|
const selection = controller.showContextMenu(
|
|
petWindow as never,
|
|
'关闭宠物',
|
|
menuFactory as never,
|
|
)
|
|
clickClose?.()
|
|
dismissMenu?.()
|
|
|
|
await expect(selection).resolves.toBe(true)
|
|
expect(menuFactory.buildFromTemplate).toHaveBeenCalledWith([{
|
|
label: '关闭宠物',
|
|
click: expect.any(Function),
|
|
}])
|
|
expect(popup).toHaveBeenCalledWith({
|
|
window: petWindow,
|
|
callback: expect.any(Function),
|
|
})
|
|
})
|
|
|
|
it('returns false when the native pet context menu is dismissed', async () => {
|
|
const petWindow = createFakeWindow()
|
|
const controller = new PetWindowController({
|
|
createWindow: vi.fn(() => petWindow) as never,
|
|
getCurrentWorkArea: () => ({ x: 0, y: 0, width: 1440, height: 900 }),
|
|
load: vi.fn().mockResolvedValue(undefined),
|
|
platform: 'darwin',
|
|
preloadPath: '/app/electron-dist/preload.cjs',
|
|
})
|
|
await controller.show()
|
|
|
|
const menuFactory = {
|
|
buildFromTemplate: vi.fn(() => ({
|
|
popup: ({ callback }: { callback: () => void }) => callback(),
|
|
})),
|
|
}
|
|
|
|
await expect(controller.showContextMenu(
|
|
petWindow as never,
|
|
'Close pet',
|
|
menuFactory as never,
|
|
)).resolves.toBe(false)
|
|
})
|
|
|
|
it('rejects native context menu requests from any non-pet window', async () => {
|
|
const petWindow = createFakeWindow()
|
|
const otherWindow = createFakeWindow()
|
|
const controller = new PetWindowController({
|
|
createWindow: vi.fn(() => petWindow) as never,
|
|
getCurrentWorkArea: () => ({ x: 0, y: 0, width: 1440, height: 900 }),
|
|
load: vi.fn().mockResolvedValue(undefined),
|
|
platform: 'darwin',
|
|
preloadPath: '/app/electron-dist/preload.cjs',
|
|
})
|
|
await controller.show()
|
|
|
|
await expect(controller.showContextMenu(
|
|
otherWindow as never,
|
|
'Close pet',
|
|
{ buildFromTemplate: vi.fn() } as never,
|
|
)).rejects.toThrow('does not own')
|
|
})
|
|
|
|
it('destroys a partially created window when renderer loading fails', async () => {
|
|
const window = createFakeWindow()
|
|
const controller = new PetWindowController({
|
|
createWindow: vi.fn(() => window) as never,
|
|
getCurrentWorkArea: () => ({ x: 0, y: 0, width: 1440, height: 900 }),
|
|
load: vi.fn().mockRejectedValue(new Error('load failed')),
|
|
platform: 'win32',
|
|
preloadPath: '/app/electron-dist/preload.cjs',
|
|
})
|
|
|
|
await expect(controller.show()).rejects.toThrow('load failed')
|
|
expect(window.destroy).toHaveBeenCalledTimes(1)
|
|
})
|
|
})
|