use super::cast::cast_player_page;
use super::client_js::live_client_js;
use super::escape::esc;
use super::proc::{empty_output_html, empty_output_label};
use super::session::session_page;
use super::session_export::session_export_page;
use crate::daemon::model::{DaemonMode, ProcKind, ProcRecord, ProcStatus, Session, Store};

/// A one-proc store for the cast player page tests: the proc has a registered cast and
/// the given status.
fn store_with_cast_proc(status: ProcStatus) -> Store {
  let mut store = Store::new(DaemonMode::Persistent, 7274, 1);
  store.sessions.insert(
    "castab".into(),
    Session {
      id: "castab".into(),
      started_at: 1,
      ended_at: None,
      profile: Some("default".into()),
      kind: None,
      repo: "/tmp/repo".into(),
      branch: "main".into(),
      last_seen_at: 1,
      client_connected: true,
      run_pid: None,
      skills: vec![],
      procs: vec![ProcRecord {
        index: 0,
        kind: ProcKind::Skill,
        label: "claude: add".into(),
        status,
        note: None,
        detail: None,
        fail_reason: None,
        container_name: None,
        cast_path: Some("/tmp/x.cast".into()),
        diff_path: None,
        skill_source: None,
        route: None,
        result_path: None,
        harness: Some("claude".into()),
        skill_name: Some("add".into()),
        model: None,
        started_at: Some(1),
        elapsed: None,
        lines: vec![],
      }],
    },
  );
  store
}

fn session_procs_html(html: &str) -> &str {
  let needle = r#"<div class="procs" id="session-procs">"#;
  let start = html.find(needle).expect("session-procs") + needle.len();
  let tail = &html[start..];
  // The procs div is the body's last element; the page footer is the script block.
  let end = tail.find("<script").expect("script block after procs");
  &tail[..end]
}

#[test]
fn esc_handles_basic_html() {
  assert_eq!(esc("<a>"), "&lt;a&gt;");
}

#[test]
fn browser_player_is_first_party_and_carries_no_third_party_license() {
  // The whole point of the first-party beecast-player: neither the session browser nor
  // the exported pages (same crate family) ship ANY third-party code.
  let js = super::PLAYER_JS;
  let css = super::PLAYER_CSS;
  assert!(js.contains("BeeCastPlayer"), "the first-party player global must be defined");
  assert!(js.contains("BeeCastVT"), "the DOM-free core must be bundled first");
  assert!(js.contains("Clean-room implementation"), "the clean-room statement rides in the asset");
  for banned in ["asciinema-player", "AsciinemaPlayer", "@license", "Apache"] {
    assert!(!js.contains(banned), "browser player JS must not carry '{banned}'");
    assert!(!css.contains(banned), "browser player CSS must not carry '{banned}'");
  }
}

/// Run the DOM-free VT core's behavior tests under Node (parsing all three asciicast
/// versions plus the terminal state machine). Skips silently when `node` is not on PATH —
/// the Rust-side structural tests above still gate the asset itself.
#[test]
fn vt_core_node_selftest() {
  if crate::runtime::which("node").is_none() {
    return;
  }
  let dir = std::env::temp_dir().join(format!("scsh-vt-selftest-{}", std::process::id()));
  std::fs::create_dir_all(&dir).unwrap();
  let bundle = dir.join("player.js");
  std::fs::write(&bundle, super::PLAYER_JS).unwrap();
  let script = format!(
    r#"
const assert = require('assert');
require({bundle:?});
const VT = globalThis.BeeCastVT;

// v3: intervals sum; term size from header; resize + marker events survive; # comments skip.
let c = VT.parseCast('{{"version":3,"term":{{"cols":10,"rows":3}}}}\n# note\n[0.5,"o","hi"]\n[0.5,"m","chapter"]\n[1.0,"r","20x5"]\n');
assert.strictEqual(c.cols, 10); assert.strictEqual(c.rows, 3);
assert.strictEqual(c.events.length, 3);
assert.strictEqual(c.duration, 2);
assert.strictEqual(c.events[2].t, 2);

// v2: absolute times.
c = VT.parseCast('{{"version":2,"width":80,"height":24}}\n[0.5,"o","a"]\n[2.0,"o","b"]\n');
assert.strictEqual(c.duration, 2); assert.strictEqual(c.events[1].t, 2);

// v1: one JSON doc, stdout deltas.
c = VT.parseCast('{{"version":1,"width":5,"height":2,"stdout":[[0.1,"x"],[0.2,"y"]]}}');
assert.strictEqual(c.cols, 5); assert.strictEqual(c.events.length, 2);
assert(Math.abs(c.duration - 0.3) < 1e-9);

// Plain text + CR/LF.
let t = new VT.Term(10, 3);
t.write('hello\r\nworld');
assert.deepStrictEqual(t.textLines(), ['hello', 'world', '']);

// CUP + overwrite mid-screen.
t.write('\x1b[1;3Hga');
assert.strictEqual(t.textLines()[0], 'hegao');

// ED 2 clears everything.
t.write('\x1b[2J');
assert.deepStrictEqual(t.textLines(), ['', '', '']);

// SGR runs merge; colors land on cells.
t = new VT.Term(10, 1);
t.write('\x1b[31mred\x1b[0m ok');
const runs = t.snapshot().rows[0];
assert.strictEqual(runs[0].text, 'red'); assert.strictEqual(runs[0].fg, 1);
assert.strictEqual(runs[1].fg, null);

// 256-color + truecolor.
t = new VT.Term(4, 1);
t.write('\x1b[38;5;196mX\x1b[38;2;1;2;3mY');
const r2 = t.snapshot().rows[0];
assert.strictEqual(r2[0].fg, 196);
assert.strictEqual(r2[1].fg, '#010203');
assert.strictEqual(VT.color256(196), '#ff0000');
assert.strictEqual(VT.color256(232), '#080808');

// Deferred wrap: printing in the last column does not wrap until the next char.
t = new VT.Term(3, 2);
t.write('abc');
assert.strictEqual(t.snapshot().cursor.y, 0);
t.write('d');
assert.deepStrictEqual(t.textLines(), ['abc', 'd']);

// Scroll region: LF at the region bottom scrolls only the region.
t = new VT.Term(5, 4);
t.write('aa\r\nbb\r\ncc\r\ndd');
t.write('\x1b[2;3r\x1b[3;1H\n');
const lines = t.textLines();
assert.strictEqual(lines[0], 'aa');
assert.strictEqual(lines[1], 'cc');
assert.strictEqual(lines[3], 'dd');

// Alternate screen: primary content comes back on exit.
t = new VT.Term(5, 2);
t.write('main');
t.write('\x1b[?1049h\x1b[Halt');
assert.strictEqual(t.textLines()[0], 'alt');
t.write('\x1b[?1049l');
assert.strictEqual(t.textLines()[0], 'main');

// DEC special graphics: tmux border characters.
t = new VT.Term(4, 1);
t.write('\x1b(0qqx\x1b(B');
assert.strictEqual(t.textLines()[0], '──│');

// Cursor hide/show.
t = new VT.Term(2, 1);
t.write('\x1b[?25l');
assert.strictEqual(t.snapshot().cursor.visible, false);
t.write('\x1b[?25h');
assert.strictEqual(t.snapshot().cursor.visible, true);

// OSC titles are consumed, never printed.
t = new VT.Term(8, 1);
t.write('\x1b]0;title\x07ok');
assert.strictEqual(t.textLines()[0], 'ok');

console.log('vt selftest OK');
"#,
    bundle = bundle
  );
  let out = std::process::Command::new("node")
    .arg("-")
    .arg("--input-type=commonjs")
    .stdin(std::process::Stdio::piped())
    .stdout(std::process::Stdio::piped())
    .stderr(std::process::Stdio::piped())
    .spawn()
    .and_then(|mut child| {
      use std::io::Write;
      child.stdin.take().unwrap().write_all(script.as_bytes())?;
      child.wait_with_output()
    })
    .expect("node runs");
  let _ = std::fs::remove_dir_all(&dir);
  assert!(
    out.status.success() && String::from_utf8_lossy(&out.stdout).contains("vt selftest OK"),
    "vt selftest failed:\nstdout: {}\nstderr: {}",
    String::from_utf8_lossy(&out.stdout),
    String::from_utf8_lossy(&out.stderr)
  );
}

#[test]
fn skipped_workflow_step_renders_as_a_dim_slashed_row() {
  let mut store = store_with_cast_proc(ProcStatus::Skipped);
  {
    let p = &mut store.sessions.get_mut("castab").unwrap().procs[0];
    p.cast_path = None; // a skipped step never ran, so it has no recording
    p.detail = Some("skipped — its when: gate is false".into());
    p.note = Some("step 2/2 · needs probe_credentials".into());
  }
  let html = session_page(&store, "castab").expect("session renders");
  let procs = session_procs_html(&html);
  assert!(procs.contains(r#"class="proc skipped""#), "got: {procs}");
  assert!(!procs.contains("class=\"glyph\""), "proc rows no longer carry a status glyph: {procs}");
  assert!(procs.contains(">skipped</span>"), "skipped elapsed phrase: {procs}");
  // A skipped step is FINISHED, so its collapsed row shows the outcome (the skip reason),
  // not the transient step note — same rule that puts a finished skill's answer in the row.
  assert!(procs.contains(r#"<span class="note dim">skipped — its when: gate is false</span>"#), "skip reason in the collapsed row: {procs}");
  assert!(!procs.contains("data-proc-stop"), "a skipped step offers no kill button: {procs}");
  // Live updates speak the same phrases (no glyph map).
  let js = live_client_js();
  assert!(js.contains("function elapsedPhrase"));
  assert!(js.contains("'skipped'"));
  assert!(!js.contains("skipped:'⊘'"));
}

#[test]
fn start_panel_offers_project_creation_and_the_client_wires_it() {
  let store = Store::new(DaemonMode::Persistent, 7274, 1);
  let html = super::index_page(&store);
  for id in ["project-name", "project-create"] {
    assert!(html.contains(&format!("id=\"{id}\"")), "index page should contain #{id}");
  }
  assert!(html.contains("~/.scsh/projects/"), "the panel explains where projects live");
  let js = live_client_js();
  assert!(js.contains("/api/v1/projects/create"), "client js posts project creation");
  assert!(js.contains("function createProject"), "client js wires the button");
  assert!(js.contains("function handleRepoOpened"), "open and create share the response path");
}

#[test]
fn running_cast_preview_starts_near_the_end() {
  let js = live_client_js();
  // A still-running proc's player opens in DECLARED-LIVE mode: parked at the growing edge,
  // the seek bar pinned full-width in live green (player.setLive) — not a near-end
  // autoplay whose playhead jitters as the duration grows.
  assert!(js.contains("box._live = true; createCastPlayer(box, 'end')"), "running casts open live at the edge");
  assert!(!js.contains("near-end"), "the jittery near-end preview is gone");
  assert!(js.contains("beecast-livechange"), "the toggle mirrors the player's own live state");
}

#[test]
fn ui_review_fixes_hold() {
  // 1. Agent-route badges: the chamfer overlay must not swallow the text (the
  //    empty-rectangle bug — .agent-badge's inner span needs the z-index lift too).
  let html = super::index_page(&Store::new(DaemonMode::Persistent, 7274, 1));
  assert!(
    html.contains(".badge > span, .session-status > span, .agent-badge > span"),
    "agent-badge text must sit above the chamfer overlay"
  );
  // 2. Clicking something that renders inputs further down scrolls there.
  let js = live_client_js();
  assert_eq!(js.matches("scrollIntoView").count() >= 2, true, "def form + defs panel scroll into view");
  // 3. A finished proc's collapsed row shows its ANSWER, not the stale run note.
  let mut store = store_with_cast_proc(ProcStatus::Ok);
  {
    let p = &mut store.sessions.get_mut("castab").unwrap().procs[0];
    p.detail = Some("2 + 3 = 5".into());
    p.note = Some("claude run…".into());
  }
  let page = session_page(&store, "castab").expect("session renders");
  assert!(page.contains(r#"<span class="note dim">2 + 3 = 5</span>"#), "the answer rides the collapsed row");
  assert!(!page.contains(r#"<span class="note dim">claude run…</span>"#), "the stale note does not");
  // 4. The meta island is purple and owns the action buttons (top-right corner).
  assert!(page.contains(r#"<div class="card card--accent-left-purple"><div class="session-actions">"#));
  // 5. Proc islands wear status on the left accent bar (and tint the label).
  assert!(html.contains("details.proc.ok { border-left-color: var(--green); }"));
  assert!(html.contains("details.proc.running { border-left-color: var(--orange); }"));
  assert!(html.contains("details.proc.running summary .label { color: var(--orange); }"));
  {
    let p = &mut store.sessions.get_mut("castab").unwrap().procs[0];
    p.elapsed = Some(18.0);
  }
  let page_with_elapsed = session_page(&store, "castab").expect("session renders");
  assert!(
    page_with_elapsed.contains(r#"data-proc-elapsed="0">done in 18s</span>"#),
    "ok rows say done in N: {page_with_elapsed}"
  );
  assert!(!page_with_elapsed.contains(r#"class="glyph""#), "no status glyph on proc rows");
  // 6. The builtin source badge wears purple.
  assert!(html.contains(".badge--purple"), "purple badge class ships");
  assert!(live_client_js().contains(r#"chamfer badge badge--purple"><span>builtin"#), "builtin badge is purple");
}

#[test]
fn session_header_carries_breadcrumbs_and_honest_kind() {
  // The top island: location path on the left (bold, plain text), daemon status right.
  let mut store = store_with_cast_proc(ProcStatus::Running);
  {
    let s = store.sessions.get_mut("castab").unwrap();
    s.kind = Some("workflow".into());
    s.profile = Some("arith".into());
  }
  let html = session_page(&store, "castab").expect("session renders");
  assert!(
    html.contains(r#"<a href="/">scsh</a><span class="crumb-sep">›</span><a href="/">jobs</a><span class="crumb-sep">›</span><a class="job-id" href="/session/castab">castab</a>"#),
    "breadcrumb permalinks in the top island (the id in a fixed font)"
  );
  // The status dot sits at the very RIGHT edge of the island.
  assert!(html.contains(r#"{}<span class="dot" aria-hidden="true"></span></span></div>"#.trim_start_matches("{}")), "dot last in the island");
  assert!(html.contains(r#"<span class="daemon-right">"#), "daemon status keeps the island's right side");
  assert!(!html.contains("<h1>"), "the body no longer duplicates the path as an h1");
  // A workflow session says so — not "profile" — inside the purple island. (The heading
  // stays flush-left; the resting lifecycle badge may follow it, so don't pin the </p>.)
  assert!(html.contains(r#"<p class="session-kind">workflow <strong>arith</strong>"#), "got: {html}");
  // A session with no kind (persisted by an older build) still reads as a profile.
  let mut old = store_with_cast_proc(ProcStatus::Running);
  old.sessions.get_mut("castab").unwrap().profile = Some("default".into());
  let html = session_page(&old, "castab").expect("session renders");
  assert!(html.contains(r#"<p class="session-kind">profile <strong>default</strong>"#), "got: {html}");
  // The index island shows just "scsh".
  let html = super::index_page(&store);
  assert!(html.contains(r#"<span class="crumbs"><a href="/">scsh</a></span>"#), "got crumbs on index");
}

#[test]
fn stop_strip_and_kill_buttons_ignore_zombie_sessions() {
  // A dead client's session stays un-ended with "running" procs forever. It must get NO
  // stop-all-harness button on the index and NO per-proc kill button on its session page —
  // there is nothing left to stop. (store_with_cast_proc's session was last seen at t=1.)
  let store = store_with_cast_proc(ProcStatus::Running);
  let html = super::index_page(&store);
  // (The embedded client JS always contains the bare attribute selectors, so assert on the
  // rendered `attr="` form, which only a server-side button carries.)
  assert!(!html.contains(r#"data-harness-stop=""#), "zombie sessions must not raise stop-all buttons");
  let page = session_page(&store, "castab").expect("session renders");
  assert!(!page.contains(r#"data-proc-stop=""#), "zombie sessions must not offer per-proc kill");

  // The same session, seen moments ago, gets both.
  let mut live = store_with_cast_proc(ProcStatus::Running);
  live.sessions.get_mut("castab").unwrap().last_seen_at = crate::daemon::paths::now_unix_secs();
  let html = super::index_page(&live);
  assert!(html.contains(r#"data-harness-stop="claude""#), "live sessions raise the stop-all button");
  let page = session_page(&live, "castab").expect("session renders");
  assert!(page.contains(r#"data-proc-stop="0""#), "live sessions offer per-proc kill");
}

#[test]
fn index_page_shows_colored_harness_chips_per_proc() {
  let mut store = store_with_cast_proc(ProcStatus::Running);
  // A second, finished proc on another harness: its chip renders dimmed.
  {
    let session = store.sessions.get_mut("castab").unwrap();
    let mut done = session.procs[0].clone();
    done.index = 1;
    done.status = ProcStatus::Ok;
    done.harness = Some("grok".into());
    done.label = "grok: add".into();
    session.procs.push(done);
    // Build procs never get a chip — only skill runs count.
    let mut build = session.procs[0].clone();
    build.index = 2;
    build.kind = ProcKind::Build;
    build.harness = Some("codex".into());
    session.procs.push(build);
  }
  let html = super::index_page(&store);
  // A running chip's tip is just `harness · skill`; its start time rides in
  // data-tip-running, from which the tip module ticks a live "running for …" line.
  assert!(
    html.contains(r#"<span class="hchip hchip--claude" data-tip="claude · add" data-tip-running="1">C</span>"#),
    "got: {html}"
  );
  // A finished chip's tip is two lines: `harness · skill`, then the plain status word.
  assert!(
    html.contains("<span class=\"hchip hchip--grok hchip--done\" data-tip=\"grok · add\ndone\">G</span>"),
    "got: {html}"
  );
  assert!(!html.contains(r#"class="hchip hchip--codex"#), "build procs must not render a chip");
  // The stylesheet distinguishes the same letter by harness color, and the client JS
  // mirrors the markup for live re-renders.
  assert!(html.contains(".hchip--claude"));
  assert!(html.contains(".hchip--codex"));
  assert!(html.contains("function harnessChipsHtml"));
}

#[test]
fn index_page_carries_the_images_panel_and_its_client_wiring() {
  let store = Store::new(DaemonMode::Persistent, 7274, 1);
  let html = super::index_page(&store);
  // The panel skeleton: status table body plus every control the client script binds to.
  for id in ["images-body", "images-build-selected", "images-build-all", "images-rebuild-base", "images-force"] {
    assert!(html.contains(&format!("id=\"{id}\"")), "index page should contain #{id}");
  }
  // First paint already lists every known image (§13: no empty limbo while inspect runs).
  assert!(html.contains("checking…"), "skeleton rows start in checking…");
  assert!(html.contains("scsh-base:latest"), "base image row on first paint");
  for tag in ["scsh-opencode:latest", "scsh-claude:latest", "scsh-codex:latest", "scsh-grok:latest", "scsh-cursor:latest"] {
    assert!(html.contains(tag), "harness image {tag} on first paint");
  }
  assert!(html.contains("checking container runtime…"), "note explains the pending inspect");
  // The embedded client script populates the panel from the images API without blanking rows.
  let js = live_client_js();
  assert!(js.contains("/api/v1/images"), "client js should fetch the images API");
  assert!(js.contains("/api/v1/images/build"), "client js should post builds");
  assert!(js.contains("function markImagesChecking"), "refresh keeps rows visible while checking");
  assert!(!js.contains("loading…"), "must not replace the table with a blank loading row");
  // Each image row carries its own [re]build button (base row rebuilds base + everything).
  assert!(js.contains("data-image-build"), "per-row build buttons are rendered");
  assert!(js.contains("function startImageBuildOne"), "per-row build buttons are wired");
  assert!(html.contains("image-action-cell"), "skeleton rows reserve the per-row action cell");
}

#[test]
fn index_page_carries_the_repositories_panel_and_its_client_wiring() {
  let store = Store::new(DaemonMode::Persistent, 7274, 1);
  let html = super::index_page(&store);
  for id in ["repo-path", "repo-pick", "repo-open", "repo-blockers", "defs-panel", "defs-list", "def-form", "repos-body"] {
    assert!(html.contains(&format!("id=\"{id}\"")), "index page should contain #{id}");
  }
  // The four tabs, and their panels.
  for (tab, panel) in [("jobs", "tab-jobs"), ("dirs", "tab-dirs"), ("start", "tab-start"), ("images", "tab-images")] {
    assert!(html.contains(&format!("data-tab=\"{tab}\"")), "index page should have the {tab} tab");
    assert!(html.contains(&format!("id=\"{panel}\"")), "index page should have panel #{panel}");
  }
  let js = live_client_js();
  assert!(js.contains("/api/v1/repos/open"), "client js opens a repo");
  assert!(js.contains("/api/v1/repos/pick"), "client js pops the folder picker");
  assert!(js.contains("/api/v1/jobs/start"), "client js starts a job");
  assert!(js.contains("function renderRepoJobs"), "client js renders jobs by repository");
  assert!(js.contains("OPEN_REPO_RUNNABLE"), "client js gates Start on the repo being runnable");
  assert!(js.contains("function initTabs"), "client js wires the tabs");
}

#[test]
fn empty_output_label_depends_on_proc_status() {
  assert_eq!(empty_output_label(ProcStatus::Running), "No output yet.");
  assert_eq!(empty_output_label(ProcStatus::Waiting), "No output yet.");
  assert_eq!(empty_output_label(ProcStatus::Ok), "No output.");
  assert_eq!(empty_output_label(ProcStatus::Fail), "No output.");
}

#[test]
fn session_proc_html_has_no_stray_backslashes() {
  let mut store = Store::new(DaemonMode::Persistent, 7274, 1);
  store.sessions.insert(
    "test".into(),
    Session {
      id: "test".into(),
      started_at: 1,
      ended_at: None,
      profile: Some("default".into()),
      kind: None,
      repo: "/tmp/repo".into(),
      branch: "main".into(),
      last_seen_at: crate::daemon::paths::now_unix_secs(), // live: Force stop only renders for running sessions
      client_connected: false,
      run_pid: None,
      skills: vec![],
      procs: vec![ProcRecord {
        index: 0,
        kind: ProcKind::Skill,
        label: "opencode: add".into(),
        status: ProcStatus::Running,
        note: None,
        detail: None,
        fail_reason: None,
        container_name: None,
        cast_path: None,
        diff_path: None,
        skill_source: None,
        route: None,
        result_path: None,
        harness: Some("opencode".into()),
        skill_name: Some("add".into()),
        model: None,
        started_at: Some(1),
        elapsed: None,
        lines: vec![],
      }],
    },
  );
  let html = session_page(&store, "test").expect("session page");
  let procs = session_procs_html(&html);
  assert!(!html.contains("\\\n"), "raw-string line continuations must not leak backslashes");
  assert!(!procs.contains("\\\n"), "autoscroll markup must not leak backslashes");
  assert!(procs.contains(r#"<label class="autoscroll-ctl">"#));
  assert!(procs.contains("Auto-scroll to bottom"));
  assert!(html.contains(r#"<div class="output"><div class="dim">No output yet.</div>"#));
  assert!(html.contains(r#"id="session-stop""#), "running session should offer Force stop");
  assert!(html.contains("Force stop"));
}

#[test]
fn session_page_shows_the_commits_diff_chip_only_when_packed() {
  let mut store = Store::new(DaemonMode::Persistent, 7274, 1);
  store.sessions.insert(
    "difjob".into(),
    Session {
      id: "difjob".into(),
      started_at: 1,
      ended_at: Some(10),
      profile: Some("default".into()),
      kind: None,
      repo: "/tmp/repo".into(),
      branch: "main".into(),
      last_seen_at: 10,
      client_connected: false,
      run_pid: None,
      skills: vec![],
      procs: vec![
        ProcRecord {
          index: 0,
          kind: ProcKind::Skill,
          label: "opencode: add".into(),
          status: ProcStatus::Ok,
          note: None,
          detail: None,
          fail_reason: None,
          container_name: None,
          cast_path: None,
          diff_path: Some("/tmp/scsh-home/sessions/difjob/diffs/add-p0.html".into()),
          skill_source: None,
          route: None,
          result_path: None,
          harness: Some("opencode".into()),
          skill_name: Some("add".into()),
          model: None,
          started_at: Some(1),
          elapsed: Some(2.0),
          lines: vec![],
        },
        ProcRecord {
          index: 1,
          kind: ProcKind::Skill,
          label: "claude: add".into(),
          status: ProcStatus::Ok,
          note: None,
          detail: None,
          fail_reason: None,
          container_name: None,
          cast_path: None,
          diff_path: None,
          skill_source: None,
          route: None,
          result_path: None,
          harness: Some("claude".into()),
          skill_name: Some("add".into()),
          model: None,
          started_at: Some(1),
          elapsed: Some(2.0),
          lines: vec![],
        },
      ],
    },
  );
  let html = session_page(&store, "difjob").expect("session page");
  let procs = session_procs_html(&html);
  // The step whose commits were packed links its review page; the other has no chip.
  assert!(procs.contains(r#"href="/diff/difjob/0""#), "packed step links its diff: {procs}");
  assert!(procs.contains("⇄ commits diff"), "the chip is labeled: {procs}");
  assert!(!procs.contains(r#"href="/diff/difjob/1""#), "unpacked step has no diff link: {procs}");
  assert_eq!(procs.matches("data-proc-diff").count(), 1, "exactly one chip: {procs}");
  // Plain click navigates in THIS tab; cmd/ctrl+click keeps its native new-tab meaning.
  assert!(!procs.contains("target="), "no target override on the diff chip: {procs}");
}

#[test]
fn ended_session_hides_force_stop_button() {
  let mut store = Store::new(DaemonMode::Persistent, 7274, 1);
  store.sessions.insert(
    "done01".into(),
    Session {
      id: "done01".into(),
      started_at: 1,
      ended_at: Some(10),
      profile: Some("default".into()),
      kind: None,
      repo: "/tmp/repo".into(),
      branch: "main".into(),
      last_seen_at: 10,
      client_connected: false,
      run_pid: None,
      skills: vec![],
      procs: vec![],
    },
  );
  let html = session_page(&store, "done01").expect("session page");
  assert!(!html.contains(r#"id="session-stop""#), "ended session must not offer Force stop");
  // The resting lifecycle badge FOLLOWS the heading — the kind/name stays flush-left with
  // the meta labels below it — not the top-right actions slot, where it sat awkwardly
  // against the taller download button. The actions slot keeps only the buttons.
  assert!(html.contains(r#"session-status completed"#), "ended session shows the completed badge");
  assert!(
    html.contains(r#"</strong> <span class="chamfer session-status completed">"#),
    "the badge follows the session-kind heading: {html}"
  );
  assert!(
    !html.contains(r#"session-actions"><span class="chamfer session-status"#),
    "no badge in the top-right actions slot"
  );
}

#[test]
fn offline_export_shows_lifecycle_chip_after_heading() {
  let session = Session {
    id: "exp01".into(),
    started_at: 1,
    ended_at: Some(10),
    profile: Some("code-review".into()),
    kind: Some("profile".into()),
    repo: "/tmp/repo".into(),
    branch: "main".into(),
    last_seen_at: 10,
    client_connected: false,
    run_pid: None,
    skills: vec![],
    procs: vec![],
  };
  let html = session_export_page(&session, &[]);
  assert!(
    html.contains(r#"</strong> <span class="chamfer session-status completed"><span>completed</span></span>"#),
    "export heading carries the same resting lifecycle chip as the live page: {html}"
  );
  assert!(html.contains(r#"profile <strong>code-review</strong>"#), "kind/profile still lead: {html}");
  assert!(html.contains("accessibility: 'snapshot'"), "export player opts enable a11y snapshot");
}

#[test]
fn offline_export_embeds_commits_diff_when_present() {
  use super::session_export::CastExport;
  let session = Session {
    id: "expdf".into(),
    started_at: 1,
    ended_at: Some(10),
    profile: Some("default".into()),
    kind: Some("profile".into()),
    repo: "/tmp/repo".into(),
    branch: "main".into(),
    last_seen_at: 10,
    client_connected: false,
    run_pid: None,
    skills: vec![],
    procs: vec![ProcRecord {
      index: 0,
      kind: ProcKind::Skill,
      label: "opencode: add".into(),
      status: ProcStatus::Ok,
      note: None,
      detail: Some("ok".into()),
      fail_reason: None,
      container_name: None,
      cast_path: None,
      diff_path: Some("/tmp/diff.html".into()),
      skill_source: None,
      route: None,
      result_path: None,
      harness: Some("opencode".into()),
      skill_name: Some("add".into()),
      model: None,
      started_at: Some(1),
      elapsed: Some(1.0),
      lines: vec![],
    }],
  };
  let hostile = r#"<html><body></script><p>diff</p></body></html>"#;
  let exports = [CastExport::Note { text: "no recording".into(), diff_html: Some(hostile.into()) }];
  let html = session_export_page(&session, &exports);
  assert!(html.contains(r#"<span class="proc-diff""#), "summary carries static commits-diff chip");
  assert!(html.contains(r#"<details class="proc-diff">"#), "body embeds the packed diff");
  assert!(html.contains("srcdoc="), "diff rides in an iframe srcdoc");
  assert!(html.contains("<\\/"), "hostile </ is broken for srcdoc like CASTS");
  assert!(!html.contains("</script><p>diff"), "raw </script> must not appear unescaped");
}

#[test]
fn session_page_renders_fleet_comparison_for_shared_skill_source() {
  let mut store = Store::new(DaemonMode::Persistent, 7274, 1);
  store.sessions.insert(
    "fleet1".into(),
    Session {
      id: "fleet1".into(),
      started_at: 1,
      ended_at: Some(10),
      profile: Some("default".into()),
      kind: Some("profile".into()),
      repo: "/tmp/repo".into(),
      branch: "main".into(),
      last_seen_at: 10,
      client_connected: false,
      run_pid: None,
      skills: vec![],
      procs: vec![
        ProcRecord {
          index: 0,
          kind: ProcKind::Skill,
          label: "opencode: add-opencode".into(),
          status: ProcStatus::Ok,
          note: None,
          detail: Some("2 + 3 = 5".into()),
          fail_reason: None,
          container_name: None,
          cast_path: None,
          diff_path: None,
          skill_source: Some("add".into()),
          route: Some("opencode".into()),
          result_path: None,
          harness: Some("opencode".into()),
          skill_name: Some("add-opencode".into()),
          model: None,
          started_at: Some(1),
          elapsed: Some(1.0),
          lines: vec![],
        },
        ProcRecord {
          index: 1,
          kind: ProcKind::Skill,
          label: "claude: add-claude".into(),
          status: ProcStatus::Ok,
          note: None,
          detail: Some("2 + 3 = 5".into()),
          fail_reason: None,
          container_name: None,
          cast_path: None,
          diff_path: None,
          skill_source: Some("add".into()),
          route: Some("claude".into()),
          result_path: None,
          harness: Some("claude".into()),
          skill_name: Some("add-claude".into()),
          model: None,
          started_at: Some(1),
          elapsed: Some(1.2),
          lines: vec![],
        },
      ],
    },
  );
  let html = session_page(&store, "fleet1").expect("session page");
  assert!(html.contains(r#"class="fleets""#), "fleet section present: {html}");
  assert!(html.contains(r#"class="fleet-compare""#), "comparison table present");
  assert!(html.contains(r#"data-skill-source="add""#), "grouped by skill_source");
  assert!(html.contains(r#"class="fleet-jump" data-proc="0""#), "jump to first route");
  assert!(html.contains(r#"class="fleet-jump" data-proc="1""#), "jump to second route");
  let fleets_at = html.find(r#"class="fleets""#).expect("fleets");
  let procs_at = html.find(r#"id="session-procs""#).expect("procs");
  assert!(fleets_at < procs_at, "fleet HTML sits before #session-procs");
}

#[test]
fn client_js_wires_fleet_jumps_and_accessibility_snapshot() {
  let js = live_client_js();
  assert!(js.contains("function initFleetJumps"), "client js wires fleet jump buttons");
  assert!(js.contains("accessibility: 'snapshot'"), "live player opts enable a11y snapshot");
}

#[test]
fn client_js_wires_force_stop() {
  let js = live_client_js();
  assert!(js.contains("/api/v1/session/stop"), "client js posts session stop");
  assert!(js.contains("function forceStopSession"), "client js defines forceStopSession");
}

#[test]
fn client_js_mirrors_the_commits_diff_chip() {
  // Integration (and the packdiff pack) happens after a step finished, so the chip usually
  // arrives on a live tick: the client must render the same markup session.rs serves.
  let js = live_client_js();
  assert!(js.contains("function procDiffBtnHtml"), "client js builds the diff chip");
  assert!(js.contains("p.diff_path"), "client js keys the chip on the proc's diff_path");
  assert!(js.contains("⇄ commits diff"), "the live chip carries the same label");
  assert!(js.contains("initProcDiffs"), "chips present at page render are wired too");
}

#[test]
fn recorded_proc_embeds_cast_player_instead_of_text_output() {
  let mut store = Store::new(DaemonMode::Persistent, 7274, 1);
  store.sessions.insert(
    "castab".into(),
    Session {
      id: "castab".into(),
      started_at: 1,
      ended_at: None,
      profile: Some("default".into()),
      kind: None,
      repo: "/tmp/repo".into(),
      branch: "main".into(),
      last_seen_at: 1,
      client_connected: true,
      run_pid: None,
      skills: vec![],
      procs: vec![ProcRecord {
        index: 2,
        kind: ProcKind::Skill,
        label: "claude: add".into(),
        status: ProcStatus::Ok,
        note: None,
        detail: None,
        fail_reason: None,
        container_name: None,
        cast_path: Some("/tmp/x.cast".into()),
        diff_path: None,
        skill_source: None,
        route: None,
        result_path: None,
        harness: Some("claude".into()),
        skill_name: Some("add".into()),
        model: None,
        started_at: Some(1),
        elapsed: Some(3.0),
        lines: vec![],
      }],
    },
  );
  let html = session_page(&store, "castab").expect("session page");
  // The page loads the player assets and embeds a player box wired to the cast endpoint.
  assert!(html.contains(r#"<link rel="stylesheet" href="/assets/scsh-cast-player.css">"#), "player css");
  assert!(html.contains(r#"<script src="/assets/scsh-cast-player.js"></script>"#), "player js");
  let procs = session_procs_html(&html);
  assert!(procs.contains(r#"<div class="cast" data-cast-url="/cast/castab/2""#), "cast embed");
  // Fullscreen lives in the player's own control bar now (⛶ + the f key, via
  // fullscreenEl) — the page toolbar carries no fullscreen button of its own. Opening a
  // section focuses its player, so space and f work with no click first.
  assert!(!procs.contains("data-cast-fs"), "no page-side fullscreen button");
  assert!(procs.contains("f fullscreen"), "the keys hint teaches f");
  // Streaming drives itself (WS growth appends + the finish reload), so there is no manual
  // Reload button; chapters are the player's own chrome (☰ panel + c key + seek ticks) —
  // no scsh-side chip row or fullscreen sidebar.
  assert!(!procs.contains("data-cast-reload"), "no manual reload in a streaming toolbar");
  assert!(procs.contains("c chapters"), "the keys hint teaches the chapters panel");
  let js = live_client_js();
  assert!(!js.contains("data-cast-reload"), "client js builds no reload button");
  assert!(!js.contains("cast-chapters"), "no scsh-side chapter chips");
  assert!(!js.contains("cast-fs-chapters"), "no scsh-side fullscreen chapters sidebar");
  assert!(js.contains("markers"), "chapters reach the player as markers");
  assert!(js.contains("function focusCastPlayer"), "open sections hand the player the keyboard");
  assert!(js.contains("if (det.open) focusCastPlayer(box)"), "focus follows the section toggle");
  // Link-at-time left the inline toolbar (the /play page keeps deep links); the run
  // snapshot download is cyan but the SAME size and shape as its toolbar siblings —
  // no chamfered .btn misfit in this row.
  assert!(!procs.contains("data-cast-link"), "no link-at-time in the inline toolbar");
  assert!(procs.contains(r#"<a href="/cast/castab/2/export.html" data-cast-export"#), "run snapshot link");
  assert!(!procs.contains(r#"btn--cyan" href="/cast/"#), "no .btn styling inside the cast toolbar");
  assert!(procs.contains(r#"<a href="/cast/castab/2?dl=1" download>"#), "download link");
  // A recorded proc shows the player, NOT the text output / autoscroll control.
  assert!(!procs.contains(r#"<div class="output">"#), "no text output for recorded proc");
  assert!(!procs.contains("autoscroll-ctl"), "no autoscroll control for recorded proc");
}

#[test]
fn empty_output_html_has_no_backslash_artifacts() {
  let html = empty_output_html(ProcStatus::Ok);
  assert_eq!(html, "<div class=\"dim\">No output.</div>\n");
  assert!(!html.contains("\\"));
  let running = empty_output_html(ProcStatus::Running);
  assert_eq!(running, "<div class=\"dim\">No output yet.</div>\n");
  assert!(!running.contains("\\"));
}

#[test]
fn session_proc_html_shows_autoscroll_while_running() {
  let mut store = Store::new(DaemonMode::Persistent, 7274, 1);
  store.sessions.insert(
    "test".into(),
    Session {
      id: "test".into(),
      started_at: 1,
      ended_at: None,
      profile: Some("default".into()),
      kind: None,
      repo: "/tmp/repo".into(),
      branch: "main".into(),
      last_seen_at: 1,
      client_connected: true,
      run_pid: None,
      skills: vec![],
      procs: vec![ProcRecord {
        index: 0,
        kind: ProcKind::Skill,
        label: "opencode: add".into(),
        status: ProcStatus::Running,
        note: None,
        detail: None,
        fail_reason: None,
        container_name: None,
        cast_path: None,
        diff_path: None,
        skill_source: None,
        route: None,
        result_path: None,
        harness: Some("opencode".into()),
        skill_name: Some("add".into()),
        model: None,
        started_at: Some(1),
        elapsed: None,
        lines: vec![],
      }],
    },
  );
  let html = session_page(&store, "test").expect("session page");
  let procs = session_procs_html(&html);
  assert!(procs.contains(r#"<label class="autoscroll-ctl">"#));
}

#[test]
fn empty_cast_shows_placeholder_instead_of_player_error() {
  // Both the session-page embed and the standalone player page fetch the cast text first
  // and render a calm placeholder when it has no complete event lines yet, instead of
  // handing the player an empty/404 cast (which errors).
  let js = live_client_js();
  assert!(js.contains("Recording in progress — no frames yet."));
  assert!(js.contains("No recorded frames."));
  assert!(js.contains("cast-placeholder"));
  assert!(js.contains("{ data: text }"), "player mounts over the already-fetched text");
  let page = cast_player_page(&store_with_cast_proc(ProcStatus::Running), "castab", 0).expect("player page");
  assert!(page.contains("Recording in progress — no frames yet."));
  assert!(page.contains("cast-placeholder"));
  assert!(page.contains("const LIVE = true;"));
  let done = cast_player_page(&store_with_cast_proc(ProcStatus::Ok), "castab", 0).expect("player page");
  assert!(done.contains("const LIVE = false;"));
}

#[test]
fn cast_growth_notifications_append_in_place() {
  // The session page routes WS messages by type: cast_growth appends the newly recorded
  // suffix to the mounted player IN PLACE (no re-creation, no seek, no reload banner) —
  // smooth live following. Everything else stays on the tick path.
  let js = live_client_js();
  assert!(js.contains("if (msg.type === 'cast_growth') { onCastGrowth(msg); return; }"));
  assert!(js.contains("onWsMessage(JSON.parse(ev.data))"));
  assert!(js.contains("function followCastGrowth"));
  assert!(js.contains("box._player.append(text.slice(prev))"));
  assert!(!js.contains("Recording grew: +"), "the reload banner is gone — growth is invisible and smooth");
  // The standalone player page listens on its own WS connection — but only while the proc
  // runs — and follows growth the same way.
  let page = cast_player_page(&store_with_cast_proc(ProcStatus::Running), "castab", 0).expect("player page");
  assert!(page.contains("'cast_growth'"));
  assert!(page.contains("const SESSION = 'castab';"));
  assert!(page.contains("const PROC = 0;"));
  assert!(page.contains("player.append(text.slice(loadedChars))"));
  assert!(!page.contains("Recording grew: +"));
  assert!(page.contains("if (!castRunning) return;"), "no WS connect once the proc finished");
  // The player bundle itself carries the live-follow API the pages rely on.
  assert!(super::PLAYER_JS.contains("Player.prototype.append"), "the vendored player must have append");
  assert!(super::PLAYER_JS.contains("appendCast"), "the DOM-free core must parse appends");
}

#[test]
fn live_toggle_renders_only_while_the_proc_runs() {
  // Session-page embed: the Live toggle is in the toolbar, hidden unless the proc runs.
  let running = super::proc::cast_embed_html("castab", &store_with_cast_proc(ProcStatus::Running).sessions["castab"].procs[0]);
  assert!(running.contains(r#"<button type="button" data-cast-live>● Live</button>"#));
  let done = super::proc::cast_embed_html("castab", &store_with_cast_proc(ProcStatus::Ok).sessions["castab"].procs[0]);
  assert!(done.contains(r#"<button type="button" data-cast-live hidden>● Live</button>"#));
  // The toggle drives the player's declared-live mode (parked at the edge, green pinned
  // bar); the player renders each appended chunk in place and drops live on a rewind.
  let js = live_client_js();
  assert!(js.contains("function setCastLive(box, on)"));
  assert!(js.contains("box._player.setLive(true)"));
  // Standalone page: toggle present while running, hidden for finished procs, and the
  // finish notice disables it after the final reload.
  let page = cast_player_page(&store_with_cast_proc(ProcStatus::Running), "castab", 0).expect("player page");
  assert!(page.contains(r#"<button id="live-toggle">● Live</button>"#));
  assert!(page.contains("toggle.disabled = true;"));
  let finished = cast_player_page(&store_with_cast_proc(ProcStatus::Ok), "castab", 0).expect("player page");
  assert!(finished.contains(r#"<button id="live-toggle" hidden>● Live</button>"#));
}

#[test]
fn export_html_download_renders_on_both_pages_and_hides_without_frames() {
  // Standalone player page: the download link points at the export endpoint, starts
  // hidden, and rides the same no-frames state as the placeholder.
  let page = cast_player_page(&store_with_cast_proc(ProcStatus::Ok), "castab", 0).expect("player page");
  assert!(page.contains(r#"<a id="dl-html" href="/cast/castab/0/export.html" download hidden>⬇ download .html</a>"#));
  assert!(page.contains("document.getElementById('dl-html').hidden = !stats.events;"));
  // Session-page embed: same link, same hide-until-frames wiring — both in the
  // server-rendered snippet and in the client JS that regenerates it.
  let session = session_page(&store_with_cast_proc(ProcStatus::Ok), "castab").expect("session page");
  let procs = session_procs_html(&session);
  assert!(
    procs.contains(r#"<a href="/cast/castab/0/export.html" data-cast-export download hidden>⬇ Download run snapshot</a>"#)
  );
  let js = live_client_js();
  assert!(js.contains("/export.html\" data-cast-export download hidden>⬇ Download run snapshot</a>"));
  assert!(js.contains("exportLink.hidden = !stats.events;"));
}

#[test]
fn session_page_header_offers_the_session_export_download() {
  // A session with a recorded proc gets the whole-session download button in the header
  // (decided server-side: any proc with a registered cast; the endpoint 404s edge cases).
  let html = session_page(&store_with_cast_proc(ProcStatus::Ok), "castab").expect("session page");
  assert!(
    html.contains(r#"href="/session/castab/export.html" download"#) && html.contains("session-export"),
    "session export button"
  );
  // No recorded proc anywhere → no button (nothing to export; the 404 would only confuse).
  let mut store = store_with_cast_proc(ProcStatus::Ok);
  store.sessions.get_mut("castab").unwrap().procs[0].cast_path = None;
  let bare = session_page(&store, "castab").expect("session page");
  // (The `.session-export` CSS rule is in the shared shell, so match the anchor itself.)
  assert!(!bare.contains("<a class=\"session-export\""), "no export button without any registered cast");
}

#[test]
fn live_client_js_counts_alive_clients_and_shutdown() {
  let js = live_client_js();
  assert!(js.contains("alive_clients"));
  assert!(js.contains("shutting down in"));
}

#[test]
fn live_client_js_skips_index_render_without_sessions() {
  let js = live_client_js();
  assert!(js.contains("if (!body || sessions == null) return"));
  // renderIndex (and the jobs-per-repo view) run only when a snapshot is present.
  assert!(js.contains("if (snapshot) {"));
  assert!(js.contains("renderIndex(snapshot, nowUnix)"));
}

#[test]
fn live_client_js_shows_connecting_on_ws_close() {
  let js = live_client_js();
  assert!(js.contains("setDaemonStatus('connecting', 'connecting…', null)"));
  assert!(!js.contains("daemon unreachable"));
}

#[test]
fn wrap_page_connecting_status_uses_blue() {
  use super::layout::wrap_page;
  let html = wrap_page("scsh sessions", 7274, None, "<p>body</p>");
  assert!(html.contains("class=\"daemon-status connecting\""));
  assert!(html.contains(".daemon-status.connecting .dot { background: var(--cyan);"));
}

#[test]
fn every_daemon_page_carries_the_inline_favicon() {
  use super::layout::wrap_page;
  // A data: URI, so the dashboard and the standalone player page stay request-free.
  let html = wrap_page("scsh sessions", 7274, None, "<p>body</p>");
  assert!(html.contains("<link rel=\"icon\" href=\"data:image/svg+xml,"), "dashboard favicon");
  let player = cast_player_page(&store_with_cast_proc(ProcStatus::Ok), "castab", 0).expect("player page");
  assert!(player.contains("<link rel=\"icon\" href=\"data:image/svg+xml,"), "player-page favicon");
}

#[test]
fn wrap_page_serves_valid_css_braces() {
  use super::layout::wrap_page;
  let html = wrap_page("scsh sessions", 7274, None, "<p>body</p>");
  assert!(html.contains(":root {"));
  assert!(!html.contains(":root {{"));
  assert!(html.contains(".daemon-status {"));
}

#[test]
fn review_round_four_fixes_hold() {
  use crate::daemon::model::OpenRepo;
  // (1) The Projects tab is populated server-side — jobs grouped by repository, plus a
  // "no jobs yet" row for repos opened with none — so it shows on first paint instead of
  // waiting for a full WebSocket snapshot a quiet daemon never sends.
  let mut store = store_with_cast_proc(ProcStatus::Ok);
  store.sessions.get_mut("castab").unwrap().ended_at = Some(5);
  store.open_repo(OpenRepo { path: "/work/empty".into(), opened_at: 9, clean: true });
  let html = super::index_page(&store);
  assert!(html.contains(r#"<td class="repo-path" title="/tmp/repo">/tmp/repo</td>"#), "got: {html}");
  // Jobs are grouped by the task they ran, with a compact age stamp per job (the exact
  // age depends on the wall clock, so pin up to the stamp). The link — color and
  // underline — covers EXACTLY the six-letter id, in a fixed font (.job-id): never the
  // badge or the age stamp.
  assert!(
    html.contains(
      r#"<div class="repo-jobgroup"><span class="repo-jobgroup-name">default</span><div class="repo-job"><span class="chamfer session-status completed"><span>completed</span></span> <a class="job-id" href="/session/castab">castab</a> <span class="dim">"#
    ),
    "got: {html}"
  );
  assert!(
    html.contains(r#"<td class="repo-path" title="/work/empty">/work/empty</td><td><span class="dim">no jobs yet</span></td>"#),
    "got: {html}"
  );
  // (2) Chips and counts carry instant data-tip tooltips, served by the shared floating tip
  // (native title tooltips were reset by every live table re-render).
  assert!(html.contains(r#"<span class="chip-count" data-tip="1 run in this job">1</span>"#), "got: {html}");
  assert!(html.contains(".ui-tip"), "tooltip CSS ships");
  assert!(html.contains("initTips"), "tooltip delegation ships");
  assert!(!super::index_page(&store).contains(r#"hchip--claude hchip--done" title="#), "chips use data-tip, not title");
  // (3) The UI speaks "jobs": table header, breadcrumb, empty states.
  assert!(html.contains("<th>Job</th>"), "got: {html}");
  assert!(!html.contains("<th>Session</th>"));
  // (4) A finished recording advertises WHEN it ended, and the chapters poll is bounded by
  // it — no more eternal "summarizing…" on casts that will never gain chapters.
  let mut ended = store_with_cast_proc(ProcStatus::Ok);
  ended.sessions.get_mut("castab").unwrap().procs[0].elapsed = Some(30.0);
  let shtml = session_page(&ended, "castab").expect("session renders");
  assert!(shtml.contains(r#" data-status="ok" data-ended="31">"#), "got: {shtml}");
  assert!(shtml.contains("CHAPTERS_WAIT_SECS"), "bounded summarizing window ships");
  // A still-running recording has no end yet (the session-meta dl has its own unrelated
  // data-ended, so pin the cast box's tag specifically).
  let running = session_page(&store_with_cast_proc(ProcStatus::Running), "castab").expect("session renders");
  assert!(running.contains(r#" data-status="running">"#), "got: {running}");
  assert!(!running.contains(r#" data-status="running" data-ended"#), "got: {running}");
  // (5) The per-container button reads "Force stop", not "kill".
  let mut live = store_with_cast_proc(ProcStatus::Running);
  live.sessions.get_mut("castab").unwrap().last_seen_at = crate::daemon::paths::now_unix_secs();
  let shtml = session_page(&live, "castab").expect("session renders");
  assert!(shtml.contains("✕ Force stop"), "got: {shtml}");
  assert!(!shtml.contains("✕ kill"));
  assert!(shtml.contains("⬇ Download job snapshot"), "got: {shtml}");
}

#[test]
fn review_round_five_fixes_hold() {
  // Projects: running jobs sort above completed ones, grouped by the task that ran, each
  // line stamped with a compact age; the launch tab reads "New job".
  let now = crate::daemon::paths::now_unix_secs();
  let mut store = store_with_cast_proc(ProcStatus::Ok);
  store.sessions.get_mut("castab").unwrap().ended_at = Some(5);
  {
    let done = store.sessions.get("castab").unwrap().clone();
    let mut live = done.clone();
    live.id = "livejb".into();
    live.ended_at = None;
    live.last_seen_at = now;
    live.profile = Some("arith".into());
    live.procs[0].status = ProcStatus::Running;
    store.sessions.insert("livejb".into(), live);
  }
  let html = super::index_page(&store);
  let arith = html.find(r#"<span class="repo-jobgroup-name">arith</span>"#).expect("arith group");
  let default = html.find(r#"<span class="repo-jobgroup-name">default</span>"#).expect("default group");
  assert!(arith < default, "the group with a running job sorts above the finished one: {html}");
  assert!(html.contains(r#"<span class="chamfer session-status running"><span>running</span></span> <a class="job-id" href="/session/livejb">livejb</a> <span class="dim">"#), "got: {html}");
  assert!(html.contains(r#"data-tab="start">New job</button>"#), "got: {html}");
  assert!(!html.contains("Start a job"));
  // Short ages are single-unit; both renderers ship the same helper and group markup.
  assert_eq!(super::format::format_short_age(45), "45s");
  assert_eq!(super::format::format_short_age(200), "3m");
  assert_eq!(super::format::format_short_age(7300), "2h");
  assert_eq!(super::format::format_short_age(200_000), "2d");
  assert!(html.contains("function formatShortAge"), "JS mirror ships");
  assert!(html.contains(".repo-jobgroup"), "group CSS ships");
  // The inline player pane has NO forced height — the player sizes its own box to the
  // recording's aspect at full width, so the pane is exactly as tall as the terminal wants
  // (the page-side sizeCastPane workaround is gone).
  let shtml = session_page(&store, "castab").expect("session renders");
  assert!(!shtml.contains("sizeCastPane"), "the pane-sizing workaround must stay gone");
  assert!(!shtml.contains(".cast-player { width: 100%; height:"), "no forced pane height");
  assert!(shtml.contains("height: auto !important"), "fullscreen overrides any inline pane height");
}

#[test]
fn review_round_six_fixes_hold() {
  let store = store_with_cast_proc(ProcStatus::Ok);
  let html = super::index_page(&store);
  // Durations can never render backwards: stale tick frames are dropped, and a superseded
  // WebSocket is fully retired before a reconnect (the "oscillating Duration" bug).
  assert!(html.contains("lastTickSecs"), "monotonic tick guard ships");
  assert!(html.contains("Retire any superseded socket"), "socket retirement ships");
  // The runtime switcher is a segmented control above the images table, not loose buttons
  // in the action strip; tips are multi-line and can tick a live running-for line.
  assert!(html.contains(r#"<div id="images-runtimes" class="images-runtimes"></div>"#), "got: {html}");
  assert!(html.contains(".seg-opt"), "segmented-control CSS ships");
  assert!(html.contains("data-tip-running"), "live-ticking tip support ships");
  assert!(html.contains("white-space: pre-line"), "multi-line tip CSS ships");
  // Both JS chip-count writers share one renderer, so live re-syncs keep the tooltip.
  assert!(html.contains("function chipCountHtml"), "shared chip-count renderer ships");
}
