Compare commits

...
6 changed files with 374 additions and 61 deletions

No files matched your search

+81
View File
@@ -0,0 +1,81 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { AsidePanel } from './ui/aside-panel';
import { createDomRenderer, dispatchDomEvent, installDomEnvironment } from './test-support/renderReactDom';
async function fixture(t: test.TestContext, layout: 'inline' | 'overlay' = 'inline') {
const env = installDomEnvironment();
const previousStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: env.window.localStorage });
env.window.localStorage.setItem('desired-width', '720');
const renderer = await createDomRenderer(env.document);
const writes: number[] = [];
const setItem = env.window.Storage.prototype.setItem;
t.mock.method(env.window.Storage.prototype, 'setItem', function (this: Storage, key: string, value: string) {
if (key === 'desired-width') writes.push(Number(value));
setItem.call(this, key, value);
});
const render = (open = true) => renderer.render(<AsidePanel open={open} onClose={() => {}} title="Editor"
layout={layout} resizable persistWidthStorageKey="desired-width" resizeAriaLabel="Resize details"
dataSection="test-panel">Content</AsidePanel>);
await render();
const panel = env.document.querySelector<HTMLElement>('[data-section="test-panel"]')!;
// Browser layout is covered by native-matrix.mjs. Here the actual rendered
// border box is narrower than the remembered 720px preference.
panel.getBoundingClientRect = () => new env.window.DOMRect(320, 36, 378, 494);
const separator = panel.querySelector('[role="separator"]')!;
const pointer = (target: EventTarget, type: string, x: number) => dispatchDomEvent(target,
new env.window.MouseEvent(type, { bubbles: true, clientX: x }));
t.after(async () => {
await pointer(env.window, 'pointerup', 100);
await renderer.unmount();
if (previousStorage) Object.defineProperty(globalThis, 'localStorage', previousStorage);
else Reflect.deleteProperty(globalThis, 'localStorage');
env.cleanup();
});
return { writes, render, unmount: () => renderer.unmount(),
start: () => pointer(separator, 'pointerdown', 100),
move: (x: number) => pointer(env.window, 'pointermove', x),
up: (x: number) => pointer(env.window, 'pointerup', x),
cancel: (x: number) => pointer(env.window, 'pointercancel', x), env };
}
test('dragging a fitted inline panel starts at its visible edge, then remembers the explicit size', async (t) => {
const f = await fixture(t);
await f.start(); await f.move(120); await f.up(120);
assert.deepEqual(f.writes, [358], '20px inward from visible 378px must remember 358px, without a jump from 720px');
});
test('pressing a fitted resize handle without dragging preserves the larger desired width', async (t) => {
const f = await fixture(t);
assert.deepEqual(f.writes, [], 'mount must not persist');
await f.start(); await f.up(100);
assert.deepEqual(f.writes, [], 'a click must not replace the remembered desired width');
});
test('canceling a resize leaves the remembered width unchanged and restores pointer styles', async (t) => {
const f = await fixture(t);
await f.start(); await f.move(120); await f.cancel(120); await f.up(120);
assert.deepEqual(f.writes, []);
assert.equal(f.env.document.body.style.cursor, '');
assert.equal(f.env.document.body.style.userSelect, '');
});
for (const mode of ['hidden', 'unmounted'] as const) {
test(`${mode} panels do not persist a viewport clamp or a stale drag`, async (t) => {
const f = await fixture(t);
await f.start(); await f.move(120);
if (mode === 'hidden') await f.render(false); else await f.unmount();
await f.up(120);
assert.deepEqual(f.writes, []);
assert.equal(f.env.document.body.style.cursor, '');
assert.equal(f.env.document.body.style.userSelect, '');
});
}
test('overlay resizing retains its existing desired-width behavior', async (t) => {
const f = await fixture(t, 'overlay');
await f.start(); await f.move(120); await f.up(120);
assert.deepEqual(f.writes, [700]);
});
+26 -7
View File
@@ -370,14 +370,21 @@ export const AsidePanel: React.FC<AsidePanelProps> = ({
resizable ? readPersistedAsideWidth(persistWidthStorageKey, fallbackWidthPx) : fallbackWidthPx,
);
const [isResizing, setIsResizing] = useState(false);
const resizeCleanupRef = React.useRef<(() => void) | null>(null);
React.useEffect(() => () => resizeCleanupRef.current?.(), [open]);
const effectivePanelWidthPx = resizable ? panelWidthPx : fallbackWidthPx;
// Only containers opting into this constraint limit the rendered width.
// Keep the animation's final minimum on the same cap as its width.
const renderedWidth = resizable && layout === 'inline'
? `min(${effectivePanelWidthPx}px, var(--aside-inline-max-width, ${effectivePanelWidthPx}px))`
: `${effectivePanelWidthPx}px`;
// Resizable panels always use pixel width so overlay and inline share the same drag path.
const usesPixelWidth = resizable || layout === 'inline';
const panelStyle = usesPixelWidth
? ({
width: `${effectivePanelWidthPx}px`,
['--aside-inline-width' as string]: `${effectivePanelWidthPx}px`,
width: renderedWidth,
['--aside-inline-width' as string]: renderedWidth,
} as React.CSSProperties)
: undefined;
@@ -386,9 +393,12 @@ export const AsidePanel: React.FC<AsidePanelProps> = ({
event.preventDefault();
event.stopPropagation();
resizeCleanupRef.current?.();
const startX = event.clientX;
const startWidth = panelWidthPx;
const startWidth = layout === 'inline'
? event.currentTarget.parentElement?.getBoundingClientRect().width || panelWidthPx
: panelWidthPx;
const previousCursor = document.body.style.cursor;
const previousUserSelect = document.body.style.userSelect;
@@ -397,26 +407,35 @@ export const AsidePanel: React.FC<AsidePanelProps> = ({
document.body.style.userSelect = 'none';
const handlePointerMove = (moveEvent: PointerEvent) => {
setPanelWidthPx(clampAsideInlineWidth(startWidth + startX - moveEvent.clientX));
setPanelWidthPx(moveEvent.clientX === startX ? panelWidthPx
: clampAsideInlineWidth(startWidth + startX - moveEvent.clientX));
};
const handlePointerUp = (upEvent: PointerEvent) => {
cleanup();
// A handle click is not a request to forget a larger preference.
if (upEvent.clientX === startX) return;
const nextWidth = clampAsideInlineWidth(startWidth + startX - upEvent.clientX);
setPanelWidthPx(nextWidth);
if (persistWidthStorageKey) {
localStorageAdapter.writeNumber(persistWidthStorageKey, nextWidth);
}
};
const cleanup = () => {
resizeCleanupRef.current = null;
setPanelWidthPx(panelWidthPx);
setIsResizing(false);
document.body.style.cursor = previousCursor;
document.body.style.userSelect = previousUserSelect;
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
window.removeEventListener('pointercancel', handlePointerUp);
window.removeEventListener('pointercancel', cleanup);
};
resizeCleanupRef.current = cleanup;
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
window.addEventListener('pointercancel', handlePointerUp);
}, [panelWidthPx, persistWidthStorageKey, resizable]);
window.addEventListener('pointercancel', cleanup);
}, [layout, panelWidthPx, persistWidthStorageKey, resizable]);
if (!open) return null;
+8 -1
View File
@@ -15,6 +15,7 @@ import {
} from "../../domain/vaultGroupSelection";
import { STORAGE_KEY_VAULT_HOST_PANEL_WIDTH } from "@/infrastructure/config/storageKeys.ts";
import { VaultHostListSection } from "./VaultHostListSection";
import { VAULT_HOST_GRID_MIN_CARD_WIDTH } from "./vaultHostGridLayout";
import { LocalShellHostDetailsPanel } from "../LocalShellHostDetailsPanel";
import { VaultImportProgressPanel } from "./ImportVaultDialog";
import {
@@ -860,7 +861,13 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
data-section="vault-stage"
>
<div
className="relative flex min-h-0 flex-1 overflow-hidden rounded-xl border border-border/60 bg-background shadow-sm"
className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden rounded-xl border border-border/60 bg-background shadow-sm"
// Keep one host card and its existing px-4 padding beside an editor.
// CSS resolves this against the current surface, including when the
// retained vault is shown again; remembered widths remain untouched.
style={{
["--aside-inline-max-width" as string]: `max(0px, calc(100% - ${VAULT_HOST_GRID_MIN_CARD_WIDTH + 32}px))`,
}}
data-section="vault-surface"
>
{/* Main Area */}
+2 -2
View File
@@ -84,8 +84,8 @@ const DEFAULT_WINDOW_HEIGHT = 900;
// Small displays shrink this fit to their work area at window-creation time
// (see resolveWindowMinimumSize in windowManager/mainWindow.cjs, #3647);
// the minimum tracks the display the window currently occupies.
const MIN_WINDOW_WIDTH = 1100;
const MIN_WINDOW_HEIGHT = 640;
const MIN_WINDOW_WIDTH = 960;
const MIN_WINDOW_HEIGHT = 540;
function debugLog(...args) {
if (!DEBUG_WINDOWS) return;
+60 -51
View File
@@ -7,7 +7,7 @@ const perfTrace = require("../../perfTrace.cjs");
const TERMINAL_KEYBOARD_FOCUS = Symbol("netcattyTerminalKeyboardFocus");
// #3647: the 1100×640 layout minimum is larger than the usable workspace on
// #3647: the layout minimum can be larger than the usable workspace on
// small laptops (e.g. 1366×768 at 125% Windows scaling leaves only ~1093×574
// logical px), which made those windows impossible to fit — let alone shrink.
// Keep the layout minimum on capable screens, but shrink it to the work area
@@ -33,14 +33,19 @@ function pickTargetDisplay(screenModule, bounds) {
}
function resolveWindowMinimumSize(screenModule, minWidth, minHeight, bounds) {
let display;
try {
display = bounds ? pickTargetDisplay(screenModule, bounds) : screenModule?.getPrimaryDisplay?.();
} catch {
// screen unavailable (e.g. before app ready): keep the static minimums
}
return minimumSizeForDisplay(display, minWidth, minHeight);
}
function minimumSizeForDisplay(display, minWidth, minHeight) {
let effectiveWidth = minWidth;
let effectiveHeight = minHeight;
try {
// Size for the display the window actually lives on (saved position or
// current placement), falling back to the primary display.
const display = bounds
? pickTargetDisplay(screenModule, bounds)
: screenModule?.getPrimaryDisplay?.();
const workArea = display?.workArea;
if (Number.isFinite(workArea?.width) && workArea.width > 0) {
effectiveWidth = Math.min(effectiveWidth, Math.floor(workArea.width * MIN_SIZE_FIT_FACTOR));
@@ -57,6 +62,52 @@ function resolveWindowMinimumSize(screenModule, minWidth, minHeight, bounds) {
};
}
function intersectsBounds(a, b) {
return b && a.x < b.x + b.width && a.x + a.width > b.x
&& a.y < b.y + b.height && a.y + a.height > b.y;
}
// Initial normal bounds only: live display recovery and manual placement keep
// their existing lifecycle. Electron screen coordinates are already DIPs.
function resolveStartupBounds(screenModule, savedState, defaults, minWidth, minHeight) {
const savedPosition = savedState && Number.isFinite(savedState.x) && Number.isFinite(savedState.y)
? savedState : null;
let display;
try {
display = screenModule?.getPrimaryDisplay?.();
if (savedPosition) {
const matched = pickTargetDisplay(screenModule, savedPosition);
// getDisplayMatching returns the nearest screen even for removed displays.
// Only select it when the saved rectangle actually intersects that screen.
if (intersectsBounds(savedPosition, matched?.bounds)) display = matched;
}
} catch {
// Keep static sizes when Electron cannot supply a display.
}
const minimum = minimumSizeForDisplay(display, minWidth, minHeight);
const workArea = display?.workArea;
const bounds = {};
for (const axis of ["width", "height"]) {
const intended = savedState?.[axis] ?? defaults[axis];
const available = workArea?.[axis];
bounds[axis] = Math.max(minimum[axis], Number.isFinite(available) && available > 0 && intended > available
? Math.floor(available * MIN_SIZE_FIT_FACTOR)
: intended);
}
if (savedPosition && workArea) {
bounds.x = savedPosition.x;
bounds.y = savedPosition.y;
// Preserve all usable placements, including negative monitor origins and
// partial overlap. Resizing can make a previously visible position unusable.
if (!intersectsBounds(bounds, workArea)) {
for (const [axis, size] of [["x", "width"], ["y", "height"]]) {
bounds[axis] = Math.max(workArea[axis], Math.min(bounds[axis], workArea[axis] + workArea[size] - bounds[size]));
}
}
}
return { bounds, minimum };
}
// Keep the BrowserWindow minimum in sync with the display it currently lives
// on: docking, unplugging a screen, or display-scaling changes can move the
// window onto a display with a different work area.
@@ -207,56 +258,14 @@ function createMainWindowApi(ctx) {
// Load saved window state
const savedState = persistWindowState ? loadWindowState() : null;
let windowBounds = {
width: DEFAULT_WINDOW_WIDTH,
height: DEFAULT_WINDOW_HEIGHT,
};
// Size the minimum to the display the restored window will live on
// (a saved position may target a smaller secondary display), falling
// back to the primary display when there is no usable saved position.
const savedTargetBounds = savedState && Number.isFinite(savedState.x) && Number.isFinite(savedState.y)
? { x: savedState.x, y: savedState.y, width: savedState.width, height: savedState.height }
: null;
const windowMinimumSize = resolveWindowMinimumSize(
const { bounds: windowBounds, minimum: windowMinimumSize } = resolveStartupBounds(
screen,
savedState,
{ width: DEFAULT_WINDOW_WIDTH, height: DEFAULT_WINDOW_HEIGHT },
MIN_WINDOW_WIDTH,
MIN_WINDOW_HEIGHT,
savedTargetBounds,
);
if (savedState) {
// Use saved dimensions, but clamp to the minimum so a previously
// shrunk window from an older build cannot start below the minimum.
windowBounds.width = Math.max(savedState.width, windowMinimumSize.width);
windowBounds.height = Math.max(savedState.height, windowMinimumSize.height);
// Only use saved position if the screen is available at that location
if (typeof savedState.x === "number" && typeof savedState.y === "number") {
try {
// Check if the saved position is within any available display
const displays = screen?.getAllDisplays?.() || [];
const isPositionVisible = displays.some((display) => {
const { x, y, width, height } = display.bounds;
// Check if at least part of the window would be visible on this display
return (
savedState.x < x + width &&
savedState.x + savedState.width > x &&
savedState.y < y + height &&
savedState.y + savedState.height > y
);
});
if (isPositionVisible) {
windowBounds.x = savedState.x;
windowBounds.y = savedState.y;
}
} catch {
// Ignore screen check errors, just don't set position
}
}
}
const windowsChrome = !isMac ? windowsFramelessContentChromeOptions() : {};
perfTrace.mark("main:create-window");
@@ -0,0 +1,197 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { EventEmitter } = require("node:events");
const { createMainWindowApi } = require("./mainWindow.cjs");
const large = {
id: 1, bounds: { x: 0, y: 0, width: 1920, height: 1080 },
workArea: { x: 0, y: 0, width: 1920, height: 1040 },
};
const small = {
id: 2, bounds: { x: 0, y: 0, width: 1093, height: 614 },
workArea: { x: 0, y: 0, width: 1093, height: 585 },
};
const intersects = (a, b) => a.x < b.x + b.width && a.x + a.width > b.x
&& a.y < b.y + b.height && a.y + a.height > b.y;
// Exercise the real constructor and lifecycle with recorded native calls.
async function create(saved, primary = large, displays = [primary], matching) {
const writes = [];
const screen = new EventEmitter();
screen.getPrimaryDisplay = () => primary;
screen.getAllDisplays = () => displays;
screen.getDisplayMatching = matching || ((bounds) => displays.find((d) => intersects(bounds, d.bounds)) || displays.at(-1));
let options;
class Window extends EventEmitter {
constructor(opts) {
super();
options = opts;
this.bounds = { x: opts.x ?? primary?.workArea.x ?? 0, y: opts.y ?? primary?.workArea.y ?? 0, width: opts.width, height: opts.height };
this.minimum = [opts.minWidth, opts.minHeight];
this.maximized = false;
this.fullscreen = false;
this.webContents = Object.assign(new EventEmitter(), {
id: 71, isDestroyed: () => false, setWindowOpenHandler() {}, send() {},
});
}
isDestroyed() { return false; }
isMaximized() { return this.maximized; }
isFullScreen() { return this.fullscreen; }
isMinimized() { return this.minimized === true; }
getBounds() { return { ...this.bounds }; }
getMinimumSize() { return this.minimum; }
setMinimumSize(w, h) { this.minimum = [w, h]; }
maximize() { this.maximized = true; }
setFullScreen(value) { this.fullscreen = value; }
setBackgroundColor() {}
loadURL() { return Promise.resolve(); }
close() {}
}
const api = createMainWindowApi({
mainWindow: null, electronApp: null, currentTheme: "light", isQuitting: true,
pendingWindowStateWrite: null, queuedWindowState: null,
DEFAULT_WINDOW_WIDTH: 1400, DEFAULT_WINDOW_HEIGHT: 900, MIN_WINDOW_WIDTH: 960, MIN_WINDOW_HEIGHT: 540,
V8_CACHE_OPTIONS: "code", THEME_COLORS: { light: {} },
unhealthyWebContentsIds: new Set(), rendererReadySeenByWebContentsId: new Set(),
__dirname, URL, require, console, setTimeout, clearTimeout,
debugLog() {}, resolveFrontendBackgroundColor() { return null; }, loadWindowState() { return saved; },
getWindowBoundsState(win, normal) {
return { ...(normal || win.getBounds()), isMaximized: win.isMaximized(), isFullScreen: win.isFullScreen() };
},
queueWindowStateSave: (state) => writes.push(state), saveWindowStateSync: (state) => writes.push(state),
setupDeferredShow() {}, createExternalOnlyWindowOpenHandler() { return {}; }, createAppWindowOpenHandler() { return {}; },
attachOAuthLoadingOverlay() {}, registerWindowHandlers() {}, registerMainWindow() {}, unregisterMainWindow() {},
closeSettingsWindow() {}, applyWindowOpacityToWindow() {},
});
const win = await api.createWindow(
{ BrowserWindow: Window, nativeTheme: {}, app: {}, screen, shell: {}, ipcMain: {} },
{ preload: "/unused/preload.cjs", isDev: false, isMac: true, electronDir: __dirname },
);
assert.deepEqual(writes, [], "calculating startup bounds must not persist settings");
return { win, options, screen, writes };
}
const geometry = (o) => [o.width, o.height, o.minWidth, o.minHeight];
test("ordinary large-display default stays 1400x900 with 960x540 minimum", async () => {
assert.deepEqual(geometry((await create(null)).options), [1400, 900, 960, 540]);
});
test("fresh small-display default fits each overflowing axis", async () => {
assert.deepEqual(geometry((await create(null, small)).options), [983, 526, 960, 526]);
});
test("huge saved bounds fit the target work area without mutating saved settings", async () => {
const saved = Object.freeze({ x: 10, y: 20, width: 4000, height: 3000 });
const { options } = await create(saved, small);
assert.deepEqual(geometry(options), [983, 526, 960, 526]);
assert.deepEqual([options.x, options.y], [10, 20]);
});
test("fitting dimensions and usable partial placements remain exact", async () => {
for (const [x, y] of [[30, 15], [-20, -10], [1000, 500]]) {
const { options } = await create({ x, y, width: 1000, height: 560 }, small);
assert.deepEqual([options.x, options.y, options.width, options.height], [x, y, 1000, 560]);
}
});
test("only overflowing axes shrink; undersized axes rise to the effective minimum", async () => {
for (const [width, height, expected] of [
[1600, 560, [983, 560]], [1000, 1000, [1000, 526]],
[800, 400, [960, 526]], [1093, 585, [1093, 585]],
]) {
const { options } = await create({ x: 0, y: 0, width, height }, small);
assert.deepEqual([options.width, options.height], expected);
}
});
test("workArea offsets/taskbars recover inaccessible positions only", async () => {
const display = { ...small, workArea: { x: 80, y: 40, width: 1013, height: 545 } };
const { options } = await create({ x: -950, y: 0, width: 1000, height: 540 }, display);
assert.deepEqual([options.x, options.y, options.width, options.height], [80, 40, 1000, 540]);
const usable = (await create({ x: 90, y: 42, width: 1000, height: 540 }, display)).options;
assert.deepEqual([usable.x, usable.y, usable.width, usable.height], [90, 42, 1000, 540]);
});
test("negative secondary origin selects that display and preserves valid position", async () => {
const secondary = { ...small, bounds: { ...small.bounds, x: -1093 }, workArea: { ...small.workArea, x: -1093 } };
const { options } = await create({ x: -1080, y: 15, width: 1000, height: 560 }, large, [large, secondary]);
assert.deepEqual([options.x, options.y, ...geometry(options)], [-1080, 15, 1000, 560, 960, 526]);
});
test("removed saved screen falls back to primary even when Electron matches the nearest secondary", async () => {
const secondary = { ...large, id: 3, bounds: { ...large.bounds, x: 1920 }, workArea: { ...large.workArea, x: 1920 } };
const { options } = await create({ x: 8000, y: 100, width: 4000, height: 3000 }, small, [small, secondary], () => secondary);
assert.deepEqual(geometry(options), [983, 526, 960, 526]);
assert.ok(intersects({ ...options }, small.workArea));
});
test("DIP startup sizing does not rescale at 1, 1.25, 1.5 or 2", async () => {
for (const scaleFactor of [1, 1.25, 1.5, 2]) {
const { options } = await create(null, { ...small, scaleFactor });
assert.deepEqual(geometry(options), [983, 526, 960, 526]);
}
});
test("representative work areas retain the adaptive 90 percent minimum and emergency floor", async () => {
for (const [width, height, expected] of [
[1366, 728, [1229, 655, 960, 540]], [1280, 680, [1152, 612, 960, 540]],
[960, 540, [864, 486, 864, 486]], [300, 200, [320, 240, 320, 240]],
]) {
const display = { bounds: { x: 0, y: 0, width, height }, workArea: { x: 0, y: 0, width, height } };
assert.deepEqual(geometry((await create(null, display)).options), expected);
}
});
test("legacy dimension-only state and unavailable screen keep usable dimensions", async () => {
assert.deepEqual(geometry((await create({ width: 1000, height: 560 }, small)).options), [1000, 560, 960, 526]);
const { options } = await create({ width: 800, height: 400 }, null, []);
assert.deepEqual(geometry(options), [960, 540, 960, 540]);
});
test("saved flags retain existing startup semantics with fitted normal bounds and no startup write", async () => {
for (const [isMaximized, isFullScreen] of [[true, false], [false, true], [true, true]]) {
const { win, options, writes } = await create({ x: 10, y: 20, width: 4000, height: 3000, isMaximized, isFullScreen }, small);
assert.deepEqual(geometry(options), [983, 526, 960, 526]);
win.emit("ready-to-show");
assert.equal(win.isMaximized(), isMaximized && !isFullScreen);
// Existing startup deliberately does not re-enter fullscreen.
assert.equal(win.isFullScreen(), false);
assert.deepEqual(writes, []);
win.emit("close", { preventDefault() {} });
win.emit("closed");
}
});
test("manual normal bounds persist across maximized/fullscreen/minimized close", async () => {
for (const state of ["maximized", "fullscreen", "minimized"]) {
const { win, writes } = await create(null);
const normal = { x: 75, y: 80, width: 1040, height: 620 };
win.bounds = normal;
win.emit("move");
win[state] = true;
// A minimized window retains its normal native bounds; max/fullscreen do not.
if (state !== "minimized") win.bounds = { x: 0, y: 0, width: 1920, height: 1080 };
win.emit("resize");
win.emit("close", { preventDefault() {} });
assert.deepEqual(writes.at(-1), { ...normal, isMaximized: state === "maximized", isFullScreen: state === "fullscreen" });
win.emit("closed");
}
});
test("live minimum sync moves between displays, recovers after removal, and detaches on close", async () => {
const secondary = { ...small, bounds: { ...small.bounds, x: -1093 }, workArea: { ...small.workArea, x: -1093 } };
const { win, screen } = await create(null, large, [large, secondary]);
win.bounds = { x: -1080, y: 0, width: 1000, height: 560 };
win.emit("move");
assert.deepEqual(win.minimum, [960, 526]);
screen.getDisplayMatching = () => large;
screen.emit("display-removed");
assert.deepEqual(win.minimum, [960, 540]);
screen.getDisplayMatching = () => small;
screen.emit("display-metrics-changed");
assert.deepEqual(win.minimum, [960, 526]);
win.emit("close", { preventDefault() {} });
win.emit("closed");
assert.equal(screen.listenerCount("display-removed"), 0);
assert.equal(screen.listenerCount("display-metrics-changed"), 0);
});