mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-08-01 16:43:37 +08:00
The mascot sits at the bottom of a mostly transparent 384x400 window, so clamping a drag against the mascot rather than against the whole window deliberately asks for a negative y that pushes the padding above it off-screen. macOS runs a visible window's frame through -[NSWindow constrainFrameRect:toScreen:], which rewrites any y above the work area back down to its top edge -- silently. The request was refused, the controller never noticed, and the mascot stranded a padding-height below the menu bar: 208px of dead band on this display, with ~/.claude/cc-haha/pet-window.json recording y=-175 for a window that never left y=33. Measured on Electron 42.7.0 / Darwin 25.4: only the top edge is constrained. Off-screen x and off-screen bottom y are kept verbatim, which is why the other three edges always worked. Nothing else escapes it -- not window level (all eight, up to screen-saver), movable, frame, transparent, panel type, and not positioning the window while hidden, since showInactive re-runs the constraint. enableLargerThanScreen is the one switch that skips the method, and it leaves size, getContentBounds and ordinary moves untouched. clampPetWindowPosition is already a complete four-edge bound, so opting out makes it the only clamp rather than removing one. Restoring then exposed a second problem the constraint had been hiding. A saved position deliberately leaves the padding off-screen, but the window is created before the renderer reports any region, so getPetWindowBounds clamped it against the whole window and opened a padding-height lower -- a jump of zero while the window was pinned, 208px once it is not. Persist the mascot box next to the position so the first frame lands where the drag left it. State written without one still restores the way it always did, and an empty box is dropped rather than trusted. The fake window accepted every y, so no top-edge assertion could fail and the suite stayed green through all of this -- the same blind spot that let the Windows DIP bugs hide. It now applies the constraint the way AppKit does, only while visible and only when the constructor options did not opt out, so the assertions prove the fix rather than the platform. Reverting either half turns the new cases red.
1101 lines
41 KiB
TypeScript
1101 lines
41 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
|
|
/**
|
|
* Work area top edge, when the platform constrains the window to it.
|
|
*
|
|
* macOS runs a *visible* window's frame through
|
|
* -[NSWindow constrainFrameRect:toScreen:], which rewrites any y above the
|
|
* work area back down to that edge — so the negative y the mascot clamp asks
|
|
* for is silently refused and the mascot strands a padding-height below the
|
|
* menu bar. A fake window that accepts every y proves nothing about the top
|
|
* edge, which is why the bug survived a green suite. Left undefined the fake
|
|
* behaves like Windows and Linux, which impose no such limit.
|
|
*/
|
|
constrainTopTo?: number
|
|
}
|
|
|
|
function createFakeWindow(
|
|
initialBounds = {
|
|
x: 100,
|
|
y: 100,
|
|
width: PET_WINDOW_WIDTH,
|
|
height: PET_WINDOW_HEIGHT,
|
|
},
|
|
{
|
|
scaleFactor = 1,
|
|
contentSize,
|
|
withoutContentBounds = false,
|
|
constrainTopTo,
|
|
}: FakeWindowOptions = {},
|
|
) {
|
|
const handlers = new Map<string, () => void>()
|
|
let visible = false
|
|
let destroyed = false
|
|
let escapesConstraint = 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),
|
|
})
|
|
// enableLargerThanScreen is the one flag that skips constrainFrameRect, so
|
|
// the constraint has to read it off the constructor options the controller
|
|
// actually passed — otherwise the fake proves the platform's behaviour
|
|
// rather than the fix for it.
|
|
const constrainTop = (y: number) =>
|
|
constrainTopTo !== undefined && visible && !escapesConstraint
|
|
? Math.max(y, constrainTopTo)
|
|
: y
|
|
const setBounds = vi.fn((next: Partial<typeof physical>) => {
|
|
const merged = { ...readBounds(), ...next }
|
|
physical = {
|
|
x: merged.x,
|
|
y: constrainTop(merged.y),
|
|
width: toPhysical(merged.width),
|
|
height: toPhysical(merged.height),
|
|
}
|
|
})
|
|
|
|
return {
|
|
handlers,
|
|
applyConstructorOptions(options: { enableLargerThanScreen?: boolean }) {
|
|
escapesConstraint = options.enableLargerThanScreen === true
|
|
},
|
|
isDestroyed: vi.fn(() => destroyed),
|
|
isVisible: vi.fn(() => visible),
|
|
// Showing re-runs the constraint, so parking a hidden window above the
|
|
// work area does not survive the reveal.
|
|
showInactive: vi.fn(() => {
|
|
visible = true
|
|
physical = { ...physical, y: constrainTop(physical.y) }
|
|
}),
|
|
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('persists the mascot box with the position and tolerates state without one', () => {
|
|
const root = mkdtempSync(path.join(tmpdir(), 'cc-haha-pet-region-'))
|
|
const env = { CLAUDE_CONFIG_DIR: path.join(root, 'portable') }
|
|
try {
|
|
writePetWindowPosition(
|
|
{ x: 100, y: -215, region: { x: 136.4, y: 240.2, width: 112, height: 128 } },
|
|
env,
|
|
root,
|
|
)
|
|
expect(readPetWindowPosition(env, root)).toEqual({
|
|
x: 100,
|
|
y: -215,
|
|
region: { x: 136, y: 240, width: 112, height: 128 },
|
|
})
|
|
|
|
// State written before the region was persisted still restores.
|
|
writePetWindowPosition({ x: 12, y: 34 }, env, root)
|
|
expect(readPetWindowPosition(env, root)).toEqual({ x: 12, y: 34 })
|
|
|
|
// An empty box would clamp as if the mascot filled nothing, so it is
|
|
// dropped rather than trusted.
|
|
writePetWindowPosition(
|
|
{ x: 12, y: 34, region: { x: 0, y: 0, width: 0, height: 10 } },
|
|
env,
|
|
root,
|
|
)
|
|
expect(readPetWindowPosition(env, root)).toEqual({ x: 12, y: 34 })
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('reopens a saved edge position where the drag actually left it', () => {
|
|
const workArea = { x: 0, y: 25, width: 800, height: 575 }
|
|
const region = { x: 136, y: 240, width: 112, height: 128 }
|
|
const saved = { x: 100, y: workArea.y - region.y, region }
|
|
|
|
expect(getPetWindowBounds(workArea, saved).y).toBe(saved.y)
|
|
// The same position without the box clamps against the whole window and
|
|
// opens a padding-height lower — the jump the renderer used to correct
|
|
// once it reported the live region.
|
|
expect(getPetWindowBounds(workArea, { x: saved.x, y: saved.y }).y).toBe(workArea.y)
|
|
})
|
|
|
|
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',
|
|
// Without this macOS refuses every y above the work area, so the mascot
|
|
// can never be dragged up to the menu bar: the transparent padding above
|
|
// it has to be allowed off-screen.
|
|
enableLargerThanScreen: true,
|
|
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)
|
|
// Documented macOS-only; Windows and Linux never constrain the frame.
|
|
expect(windowsOptions).not.toHaveProperty('enableLargerThanScreen')
|
|
})
|
|
|
|
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 }],
|
|
['darwin', 'top', { x: 150, y: -200 }, { x: 100, y: -215 }],
|
|
['darwin', 'bottom', { x: 150, y: 900 }, { x: 100, y: 232 }],
|
|
['win32', 'left', { x: -100, y: 220 }, { x: -136, y: 160 }],
|
|
['win32', 'right', { x: 1_000, y: 220 }, { x: 552, y: 160 }],
|
|
['win32', 'top', { x: 150, y: -200 }, { x: 100, y: -215 }],
|
|
['win32', 'bottom', { x: 150, y: 900 }, { x: 100, y: 232 }],
|
|
] 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))
|
|
},
|
|
)
|
|
|
|
// The mascot sits at the bottom of a mostly transparent window, so reaching
|
|
// the menu bar means the window's own top edge has to go *above* the work
|
|
// area. macOS refuses that for a visible window unless it opted out, and the
|
|
// refusal is silent — the controller reads back a position it never got.
|
|
const menuBarDrag = {
|
|
workArea: { x: 0, y: 25, width: 800, height: 575 },
|
|
region: { x: 136, y: 240, width: 112, height: 128 },
|
|
}
|
|
|
|
async function dragToTopEdge(
|
|
createWindow: (
|
|
window: ReturnType<typeof createFakeWindow>,
|
|
options: { enableLargerThanScreen?: boolean },
|
|
) => unknown,
|
|
) {
|
|
const petWindow = createFakeWindow(
|
|
{ x: 100, y: 120, width: PET_WINDOW_WIDTH, height: PET_WINDOW_HEIGHT },
|
|
{ constrainTopTo: menuBarDrag.workArea.y },
|
|
)
|
|
const writePosition = vi.fn()
|
|
const controller = new PetWindowController({
|
|
createWindow: vi.fn((options: { enableLargerThanScreen?: boolean }) =>
|
|
createWindow(petWindow, options)) as never,
|
|
getCurrentWorkArea: () => menuBarDrag.workArea,
|
|
getWorkAreaForPoint: () => menuBarDrag.workArea,
|
|
load: vi.fn().mockResolvedValue(undefined),
|
|
platform: 'darwin',
|
|
preloadPath: '/app/electron-dist/preload.cjs',
|
|
writePosition,
|
|
})
|
|
await controller.show()
|
|
controller.setInteractiveRegions(petWindow as never, [menuBarDrag.region])
|
|
controller.dragWindow(petWindow as never, { phase: 'start', x: 150, y: 180 })
|
|
controller.dragWindow(petWindow as never, { phase: 'end', x: 150, y: -400 })
|
|
return { petWindow, writePosition }
|
|
}
|
|
|
|
it('drags the darwin mascot up to the menu bar through the frame constraint', async () => {
|
|
// The opt-out has to come from the options the controller really built.
|
|
const { petWindow, writePosition } = await dragToTopEdge((window, options) => {
|
|
window.applyConstructorOptions(options)
|
|
return window
|
|
})
|
|
const { region, workArea } = menuBarDrag
|
|
|
|
// The window really sits above the work area, so the mascot's own top edge
|
|
// lands on it rather than a padding-height below.
|
|
expect(petWindow.getBounds().y).toBe(workArea.y - region.y)
|
|
expect(petWindow.getBounds().y + region.y).toBe(workArea.y)
|
|
// And what lands on disk is a position the window actually reached, next
|
|
// to the box that makes it mean anything.
|
|
expect(writePosition).toHaveBeenCalledWith({
|
|
x: 100,
|
|
y: workArea.y - region.y,
|
|
region,
|
|
})
|
|
})
|
|
|
|
it('strands the mascot below the menu bar when the window keeps the constraint', async () => {
|
|
// Guards the guard: a fake that accepted every y would pass the test above
|
|
// whether or not the window opts out, which is exactly how the real bug
|
|
// survived a green suite.
|
|
const { petWindow } = await dragToTopEdge((window) => {
|
|
window.applyConstructorOptions({ enableLargerThanScreen: false })
|
|
return window
|
|
})
|
|
const { region, workArea } = menuBarDrag
|
|
|
|
expect(petWindow.getBounds().y).toBe(workArea.y)
|
|
expect(petWindow.getBounds().y + region.y).toBe(workArea.y + region.y)
|
|
})
|
|
|
|
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)
|
|
})
|
|
})
|