use super::*;
use crate::session::{ContextBreakdown, ContextUsage, CtxPoint};
use chrono::{Duration as Age, Utc};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::buffer::Buffer;
use ratatui::style::{Color, Modifier};
const SMALL: (u16, u16) = (80, 24);
const LARGE: (u16, u16) = (120, 40);
fn ago(ago: Age) -> String {
(Utc::now() - ago).to_rfc3339()
}
fn proc(pid: u32, cpu: f32, mb: u64, args: &str, is_root: bool) -> crate::proc::ProcEntry {
crate::proc::ProcEntry {
pid,
cpu,
memory: mb * 1024 * 1024,
args: args.into(),
is_root,
ghost: false,
}
}
fn session(id: &str, dir: &str, model: &str, last: Age, age: Age) -> Session {
let mut s = Session::new(Provider::Claude, id.into());
s.label_source = format!("/nonexistent/{dir}");
s.abbrev_label = dir.into();
s.model = model.into();
s.last_active = ago(last);
s.started_at = ago(age);
s
}
fn sessions() -> Vec<Session> {
let mut web = session(
"11111111-aaaa-4000-8000-000000000001",
"web",
"claude-opus-4-1",
Age::seconds(20),
Age::minutes(95),
);
web.process = Some(crate::proc::ProcInfo {
pids: 3,
cpu: 12.5,
memory: 412 * 1024 * 1024,
command: "/usr/bin/claude --model opus".into(),
process_list: vec![
proc(4101, 9.8, 360, "/usr/bin/claude --model opus", true),
proc(4188, 2.4, 44, "/usr/bin/node mcp-server.js", false),
proc(4290, 0.3, 8, "/bin/bash -c npm test", false),
],
});
web.input_tokens = 1_240_000;
web.output_tokens = 38_400;
web.tool_count = 212;
web.tool_errors = 4;
web.total_cost = Some(18.42);
web.cost_today = 6.10;
web.cost_hour = 1.25;
web.context = Some(ContextUsage {
used: 151_000,
max: 200_000,
compacted: false,
});
web.last_tool = "Edit".into();
web.title = Some("Fix the checkout redirect loop".into());
let mut api = session(
"22222222-bbbb-4000-8000-000000000002",
"api",
"claude-sonnet-4-5",
Age::seconds(7 * 60 + 30),
Age::minutes(150),
);
api.process = Some(crate::proc::ProcInfo {
pids: 1,
cpu: 0.4,
memory: 180 * 1024 * 1024,
command: "/usr/bin/claude".into(),
process_list: vec![proc(5120, 0.4, 180, "/usr/bin/claude", true)],
});
api.activity_state = crate::session::ActivityState::WaitingForInput;
api.input_tokens = 310_000;
api.output_tokens = 9_800;
api.tool_count = 57;
api.total_cost = Some(2.87);
api.cost_today = 2.87;
api.context = Some(ContextUsage {
used: 48_000,
max: 200_000,
compacted: false,
});
api.last_tool = "Bash".into();
let mut docs = session(
"33333333-cccc-4000-8000-000000000003",
"docs",
"claude-haiku-4-5",
Age::minutes(3 * 60 + 30),
Age::minutes(5 * 60),
);
docs.input_tokens = 42_000;
docs.output_tokens = 3_100;
docs.tool_count = 9;
docs.total_cost = Some(0.19);
docs.last_tool = "Read".into();
let mut infra = session(
"44444444-dddd-4000-8000-000000000004",
"infra",
"gpt-5-codex",
Age::hours(2 * 24 + 12),
Age::hours(3 * 24),
);
infra.provider = Provider::Codex;
infra.input_tokens = 880_000;
infra.output_tokens = 21_000;
infra.tool_count = 140;
infra.total_cost = Some(7.03);
infra.last_tool = "shell".into();
vec![web, api, docs, infra]
}
fn web_data() -> SessionData {
let window = [
(90, 22_000, false),
(75, 61_000, false),
(60, 98_000, false),
(45, 126_000, false),
(30, 139_000, false),
(1, 151_000, false),
];
SessionData {
last_model: "claude-opus-4-1".into(),
context_breakdown: Some(ContextBreakdown {
total: 151_000,
startup: 24_000,
tool_output: 71_000,
tool_input: 12_000,
attachments: 4_000,
user_text: 6_000,
assistant_text: 19_000,
after_compaction: false,
superseded: false,
}),
context_series: window
.iter()
.map(|&(mins, window, after_compaction)| CtxPoint {
ts: ago(Age::minutes(mins)),
window,
after_compaction,
})
.collect(),
..Default::default()
}
}
fn fixture() -> App {
let mut app = tests::test_app();
app.hidden_columns = Vec::new();
app.launch_root = Some("/nonexistent".into());
let pending = || {
vec![crate::quota::ProfileQuota {
profile: "default".into(),
status: crate::quota::ProviderStatus::Pending,
source: crate::config::AccountSource::Directory,
}]
};
app.quota = crate::quota::Quota {
fetched: false,
claude: pending(),
codex: pending(),
};
app.sessions = sessions();
app.loaded = true;
app.stats = Stats {
total: 4,
total_claude: 3,
total_codex: 1,
active_1h: 2,
active_24h: 3,
active_7d: 4,
running: 2,
total_input: 2_472_000,
total_output: 72_300,
total_cpu: 3.2,
total_memory: 592 * 1024 * 1024,
total_tools: 418,
spend_total: 28.51,
spend_claude: 21.48,
spend_codex: 7.03,
spend_hour: 1.25,
spend_today: 8.97,
spend_week: 28.51,
spend_month: 28.51,
spend_per_min: 0.04,
top_today: vec![("web".into(), 6.10), ("api".into(), 2.87)],
models_today: vec![
("claude-opus-4-1".into(), 6.10),
("claude-sonnet-4-5".into(), 2.87),
],
..Default::default()
};
app.refilter();
app.selected = 0;
app.bottom_tab = tab("Processes");
app.panel_key = app.sessions[0].key();
app.panel_data = Some(web_data());
app
}
fn tab(name: &str) -> usize {
panels::TABS
.iter()
.position(|t| *t == name)
.unwrap_or_else(|| panic!("no {name} tab"))
}
fn draw(app: &mut App, (cols, rows): (u16, u16)) -> Buffer {
let mut terminal = Terminal::new(TestBackend::new(cols, rows)).expect("backend");
terminal
.draw(|frame| {
render::draw(frame, app);
})
.expect("draw");
terminal.backend().buffer().clone()
}
fn text(buffer: &Buffer) -> String {
let area = buffer.area;
(area.top()..area.bottom())
.map(|y| {
let line: String = (area.left()..area.right())
.map(|x| buffer[(x, y)].symbol())
.collect();
line.trim_end().to_string()
})
.collect::<Vec<_>>()
.join("\n")
}
fn styles(buffer: &Buffer) -> String {
const MARKS: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let area = buffer.area;
let mut seen: Vec<(Color, Color, Modifier)> = Vec::new();
let mut grid = Vec::new();
for y in area.top()..area.bottom() {
let mut line = String::new();
for x in area.left()..area.right() {
let cell = &buffer[(x, y)];
let key = (cell.fg, cell.bg, cell.modifier);
let plain = key == (Color::Reset, Color::Reset, Modifier::empty());
let mark = match plain {
true => '.',
false => {
let at = seen.iter().position(|k| *k == key).unwrap_or_else(|| {
seen.push(key);
seen.len() - 1
});
MARKS.chars().nth(at).unwrap_or('?')
}
};
line.push(mark);
}
grid.push(line.trim_end_matches('.').to_string());
}
let legend = seen
.iter()
.enumerate()
.map(|(i, (fg, bg, modifier))| {
format!(
"{} fg={fg:?} bg={bg:?} mod={modifier:?}",
MARKS.chars().nth(i).unwrap_or('?')
)
})
.collect::<Vec<_>>()
.join("\n");
format!("{}\n\n{legend}", grid.join("\n"))
}
fn snap(name: &str, app: &mut App) {
for size in [LARGE, SMALL] {
let label = super::modals::build_label();
let screen = text(&draw(app, size)).replace(
&label,
&format!("{:<w$}", "cctop VERSION", w = label.chars().count()),
);
assert!(
!screen.contains("Account "),
"{name} drew the signed-in account:\n{screen}"
);
insta::assert_snapshot!(format!("{name}_{}x{}", size.0, size.1), screen);
}
}
#[test]
fn dashboard() {
let mut app = fixture();
snap("dashboard", &mut app);
}
#[test]
fn dashboard_styles() {
let mut app = fixture();
let buffer = draw(&mut app, LARGE);
insta::assert_snapshot!(styles(&buffer));
}
#[test]
fn help_modal() {
let mut app = fixture();
app.mode = Mode::Help;
snap("help", &mut app);
}
#[test]
fn settings_page() {
let mut app = fixture();
app.settings_open = true;
snap("settings", &mut app);
let mut app = fixture();
app.settings_open = true;
app.settings_filter = "alert when".into();
snap("settings_filtered", &mut app);
}
#[test]
fn context_panel() {
let mut app = fixture();
app.bottom_tab = tab("Context");
snap("context", &mut app);
}
#[test]
fn filter_active() {
let mut app = fixture();
app.search = "web".into();
app.refilter();
snap("filter_active", &mut app);
}
#[test]
fn search_typing() {
let mut app = fixture();
app.mode = Mode::Search;
app.search = "ap".into();
app.refilter();
snap("search_typing", &mut app);
}
#[test]
fn sort_modal() {
let mut app = fixture();
app.mode = Mode::SortBy;
snap("sort", &mut app);
}
#[test]
fn row_menu() {
let mut app = fixture();
app.mode = Mode::RowMenu;
snap("row_menu", &mut app);
}
#[test]
fn new_worktree() {
let mut app = fixture();
app.worktree_base = "/home/me/src/cctop/.claude/worktrees/side".into();
app.worktree_repo = "/home/me/src/cctop".into();
app.worktree_input = "try-fork".into();
app.mode = Mode::NewWorktree;
snap("new_worktree", &mut app);
}
#[test]
fn delete_confirm() {
let mut app = fixture();
app.selected = 2;
app.panel_key = app.sessions[2].key();
app.panel_data = Some(SessionData::default());
app.bottom_tab = tab("Context");
app.mode = Mode::DeleteConfirm;
snap("delete_confirm", &mut app);
}
#[test]
fn idle_view_and_reclaim() {
let mut app = fixture();
app.sessions[0].last_active = ago(Age::hours(9));
app.sessions[1].last_active = ago(Age::hours(26));
app.toggle_idle_view();
app.toasts = Default::default();
snap("idle_view", &mut app);
app.batch(super::BatchKind::Reclaim);
assert_eq!(app.mode, Mode::BatchConfirm);
snap("idle_reclaim", &mut app);
}