← back to Kickbacks Ai Upstream

test/cliTick.test.ts

184 lines

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { setupCliTick, type CliTickDeps } from "../src/activation/cliTick";
import type { Activity } from "../src/activity/logTail";

// The statusline view-tick loop (the Steven fix): TUI surfaces used to emit
// impression_viewable only — no view_tick, no credit, no advertiser debit —
// so a terminal-only user saw ads all day and earned nothing. These tests pin
// the dwell loop's gates and cadence.

function makeDeps(overrides: Partial<CliTickDeps> = {}): CliTickDeps & {
  metrics: { send: ReturnType<typeof vi.fn> };
  cliTail: {
    current: ReturnType<typeof vi.fn>;
    activityAgeMs: ReturnType<typeof vi.fn>;
  };
} {
  const metrics = { send: vi.fn() };
  const cliTail = {
    current: vi.fn().mockReturnValue(null),
    activityAgeMs: vi.fn().mockReturnValue(null),
  };
  return {
    cliTail: cliTail as any,
    metrics: metrics as any,
    adRef: { current: { adId: "ad1", campaignId: "c1",
      adText: "Try Acme Widgets", iconRef: "", iconUrl: "",
      clickUrl: "https://acme.com", bannerEnabled: false,
      sessionToken: "tok1" } },
    killedRef: { current: false },
    signedIn: () => true,
    surfaceApplied: () => true,
    ccVersion: "2.1.143",
    timers: [],
    cliModeFn: () => "on",
    canPatchFn: () => true,
    ...overrides,
  } as any;
}

function turn(): Activity {
  return { tool: "Edit", elapsedMs: 1000, ts: Date.now(), done: false };
}
function turnDone(): Activity {
  return { tool: "Edit", elapsedMs: 5000, ts: Date.now(), done: true };
}

describe("setupCliTick", () => {
  beforeEach(() => { vi.useFakeTimers(); });
  afterEach(() => { vi.useRealTimers(); });

  it("emits view_tick with surface statusline every 5s during a terminal turn", () => {
    const d = makeDeps();
    d.cliTail.current.mockReturnValue(turn());
    setupCliTick(d);
    vi.advanceTimersByTime(11_000);
    const ticks = d.metrics.send.mock.calls.filter(
      (c: unknown[]) => c[0] === "view_tick");
    expect(ticks.length).toBeGreaterThanOrEqual(2);
    expect(ticks[0][1]).toMatchObject({
      adId: "ad1", surface: "statusline", sessionToken: "tok1" });
    expect((ticks[0][1] as { visibleMs: number }).visibleMs).toBeGreaterThan(0);
  });

  it("emits nothing when there is no terminal activity", () => {
    const d = makeDeps();
    setupCliTick(d);
    vi.advanceTimersByTime(12_000);
    expect(d.metrics.send).not.toHaveBeenCalled();
  });

  it("starts on a fresh-but-unparseable transcript (activityAgeMs fallback)", () => {
    const d = makeDeps();
    d.cliTail.current.mockReturnValue(null);
    d.cliTail.activityAgeMs.mockReturnValue(500);
    setupCliTick(d);
    vi.advanceTimersByTime(6000);
    const ticks = d.metrics.send.mock.calls.filter(
      (c: unknown[]) => c[0] === "view_tick");
    expect(ticks.length).toBeGreaterThanOrEqual(1);
  });

  it("stops ticking when the turn completes", () => {
    const d = makeDeps();
    d.cliTail.current.mockReturnValue(turn());
    setupCliTick(d);
    vi.advanceTimersByTime(6000);
    const before = d.metrics.send.mock.calls.filter(
      (c: unknown[]) => c[0] === "view_tick").length;
    expect(before).toBeGreaterThanOrEqual(1);
    d.cliTail.current.mockReturnValue(turnDone());
    vi.advanceTimersByTime(15_000);
    const after = d.metrics.send.mock.calls.filter(
      (c: unknown[]) => c[0] === "view_tick").length;
    expect(after).toBe(before);
  });

  it("never ticks while signed out", () => {
    const d = makeDeps({ signedIn: () => false });
    d.cliTail.current.mockReturnValue(turn());
    setupCliTick(d);
    vi.advanceTimersByTime(12_000);
    expect(d.metrics.send).not.toHaveBeenCalled();
  });

  it("never ticks a demo ad", () => {
    const d = makeDeps();
    d.adRef.current = { ...d.adRef.current!, demo: true } as any;
    d.cliTail.current.mockReturnValue(turn());
    setupCliTick(d);
    vi.advanceTimersByTime(12_000);
    expect(d.metrics.send).not.toHaveBeenCalled();
  });

  it("never ticks when killed, and stops mid-show when the kill trips", () => {
    const d = makeDeps();
    d.cliTail.current.mockReturnValue(turn());
    setupCliTick(d);
    vi.advanceTimersByTime(6000);
    const before = d.metrics.send.mock.calls.filter(
      (c: unknown[]) => c[0] === "view_tick").length;
    d.killedRef.current = true;
    vi.advanceTimersByTime(15_000);
    const after = d.metrics.send.mock.calls.filter(
      (c: unknown[]) => c[0] === "view_tick").length;
    expect(after).toBe(before);
  });

  it("never ticks when the statusline surface is not actually applied", () => {
    const d = makeDeps({ surfaceApplied: () => false });
    d.cliTail.current.mockReturnValue(turn());
    setupCliTick(d);
    vi.advanceTimersByTime(12_000);
    expect(d.metrics.send).not.toHaveBeenCalled();
  });

  it("never ticks when cliMode is off", () => {
    const d = makeDeps({ cliModeFn: () => "off" });
    d.cliTail.current.mockReturnValue(turn());
    setupCliTick(d);
    vi.advanceTimersByTime(12_000);
    expect(d.metrics.send).not.toHaveBeenCalled();
  });

  it("adopts a fresh session token mid-show (audit #1 contract)", () => {
    const d = makeDeps();
    d.cliTail.current.mockReturnValue(turn());
    setupCliTick(d);
    vi.advanceTimersByTime(1000);             // show opens
    d.adRef.current = { ...d.adRef.current!, sessionToken: "tok2" };
    vi.advanceTimersByTime(5000);             // next tick adopts
    const ticks = d.metrics.send.mock.calls.filter(
      (c: unknown[]) => c[0] === "view_tick");
    expect(ticks[ticks.length - 1][1]).toMatchObject(
      { adId: "ad1", sessionToken: "tok2" });
  });

  it("restarts the session when the rotation swaps the ad (statusline repaints)", () => {
    const d = makeDeps();
    d.cliTail.current.mockReturnValue(turn());
    setupCliTick(d);
    vi.advanceTimersByTime(6000);             // ticking ad1
    d.adRef.current = { ...d.adRef.current!, adId: "ad2",
      sessionToken: "tok9", adText: "Other Ad" } as any;
    vi.advanceTimersByTime(6000);             // session restarts on ad2
    const ticks = d.metrics.send.mock.calls.filter(
      (c: unknown[]) => c[0] === "view_tick");
    expect(ticks[ticks.length - 1][1]).toMatchObject(
      { adId: "ad2", sessionToken: "tok9" });
  });

  it("suspend clamp: a sleep gap is not billed as visible time", () => {
    const d = makeDeps();
    d.cliTail.current.mockReturnValue(turn());
    setupCliTick(d);
    vi.advanceTimersByTime(5000);             // ~5s genuinely active
    vi.setSystemTime(Date.now() + 8 * 3600_000);  // suspend, no timer ticks
    vi.advanceTimersByTime(5000);
    const ticks = d.metrics.send.mock.calls.filter(
      (c: unknown[]) => c[0] === "view_tick");
    const last = ticks[ticks.length - 1][1] as { visibleMs: number };
    expect(last.visibleMs).toBeLessThan(30_000);  // never the 8h gap
  });
});