Files
scrabble-game/ui/src/lib/coachmark.test.ts
T
Ilia Denisov 6636d7c309
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m20s
feat(ui): first-run onboarding coachmarks
A one-time coachmark overlay walks a new player through the lobby and their
first game board: a light dimmed layer draws one tail-pointed hint bubble at a
time, advancing on a tap anywhere and removing itself for good after the last
hint. Two independent series (lobby: settings/stats/new game; game:
header/pass-exchange/hints/shuffle/rack), gated by a per-device persisted flag
and marked done only after the last hint, so an interrupted run replays from
the start. A deep-link into Settings -> Friends still triggers the lobby series
on the first trip back to the lobby.

Targets carry a data-coach attribute, so one positioning engine anchors the
bubble in both portrait and landscape, re-measuring each frame until the
geometry settles (route slide, hidden-banner reflow, fonts). The promo banner
hides while the overlay is up (app.coachActive); a hidden DebugPanel "Reset
visited" control replays the walk-through. Off by default in the mock build so
the Playwright smoke is unaffected; ?coach forces it on for the dedicated e2e.

Pure geometry (step lists, nextVisibleStep, placeBubble) in lib/coachmark.ts
(unit-tested); Coachmark.svelte renders. Docs: FUNCTIONAL(+ru) onboarding
story, UI_DESIGN coachmark section.
2026-06-30 21:48:56 +02:00

91 lines
3.7 KiB
TypeScript

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);
});
});