import { describe, it, expect } from 'vitest'; import { computeHoverCardPosition, CARD_WIDTH_PX, CARD_HEIGHT_PX, CARD_GAP_PX, BOTTOM_BAND_RATIO, RIGHT_FLIP_THRESHOLD_PX } from './hoverCardPosition'; const makeRect = (overrides: Partial = {}): DOMRect => { const base = { top: 100, left: 200, bottom: 120, right: 300, width: 100, height: 20 }; const merged = { ...base, ...overrides }; return { ...merged, x: merged.left, y: merged.top, toJSON: () => merged } as DOMRect; }; const vp = { viewportWidth: 1440, viewportHeight: 900 }; describe('computeHoverCardPosition', () => { it('exports the spec constants used by the spec/CSS layer', () => { // Pin the values the design spec calls out — if these drift, the design spec // in #5329 needs to drift with them. Felix's PR review #2 (named constants). expect(CARD_WIDTH_PX).toBe(320); expect(CARD_HEIGHT_PX).toBe(180); expect(CARD_GAP_PX).toBe(6); expect(BOTTOM_BAND_RATIO).toBe(0.7); expect(RIGHT_FLIP_THRESHOLD_PX).toBe(300); }); describe('default placement (below-right)', () => { it('positions the card below the rect with a small gap', () => { const rect = makeRect({ top: 100, bottom: 120, left: 200 }); const result = computeHoverCardPosition(rect, vp); expect(result.top).toBe(120 + CARD_GAP_PX); expect(result.left).toBe(200); }); }); describe('flip-up rule (Leonie #5329)', () => { it('flips up when the card would overflow the bottom edge', () => { // Mention sits 50px above the viewport bottom — card is 180px tall, can't fit below const rect = makeRect({ top: 800, bottom: 850 }); const result = computeHoverCardPosition(rect, vp); expect(result.top).toBe(800 - CARD_HEIGHT_PX - CARD_GAP_PX); }); it('flips up when the mention sits in the bottom 30% of the viewport (BOTTOM_BAND_RATIO)', () => { // rect.top is at 80% of viewport — fits below numerically, but poor UX const rect = makeRect({ top: 720, bottom: 740 }); const result = computeHoverCardPosition(rect, vp); expect(result.top).toBe(720 - CARD_HEIGHT_PX - CARD_GAP_PX); }); }); describe('flip-left rule', () => { it('flips left when the rect is within RIGHT_FLIP_THRESHOLD_PX of the right edge', () => { // vw - rect.left = 1440 - 1200 = 240 < 300, so flip const rect = makeRect({ left: 1200, right: 1300, top: 100, bottom: 120 }); const result = computeHoverCardPosition(rect, { viewportWidth: 1440, viewportHeight: 900 }); // left = right - CARD_WIDTH = 1300 - 320 = 980 expect(result.left).toBe(980); }); it('does not flip left when the rect has plenty of right-side room', () => { // vw - rect.left = 1440 - 200 = 1240 >> 300 → no flip const rect = makeRect({ left: 200, right: 300 }); const result = computeHoverCardPosition(rect, vp); expect(result.left).toBe(200); }); }); describe('viewport clamping (Leonie FINDING-05)', () => { it('clamps left so the card never overflows the right edge', () => { // On a 320px viewport, even with flip the card width equals the viewport. // Without clamping the card would be at left=0 but extend to 320 — fine. // At viewport=400px with rect.left=200, flip puts left=300-320=-20, clamped to 0. const rect = makeRect({ left: 200, right: 300, top: 100, bottom: 120 }); const result = computeHoverCardPosition(rect, { viewportWidth: 400, viewportHeight: 900 }); expect(result.left).toBeGreaterThanOrEqual(0); expect(result.left + CARD_WIDTH_PX).toBeLessThanOrEqual(400); }); it('never returns a negative top or left', () => { const rect = makeRect({ top: -50, left: -100, bottom: -30, right: 0 }); const result = computeHoverCardPosition(rect, vp); expect(result.top).toBeGreaterThanOrEqual(0); expect(result.left).toBeGreaterThanOrEqual(0); }); }); describe('position: fixed (viewport-relative coordinates)', () => { it('returns viewport-relative top — does not add scroll offset', () => { // getBoundingClientRect values are already viewport-relative; with position:fixed // we use them directly without adding scrollY. const rect = makeRect({ top: 100, bottom: 120 }); const result = computeHoverCardPosition(rect, vp); expect(result.top).toBe(120 + CARD_GAP_PX); }); it('returns viewport-relative left — does not add scroll offset', () => { const rect = makeRect({ top: 100, bottom: 120, left: 200, right: 300 }); const result = computeHoverCardPosition(rect, vp); expect(result.left).toBe(200); }); }); });