use super::*;
use std::path::PathBuf;
use whycodes_core::types::ImageSource;
fn session() -> Session {
Session::new(PathBuf::from("/tmp/tree"), "sys".into())
}
fn msg(role: Role, content: MessageContent) -> Message {
Message {
role,
content,
tool_call_id: None,
name: None,
created_at: None,
}
}
fn turn(s: &mut Session, prompt: &str) {
s.add_user_message(prompt);
s.add_assistant_message(vec![ContentBlock::Text {
text: format!("re: {prompt}"),
}]);
}
fn labels(rows: &[TreeRow]) -> Vec<String> {
rows.iter().map(TreeRow::line).collect()
}
fn active_texts(s: &Session) -> Vec<String> {
s.messages
.iter()
.filter(|m| m.role == Role::User)
.filter_map(|m| m.content.as_text().map(str::to_string))
.collect()
}
fn row<'a>(rows: &'a [TreeRow], label: &str) -> &'a TreeRow {
rows.iter().find(|r| r.label == label).unwrap()
}
#[test]
fn a_linear_conversation_is_one_flat_line() {
let mut s = session();
assert!(s.tree_rows().is_empty());
turn(&mut s, "one");
turn(&mut s, "two");
turn(&mut s, "three");
let rows = s.tree_rows();
assert_eq!(labels(&rows), ["• one", "• two", "● three"]);
assert!(rows.iter().all(|r| r.on_active_path && r.depth == 0));
assert!(rows[2].active_leaf && !rows[1].active_leaf);
}
#[test]
fn mark_branch_names_the_current_point() {
let mut s = session();
assert_eq!(s.mark_branch("x"), Err(BranchError::Empty));
turn(&mut s, "one");
assert_eq!(s.mark_branch(" ").unwrap(), "branch 1");
turn(&mut s, "two");
assert_eq!(s.mark_branch(" idea ").unwrap(), "idea");
let rows = s.tree_rows();
assert_eq!(labels(&rows), ["• one [branch 1]", "● two [idea]"]);
assert_eq!(s.branches.len(), 2);
}
#[test]
fn switching_to_an_earlier_turn_keeps_the_old_path_as_a_sibling() {
let mut s = session();
turn(&mut s, "one");
turn(&mut s, "two");
turn(&mut s, "three");
s.mark_checkpoint("explore");
let target = row(&s.tree_rows(), "one").target;
assert_eq!(s.switch_branch(target), Ok(true));
assert_eq!(active_texts(&s), ["one"]);
assert_eq!(s.messages.len(), 2);
assert!(s.checkpoint.is_none());
assert_eq!(s.branches.len(), 1);
assert!(s.branches[0].name.is_none());
let rows = s.tree_rows();
assert_eq!(labels(&rows), ["● one", "○ two", "○ three"]);
turn(&mut s, "alt");
let rows = s.tree_rows();
assert_eq!(labels(&rows), ["• one", "↳ ● alt", "↳ ○ two", " ○ three"]);
assert!(row(&rows, "alt").fork_start && row(&rows, "two").fork_start);
assert!(!row(&rows, "three").fork_start);
assert_eq!(row(&rows, "three").depth, 1);
let target = row(&rows, "three").target;
assert_eq!(s.switch_branch(target), Ok(true));
assert_eq!(active_texts(&s), ["one", "two", "three"]);
assert_eq!(s.branches.len(), 1);
assert_eq!(s.branches[0].messages.len(), 4);
let rows = s.tree_rows();
assert_eq!(labels(&rows), ["• one", "↳ • two", " ● three", "↳ ○ alt"]);
}
#[test]
fn a_path_held_by_another_is_not_stored_twice() {
let mut s = session();
turn(&mut s, "one");
turn(&mut s, "two");
let one = row(&s.tree_rows(), "one").target;
s.switch_branch(one).unwrap();
assert_eq!(s.branches.len(), 1);
let two = row(&s.tree_rows(), "two").target;
s.switch_branch(two).unwrap();
assert!(s.branches.is_empty());
assert_eq!(active_texts(&s), ["one", "two"]);
}
#[test]
fn a_named_branch_stays_marked_after_switching_to_it() {
let mut s = session();
turn(&mut s, "one");
s.mark_branch("keep").unwrap();
turn(&mut s, "two");
let one = row(&s.tree_rows(), "one").target;
s.switch_branch(one).unwrap();
assert_eq!(s.branches.len(), 2);
let rows = s.tree_rows();
assert_eq!(labels(&rows), ["● one [keep]", "○ two"]);
let keep = TreeTarget { path: 1, end: 2 };
assert_eq!(s.switch_branch(keep), Ok(false));
assert_eq!(s.branches.len(), 2);
}
#[test]
fn stale_targets_are_refused() {
let mut s = session();
turn(&mut s, "one");
let before = s.messages.len();
assert_eq!(
s.switch_branch(TreeTarget { path: 3, end: 1 }),
Err(BranchError::Stale)
);
assert_eq!(
s.switch_branch(TreeTarget { path: 0, end: 9 }),
Err(BranchError::Stale)
);
assert_eq!(s.messages.len(), before);
assert_eq!(
BranchError::Stale.to_string(),
"the conversation changed; open /tree again"
);
assert_eq!(BranchError::Empty.to_string(), "nothing to branch from yet");
}
#[test]
fn pruned_tool_bodies_still_match_their_branch() {
let mut s = session();
s.add_user_message("run it");
s.add_assistant_message(vec![ContentBlock::ToolUse {
id: "c1".into(),
name: "bash".into(),
input: serde_json::json!({"cmd": "ls"}),
}]);
s.add_tool_results(vec![whycodes_core::types::ToolResult {
tool_call_id: "c1".into(),
content: "a long body".into(),
is_error: false,
}]);
turn(&mut s, "next");
s.mark_branch("snap").unwrap();
s.messages[2].content = MessageContent::text("[pruned]");
let rows = s.tree_rows();
assert_eq!(labels(&rows), ["• run it", "● next [snap]"]);
}
#[test]
fn a_compacted_path_forks_from_the_root() {
let mut s = session();
turn(&mut s, "one");
turn(&mut s, "two");
s.mark_branch("before").unwrap();
s.set_messages(vec![msg(
Role::User,
MessageContent::text("[Compacted earlier]\nsummary"),
)]);
let rows = s.tree_rows();
assert_eq!(
labels(&rows),
["↳ ● [Compacted earlier]", "↳ ○ one", " ○ two [before]"]
);
assert!(rows.iter().filter(|r| r.fork_start).all(|r| r.depth == 1));
}
#[test]
fn labels_use_the_first_text_line_and_are_capped() {
let mut s = session();
s.messages
.push(msg(Role::Assistant, MessageContent::text("hello")));
s.add_user_message("\n\n first line \nsecond");
s.add_user_message(&"x".repeat(100));
s.add_user_message_blocks(vec![ContentBlock::Image {
source: ImageSource::Base64 {
media_type: "image/png".into(),
data: "AA==".into(),
},
}]);
let rows = s.tree_rows();
assert_eq!(rows.len(), 3);
assert_eq!(rows[0].label, "first line");
assert_eq!(rows[1].label.chars().count(), LABEL_MAX_CHARS);
assert!(rows[1].label.ends_with('…'));
assert_eq!(rows[2].label, "(no text)");
let only_reply = vec![msg(Role::Assistant, MessageContent::text("hi"))];
assert_eq!(turn_label(&only_reply), "(no text)");
assert_eq!(turns(&only_reply), [(0, 1)]);
assert!(turns(&[]).is_empty());
}
#[test]
fn every_block_kind_feeds_the_fingerprint() {
let block_msg = |b: ContentBlock| msg(Role::Assistant, MessageContent::Blocks(vec![b]));
let blocks = [
ContentBlock::Text { text: "t".into() },
ContentBlock::Thinking {
text: "t".into(),
signature: None,
},
ContentBlock::ToolUse {
id: "i".into(),
name: "n".into(),
input: serde_json::Value::Null,
},
ContentBlock::ToolResult {
tool_use_id: "i".into(),
content: "body".into(),
is_error: None,
},
ContentBlock::RedactedThinking { data: "d".into() },
ContentBlock::Image {
source: ImageSource::Base64 {
media_type: "image/png".into(),
data: "AA==".into(),
},
},
ContentBlock::Compaction {
content: "summary".into(),
raw: serde_json::Value::Null,
},
];
let prints: Vec<u64> = blocks
.into_iter()
.map(|b| fingerprint(&block_msg(b)))
.collect();
let unique: std::collections::BTreeSet<_> = prints.iter().collect();
assert_eq!(unique.len(), prints.len());
let result = |body: &str| ContentBlock::ToolResult {
tool_use_id: "i".into(),
content: body.into(),
is_error: None,
};
assert_eq!(
fingerprint(&block_msg(result("a"))),
fingerprint(&block_msg(result("b")))
);
}
#[test]
fn branches_survive_a_save_and_load() {
let db = whycodes_storage::db::Database::open_in_memory().unwrap();
let mut s = session();
turn(&mut s, "one");
s.mark_branch("mark").unwrap();
turn(&mut s, "two");
let one = row(&s.tree_rows(), "one").target;
s.switch_branch(one).unwrap();
turn(&mut s, "alt");
s.save_to_db(&db).unwrap();
let loaded = Session::load_from_db(&db, &s.id).unwrap().unwrap();
assert_eq!(active_texts(&loaded), ["one", "alt"]);
assert_eq!(loaded.branches.len(), 2);
assert_eq!(loaded.branches[0].name.as_deref(), Some("mark"));
assert_eq!(labels(&loaded.tree_rows()), labels(&s.tree_rows()));
let mut fewer = loaded.clone();
fewer.branches.truncate(1);
fewer.save_to_db(&db).unwrap();
let again = Session::load_from_db(&db, &s.id).unwrap().unwrap();
assert_eq!(again.branches.len(), 1);
}
#[test]
fn a_session_saved_before_branches_loads_as_one_path() {
let db = whycodes_storage::db::Database::open_in_memory().unwrap();
db.create_session("old", "Old", "/tmp/tree").unwrap();
let user = serde_json::to_string(&msg(Role::User, MessageContent::text("hi"))).unwrap();
db.insert_message("m1", "old", "user", &user, None, None)
.unwrap();
let loaded = Session::load_from_db(&db, "old").unwrap().unwrap();
assert!(loaded.branches.is_empty());
assert_eq!(labels(&loaded.tree_rows()), ["● hi"]);
}
#[test]
fn a_corrupt_branch_row_fails_the_load() {
let db = whycodes_storage::db::Database::open_in_memory().unwrap();
let s = session();
s.save_to_db(&db).unwrap();
db.replace_transcript(&s.id, &[], &[(None, vec!["not json".into()])])
.unwrap();
assert!(Session::load_from_db(&db, &s.id).is_err());
}
#[test]
fn branches_round_trip_through_session_json() {
let mut s = session();
let bare = serde_json::to_value(&s).unwrap();
assert!(bare.get("branches").is_none());
let back: Session = serde_json::from_value(bare).unwrap();
assert!(back.branches.is_empty());
turn(&mut s, "one");
s.mark_branch("m").unwrap();
let back: Session = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
assert_eq!(back.branches.len(), 1);
assert_eq!(back.branches[0].name.as_deref(), Some("m"));
}
#[test]
fn fork_copies_the_branches() {
let mut s = session();
turn(&mut s, "one");
s.mark_branch("m").unwrap();
assert_eq!(s.fork().branches.len(), 1);
}
#[test]
fn compaction_and_undo_leave_stored_branches_alone() {
let mut s = session();
for i in 0..8 {
turn(&mut s, &format!("turn {i} {}", "w".repeat(400)));
}
s.mark_branch("all").unwrap();
let before = serde_json::to_string(&s.branches).unwrap();
s.compact(50);
s.undo_last_turn();
s.compact_full_replace_local();
assert_eq!(serde_json::to_string(&s.branches).unwrap(), before);
assert_eq!(s.branches[0].messages.len(), 16);
}