import { describe, expect, it } from 'vitest'; import { GAME_STEPS, LOBBY_STEPS, nextVisibleStep, placeBubble, stepsFor, type Rect, type Viewport, } from './coachmark'; describe('stepsFor', () => { it('returns the owner-defined order for each series', () => { expect(stepsFor('lobby')).toBe(LOBBY_STEPS); expect(stepsFor('game')).toBe(GAME_STEPS); expect(LOBBY_STEPS.map((s) => s.target)).toEqual(['lobby-settings', 'lobby-stats', 'lobby-new']); expect(GAME_STEPS.map((s) => s.target)).toEqual(['game-header', 'game-turn', 'game-hints', 'game-shuffle', 'game-rack']); }); }); describe('nextVisibleStep', () => { const present = () => true; it('returns the start index when its target is present', () => { expect(nextVisibleStep(LOBBY_STEPS, 0, present)).toBe(0); expect(nextVisibleStep(LOBBY_STEPS, 1, present)).toBe(1); }); it('skips steps whose target is absent', () => { const isPresent = (t: string) => t !== 'game-hints' && t !== 'game-shuffle'; // From the hints step (index 2), both hints and shuffle are absent, so it lands on the rack. expect(nextVisibleStep(GAME_STEPS, 2, isPresent)).toBe(4); }); it('returns -1 when no further step is visible', () => { expect(nextVisibleStep(GAME_STEPS, 5, present)).toBe(-1); expect(nextVisibleStep(GAME_STEPS, 0, () => false)).toBe(-1); }); }); describe('placeBubble', () => { const portrait: Viewport = { width: 390, height: 844 }; const bubble = { width: 280, height: 80 }; it('places the bubble below a target at the top (scoreboard strip)', () => { const target: Rect = { left: 0, top: 50, width: 390, height: 40 }; const p = placeBubble(target, portrait, bubble); expect(p.side).toBe('bottom'); expect(p.top).toBe(100); // 50 + 40 + gap(10) expect(p.left).toBe(55); // centred under the target, room to spare expect(p.tail).toBeGreaterThan(8); }); it('places the bubble above a bottom tab-bar target and clamps it on screen', () => { const target: Rect = { left: 260, top: 790, width: 120, height: 54 }; const p = placeBubble(target, portrait, bubble); expect(p.side).toBe('top'); expect(p.top).toBe(700); // 790 - gap(10) - height(80) // Centring would overflow the right edge, so the bubble is clamped... expect(p.left).toBe(portrait.width - bubble.width - 8); // ...yet the tail still aims at the target centre. const tailViewportX = p.left + p.tail; expect(Math.abs(tailViewportX - (target.left + target.width / 2))).toBeLessThan(1); }); it('places the bubble to the side of a tall left-panel target (landscape)', () => { const landscape: Viewport = { width: 1100, height: 640 }; const wide = { width: 300, height: 120 }; const target: Rect = { left: 30, top: 20, width: 60, height: 600 }; const p = placeBubble(target, landscape, wide); expect(p.side).toBe('right'); expect(p.left).toBe(100); // 30 + 60 + gap(10) }); it('clamps the bubble and the tail at a corner target', () => { const target: Rect = { left: 0, top: 0, width: 30, height: 30 }; const p = placeBubble(target, portrait, { width: 200, height: 60 }); expect(p.side).toBe('bottom'); expect(p.left).toBe(8); // left margin expect(p.tail).toBe(8); // tail clamped to its minimum off the corner }); it('falls back to a side and keeps the bubble within the viewport when nothing fits', () => { const target: Rect = { left: 0, top: 0, width: 390, height: 844 }; const p = placeBubble(target, portrait, { width: 200, height: 80 }); expect(p.left).toBeGreaterThanOrEqual(8); expect(p.left).toBeLessThanOrEqual(portrait.width - 200 - 8); expect(p.top).toBeGreaterThanOrEqual(8); expect(p.top).toBeLessThanOrEqual(portrait.height - 80 - 8); }); });