← back to Kickbacks Ai Upstream

test/viewTimer.test.ts

235 lines

import { describe, it, expect, vi } from "vitest";
import { ViewTimer, type ThresholdEvent, type TickEvent,
  type ErrorImpressionEvent } from "../src/viewTracking/timer";

function clock(): { now: () => number; advance: (ms: number) => void } {
  let t = 0;
  return { now: () => t, advance: (ms) => { t += ms; } };
}

// NOTE (audit 2026-06-09 #23 / wave-1 EXT-03, verified 2026-06-10): the
// ViewTimer class under test is NOT shipped — no production code imports it
// (the live timers are block.asset.js `_vt`, codex/block.asset.js, and
// statusBarAd.ts's inline interval). The no-op hide()/pause() and unbounded
// catch-up semantics pinned below are therefore DEAD-CODE contracts kept for
// the reference implementation only; the shipped surfaces END sessions on
// hide and clamp suspend/wake gaps — see test/cc-viewtimer.test.ts for the
// production contract. Port that clamp before ever wiring this class up.
describe("ViewTimer (W3)", () => {
  // Tests focused on the natural threshold-met path disable the
  // error_impression safety net (`maxSessionMs: 0`). With the default cap of
  // 5s and threshold of 15s, mutual exclusion (added 2026-05-21 to fix the
  // Codex-flagged double-bill) would make error_impression fire first and
  // suppress threshold_met. The "fires onErrorImpression exactly once" test
  // below covers the safety-net case independently.
  it("fires onThresholdMet exactly once after 15s of visibility", () => {
    const c = clock();
    const met = vi.fn();
    const vt = new ViewTimer({ thresholdMs: 15_000, maxSessionMs: 0,
      now: c.now, onThresholdMet: met });
    vt.show("ad-A", "overlay", "sess-1");
    for (let i = 0; i < 16; i++) { c.advance(1_000); vt.poll(); }
    expect(met).toHaveBeenCalledTimes(1);
    const [evt] = met.mock.calls[0] as [ThresholdEvent];
    expect(evt.adId).toBe("ad-A");
    expect(evt.surface).toBe("overlay");
    expect(evt.sessionNonce).toBe("sess-1");
    expect(evt.thresholdMs).toBe(15_000);

    // Continuing past the threshold must NOT re-fire.
    for (let i = 0; i < 30; i++) { c.advance(1_000); vt.poll(); }
    expect(met).toHaveBeenCalledTimes(1);
  });

  it("does not fire threshold below the configured time", () => {
    const c = clock();
    const met = vi.fn();
    const vt = new ViewTimer({ thresholdMs: 15_000, maxSessionMs: 0,
      now: c.now, onThresholdMet: met });
    vt.show("ad-A", "overlay", "s");
    for (let i = 0; i < 14; i++) { c.advance(1_000); vt.poll(); }
    expect(met).not.toHaveBeenCalled();
  });

  it("hide() is a no-op under the absolute-epoch baseline", () => {
    // Post-refactor (2026-05-22): elapsed time = now() - sessionStartedAt,
    // not an accumulator. hide() no longer pauses accumulation because the
    // 250ms poll cadence can't be relied on to re-anchor accurately under
    // throttling. A hidden ad still progresses toward billing; mutual
    // exclusion + server-side gates bound the impact.
    const c = clock();
    const met = vi.fn();
    const vt = new ViewTimer({ thresholdMs: 15_000, maxSessionMs: 0,
      now: c.now, onThresholdMet: met });
    vt.show("ad-A", "overlay", "s");
    for (let i = 0; i < 5; i++) { c.advance(1_000); vt.poll(); }
    vt.hide("ad-A", "overlay");
    // Elapsed clock keeps running through "hidden"; threshold fires at 15s.
    for (let i = 0; i < 10; i++) { c.advance(1_000); vt.poll(); }
    expect(met).toHaveBeenCalledTimes(1);
  });

  it("pause() and resume() are no-ops under the absolute-epoch baseline", () => {
    // Same rationale as hide(): the baseline is sticky; pause() no longer
    // gates accumulation. Kept as public API so callers don't need rewriting.
    const c = clock();
    const met = vi.fn();
    const vt = new ViewTimer({ thresholdMs: 15_000, maxSessionMs: 0,
      now: c.now, onThresholdMet: met });
    vt.show("ad-A", "overlay", "s");
    vt.pause();
    for (let i = 0; i < 15; i++) { c.advance(1_000); vt.poll(); }
    expect(met).toHaveBeenCalledTimes(1);   // fires anyway at 15s elapsed
    vt.resume();   // no-op, no crash
  });

  it("tracks overlay and banner independently for the same ad", () => {
    const c = clock();
    const met = vi.fn();
    const vt = new ViewTimer({ thresholdMs: 15_000, maxSessionMs: 0,
      now: c.now, onThresholdMet: met });
    vt.show("ad-A", "overlay", "s1");
    vt.show("ad-A", "banner", "s1");
    for (let i = 0; i < 16; i++) { c.advance(1_000); vt.poll(); }
    expect(met).toHaveBeenCalledTimes(2);
    const surfaces = met.mock.calls.map((c) => (c[0] as ThresholdEvent).surface);
    expect(surfaces.sort()).toEqual(["banner", "overlay"]);
  });

  it("emits view_tick at every tickMs of accumulated visible time", () => {
    const c = clock();
    const tick = vi.fn();
    const vt = new ViewTimer({ thresholdMs: 60_000, tickMs: 5_000, now: c.now,
      onTick: tick });
    vt.show("ad-A", "overlay", "s");
    for (let i = 0; i < 16; i++) { c.advance(1_000); vt.poll(); }
    // Visible 16s, ticks at 5/10/15 → 3 ticks.
    expect(tick).toHaveBeenCalledTimes(3);
    const visibleAt = tick.mock.calls.map((c) => (c[0] as TickEvent).visibleMs);
    expect(visibleAt).toEqual([5_000, 10_000, 15_000]);
  });

  it("fires onErrorImpression at every maxSessionMs boundary (5s cadence)", () => {
    // New repeated-billing contract (2026-05-22): error_impression is
    // not one-shot — it fires at every multiple of maxSessionMs (5/10/
    // 15/20/...) so a stuck session keeps generating billable events.
    // The backend cooldown gate decides which credit.
    const c = clock();
    const met = vi.fn();
    const errImp = vi.fn();
    const vt = new ViewTimer({ thresholdMs: 60_000, maxSessionMs: 5_000,
      now: c.now, onThresholdMet: met, onErrorImpression: errImp });
    vt.show("ad-A", "overlay", "s1");
    for (let i = 0; i < 4; i++) { c.advance(1_000); vt.poll(); }
    expect(errImp).not.toHaveBeenCalled();   // not yet at 5s
    c.advance(1_000); vt.poll();             // 5s → fire #1
    expect(errImp).toHaveBeenCalledTimes(1);
    const [evt] = errImp.mock.calls[0] as [ErrorImpressionEvent];
    expect(evt.adId).toBe("ad-A");
    expect(evt.surface).toBe("overlay");
    expect(evt.sessionNonce).toBe("s1");
    expect(evt.maxSessionMs).toBe(5_000);
    expect(evt.visibleMs).toBeGreaterThanOrEqual(5_000);

    // 10s → fire #2; 15s → fire #3; ... etc.
    for (let i = 0; i < 5; i++) { c.advance(1_000); vt.poll(); }
    expect(errImp).toHaveBeenCalledTimes(2);
    for (let i = 0; i < 5; i++) { c.advance(1_000); vt.poll(); }
    expect(errImp).toHaveBeenCalledTimes(3);
    for (let i = 0; i < 25; i++) { c.advance(1_000); vt.poll(); }
    expect(errImp).toHaveBeenCalledTimes(8);   // 5/10/15/20/25/30/35/40s

    // threshold_met is suppressed for the whole stuck session — any
    // error_impression having fired blocks it permanently.
    expect(met).not.toHaveBeenCalled();
  });

  it("does NOT burst-fire on a throttled poll (one fire per call)", () => {
    // If poll() skips many boundaries (e.g. the tab was throttled for
    // 30s and only one poll fires), we MUST NOT spam N fires in one
    // tick — that would double-bill on a recovery from throttling.
    // The contract is "fire one, advance counter by one, fire the rest
    // on subsequent polls."
    const c = clock();
    const errImp = vi.fn();
    const vt = new ViewTimer({ thresholdMs: 60_000, maxSessionMs: 5_000,
      now: c.now, onErrorImpression: errImp });
    vt.show("ad-A", "overlay", "s1");
    c.advance(30_000);   // 30s elapsed in one jump
    vt.poll();           // single poll
    expect(errImp).toHaveBeenCalledTimes(1);  // only one fire, not 6
    vt.poll();
    expect(errImp).toHaveBeenCalledTimes(2);
    vt.poll();
    expect(errImp).toHaveBeenCalledTimes(3);
  });

  it("threshold_met and error_impression mutex still holds with repeated firing", () => {
    // Codex adversarial review (2026-05-21) plus the every-5s refactor
    // (2026-05-22): the two billing paths must not BOTH bill a session.
    //
    // threshold-first config (threshold < cap): threshold_met fires at 3s,
    // error_impression at 5s is suppressed (thresholdMet flag latches it).
    const c1 = clock();
    const met1 = vi.fn();
    const err1 = vi.fn();
    const vt1 = new ViewTimer({ thresholdMs: 3_000, maxSessionMs: 5_000,
      now: c1.now, onThresholdMet: met1, onErrorImpression: err1 });
    vt1.show("ad-A", "overlay", "s1");
    for (let i = 0; i < 30; i++) { c1.advance(1_000); vt1.poll(); }
    expect(met1).toHaveBeenCalledTimes(1);
    expect(err1).not.toHaveBeenCalled();

    // error-first config (cap < threshold; current ship default of 5s/15s):
    // error_impression fires at 5/10/15/20s (4 events in 20s), threshold_met
    // is suppressed for the whole session.
    const c2 = clock();
    const met2 = vi.fn();
    const err2 = vi.fn();
    const vt2 = new ViewTimer({ thresholdMs: 15_000, maxSessionMs: 5_000,
      now: c2.now, onThresholdMet: met2, onErrorImpression: err2 });
    vt2.show("ad-B", "overlay", "s2");
    for (let i = 0; i < 20; i++) { c2.advance(1_000); vt2.poll(); }
    expect(err2).toHaveBeenCalledTimes(4);   // 5/10/15/20s
    expect(met2).not.toHaveBeenCalled();
  });

  it("error_impression resets with a new sessionNonce", () => {
    const c = clock();
    const errImp = vi.fn();
    const vt = new ViewTimer({ thresholdMs: 60_000, maxSessionMs: 5_000,
      now: c.now, onErrorImpression: errImp });
    vt.show("ad-A", "overlay", "s1");
    for (let i = 0; i < 6; i++) { c.advance(1_000); vt.poll(); }
    expect(errImp).toHaveBeenCalledTimes(1);
    vt.show("ad-A", "overlay", "s2");   // fresh session
    for (let i = 0; i < 6; i++) { c.advance(1_000); vt.poll(); }
    expect(errImp).toHaveBeenCalledTimes(2);
  });

  it("maxSessionMs: 0 disables the error_impression safety net", () => {
    const c = clock();
    const errImp = vi.fn();
    const vt = new ViewTimer({ thresholdMs: 60_000, maxSessionMs: 0,
      now: c.now, onErrorImpression: errImp });
    vt.show("ad-A", "overlay", "s");
    for (let i = 0; i < 60; i++) { c.advance(1_000); vt.poll(); }
    expect(errImp).not.toHaveBeenCalled();
  });

  it("new sessionNonce resets accumulation for the same (ad, surface)", () => {
    const c = clock();
    const met = vi.fn();
    const vt = new ViewTimer({ thresholdMs: 15_000, maxSessionMs: 0,
      now: c.now, onThresholdMet: met });
    vt.show("ad-A", "overlay", "s1");
    for (let i = 0; i < 16; i++) { c.advance(1_000); vt.poll(); }
    expect(met).toHaveBeenCalledTimes(1);
    vt.show("ad-A", "overlay", "s2");   // fresh session
    for (let i = 0; i < 14; i++) { c.advance(1_000); vt.poll(); }
    expect(met).toHaveBeenCalledTimes(1);
    for (let i = 0; i < 2; i++) { c.advance(1_000); vt.poll(); }
    expect(met).toHaveBeenCalledTimes(2);
  });
});