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 < b & c'); expect(linkify('see [docs](https://x.com) now')).toBe( 'see docs 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 home or ' + 'docs', ); }); }); 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()); 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(), sched.next(), sched.next(), sched.next()]).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()).toBe('keep'); }); it('is empty for no campaigns', () => { const sched = createScheduler([]); expect(sched.total).toBe(0); expect(sched.next()).toBe(''); }); }); // 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); }); });