Files
scrabble-game/ui/src/lib/banner.test.ts
T
Ilia Denisov 6db9178449
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m50s
feat(banner): per-campaign colour overrides and urgent alerts
Non-default campaigns gain an optional colour override (background / text /
link) in two sets — one for every theme, one for the dark theme only — and an
"urgent" flag.

- Colours ride profile.get as six trailing FlatBuffers strings on
  BannerCampaign (backward-compatible). The client resolves the cascade
  (dark <- dark ?? all, light <- all) per rendered theme and derives the strip
  border from the background in JS (no CSS color-mix, for the old Android
  WebView floor); AdBanner applies them as inline vars scoped to the strip.
- Urgent is resolved entirely server-side: while any enabled, in-window urgent
  campaign exists, computeActiveSet returns only the urgent campaigns and
  bannerFor skips the eligibility gate — so a system notice reaches every viewer
  (paid / hint-holding / no_banner included) and preempts the ordinary feed. No
  wire field; it appears on each viewer's next profile.get.
- Admin console (/_gm/banners): native colour pickers + a live light/dark
  preview of the strip, and an urgent toggle. The default campaign stays plain,
  enforced by the service and a DB CHECK.

Migration 00009 is additive (nullable colour columns + a bool default +
all-or-nothing / hex / default-plain CHECKs) — expand-contract, rollback-safe.

Docs: ARCHITECTURE §10, UI_DESIGN, FUNCTIONAL (+ru). Tests: ads unit (urgent
preempt + colour validation), codec + resolver unit, gateway transcode, and
integration (colour round-trip + urgent bypass against real Postgres).
2026-07-05 15:36:35 +02:00

312 lines
13 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest';
import { createBannerRotator, createScheduler, defaultBannerTimings, linkify, type BannerHost } from './banner';
import { attachBannerHost, bannerCurrent, configureBanner, detachBannerHost, remeasureBanner } from './bannerEngine';
import type { BannerTimings } from './model';
describe('linkify', () => {
it('escapes html and renders markdown links', () => {
expect(linkify('a < b & c')).toBe('a &lt; b &amp; c');
expect(linkify('see [docs](https://x.com) now')).toBe(
'see <a href="https://x.com" target="_blank" rel="noopener noreferrer">docs</a> now',
);
});
it('drops a non-http(s) link target (keeps the label)', () => {
expect(linkify('[x](ftp://evil)')).toBe('x');
expect(linkify('[y](javascript:boom)')).toBe('y');
});
it('keeps root-relative links and renders several in one string', () => {
expect(linkify('go [home](/lobby) or [docs](https://x.com)')).toBe(
'go <a href="/lobby" target="_blank" rel="noopener noreferrer">home</a> or ' +
'<a href="https://x.com" target="_blank" rel="noopener noreferrer">docs</a>',
);
});
});
describe('createScheduler', () => {
it('serves each campaign its weight share over a cycle, evenly spread', () => {
const sched = createScheduler([
{ weight: 3, messages: ['a'] },
{ weight: 7, messages: ['b'] },
]);
expect(sched.total).toBe(2);
const cycle = Array.from({ length: 10 }, () => sched.next().md);
const a = cycle.filter((m) => m === 'a').length;
const b = cycle.filter((m) => m === 'b').length;
expect(a).toBe(3);
expect(b).toBe(7);
// Smoothly interleaved, not clustered: the minority campaign appears in both halves.
expect(cycle.slice(0, 5)).toContain('a');
expect(cycle.slice(5)).toContain('a');
});
it('round-robins messages within a campaign', () => {
const sched = createScheduler([{ weight: 1, messages: ['m0', 'm1', 'm2'] }]);
expect(sched.total).toBe(3);
expect([sched.next().md, sched.next().md, sched.next().md, sched.next().md]).toEqual(['m0', 'm1', 'm2', 'm0']);
});
it('drops empty and non-positive-weight campaigns', () => {
const sched = createScheduler([
{ weight: 0, messages: ['skip-zero'] },
{ weight: 5, messages: [] },
{ weight: 2, messages: ['keep'] },
]);
expect(sched.total).toBe(1);
expect(sched.next().md).toBe('keep');
});
it('is empty for no campaigns', () => {
const sched = createScheduler([]);
expect(sched.total).toBe(0);
expect(sched.next().md).toBe('');
});
it('carries each campaign colour override with its message', () => {
const red = { bg: '#aa0000', fg: '#ffffff', link: '#ffdd00' };
const darkRed = { bg: '#330000', fg: '#eeeeee', link: '#ffcc00' };
const sched = createScheduler([
{ weight: 1, messages: ['plain'] },
{ weight: 1, messages: ['branded'], overrideAll: red, overrideDark: darkRed },
]);
const two = [sched.next(), sched.next()];
const branded = two.find((it) => it.md === 'branded');
const plain = two.find((it) => it.md === 'plain');
expect(branded?.colors).toEqual({ all: red, dark: darkRed });
expect(plain?.colors).toEqual({ all: null, dark: null });
});
});
// recordingHost captures the rotator's host calls as a sequence, with a fixed overflow.
function recordingHost(overflow = 0): { host: BannerHost; calls: string[]; shown: string[] } {
const calls: string[] = [];
const shown: string[] = [];
return {
calls,
shown,
host: {
show: (md) => {
shown.push(md);
calls.push('show');
},
resetScroll: () => calls.push('reset'),
hide: () => calls.push('hide'),
overflowPx: () => overflow,
scrollTo: () => calls.push('scroll'),
resumeScroll: () => calls.push('resume'),
},
};
}
const cfg: BannerTimings = {
holdMs: 1000,
edgePauseMs: 100,
scrollPxPerSec: 200,
fadeOutMs: 300,
gapMs: 50,
fadeInMs: 200,
};
describe('createBannerRotator', () => {
afterEach(() => vi.useRealTimers());
it('runs the fade-out / gap / fade-in sequence between two messages', () => {
vi.useFakeTimers();
const { host, calls, shown } = recordingHost(0);
const r = createBannerRotator([{ weight: 1, messages: ['a'] }, { weight: 1, messages: ['b'] }], host, cfg);
r.start();
expect(shown).toEqual(['a']); // first message shown immediately
vi.advanceTimersByTime(cfg.fadeInMs); // fade in -> measure (fits)
vi.advanceTimersByTime(cfg.holdMs - 1);
expect(calls).not.toContain('hide'); // still holding
vi.advanceTimersByTime(1); // hold elapsed -> fade out
expect(calls).toContain('hide');
expect(shown).toEqual(['a']); // next not shown yet (during fade-out + gap)
vi.advanceTimersByTime(cfg.fadeOutMs + cfg.gapMs); // -> show the next message
expect(shown).toEqual(['a', 'b']);
r.stop();
});
it('scrolls an overflowing message', () => {
vi.useFakeTimers();
const { host, calls } = recordingHost(200); // overflow 200px, 200px/s -> 1000ms scroll
const r = createBannerRotator([{ weight: 1, messages: ['long'] }, { weight: 1, messages: ['x'] }], host, cfg);
r.start();
vi.advanceTimersByTime(cfg.fadeInMs + cfg.edgePauseMs); // fade in + edge pause -> scrollTo
expect(calls).toContain('scroll');
r.stop();
});
it('pulses a lone message through the fade cycle (fades out, then back in)', () => {
vi.useFakeTimers();
const { host, calls, shown } = recordingHost(0);
const r = createBannerRotator([{ weight: 1, messages: ['solo'] }], host, cfg);
r.start();
expect(shown).toEqual(['solo']);
vi.advanceTimersByTime(cfg.fadeInMs + cfg.holdMs); // hold elapses -> fade out
expect(calls).toContain('hide');
vi.advanceTimersByTime(cfg.fadeOutMs + cfg.gapMs + 1); // -> the same message fades back in
expect(shown).toEqual(['solo', 'solo']);
r.stop();
});
it('fades the scroll loop of a long message (out, rewind, in) instead of a hard reset', () => {
vi.useFakeTimers();
const c: BannerTimings = { holdMs: 30000, edgePauseMs: 100, scrollPxPerSec: 1000, fadeOutMs: 100, gapMs: 50, fadeInMs: 100 };
const { host, calls } = recordingHost(200); // overflows -> scroll dur = 200ms
const r = createBannerRotator([{ weight: 1, messages: ['long'] }], host, c);
r.start();
// fadeIn -> measure -> edgePause -> scrollTo -> (dur + edgePause) -> loop boundary
vi.advanceTimersByTime(c.fadeInMs + c.edgePauseMs + 200 + c.edgePauseMs + 5);
expect(calls).toContain('scroll');
expect(calls).toContain('hide'); // the loop fades out at the right edge (not a hard reset)
vi.advanceTimersByTime(c.fadeOutMs + c.gapMs + 5); // -> the same message fades back in
expect(calls.filter((x) => x === 'show').length).toBeGreaterThanOrEqual(2);
r.stop();
});
it('swaps instantly when fade timings are zero (reduce-motion)', () => {
vi.useFakeTimers();
const zero: BannerTimings = { ...cfg, fadeOutMs: 0, gapMs: 0, fadeInMs: 0 };
const { host, shown } = recordingHost(0); // reduce-motion host reports no overflow
const r = createBannerRotator([{ weight: 1, messages: ['a'] }, { weight: 1, messages: ['b'] }], host, zero);
r.start();
// fadeIn(0) + hold -> fadeOut(0) + gap(0) -> next; the +5 flushes the trailing
// zero-delay timer chain that lands on the hold boundary.
vi.advanceTimersByTime(zero.holdMs + 5);
expect(shown).toEqual(['a', 'b']);
r.stop();
});
it('stop() halts further advancement', () => {
vi.useFakeTimers();
const { host, shown } = recordingHost(0);
const r = createBannerRotator([{ weight: 1, messages: ['a'] }, { weight: 1, messages: ['b'] }], host, cfg);
r.start();
vi.advanceTimersByTime(cfg.fadeInMs);
r.stop();
vi.advanceTimersByTime(60_000);
expect(shown).toEqual(['a']);
});
it('does not start with no campaigns', () => {
vi.useFakeTimers();
const { host, shown } = recordingHost(0);
const r = createBannerRotator([], host, defaultBannerTimings);
r.start();
vi.advanceTimersByTime(60_000);
expect(shown).toEqual([]);
r.stop();
});
});
describe('bannerEngine', () => {
afterEach(() => vi.useRealTimers());
// A minimal host that records the messages it is told to show.
function engineHost(): { host: BannerHost; shown: string[] } {
const shown: string[] = [];
return {
shown,
host: { show: (md) => shown.push(md), resetScroll() {}, hide() {}, overflowPx: () => 0, scrollTo() {}, resumeScroll() {} },
};
}
const period = (c: BannerTimings) => c.fadeInMs + c.holdMs + c.fadeOutMs + c.gapMs + 5;
const cfg: BannerTimings = { holdMs: 1000, edgePauseMs: 100, scrollPxPerSec: 200, fadeOutMs: 100, gapMs: 50, fadeInMs: 100 };
it('keeps the live message across a host detach/attach, and keeps advancing (continuity)', () => {
vi.useFakeTimers();
const camps = [{ weight: 1, messages: ['x0'] }, { weight: 1, messages: ['x1'] }];
configureBanner(camps, cfg);
const a = engineHost();
attachBannerHost(a.host);
// Advance mid-sequence so the live message is no longer the first.
vi.advanceTimersByTime(period(cfg));
expect(bannerCurrent()).toBe('x1');
// Remount the view (as navigation does): the live message is preserved (not reset to 'x0'),
// and a fresh view receives no replayed show() on attach (it reads bannerCurrent() itself).
detachBannerHost(a.host);
const b = engineHost();
attachBannerHost(b.host);
expect(bannerCurrent()).toBe('x1');
expect(b.shown).toEqual([]);
// The engine keeps advancing and drives the new host — the cycle continued, not restarted.
vi.advanceTimersByTime(period(cfg));
expect(b.shown).toContain('x0');
expect(bannerCurrent()).toBe('x0');
detachBannerHost(b.host);
});
it('does not restart on a no-op reconfigure with unchanged data', () => {
vi.useFakeTimers();
const camps = [{ weight: 1, messages: ['y0'] }, { weight: 1, messages: ['y1'] }];
configureBanner(camps, cfg);
const a = engineHost();
attachBannerHost(a.host);
vi.advanceTimersByTime(period(cfg));
expect(bannerCurrent()).toBe('y1');
// Re-configuring with identical data keeps the running cycle (the live message is unchanged).
configureBanner(camps, cfg);
vi.advanceTimersByTime(5);
expect(bannerCurrent()).toBe('y1');
detachBannerHost(a.host);
});
it('re-presents the current message on remeasure, engaging the scroll on a size change', () => {
vi.useFakeTimers();
const big: BannerTimings = { ...cfg, holdMs: 5000 };
const shown: string[] = [];
let scrolled = 0;
let overflow = 0;
const host: BannerHost = {
show: (md) => shown.push(md),
resetScroll() {},
hide() {},
overflowPx: () => overflow,
scrollTo: () => scrolled++,
resumeScroll() {},
};
configureBanner([{ weight: 1, messages: ['z0'] }], big);
attachBannerHost(host);
vi.advanceTimersByTime(big.fadeInMs + 5); // initial measure: fits, no scroll
expect(scrolled).toBe(0);
// The viewport shrank so the message now overflows; remeasure re-presents it and the scroll
// engages (the owner accepts the cycle restarting on a size change).
overflow = 300;
remeasureBanner();
expect(shown).toContain('z0');
vi.advanceTimersByTime(big.fadeInMs + big.edgePauseMs + 5);
expect(scrolled).toBeGreaterThan(0);
detachBannerHost(host);
});
it('resumes a long message scroll mid-flight on a new host (navigation)', () => {
vi.useFakeTimers();
const big: BannerTimings = { holdMs: 10000, edgePauseMs: 100, scrollPxPerSec: 100, fadeOutMs: 100, gapMs: 50, fadeInMs: 100 };
const noop: BannerHost = { show() {}, resetScroll() {}, hide() {}, overflowPx: () => 500, scrollTo() {}, resumeScroll() {} };
let resumed: { fromTx: number; toPx: number; dur: number } | null = null;
const b: BannerHost = { ...noop, resumeScroll: (fromTx, toPx, dur) => (resumed = { fromTx, toPx, dur }) };
configureBanner([{ weight: 1, messages: ['scrollme'] }], big);
attachBannerHost(noop);
// fadeIn(100) + edgePause(100) -> scrollTo (dur = 500/100*1000 = 5000ms); then 2000ms into it.
vi.advanceTimersByTime(big.fadeInMs + big.edgePauseMs + 2000);
// Remount mid-scroll: the new host resumes from ~40% of the way, not from the start.
detachBannerHost(noop);
attachBannerHost(b);
expect(resumed).not.toBeNull();
expect(resumed!.toPx).toBe(500);
expect(resumed!.fromTx).toBeLessThan(0); // already scrolled left of 0
expect(resumed!.fromTx).toBeGreaterThan(-500); // but not yet at the end
expect(resumed!.dur).toBeGreaterThan(0);
expect(resumed!.dur).toBeLessThan(5000); // only the remaining time
detachBannerHost(b);
});
});