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
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.
91 lines
3.7 KiB
TypeScript
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);
|
|
});
|
|
});
|