feat(ads): client banner rotation, fade UX & live toggle (PR2)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 50s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 58s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 50s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 58s
Consume the server-driven banner block (PR1) in the UI and retire the gate. - banner.ts: createScheduler — a smooth weighted round-robin over campaigns (each appears its weight share per cycle, evenly interleaved) with round-robin over a campaign's messages; the rotator drives fade-in -> hold/scroll -> fade-out -> gap -> fade-in, a lone message stays put, reduce-motion swaps instantly without scroll. - model.ts/codec.ts: Profile.banner (Banner/BannerCampaign/BannerTimings) decoded from the fbs block. - Screen.svelte: drop the compile-time SHOW_AD_BANNER; render AdBanner from app.profile.banner (campaigns + timings + reduceMotion). - AdBanner.svelte: opacity-driven fades + scroll host; the rotator is recreated when the campaigns/timings change (a `banner` notify re-fetch swaps them in place). - app.svelte.ts: on the `notify` `banner` sub-kind, refreshProfile() so the banner shows/hides in place. - tests: scheduler distribution + round-robin, the fade sequence, single-message, reduce-motion, stop(); codec banner decode. UI_DESIGN.md + trackers updated.
This commit is contained in:
+128
-51
@@ -1,5 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createBannerRotator, defaultBannerConfig, linkify } from './banner';
|
||||
import { createBannerRotator, createScheduler, defaultBannerTimings, linkify, type BannerHost } from './banner';
|
||||
import type { BannerTimings } from './model';
|
||||
|
||||
describe('linkify', () => {
|
||||
it('escapes html and renders markdown links', () => {
|
||||
@@ -20,71 +21,147 @@ describe('linkify', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('banner rotator', () => {
|
||||
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'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const cfg: BannerTimings = {
|
||||
holdMs: 1000,
|
||||
edgePauseMs: 100,
|
||||
scrollPxPerSec: 200,
|
||||
fadeOutMs: 300,
|
||||
gapMs: 50,
|
||||
fadeInMs: 200,
|
||||
};
|
||||
|
||||
describe('createBannerRotator', () => {
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('holds a fitting message then advances, and scrolls an overflowing one', () => {
|
||||
it('runs the fade-out / gap / fade-in sequence between two messages', () => {
|
||||
vi.useFakeTimers();
|
||||
const cfg = { ...defaultBannerConfig, holdMs: 1000, edgePauseMs: 100, fadeMs: 10, scrollPxPerSec: 50 };
|
||||
const shown: number[] = [];
|
||||
let scrolled = 0;
|
||||
const overflow = [0, 200]; // item 0 fits, item 1 overflows
|
||||
const r = createBannerRotator(
|
||||
[{ md: 'a' }, { md: 'b' }],
|
||||
{
|
||||
overflowPx: (i) => overflow[i],
|
||||
show: (i) => shown.push(i),
|
||||
scrollTo: () => scrolled++,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
|
||||
const { host, calls, shown } = recordingHost(0);
|
||||
const r = createBannerRotator([{ weight: 1, messages: ['a'] }, { weight: 1, messages: ['b'] }], host, cfg);
|
||||
r.start();
|
||||
expect(shown).toEqual([0]);
|
||||
vi.advanceTimersByTime(cfg.fadeMs); // settle + measure item 0
|
||||
vi.advanceTimersByTime(cfg.holdMs); // advance to item 1
|
||||
expect(shown).toEqual([0, 1]);
|
||||
vi.advanceTimersByTime(cfg.fadeMs); // settle + measure item 1 (overflows)
|
||||
vi.advanceTimersByTime(cfg.edgePauseMs); // edge pause -> scrollTo
|
||||
expect(scrolled).toBe(1);
|
||||
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('repeats the scroll cycle while under holdMs, then advances', () => {
|
||||
it('scrolls an overflowing message', () => {
|
||||
vi.useFakeTimers();
|
||||
const cfg = { holdMs: 2500, edgePauseMs: 100, fadeMs: 10, scrollPxPerSec: 100 };
|
||||
const shown: number[] = [];
|
||||
let scrolls = 0;
|
||||
const r = createBannerRotator(
|
||||
[{ md: 'long' }, { md: 'short' }],
|
||||
{ overflowPx: (i) => (i === 0 ? 100 : 0), show: (i) => shown.push(i), scrollTo: () => scrolls++ },
|
||||
cfg,
|
||||
);
|
||||
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(110); // fade(10) + edgePause(100) -> first scroll
|
||||
expect(scrolls).toBe(1);
|
||||
expect(shown).toEqual([0]);
|
||||
vi.advanceTimersByTime(1200); // scrollDur(1000) + edgePause + edgePause -> re-show + second scroll
|
||||
expect(scrolls).toBe(2);
|
||||
expect(shown).toEqual([0, 0]); // re-shown to reset scroll, still item 0 (under holdMs)
|
||||
vi.advanceTimersByTime(10_000);
|
||||
expect(shown).toContain(1); // eventually exceeds holdMs and advances to the fitting message
|
||||
vi.advanceTimersByTime(cfg.fadeInMs + cfg.edgePauseMs); // fade in + edge pause -> scrollTo
|
||||
expect(calls).toContain('scroll');
|
||||
r.stop();
|
||||
});
|
||||
|
||||
it('shows a single message once and never fades it out', () => {
|
||||
vi.useFakeTimers();
|
||||
const { host, calls, shown } = recordingHost(0);
|
||||
const r = createBannerRotator([{ weight: 1, messages: ['solo'] }], host, cfg);
|
||||
r.start();
|
||||
vi.advanceTimersByTime(cfg.fadeInMs + cfg.holdMs * 5);
|
||||
expect(shown).toEqual(['solo']);
|
||||
expect(calls).not.toContain('hide');
|
||||
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 cfg = { ...defaultBannerConfig, holdMs: 100, fadeMs: 5, edgePauseMs: 5 };
|
||||
const shown: number[] = [];
|
||||
const r = createBannerRotator(
|
||||
[{ md: 'a' }, { md: 'b' }],
|
||||
{ overflowPx: () => 0, show: (i) => shown.push(i), scrollTo: () => {} },
|
||||
cfg,
|
||||
);
|
||||
const { host, shown } = recordingHost(0);
|
||||
const r = createBannerRotator([{ weight: 1, messages: ['a'] }, { weight: 1, messages: ['b'] }], host, cfg);
|
||||
r.start();
|
||||
vi.advanceTimersByTime(cfg.fadeMs);
|
||||
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();
|
||||
vi.advanceTimersByTime(10_000);
|
||||
expect(shown).toEqual([0]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user