···11+/**
22+ * Unit tests for backend/electron/window-placement.ts — the pure
33+ * `computePlacement` function. All display data is fabricated via the
44+ * `fakeDisplays(...)` helper; no real Electron `screen` API is touched.
55+ *
66+ * Runs under ELECTRON_RUN_AS_NODE=1 via `yarn test:unit`.
77+ */
88+99+import { describe, it } from 'node:test';
1010+import * as assert from 'node:assert';
1111+1212+import {
1313+ computePlacement,
1414+ type Placement,
1515+ type PlacementInput,
1616+ type PlacementResult,
1717+} from './window-placement.js';
1818+1919+// ---------------------------------------------------------------------------
2020+// fakeDisplays — the lever the whole sprint depends on
2121+// ---------------------------------------------------------------------------
2222+2323+interface DisplaySpec {
2424+ id: number;
2525+ x: number;
2626+ y: number;
2727+ width: number;
2828+ height: number;
2929+ /** Optional: smaller workArea (excluding menu bar / dock). Defaults to bounds. */
3030+ workArea?: { x: number; y: number; width: number; height: number };
3131+ /** Optional: marks this as "primary" — for our purposes, primary just means
3232+ * the display whose top-left is at (0, 0). The helper enforces that for
3333+ * the spec marked `primary: true` (handy ergonomics, no semantic effect
3434+ * beyond bounds.x/y values). */
3535+ primary?: boolean;
3636+}
3737+3838+/**
3939+ * Build an array shaped like `Electron.Display[]` from compact specs.
4040+ * Only fields used by `window-placement.ts` are populated; the rest are
4141+ * filled with sensible defaults so TypeScript stops complaining when we
4242+ * cast to `Electron.Display`.
4343+ */
4444+function fakeDisplays(specs: DisplaySpec[]): Electron.Display[] {
4545+ return specs.map(s => {
4646+ if (s.primary && (s.x !== 0 || s.y !== 0)) {
4747+ throw new Error(`fakeDisplays: spec marked primary but bounds origin is (${s.x},${s.y})`);
4848+ }
4949+ const bounds = { x: s.x, y: s.y, width: s.width, height: s.height };
5050+ const workArea = s.workArea ?? { ...bounds };
5151+ // Fields below aren't referenced by computePlacement, but Electron.Display
5252+ // is a structural type — we cast `as unknown as Electron.Display` to skip
5353+ // the noise.
5454+ return {
5555+ id: s.id,
5656+ bounds,
5757+ workArea,
5858+ // Filler fields for type compatibility — never read.
5959+ workAreaSize: { width: workArea.width, height: workArea.height },
6060+ size: { width: bounds.width, height: bounds.height },
6161+ scaleFactor: 1,
6262+ rotation: 0,
6363+ internal: false,
6464+ monochrome: false,
6565+ accelerometerSupport: 'unknown',
6666+ colorDepth: 24,
6767+ colorSpace: 'srgb',
6868+ depthPerComponent: 8,
6969+ displayFrequency: 60,
7070+ touchSupport: 'unknown',
7171+ label: `display-${s.id}`,
7272+ } as unknown as Electron.Display;
7373+ });
7474+}
7575+7676+// ---------------------------------------------------------------------------
7777+// Convenience builders
7878+// ---------------------------------------------------------------------------
7979+8080+function pt(x: number, y: number): Electron.Point {
8181+ return { x, y };
8282+}
8383+8484+function rect(x: number, y: number, width: number, height: number): Electron.Rectangle {
8585+ return { x, y, width, height };
8686+}
8787+8888+/** Compute a display's workArea center. */
8989+function centerOf(d: Electron.Display): Electron.Point {
9090+ const wa = d.workArea;
9191+ return { x: wa.x + Math.floor(wa.width / 2), y: wa.y + Math.floor(wa.height / 2) };
9292+}
9393+9494+/**
9595+ * Construct a `PlacementInput` with sensible defaults you can override.
9696+ * Keeps test code readable.
9797+ */
9898+function input(partial: Partial<PlacementInput> & Pick<PlacementInput, 'placement'>): PlacementInput {
9999+ return {
100100+ currentBounds: rect(0, 0, 800, 600),
101101+ windowSize: { width: 800, height: 600 },
102102+ displays: fakeDisplays([{ id: 1, x: 0, y: 0, width: 1920, height: 1080, primary: true }]),
103103+ cursorPoint: pt(960, 540),
104104+ ...partial,
105105+ };
106106+}
107107+108108+/** Pull bounds out of a result, asserting kind === reposition. */
109109+function expectReposition(result: PlacementResult): Electron.Rectangle {
110110+ assert.strictEqual(result.kind, 'reposition', `expected reposition, got ${result.kind}`);
111111+ return (result as Extract<PlacementResult, { kind: 'reposition' }>).bounds;
112112+}
113113+114114+function expectNoChange(result: PlacementResult): void {
115115+ assert.strictEqual(result.kind, 'no-change', `expected no-change, got ${result.kind}`);
116116+}
117117+118118+// ---------------------------------------------------------------------------
119119+// Common display fixtures
120120+// ---------------------------------------------------------------------------
121121+122122+const displayA = { id: 1, x: 0, y: 0, width: 1920, height: 1080, primary: true } as DisplaySpec;
123123+const displayB = { id: 2, x: 1920, y: 0, width: 1920, height: 1080 } as DisplaySpec;
124124+const laptopOnly = { id: 1, x: 0, y: 0, width: 1440, height: 900, primary: true } as DisplaySpec;
125125+const externalUnplugged = { id: 99, x: 1920, y: 0, width: 1920, height: 1080 } as DisplaySpec;
126126+127127+// ---------------------------------------------------------------------------
128128+// Tests
129129+// ---------------------------------------------------------------------------
130130+131131+describe('computePlacement: centered mode', () => {
132132+ it('1. centered on cursor display A, displays unchanged, cursor on A → no-change', () => {
133133+ const displays = fakeDisplays([displayA, displayB]);
134134+ // Window already centered on A: A.workArea is 0,0 1920x1080, window 800x600
135135+ // centered → x=560, y=240
136136+ const result = computePlacement(input({
137137+ placement: { mode: 'centered' },
138138+ currentBounds: rect(560, 240, 800, 600),
139139+ displays,
140140+ cursorPoint: pt(960, 540), // on A
141141+ }));
142142+ expectNoChange(result);
143143+ });
144144+145145+ it('2. centered on A, cursor moves to B → reposition to B center', () => {
146146+ const displays = fakeDisplays([displayA, displayB]);
147147+ const result = computePlacement(input({
148148+ placement: { mode: 'centered' },
149149+ currentBounds: rect(560, 240, 800, 600), // currently centered on A
150150+ displays,
151151+ cursorPoint: pt(2880, 540), // on B
152152+ }));
153153+ const bounds = expectReposition(result);
154154+ // B.workArea is 1920,0 1920x1080; centered window: x=1920+560=2480, y=240
155155+ assert.strictEqual(bounds.x, 2480);
156156+ assert.strictEqual(bounds.y, 240);
157157+ assert.strictEqual(bounds.width, 800);
158158+ assert.strictEqual(bounds.height, 600);
159159+ });
160160+161161+ it('3. centered, A removed entirely → reposition to (now-only) display', () => {
162162+ // Window was on A at (560, 240), but A is gone — only laptop display remains.
163163+ // The window's old bounds at x=560 ARE inside the laptop display (0..1440),
164164+ // so it's NOT stranded. But because mode is `centered`, we must always
165165+ // re-center on cursor display every call.
166166+ const displays = fakeDisplays([laptopOnly]);
167167+ const result = computePlacement(input({
168168+ placement: { mode: 'centered' },
169169+ currentBounds: rect(560, 240, 800, 600),
170170+ displays,
171171+ cursorPoint: pt(720, 450),
172172+ windowSize: { width: 800, height: 600 },
173173+ }));
174174+ const bounds = expectReposition(result);
175175+ // laptopOnly: 1440x900 → centered 800x600: x=320, y=150
176176+ assert.strictEqual(bounds.x, 320);
177177+ assert.strictEqual(bounds.y, 150);
178178+ });
179179+180180+ it('centered: cursor off all displays → falls back to primary (origin display)', () => {
181181+ const displays = fakeDisplays([displayA, displayB]);
182182+ const result = computePlacement(input({
183183+ placement: { mode: 'centered' },
184184+ currentBounds: rect(0, 0, 800, 600),
185185+ displays,
186186+ cursorPoint: pt(-9999, -9999), // nowhere
187187+ }));
188188+ const bounds = expectReposition(result);
189189+ // Falls back to A (primary at 0,0): centered → x=560, y=240
190190+ assert.strictEqual(bounds.x, 560);
191191+ assert.strictEqual(bounds.y, 240);
192192+ });
193193+});
194194+195195+describe('computePlacement: cursor-display-fallback mode', () => {
196196+ it('4. window currently fits, cursor moves to other display → no-change', () => {
197197+ const displays = fakeDisplays([displayA, displayB]);
198198+ const result = computePlacement(input({
199199+ placement: { mode: 'cursor-display-fallback' },
200200+ currentBounds: rect(100, 100, 800, 600), // on A, fits
201201+ displays,
202202+ cursorPoint: pt(2880, 540), // moved to B
203203+ }));
204204+ expectNoChange(result);
205205+ });
206206+207207+ it('5. window stranded (display unplugged) → reposition to cursor display center', () => {
208208+ // Window was on the external monitor, which is now gone.
209209+ const displays = fakeDisplays([laptopOnly]);
210210+ const result = computePlacement(input({
211211+ placement: { mode: 'cursor-display-fallback' },
212212+ currentBounds: rect(2400, 200, 800, 600), // off-screen for laptop
213213+ displays,
214214+ cursorPoint: pt(720, 450), // on laptop
215215+ }));
216216+ const bounds = expectReposition(result);
217217+ assert.strictEqual(bounds.x, 320);
218218+ assert.strictEqual(bounds.y, 150);
219219+ });
220220+});
221221+222222+describe('computePlacement: edge mode', () => {
223223+ it('6. edge: top, cursor on A → bounds anchored to A top edge, X-centered', () => {
224224+ const displays = fakeDisplays([displayA, displayB]);
225225+ const result = computePlacement(input({
226226+ placement: { mode: 'edge', edge: 'top' },
227227+ currentBounds: rect(0, 500, 600, 200),
228228+ windowSize: { width: 600, height: 200 },
229229+ displays,
230230+ cursorPoint: pt(960, 540), // on A
231231+ }));
232232+ const bounds = expectReposition(result);
233233+ // A.workArea 0,0 1920x1080; window 600x200 anchored top, X-centered:
234234+ // x = (1920 - 600) / 2 = 660, y = 0
235235+ assert.strictEqual(bounds.x, 660);
236236+ assert.strictEqual(bounds.y, 0);
237237+ assert.strictEqual(bounds.width, 600);
238238+ assert.strictEqual(bounds.height, 200);
239239+ });
240240+241241+ it('7. edge: top, cursor moves to B → re-anchor to B top edge', () => {
242242+ const displays = fakeDisplays([displayA, displayB]);
243243+ const result = computePlacement(input({
244244+ placement: { mode: 'edge', edge: 'top' },
245245+ currentBounds: rect(660, 0, 600, 200), // top of A
246246+ windowSize: { width: 600, height: 200 },
247247+ displays,
248248+ cursorPoint: pt(2880, 540), // on B
249249+ }));
250250+ const bounds = expectReposition(result);
251251+ // B.workArea 1920,0 1920x1080; window 600x200 top, X-centered:
252252+ // x = 1920 + (1920-600)/2 = 2580, y = 0
253253+ assert.strictEqual(bounds.x, 2580);
254254+ assert.strictEqual(bounds.y, 0);
255255+ });
256256+257257+ it('edge: bottom anchors to bottom edge', () => {
258258+ const displays = fakeDisplays([displayA]);
259259+ const result = computePlacement(input({
260260+ placement: { mode: 'edge', edge: 'bottom' },
261261+ currentBounds: rect(0, 0, 600, 200),
262262+ windowSize: { width: 600, height: 200 },
263263+ displays,
264264+ cursorPoint: pt(960, 540),
265265+ }));
266266+ const bounds = expectReposition(result);
267267+ // y = 0 + 1080 - 200 = 880, X-centered → x = 660
268268+ assert.strictEqual(bounds.x, 660);
269269+ assert.strictEqual(bounds.y, 880);
270270+ });
271271+272272+ it('edge: left anchors to left edge, Y-centered', () => {
273273+ const displays = fakeDisplays([displayA]);
274274+ const result = computePlacement(input({
275275+ placement: { mode: 'edge', edge: 'left' },
276276+ currentBounds: rect(500, 0, 300, 600),
277277+ windowSize: { width: 300, height: 600 },
278278+ displays,
279279+ cursorPoint: pt(960, 540),
280280+ }));
281281+ const bounds = expectReposition(result);
282282+ // x = 0, y = (1080-600)/2 = 240
283283+ assert.strictEqual(bounds.x, 0);
284284+ assert.strictEqual(bounds.y, 240);
285285+ });
286286+287287+ it('edge: right anchors to right edge, Y-centered', () => {
288288+ const displays = fakeDisplays([displayA]);
289289+ const result = computePlacement(input({
290290+ placement: { mode: 'edge', edge: 'right' },
291291+ currentBounds: rect(0, 0, 300, 600),
292292+ windowSize: { width: 300, height: 600 },
293293+ displays,
294294+ cursorPoint: pt(960, 540),
295295+ }));
296296+ const bounds = expectReposition(result);
297297+ // x = 1920 - 300 = 1620, y = 240
298298+ assert.strictEqual(bounds.x, 1620);
299299+ assert.strictEqual(bounds.y, 240);
300300+ });
301301+302302+ it('edge: top, currently already at correct edge position → no-change', () => {
303303+ const displays = fakeDisplays([displayA]);
304304+ const result = computePlacement(input({
305305+ placement: { mode: 'edge', edge: 'top' },
306306+ currentBounds: rect(660, 0, 600, 200),
307307+ windowSize: { width: 600, height: 200 },
308308+ displays,
309309+ cursorPoint: pt(960, 540),
310310+ }));
311311+ expectNoChange(result);
312312+ });
313313+});
314314+315315+describe('computePlacement: parent-centered mode', () => {
316316+ it('8. parent on A → centered on parent bounds', () => {
317317+ const displays = fakeDisplays([displayA, displayB]);
318318+ const parentBounds = rect(200, 200, 1000, 700); // on A
319319+ const result = computePlacement(input({
320320+ placement: { mode: 'parent-centered', parentId: 42 },
321321+ currentBounds: rect(0, 0, 400, 300),
322322+ windowSize: { width: 400, height: 300 },
323323+ displays,
324324+ cursorPoint: pt(960, 540),
325325+ parentBounds,
326326+ }));
327327+ const bounds = expectReposition(result);
328328+ // Parent center: 700, 550. Child 400x300 centered on parent rect:
329329+ // x = 200 + (1000-400)/2 = 500, y = 200 + (700-300)/2 = 400
330330+ assert.strictEqual(bounds.x, 500);
331331+ assert.strictEqual(bounds.y, 400);
332332+ });
333333+334334+ it('9. parent destroyed (parentBounds undefined) → no-change (cursor-display-fallback semantics)', () => {
335335+ const displays = fakeDisplays([displayA]);
336336+ const result = computePlacement(input({
337337+ placement: { mode: 'parent-centered', parentId: 42 },
338338+ currentBounds: rect(100, 100, 400, 300), // not stranded
339339+ windowSize: { width: 400, height: 300 },
340340+ displays,
341341+ cursorPoint: pt(960, 540),
342342+ parentBounds: undefined,
343343+ }));
344344+ expectNoChange(result);
345345+ });
346346+347347+ it('parent destroyed AND stranded → rescue (stranded path takes priority)', () => {
348348+ const displays = fakeDisplays([laptopOnly]);
349349+ const result = computePlacement(input({
350350+ placement: { mode: 'parent-centered', parentId: 42 },
351351+ currentBounds: rect(2400, 200, 400, 300), // stranded
352352+ windowSize: { width: 400, height: 300 },
353353+ displays,
354354+ cursorPoint: pt(720, 450),
355355+ parentBounds: undefined,
356356+ }));
357357+ const bounds = expectReposition(result);
358358+ // Centered on laptopOnly: x=520, y=300
359359+ assert.strictEqual(bounds.x, 520);
360360+ assert.strictEqual(bounds.y, 300);
361361+ });
362362+363363+ it('parent on B, child currently on A → reposition to parent center on B', () => {
364364+ const displays = fakeDisplays([displayA, displayB]);
365365+ const parentBounds = rect(2200, 100, 800, 600); // on B
366366+ const result = computePlacement(input({
367367+ placement: { mode: 'parent-centered', parentId: 42 },
368368+ currentBounds: rect(100, 100, 400, 300), // on A
369369+ windowSize: { width: 400, height: 300 },
370370+ displays,
371371+ cursorPoint: pt(960, 540),
372372+ parentBounds,
373373+ }));
374374+ const bounds = expectReposition(result);
375375+ // Parent center: 2600, 400. Child centered: x=2400, y=250
376376+ assert.strictEqual(bounds.x, 2400);
377377+ assert.strictEqual(bounds.y, 250);
378378+ });
379379+});
380380+381381+describe('computePlacement: manual mode', () => {
382382+ it('10. manual, window fits on some display → no-change', () => {
383383+ const displays = fakeDisplays([displayA, displayB]);
384384+ const result = computePlacement(input({
385385+ placement: { mode: 'manual' },
386386+ currentBounds: rect(700, 300, 600, 400), // on A, comfortable
387387+ displays,
388388+ cursorPoint: pt(2880, 540), // cursor on B; manual ignores
389389+ }));
390390+ expectNoChange(result);
391391+ });
392392+393393+ it('11. manual, window stranded → rescue to cursor display center', () => {
394394+ // Stranded rescue applies to ALL modes including manual.
395395+ const displays = fakeDisplays([laptopOnly]);
396396+ const result = computePlacement(input({
397397+ placement: { mode: 'manual' },
398398+ currentBounds: rect(externalUnplugged.x + 100, 100, 800, 600), // off-screen
399399+ windowSize: { width: 800, height: 600 },
400400+ displays,
401401+ cursorPoint: pt(720, 450),
402402+ }));
403403+ const bounds = expectReposition(result);
404404+ assert.strictEqual(bounds.x, 320);
405405+ assert.strictEqual(bounds.y, 150);
406406+ });
407407+});
408408+409409+describe('computePlacement: clamping & edge cases', () => {
410410+ it('12. window size larger than target display → output clamped to workArea', () => {
411411+ const displays = fakeDisplays([laptopOnly]); // 1440x900
412412+ const result = computePlacement(input({
413413+ placement: { mode: 'centered' },
414414+ currentBounds: rect(0, 0, 1, 1),
415415+ windowSize: { width: 4000, height: 3000 }, // huge
416416+ displays,
417417+ cursorPoint: pt(720, 450),
418418+ }));
419419+ const bounds = expectReposition(result);
420420+ // Clamped to workArea (1440x900), then centered: x=0, y=0
421421+ assert.strictEqual(bounds.width, 1440);
422422+ assert.strictEqual(bounds.height, 900);
423423+ assert.strictEqual(bounds.x, 0);
424424+ assert.strictEqual(bounds.y, 0);
425425+ });
426426+427427+ it('zero-size window is never stranded (treated as hidden/minimized)', () => {
428428+ const displays = fakeDisplays([laptopOnly]);
429429+ const result = computePlacement(input({
430430+ placement: { mode: 'manual' },
431431+ currentBounds: rect(99999, 99999, 0, 0), // way off, but zero size
432432+ displays,
433433+ cursorPoint: pt(720, 450),
434434+ }));
435435+ // Manual + not stranded (zero area is treated as "always accessible") → no-change
436436+ expectNoChange(result);
437437+ });
438438+439439+ it('empty displays array → no-change for every mode', () => {
440440+ const modes: Placement[] = [
441441+ { mode: 'centered' },
442442+ { mode: 'cursor-display-fallback' },
443443+ { mode: 'edge', edge: 'top' },
444444+ { mode: 'parent-centered', parentId: 1 },
445445+ { mode: 'manual' },
446446+ ];
447447+ for (const placement of modes) {
448448+ const result = computePlacement(input({
449449+ placement,
450450+ displays: [],
451451+ cursorPoint: pt(0, 0),
452452+ }));
453453+ expectNoChange(result);
454454+ }
455455+ });
456456+457457+ it('point not on any display: cursor falls back to primary (origin display)', () => {
458458+ const displays = fakeDisplays([displayA, displayB]);
459459+ const result = computePlacement(input({
460460+ placement: { mode: 'centered' },
461461+ currentBounds: rect(0, 0, 800, 600),
462462+ displays,
463463+ cursorPoint: pt(99999, 99999), // far away
464464+ }));
465465+ // Should still produce a valid centering on A (primary).
466466+ const bounds = expectReposition(result);
467467+ assert.ok(bounds.x >= 0 && bounds.x < 1920);
468468+ assert.ok(bounds.y >= 0 && bounds.y < 1080);
469469+ });
470470+471471+ it('workArea smaller than bounds (menu bar / dock zones excluded)', () => {
472472+ // Display at (0,0) 1920x1080 with a 25px menu bar at top.
473473+ const displays = fakeDisplays([
474474+ {
475475+ id: 1, x: 0, y: 0, width: 1920, height: 1080, primary: true,
476476+ workArea: { x: 0, y: 25, width: 1920, height: 1055 },
477477+ },
478478+ ]);
479479+ const result = computePlacement(input({
480480+ placement: { mode: 'centered' },
481481+ currentBounds: rect(0, 0, 800, 600),
482482+ displays,
483483+ cursorPoint: pt(960, 540),
484484+ }));
485485+ const bounds = expectReposition(result);
486486+ // workArea 0,25 1920x1055; centered 800x600:
487487+ // x = 560, y = 25 + (1055-600)/2 = 25 + 227 = 252 (rounded from 227.5)
488488+ assert.strictEqual(bounds.x, 560);
489489+ assert.strictEqual(bounds.y, 253);
490490+ });
491491+492492+ it('stranded window with negative coordinates → rescue', () => {
493493+ const displays = fakeDisplays([laptopOnly]);
494494+ const result = computePlacement(input({
495495+ placement: { mode: 'cursor-display-fallback' },
496496+ currentBounds: rect(-5000, -5000, 800, 600),
497497+ displays,
498498+ cursorPoint: pt(720, 450),
499499+ }));
500500+ const bounds = expectReposition(result);
501501+ assert.strictEqual(bounds.x, 320);
502502+ assert.strictEqual(bounds.y, 150);
503503+ });
504504+505505+ it('stranded threshold: 49% overlap → rescue', () => {
506506+ // Display 1000x1000, window 1000x1000 with center at (500, 500)+offset.
507507+ // Place window so 49% of its area is on the display.
508508+ // Window 100x100, on display only at right strip 49 wide:
509509+ // Window at x=951, y=0, width=100, height=100: overlap is 49x100 = 4900, area 10000 → 49%.
510510+ const displays = fakeDisplays([
511511+ { id: 1, x: 0, y: 0, width: 1000, height: 1000, primary: true },
512512+ ]);
513513+ const result = computePlacement(input({
514514+ placement: { mode: 'manual' },
515515+ currentBounds: rect(951, 0, 100, 100),
516516+ windowSize: { width: 100, height: 100 },
517517+ displays,
518518+ cursorPoint: pt(500, 500),
519519+ }));
520520+ const bounds = expectReposition(result);
521521+ // Centered: x=450, y=450
522522+ assert.strictEqual(bounds.x, 450);
523523+ assert.strictEqual(bounds.y, 450);
524524+ });
525525+526526+ it('stranded threshold: 51% overlap → no rescue (manual stays put)', () => {
527527+ // Window at x=949, width=100 → overlap is 51 wide on display (0..1000).
528528+ // 51 * 100 = 5100 / 10000 = 51% → above threshold, manual leaves alone.
529529+ const displays = fakeDisplays([
530530+ { id: 1, x: 0, y: 0, width: 1000, height: 1000, primary: true },
531531+ ]);
532532+ const result = computePlacement(input({
533533+ placement: { mode: 'manual' },
534534+ currentBounds: rect(949, 0, 100, 100),
535535+ windowSize: { width: 100, height: 100 },
536536+ displays,
537537+ cursorPoint: pt(500, 500),
538538+ }));
539539+ expectNoChange(result);
540540+ });
541541+542542+ it('parent-centered: parent center off all displays falls back to cursor display', () => {
543543+ const displays = fakeDisplays([displayA]);
544544+ const parentBounds = rect(99000, 99000, 400, 300); // way off
545545+ const result = computePlacement(input({
546546+ placement: { mode: 'parent-centered', parentId: 42 },
547547+ currentBounds: rect(100, 100, 200, 150),
548548+ windowSize: { width: 200, height: 150 },
549549+ displays,
550550+ cursorPoint: pt(960, 540),
551551+ parentBounds,
552552+ }));
553553+ // Still proposes bounds: child centered on parent rect (parent rect is the
554554+ // recorded position even if off-screen). The point of the targetDisplay
555555+ // fallback is just to choose a clamping workArea — clamping uses A.
556556+ const bounds = expectReposition(result);
557557+ assert.strictEqual(bounds.width, 200);
558558+ assert.strictEqual(bounds.height, 150);
559559+ });
560560+561561+ it('cursor-display-fallback when window already centered on cursor display → no-change', () => {
562562+ const displays = fakeDisplays([displayA]);
563563+ const result = computePlacement(input({
564564+ placement: { mode: 'cursor-display-fallback' },
565565+ currentBounds: rect(560, 240, 800, 600),
566566+ displays,
567567+ cursorPoint: pt(960, 540),
568568+ }));
569569+ expectNoChange(result);
570570+ });
571571+572572+ it('centered: re-center is exact pixel match → no spurious reposition', () => {
573573+ const displays = fakeDisplays([displayA]);
574574+ // Compute what centered should yield, then feed it back.
575575+ const expected = rect(560, 240, 800, 600);
576576+ const result = computePlacement(input({
577577+ placement: { mode: 'centered' },
578578+ currentBounds: expected,
579579+ displays,
580580+ cursorPoint: pt(960, 540),
581581+ }));
582582+ expectNoChange(result);
583583+ });
584584+});
585585+586586+describe('computePlacement: regression coverage from plan doc', () => {
587587+ it('"External monitor unplugged, cmd panel still on the laptop screen but at old external coordinates"', () => {
588588+ const displays = fakeDisplays([laptopOnly]);
589589+ const result = computePlacement(input({
590590+ placement: { mode: 'centered' },
591591+ currentBounds: rect(2200, 200, 800, 600), // on the (now-missing) external
592592+ displays,
593593+ cursorPoint: centerOf(fakeDisplays([laptopOnly])[0]),
594594+ }));
595595+ const bounds = expectReposition(result);
596596+ assert.strictEqual(bounds.x, 320);
597597+ assert.strictEqual(bounds.y, 150);
598598+ });
599599+600600+ it('"Slide opened, user moves to second display, slide stays on first"', () => {
601601+ const displays = fakeDisplays([displayA, displayB]);
602602+ const result = computePlacement(input({
603603+ placement: { mode: 'edge', edge: 'top' },
604604+ currentBounds: rect(660, 0, 600, 200), // top of A
605605+ windowSize: { width: 600, height: 200 },
606606+ displays,
607607+ cursorPoint: pt(2880, 540), // user has moved to B
608608+ }));
609609+ const bounds = expectReposition(result);
610610+ assert.strictEqual(bounds.x, 2580); // top of B, X-centered
611611+ assert.strictEqual(bounds.y, 0);
612612+ });
613613+});
+374
backend/electron/window-placement.ts
···11+/**
22+ * Window Placement — Pure Module
33+ *
44+ * Single source of truth for "where should this window go?" decisions.
55+ * Pure function from inputs to output: no `screen.*` calls, no
66+ * `BrowserWindow.*` references, no Electron runtime imports. The caller
77+ * is responsible for collecting the current display layout, cursor point,
88+ * window bounds, and parent bounds, and for applying the result via
99+ * `setBounds()` (only when `kind === 'reposition'`).
1010+ *
1111+ * This makes display-change behavior unit-testable without real
1212+ * multi-monitor hardware — see window-placement.test.ts.
1313+ *
1414+ * See docs/window-placement-refactor.md for the sprint plan and the
1515+ * full mapping of legacy flags (`center: true`, explicit `x`/`y`,
1616+ * `screenEdge`, parent windows) to `Placement` modes.
1717+ */
1818+//
1919+// NOTE: Type-only imports of `Electron.Display`, `Electron.Rectangle`,
2020+// `Electron.Point` are fine — they're erased at compile time and don't
2121+// pull the Electron runtime into this module. Tests fabricate display
2222+// objects without touching the real `screen` API.
2323+2424+/**
2525+ * Discriminated union describing where a window wants to live.
2626+ *
2727+ * Recorded **once at window-open time** on `windowRegistry.params.placement`
2828+ * and consulted on every reuse / display-change pass thereafter.
2929+ */
3030+export type Placement =
3131+ | { mode: 'centered' }
3232+ // Always centered on the cursor's display, every time the window
3333+ // is shown. cmd panel, modal palettes, "center: true" callers.
3434+ | { mode: 'cursor-display-fallback' }
3535+ // Centered on the cursor display ONLY if no explicit position has
3636+ // been observed yet. Once positioned, stays put across reuse —
3737+ // unless stranded (see below). Page-host default.
3838+ | { mode: 'edge'; edge: 'top' | 'bottom' | 'left' | 'right' }
3939+ // Anchored to one edge of the cursor's display, every show. Slides.
4040+ | { mode: 'parent-centered'; parentId: number }
4141+ // Centered on the parent window's bounds. Quick-views, child dialogs.
4242+ | { mode: 'manual' };
4343+ // User-positioned (drag, manual setBounds). Never auto-moved
4444+ // EXCEPT when stranded.
4545+4646+export interface PlacementInput {
4747+ /** The placement intent recorded at open time. */
4848+ placement: Placement;
4949+ /** Window's bounds right now (used for "is this still placed correctly?" check). */
5050+ currentBounds: Electron.Rectangle;
5151+ /** Desired/intrinsic size — used when computing new bounds. */
5252+ windowSize: { width: number; height: number };
5353+ /** Snapshot of all displays (caller obtains via `screen.getAllDisplays()`). */
5454+ displays: Electron.Display[];
5555+ /** Cursor screen point (caller obtains via `screen.getCursorScreenPoint()`). */
5656+ cursorPoint: Electron.Point;
5757+ /** Required for `parent-centered`. Undefined if parent window has been destroyed. */
5858+ parentBounds?: Electron.Rectangle;
5959+}
6060+6161+export type PlacementResult =
6262+ | { kind: 'no-change' }
6363+ | { kind: 'reposition'; bounds: Electron.Rectangle };
6464+6565+// ---------------------------------------------------------------------------
6666+// Constants & helpers
6767+// ---------------------------------------------------------------------------
6868+6969+/**
7070+ * If a window has less than this fraction of its area on any display, it's
7171+ * considered "stranded" and gets force-rescued to the cursor display
7272+ * regardless of placement mode. Replaces the historical 30% threshold from
7373+ * `display-watcher.ts` and `isWindowAccessibleNow` in `ipc.ts`. Bumped to
7474+ * 50% so we rescue more aggressively — half the window off-screen is
7575+ * effectively unusable.
7676+ */
7777+const STRANDED_THRESHOLD = 0.5;
7878+7979+/**
8080+ * Find the display containing a point. Tries `workArea` first (avoids
8181+ * counting menu-bar / dock zones as "on display"); falls back to `bounds`
8282+ * so that a point in the OS chrome zone still resolves correctly.
8383+ *
8484+ * Returns `null` if the point is not on any display.
8585+ */
8686+function findDisplayForPoint(
8787+ displays: Electron.Display[],
8888+ point: Electron.Point,
8989+): Electron.Display | null {
9090+ for (const d of displays) {
9191+ const wa = d.workArea;
9292+ if (
9393+ point.x >= wa.x && point.x < wa.x + wa.width &&
9494+ point.y >= wa.y && point.y < wa.y + wa.height
9595+ ) {
9696+ return d;
9797+ }
9898+ }
9999+ for (const d of displays) {
100100+ const b = d.bounds;
101101+ if (
102102+ point.x >= b.x && point.x < b.x + b.width &&
103103+ point.y >= b.y && point.y < b.y + b.height
104104+ ) {
105105+ return d;
106106+ }
107107+ }
108108+ return null;
109109+}
110110+111111+/**
112112+ * Pick the cursor's display, with sensible fallbacks. Prefer the display
113113+ * the cursor is on; otherwise the first display marked primary by id (we
114114+ * use bounds.x === 0 && bounds.y === 0 as a heuristic — Electron's
115115+ * `screen.getPrimaryDisplay()` returns the display whose top-left is at
116116+ * (0,0) on macOS); otherwise the first display in the list. Returns
117117+ * `null` only if `displays` is empty.
118118+ */
119119+function pickCursorOrPrimaryDisplay(
120120+ displays: Electron.Display[],
121121+ cursorPoint: Electron.Point,
122122+): Electron.Display | null {
123123+ if (displays.length === 0) return null;
124124+ const onCursor = findDisplayForPoint(displays, cursorPoint);
125125+ if (onCursor) return onCursor;
126126+ const atOrigin = displays.find(d => d.bounds.x === 0 && d.bounds.y === 0);
127127+ return atOrigin ?? displays[0];
128128+}
129129+130130+/**
131131+ * Return the fraction (0..1) of `bounds`' area that overlaps any display's
132132+ * workArea. Zero-size windows are reported as 1 (fully accessible) — they
133133+ * can't be stranded, they're hidden/minimized.
134134+ */
135135+function maxOverlapFraction(
136136+ displays: Electron.Display[],
137137+ bounds: Electron.Rectangle,
138138+): number {
139139+ const area = bounds.width * bounds.height;
140140+ if (area <= 0) return 1;
141141+142142+ let best = 0;
143143+ for (const d of displays) {
144144+ const wa = d.workArea;
145145+ const overlapX = Math.max(bounds.x, wa.x);
146146+ const overlapY = Math.max(bounds.y, wa.y);
147147+ const overlapRight = Math.min(bounds.x + bounds.width, wa.x + wa.width);
148148+ const overlapBottom = Math.min(bounds.y + bounds.height, wa.y + wa.height);
149149+ if (overlapRight > overlapX && overlapBottom > overlapY) {
150150+ const overlap = (overlapRight - overlapX) * (overlapBottom - overlapY);
151151+ const frac = overlap / area;
152152+ if (frac > best) best = frac;
153153+ }
154154+ }
155155+ return best;
156156+}
157157+158158+/** Window has < STRANDED_THRESHOLD area on any display → needs rescue. */
159159+function isStranded(displays: Electron.Display[], bounds: Electron.Rectangle): boolean {
160160+ if (displays.length === 0) return false; // can't rescue if there are no displays
161161+ return maxOverlapFraction(displays, bounds) < STRANDED_THRESHOLD;
162162+}
163163+164164+/**
165165+ * Clamp a desired size to never exceed the workArea. Output dimensions are
166166+ * `min(desired, workArea)` — we never grow a window, only shrink to fit.
167167+ */
168168+function clampSize(
169169+ size: { width: number; height: number },
170170+ workArea: Electron.Rectangle,
171171+): { width: number; height: number } {
172172+ return {
173173+ width: Math.min(size.width, workArea.width),
174174+ height: Math.min(size.height, workArea.height),
175175+ };
176176+}
177177+178178+/** Center a (clamped) size on a workArea. */
179179+function centerOn(
180180+ workArea: Electron.Rectangle,
181181+ size: { width: number; height: number },
182182+): Electron.Rectangle {
183183+ return {
184184+ x: workArea.x + Math.round((workArea.width - size.width) / 2),
185185+ y: workArea.y + Math.round((workArea.height - size.height) / 2),
186186+ width: size.width,
187187+ height: size.height,
188188+ };
189189+}
190190+191191+/** Center a (clamped) size on an arbitrary rectangle (e.g. parent bounds). */
192192+function centerOnRect(
193193+ rect: Electron.Rectangle,
194194+ size: { width: number; height: number },
195195+): Electron.Rectangle {
196196+ return {
197197+ x: rect.x + Math.round((rect.width - size.width) / 2),
198198+ y: rect.y + Math.round((rect.height - size.height) / 2),
199199+ width: size.width,
200200+ height: size.height,
201201+ };
202202+}
203203+204204+/** Anchor a (clamped) size to one edge of a workArea. */
205205+function anchorToEdge(
206206+ workArea: Electron.Rectangle,
207207+ size: { width: number; height: number },
208208+ edge: 'top' | 'bottom' | 'left' | 'right',
209209+): Electron.Rectangle {
210210+ // Design decision (not specified explicitly in plan): for top/bottom we
211211+ // X-center; for left/right we Y-center. The "other axis" defaults to
212212+ // centered on the cursor display. This matches what the slides feature
213213+ // was doing in renderer math before this refactor.
214214+ switch (edge) {
215215+ case 'top':
216216+ return {
217217+ x: workArea.x + Math.round((workArea.width - size.width) / 2),
218218+ y: workArea.y,
219219+ width: size.width,
220220+ height: size.height,
221221+ };
222222+ case 'bottom':
223223+ return {
224224+ x: workArea.x + Math.round((workArea.width - size.width) / 2),
225225+ y: workArea.y + workArea.height - size.height,
226226+ width: size.width,
227227+ height: size.height,
228228+ };
229229+ case 'left':
230230+ return {
231231+ x: workArea.x,
232232+ y: workArea.y + Math.round((workArea.height - size.height) / 2),
233233+ width: size.width,
234234+ height: size.height,
235235+ };
236236+ case 'right':
237237+ return {
238238+ x: workArea.x + workArea.width - size.width,
239239+ y: workArea.y + Math.round((workArea.height - size.height) / 2),
240240+ width: size.width,
241241+ height: size.height,
242242+ };
243243+ }
244244+}
245245+246246+/** Bounds-equality test — used to short-circuit `setBounds()` calls. */
247247+function rectsEqual(a: Electron.Rectangle, b: Electron.Rectangle): boolean {
248248+ return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
249249+}
250250+251251+/**
252252+ * Helper that yields a `PlacementResult`: returns `no-change` when the
253253+ * computed bounds match `currentBounds`, otherwise `reposition`.
254254+ */
255255+function reposeOrNoChange(
256256+ currentBounds: Electron.Rectangle,
257257+ proposed: Electron.Rectangle,
258258+): PlacementResult {
259259+ if (rectsEqual(currentBounds, proposed)) return { kind: 'no-change' };
260260+ return { kind: 'reposition', bounds: proposed };
261261+}
262262+263263+// ---------------------------------------------------------------------------
264264+// Main entry point
265265+// ---------------------------------------------------------------------------
266266+267267+/**
268268+ * Decide where a window should go, given its placement intent + current
269269+ * display layout + cursor.
270270+ *
271271+ * Contract:
272272+ * - Pure. No global state, no Electron runtime calls.
273273+ * - Returns `{ kind: 'no-change' }` if the window is already correctly
274274+ * placed for its mode → caller skips `setBounds()`.
275275+ * - For every mode, "stranded" rescue takes priority: if `currentBounds`
276276+ * has less than 50% area overlap with any display, ALWAYS reposition
277277+ * to the cursor display center, regardless of mode. (Replaces the old
278278+ * `isWindowAccessibleNow` + `repositionOnCursorDisplay` helpers.)
279279+ * - Output bounds are always clamped so width/height never exceed the
280280+ * chosen display's workArea.
281281+ * - If `displays` is empty, returns `no-change` — there's nowhere to
282282+ * reposition to. Caller should treat this as "wait until displays
283283+ * come back."
284284+ */
285285+export function computePlacement(input: PlacementInput): PlacementResult {
286286+ const { placement, currentBounds, windowSize, displays, cursorPoint, parentBounds } = input;
287287+288288+ // No displays at all → nothing we can do. Early out before anything else.
289289+ if (displays.length === 0) return { kind: 'no-change' };
290290+291291+ // -------------------------------------------------------------------------
292292+ // Stranded-rescue path (priority over every mode, including 'manual').
293293+ // If the window is currently <50% on any display, force-reposition to the
294294+ // cursor display center using the requested windowSize. This unifies what
295295+ // the old `isWindowAccessibleNow` did for general windows with what the
296296+ // `center: true` rescue did for cmd-style windows.
297297+ // -------------------------------------------------------------------------
298298+ if (isStranded(displays, currentBounds)) {
299299+ const target = pickCursorOrPrimaryDisplay(displays, cursorPoint);
300300+ if (!target) return { kind: 'no-change' }; // unreachable given displays.length>0
301301+ const size = clampSize(windowSize, target.workArea);
302302+ const proposed = centerOn(target.workArea, size);
303303+ return reposeOrNoChange(currentBounds, proposed);
304304+ }
305305+306306+ // -------------------------------------------------------------------------
307307+ // Per-mode placement.
308308+ // -------------------------------------------------------------------------
309309+ switch (placement.mode) {
310310+311311+ case 'centered': {
312312+ // Always re-center on the cursor display, every call. cmd panel
313313+ // semantics — follow the user.
314314+ const target = pickCursorOrPrimaryDisplay(displays, cursorPoint);
315315+ if (!target) return { kind: 'no-change' };
316316+ const size = clampSize(windowSize, target.workArea);
317317+ const proposed = centerOn(target.workArea, size);
318318+ return reposeOrNoChange(currentBounds, proposed);
319319+ }
320320+321321+ case 'cursor-display-fallback': {
322322+ // "Center on cursor display only if not yet placed." We approximate
323323+ // "not yet placed" as "currentBounds happen to match what cursor-
324324+ // -display-centering would produce" → no change. Otherwise: the
325325+ // window is already positioned somewhere reasonable (we already
326326+ // ruled out stranded above), so leave it.
327327+ //
328328+ // Design decision (not specified explicitly in plan): once a window
329329+ // has any non-default position, we honor it. The stranded path
330330+ // above handles the "display unplugged" case. This matches the
331331+ // page-host behavior we want — open on cursor display, then stay
332332+ // put across reuse.
333333+ return { kind: 'no-change' };
334334+ }
335335+336336+ case 'edge': {
337337+ // Anchor to one edge of the cursor display, every show. Re-anchor
338338+ // when the cursor moves to a different display.
339339+ const target = pickCursorOrPrimaryDisplay(displays, cursorPoint);
340340+ if (!target) return { kind: 'no-change' };
341341+ const size = clampSize(windowSize, target.workArea);
342342+ const proposed = anchorToEdge(target.workArea, size, placement.edge);
343343+ return reposeOrNoChange(currentBounds, proposed);
344344+ }
345345+346346+ case 'parent-centered': {
347347+ // If parent bounds were not provided (parent destroyed), fall
348348+ // through to cursor-display-fallback semantics: leave the window
349349+ // alone unless stranded (which we already handled). Design decision
350350+ // confirmed in the plan doc.
351351+ if (!parentBounds) {
352352+ return { kind: 'no-change' };
353353+ }
354354+ // Center on parent. Clamp to the display that contains the parent's
355355+ // center; if no display contains it, fall back to cursor display.
356356+ const parentCenter: Electron.Point = {
357357+ x: parentBounds.x + Math.round(parentBounds.width / 2),
358358+ y: parentBounds.y + Math.round(parentBounds.height / 2),
359359+ };
360360+ const targetDisplay =
361361+ findDisplayForPoint(displays, parentCenter) ??
362362+ pickCursorOrPrimaryDisplay(displays, cursorPoint);
363363+ if (!targetDisplay) return { kind: 'no-change' };
364364+ const size = clampSize(windowSize, targetDisplay.workArea);
365365+ const proposed = centerOnRect(parentBounds, size);
366366+ return reposeOrNoChange(currentBounds, proposed);
367367+ }
368368+369369+ case 'manual': {
370370+ // User-positioned. Never auto-move (stranded already handled).
371371+ return { kind: 'no-change' };
372372+ }
373373+ }
374374+}