mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 20:03:13 +08:00
1535 lines
59 KiB
TypeScript
1535 lines
59 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,
|
|
petPanelBounds,
|
|
petWindowStatePath,
|
|
petWindowOptions,
|
|
readPetWindowPosition,
|
|
resolvePetPanelPlacement,
|
|
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,
|
|
})
|
|
},
|
|
)
|
|
|
|
// Reaching the menu bar puts the window's own top edge above the work area,
|
|
// and the activity panel lives in exactly that strip of the window. The
|
|
// mascot arriving at the edge is the fix working; the panel arriving behind
|
|
// the menu bar with it is the bug (#1140).
|
|
//
|
|
// These boxes are the real layout: the stack is bottom-aligned with 12px of
|
|
// padding, the card sits 12px above the mascot, and the collapse control
|
|
// hangs 31px below the card's own bottom edge.
|
|
const panelDrag = {
|
|
workArea: { x: 0, y: 25, width: 800, height: 575 },
|
|
above: {
|
|
mascot: { x: 136, y: 240, width: 112, height: 128 },
|
|
card: { x: 16, y: 88, width: 352, height: 140 },
|
|
toggle: { x: 172, y: 234, width: 25, height: 25 },
|
|
},
|
|
// What the renderer reports once it has flipped: mascot at the top of the
|
|
// stack, card below it, control on the card's mascot-facing edge.
|
|
below: {
|
|
mascot: { x: 136, y: 12, width: 112, height: 128 },
|
|
card: { x: 16, y: 152, width: 352, height: 140 },
|
|
toggle: { x: 172, y: 121, width: 25, height: 25 },
|
|
},
|
|
}
|
|
|
|
function panelController(
|
|
petWindow: ReturnType<typeof createFakeWindow>,
|
|
platform: NodeJS.Platform = 'darwin',
|
|
) {
|
|
const onPanelPlacementChanged = vi.fn()
|
|
// No cursor sampler, so the drag follows the payload coordinates the way
|
|
// the other edge tests drive it.
|
|
const controller = new PetWindowController({
|
|
createWindow: vi.fn(() => petWindow) as never,
|
|
getCurrentWorkArea: () => panelDrag.workArea,
|
|
getWorkAreaForPoint: () => panelDrag.workArea,
|
|
load: vi.fn().mockResolvedValue(undefined),
|
|
onPanelPlacementChanged,
|
|
platform,
|
|
preloadPath: '/app/electron-dist/preload.cjs',
|
|
})
|
|
return { controller, onPanelPlacementChanged }
|
|
}
|
|
|
|
it('flips the activity panel below the mascot at the menu bar and holds the mascot there', async () => {
|
|
const petWindow = createFakeWindow(
|
|
{ x: 100, y: 120, width: PET_WINDOW_WIDTH, height: PET_WINDOW_HEIGHT },
|
|
{ constrainTopTo: panelDrag.workArea.y },
|
|
)
|
|
const { controller, onPanelPlacementChanged } = panelController(petWindow)
|
|
await controller.show()
|
|
petWindow.applyConstructorOptions({ enableLargerThanScreen: true })
|
|
|
|
const { above, below, workArea } = panelDrag
|
|
// Away from the top edge the panel keeps its usual place above the mascot.
|
|
expect(controller.setInteractiveRegions(petWindow as never, [
|
|
above.mascot,
|
|
above.card,
|
|
above.toggle,
|
|
])).toEqual({ vertical: 'above', horizontal: 'center' })
|
|
|
|
controller.dragWindow(petWindow as never, { phase: 'start', x: 150, y: 180 })
|
|
const dragged = controller.dragWindow(petWindow as never, { phase: 'end', x: 150, y: -400 })
|
|
|
|
// The mascot still reaches the menu bar, which is the behaviour this must
|
|
// not regress: the window top is above the work area.
|
|
expect(petWindow.getBounds().y).toBe(workArea.y - above.mascot.y)
|
|
// The panel would be behind the menu bar there, so it has to change sides.
|
|
expect(petWindow.getBounds().y + above.card.y).toBeLessThan(workArea.y)
|
|
expect(dragged).toEqual({ vertical: 'below', horizontal: 'center' })
|
|
expect(onPanelPlacementChanged).toHaveBeenCalledWith(petWindow, {
|
|
vertical: 'below',
|
|
horizontal: 'center',
|
|
})
|
|
|
|
// The renderer re-lays out and reports the flipped boxes.
|
|
expect(controller.setInteractiveRegions(petWindow as never, [
|
|
below.mascot,
|
|
below.card,
|
|
below.toggle,
|
|
])).toEqual({ vertical: 'below', horizontal: 'center' })
|
|
|
|
// Flipping moved the mascot up inside the window, so the window has to drop
|
|
// by the same amount: the mascot stays on the menu bar rather than jumping
|
|
// a panel-height down the screen.
|
|
expect(petWindow.getBounds().y + below.mascot.y).toBe(workArea.y)
|
|
// And the whole panel is now inside the work area.
|
|
expect(petWindow.getBounds().y + below.toggle.y).toBeGreaterThanOrEqual(workArea.y)
|
|
})
|
|
|
|
it.each([
|
|
{
|
|
edge: 'right',
|
|
pointerEndX: 2_000,
|
|
horizontal: 'left' as const,
|
|
shiftedMascotX: 260,
|
|
},
|
|
{
|
|
edge: 'left',
|
|
pointerEndX: -2_000,
|
|
horizontal: 'right' as const,
|
|
shiftedMascotX: 12,
|
|
},
|
|
])('keeps the Windows task panel visible at the $edge edge without moving the mascot', async ({
|
|
pointerEndX,
|
|
horizontal,
|
|
shiftedMascotX,
|
|
}) => {
|
|
const petWindow = createFakeWindow({
|
|
x: 100,
|
|
y: 120,
|
|
width: PET_WINDOW_WIDTH,
|
|
height: PET_WINDOW_HEIGHT,
|
|
})
|
|
const { controller, onPanelPlacementChanged } = panelController(petWindow, 'win32')
|
|
await controller.show()
|
|
|
|
const { above, workArea } = panelDrag
|
|
controller.setInteractiveRegions(petWindow as never, [
|
|
above.mascot,
|
|
above.card,
|
|
above.toggle,
|
|
])
|
|
controller.dragWindow(petWindow as never, { phase: 'start', x: 150, y: 180 })
|
|
const dragged = controller.dragWindow(petWindow as never, {
|
|
phase: 'end',
|
|
x: pointerEndX,
|
|
y: 180,
|
|
})
|
|
const mascotScreenX = petWindow.getBounds().x + above.mascot.x
|
|
|
|
expect(dragged).toEqual({ vertical: 'above', horizontal })
|
|
expect(onPanelPlacementChanged).toHaveBeenCalledWith(petWindow, {
|
|
vertical: 'above',
|
|
horizontal,
|
|
})
|
|
|
|
// The renderer keeps the card centred in the fixed window and moves the
|
|
// mascot to the outside. The host absorbs that internal shift by moving
|
|
// the whole window in the opposite direction.
|
|
const shiftedMascot = { ...above.mascot, x: shiftedMascotX }
|
|
expect(controller.setInteractiveRegions(petWindow as never, [
|
|
shiftedMascot,
|
|
above.card,
|
|
above.toggle,
|
|
])).toEqual({ vertical: 'above', horizontal })
|
|
|
|
const shiftedBounds = petWindow.getBounds()
|
|
expect(shiftedBounds.x + shiftedMascot.x).toBe(mascotScreenX)
|
|
expect(shiftedBounds.x + above.card.x).toBeGreaterThanOrEqual(workArea.x)
|
|
expect(shiftedBounds.x + above.card.x + above.card.width)
|
|
.toBeLessThanOrEqual(workArea.x + workArea.width)
|
|
|
|
onPanelPlacementChanged.mockClear()
|
|
controller.setInteractiveRegions(petWindow as never, [
|
|
shiftedMascot,
|
|
above.card,
|
|
above.toggle,
|
|
])
|
|
expect(onPanelPlacementChanged).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('holds the mascot still through a flip that happens short of the menu bar', async () => {
|
|
// The panel runs out of room before the mascot reaches the edge, so most
|
|
// flips happen mid-screen where the clamp has nothing to say. Without the
|
|
// window absorbing the flip, the mascot snaps up to the work area top —
|
|
// the whole panel height away from where the pointer left it.
|
|
const petWindow = createFakeWindow(
|
|
{ x: 100, y: 120, width: PET_WINDOW_WIDTH, height: PET_WINDOW_HEIGHT },
|
|
{ constrainTopTo: panelDrag.workArea.y },
|
|
)
|
|
const { controller } = panelController(petWindow)
|
|
await controller.show()
|
|
petWindow.applyConstructorOptions({ enableLargerThanScreen: true })
|
|
|
|
const { above, below, workArea } = panelDrag
|
|
controller.setInteractiveRegions(petWindow as never, [above.mascot, above.card, above.toggle])
|
|
|
|
// Land the mascot 100px under the menu bar: clear of every clamp, but short
|
|
// of the ~183px the panel needs above it.
|
|
controller.dragWindow(petWindow as never, { phase: 'start', x: 150, y: 180 })
|
|
const dragged = controller.dragWindow(petWindow as never, { phase: 'end', x: 150, y: -55 })
|
|
const mascotScreenY = petWindow.getBounds().y + above.mascot.y
|
|
expect(mascotScreenY).toBe(workArea.y + 100)
|
|
expect(dragged).toEqual({ vertical: 'below', horizontal: 'center' })
|
|
|
|
controller.setInteractiveRegions(petWindow as never, [below.mascot, below.card, below.toggle])
|
|
|
|
expect(petWindow.getBounds().y + below.mascot.y).toBe(mascotScreenY)
|
|
expect(petWindow.getBounds().y + below.toggle.y).toBeGreaterThanOrEqual(workArea.y)
|
|
})
|
|
|
|
it('keeps a drag tracking the pointer after the panel flips mid-drag', async () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
const petWindow = createFakeWindow(
|
|
{ x: 100, y: 120, width: PET_WINDOW_WIDTH, height: PET_WINDOW_HEIGHT },
|
|
{ constrainTopTo: panelDrag.workArea.y },
|
|
)
|
|
let cursor = { x: 150, y: 180 }
|
|
const onPanelPlacementChanged = vi.fn()
|
|
const controller = new PetWindowController({
|
|
createWindow: vi.fn(() => petWindow) as never,
|
|
getCursorScreenPoint: () => cursor,
|
|
getCurrentWorkArea: () => panelDrag.workArea,
|
|
getWorkAreaForPoint: () => panelDrag.workArea,
|
|
load: vi.fn().mockResolvedValue(undefined),
|
|
onPanelPlacementChanged,
|
|
platform: 'darwin',
|
|
preloadPath: '/app/electron-dist/preload.cjs',
|
|
})
|
|
await controller.show()
|
|
petWindow.applyConstructorOptions({ enableLargerThanScreen: true })
|
|
|
|
const { above, below, workArea } = panelDrag
|
|
controller.setInteractiveRegions(petWindow as never, [above.mascot, above.card, above.toggle])
|
|
controller.dragWindow(petWindow as never, { phase: 'start', x: 150, y: 180 })
|
|
|
|
// The renderer sends no move payloads — this process samples the cursor —
|
|
// so a flip decided mid-drag has no reply to ride back on and goes out as
|
|
// an event instead.
|
|
cursor = { x: 150, y: -55 }
|
|
vi.advanceTimersByTime(16)
|
|
expect(onPanelPlacementChanged).toHaveBeenCalledWith(petWindow, {
|
|
vertical: 'below',
|
|
horizontal: 'center',
|
|
})
|
|
|
|
controller.setInteractiveRegions(petWindow as never, [below.mascot, below.card, below.toggle])
|
|
expect(petWindow.getBounds().y + below.mascot.y).toBe(workArea.y + 100)
|
|
|
|
// Dragging 20px further has to move the mascot 20px further. The drag maps
|
|
// pointer travel from a window origin captured before the flip, so unless
|
|
// that origin absorbed the flip too this snaps back.
|
|
cursor = { x: 150, y: -75 }
|
|
vi.advanceTimersByTime(16)
|
|
expect(petWindow.getBounds().y + below.mascot.y).toBe(workArea.y + 80)
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('keeps the flipped panel flipped instead of oscillating on the threshold', async () => {
|
|
const petWindow = createFakeWindow(
|
|
{ x: 100, y: 120, width: PET_WINDOW_WIDTH, height: PET_WINDOW_HEIGHT },
|
|
{ constrainTopTo: panelDrag.workArea.y },
|
|
)
|
|
const { controller } = panelController(petWindow)
|
|
await controller.show()
|
|
petWindow.applyConstructorOptions({ enableLargerThanScreen: true })
|
|
|
|
const { above, below } = panelDrag
|
|
controller.setInteractiveRegions(petWindow as never, [above.mascot, above.card, above.toggle])
|
|
controller.dragWindow(petWindow as never, { phase: 'start', x: 150, y: 180 })
|
|
controller.dragWindow(petWindow as never, { phase: 'end', x: 150, y: -400 })
|
|
controller.setInteractiveRegions(petWindow as never, [below.mascot, below.card, below.toggle])
|
|
|
|
// The flip itself frees the space it was testing for: the panel left the
|
|
// strip above the mascot. Re-reporting the flipped layout must not read
|
|
// that as room to flip back, or the panel changes sides every frame.
|
|
expect(controller.setInteractiveRegions(petWindow as never, [
|
|
below.mascot,
|
|
below.card,
|
|
below.toggle,
|
|
])).toEqual({ vertical: 'below', horizontal: 'center' })
|
|
expect(petWindow.getBounds().y + below.mascot.y).toBe(panelDrag.workArea.y)
|
|
})
|
|
|
|
it('reopens a flipped pet where the mascot was, not where the window was', async () => {
|
|
// The saved y belongs to the mascot offset it was saved with, and the panel
|
|
// being below the mascot puts that offset at the top of the window. The
|
|
// renderer always starts the panel above, so restoring the bare window
|
|
// position would drop the mascot by the whole panel height.
|
|
const { above, below, workArea } = panelDrag
|
|
const saved = { x: 100, y: workArea.y - below.mascot.y, region: below.mascot }
|
|
let petWindow: ReturnType<typeof createFakeWindow> | undefined
|
|
const controller = new PetWindowController({
|
|
createWindow: vi.fn((bounds) => {
|
|
petWindow = createFakeWindow(
|
|
bounds as { x: number, y: number, width: number, height: number },
|
|
{ constrainTopTo: workArea.y },
|
|
)
|
|
petWindow.applyConstructorOptions({ enableLargerThanScreen: true })
|
|
return petWindow
|
|
}) as never,
|
|
getCurrentWorkArea: () => workArea,
|
|
getWorkAreaForPoint: () => workArea,
|
|
load: vi.fn().mockResolvedValue(undefined),
|
|
platform: 'darwin',
|
|
preloadPath: '/app/electron-dist/preload.cjs',
|
|
readPosition: () => saved,
|
|
})
|
|
|
|
await controller.show()
|
|
// First paint is the renderer's default layout, panel above the mascot.
|
|
const placement = controller.setInteractiveRegions(petWindow as never, [
|
|
above.mascot,
|
|
above.card,
|
|
above.toggle,
|
|
])
|
|
|
|
expect(petWindow?.getBounds().y).toBe(workArea.y - above.mascot.y)
|
|
expect((petWindow?.getBounds().y ?? 0) + above.mascot.y).toBe(workArea.y)
|
|
// And it is still out of room up there, so it flips straight back.
|
|
expect(placement).toEqual({ vertical: 'below', horizontal: 'center' })
|
|
})
|
|
|
|
it('reopens a side-shifted pet where the mascot was, not where the window was', async () => {
|
|
const { above, workArea } = panelDrag
|
|
const shiftedMascot = { ...above.mascot, x: 260 }
|
|
const saved = {
|
|
x: workArea.x + workArea.width - shiftedMascot.x - shiftedMascot.width,
|
|
y: 120,
|
|
region: shiftedMascot,
|
|
}
|
|
let petWindow: ReturnType<typeof createFakeWindow> | undefined
|
|
const controller = new PetWindowController({
|
|
createWindow: vi.fn((bounds) => {
|
|
petWindow = createFakeWindow(
|
|
bounds as { x: number, y: number, width: number, height: number },
|
|
)
|
|
return petWindow
|
|
}) as never,
|
|
getCurrentWorkArea: () => workArea,
|
|
getWorkAreaForPoint: () => workArea,
|
|
load: vi.fn().mockResolvedValue(undefined),
|
|
platform: 'win32',
|
|
preloadPath: '/app/electron-dist/preload.cjs',
|
|
readPosition: () => saved,
|
|
})
|
|
|
|
await controller.show()
|
|
const placement = controller.setInteractiveRegions(petWindow as never, [
|
|
above.mascot,
|
|
above.card,
|
|
above.toggle,
|
|
])
|
|
|
|
const bounds = petWindow?.getBounds()
|
|
expect((bounds?.x ?? 0) + above.mascot.x)
|
|
.toBe(saved.x + shiftedMascot.x)
|
|
expect(placement).toEqual({ vertical: 'above', horizontal: 'left' })
|
|
})
|
|
|
|
it('leaves the panel above the mascot when only the mascot is reported', async () => {
|
|
// No panel on screen means nothing to protect, and the mascot has to keep
|
|
// its full reach.
|
|
const petWindow = createFakeWindow(
|
|
{ x: 100, y: 120, width: PET_WINDOW_WIDTH, height: PET_WINDOW_HEIGHT },
|
|
{ constrainTopTo: panelDrag.workArea.y },
|
|
)
|
|
const { controller, onPanelPlacementChanged } = panelController(petWindow)
|
|
await controller.show()
|
|
petWindow.applyConstructorOptions({ enableLargerThanScreen: true })
|
|
|
|
controller.setInteractiveRegions(petWindow as never, [panelDrag.above.mascot])
|
|
controller.dragWindow(petWindow as never, { phase: 'start', x: 150, y: 180 })
|
|
const dragged = controller.dragWindow(petWindow as never, { phase: 'end', x: 150, y: -400 })
|
|
|
|
expect(dragged).toEqual({ vertical: 'above', horizontal: 'center' })
|
|
expect(onPanelPlacementChanged).not.toHaveBeenCalled()
|
|
expect(petWindow.getBounds().y).toBe(panelDrag.workArea.y - panelDrag.above.mascot.y)
|
|
})
|
|
|
|
it('measures the panel against the room above the mascot, not the window', () => {
|
|
const mascot = { x: 136, y: 240, width: 112, height: 128 }
|
|
const panel = { x: 16, y: 88, width: 352, height: 171 }
|
|
const workArea = { x: 0, y: 25, width: 800, height: 575 }
|
|
const above = { vertical: 'above', horizontal: 'center' } as const
|
|
|
|
// 335px of room, 183px needed.
|
|
expect(resolvePetPanelPlacement({
|
|
windowPosition: { x: 100, y: 120 },
|
|
workArea,
|
|
mascot,
|
|
panel,
|
|
previous: above,
|
|
})).toEqual({ vertical: 'above', horizontal: 'center' })
|
|
|
|
// Mascot on the menu bar: no room at all.
|
|
expect(resolvePetPanelPlacement({
|
|
windowPosition: { x: 100, y: workArea.y - mascot.y },
|
|
workArea,
|
|
mascot,
|
|
panel,
|
|
previous: above,
|
|
})).toEqual({ vertical: 'below', horizontal: 'center' })
|
|
|
|
// One pixel short still flips; exactly enough does not.
|
|
expect(resolvePetPanelPlacement({
|
|
windowPosition: { x: 100, y: workArea.y - mascot.y + panel.height + 11 },
|
|
workArea,
|
|
mascot,
|
|
panel,
|
|
previous: above,
|
|
})).toEqual({ vertical: 'below', horizontal: 'center' })
|
|
expect(resolvePetPanelPlacement({
|
|
windowPosition: { x: 100, y: workArea.y - mascot.y + panel.height + 12 },
|
|
workArea,
|
|
mascot,
|
|
panel,
|
|
previous: above,
|
|
})).toEqual({ vertical: 'above', horizontal: 'center' })
|
|
|
|
// Coming back the other way costs extra, so a mascot parked on the boundary
|
|
// does not flutter.
|
|
expect(resolvePetPanelPlacement({
|
|
windowPosition: { x: 100, y: workArea.y - mascot.y + panel.height + 12 },
|
|
workArea,
|
|
mascot,
|
|
panel,
|
|
previous: { vertical: 'below', horizontal: 'center' },
|
|
})).toEqual({ vertical: 'below', horizontal: 'center' })
|
|
expect(resolvePetPanelPlacement({
|
|
windowPosition: { x: 100, y: workArea.y - mascot.y + panel.height + 36 },
|
|
workArea,
|
|
mascot,
|
|
panel,
|
|
previous: { vertical: 'below', horizontal: 'center' },
|
|
})).toEqual({ vertical: 'above', horizontal: 'center' })
|
|
})
|
|
|
|
it('bounds the panel by everything hanging off the mascot, not just the card', () => {
|
|
// The collapse control overhangs the card, and the badge replaces the card
|
|
// entirely. Taking regions[1] alone would under-measure both.
|
|
expect(petPanelBounds([
|
|
panelDrag.above.mascot,
|
|
panelDrag.above.card,
|
|
panelDrag.above.toggle,
|
|
])).toEqual({ x: 16, y: 88, width: 352, height: 171 })
|
|
expect(petPanelBounds([panelDrag.above.mascot])).toBeNull()
|
|
})
|
|
|
|
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)
|
|
})
|
|
})
|