use super::*;
#[test]
fn cached_large_block_windows_preserve_copy_ranges_and_bottom_padding() {
let mut state = MissionControlState::default();
state.transcript.push_back(format!(
"assistant: {}",
(0..200)
.map(|index| format!("row {index} café 界\n"))
.collect::<String>()
));
let width = 24;
let full = visual_lines(&state, width);
let total_rows: usize = full.iter().map(|line| visual_line_rows(line, width)).sum();
let snapshot = |lines: &[TranscriptVisualLine]| {
lines
.iter()
.map(|line| {
(
line.line.clone(),
line.copy_text.clone(),
line.copy_byte_range,
line.copyable,
line.copy_continuation,
)
})
.collect::<Vec<_>>()
};
for start in [0, 50, total_rows - 5] {
let mut row = 0;
let expected = full
.iter()
.filter(|line| {
let next = row + visual_line_rows(line, width);
let visible = next > start && row < start + 5;
row = next;
visible
})
.cloned()
.collect::<Vec<_>>();
for _ in 0..2 {
let window =
super::super::visual_lines::visible_visual_lines_windowed(&state, width, start, 5);
assert_eq!(snapshot(&window.lines), snapshot(&expected));
let tail = visible_scrollback_visual_lines(&state, width, 5, total_rows - start - 5);
assert_eq!(
tail.lines
.iter()
.map(|line| line.line.clone())
.collect::<Vec<_>>(),
expected
.iter()
.map(|line| line.line.clone())
.collect::<Vec<_>>()
);
assert_eq!(tail.scroll_offset, window.scroll_offset);
assert_eq!(tail.scrollbar.position, start);
}
}
let cached = std::sync::Arc::clone(&state.transcript_cache.borrow().visual_blocks[&width][&0]);
let rows = cached.rows;
let _ = visible_scrollback_visual_lines(&state, width, 5, 0);
assert!(std::sync::Arc::ptr_eq(
&cached,
&state.transcript_cache.borrow().visual_blocks[&width][&0]
));
assert_eq!(cached.rows, rows);
assert_eq!(
rows + 1,
total_rows,
"bottom padding must stay outside the cache"
);
}
#[test]
fn grouped_reasoning_reuses_assembled_card_and_refreshes_after_growth() {
let mut state = MissionControlState::default();
let summary = |id: &str, text: &str| OutputEvent::ThinkingSummaryCompleteIdentified {
text: text.into(),
item_id: Some(id.into()),
turn_id: Some("turn".into()),
};
state.apply_output_event(&summary("first", "first"));
state.apply_output_event(&summary("second", "second"));
let first = super::super::projection::project_card_spans(&state)
.next()
.unwrap()
.card;
let second = super::super::projection::project_card_spans(&state)
.next()
.unwrap()
.card;
assert_eq!(first.body, "first\n\nsecond");
assert!(std::sync::Arc::ptr_eq(&first, &second));
state.apply_output_event(&summary("third", "third"));
let grown = super::super::projection::project_card_spans(&state)
.next()
.unwrap()
.card;
assert_eq!(grown.body, "first\n\nsecond\n\nthird");
assert!(!std::sync::Arc::ptr_eq(&first, &grown));
assert_eq!(first.body, "first\n\nsecond");
state.apply_output_event(&summary("second", "revised"));
let revised = super::super::projection::project_card_spans_reverse(&state)
.next()
.unwrap()
.card;
assert_eq!(revised.body, "first\n\nrevised\n\nthird");
assert!(!std::sync::Arc::ptr_eq(&grown, &revised));
}
#[test]
fn unchanged_projection_reuses_shared_card_and_thinking_invalidates_only_changed_entry() {
let mut state = MissionControlState::default();
state.apply_output_event(&OutputEvent::UserPrompt {
text: "prompt".into(),
});
reset_project_entry_count_for_test();
let first_user = cached_card_ptr_for_test(&state, 0);
let second_user = cached_card_ptr_for_test(&state, 0);
assert_eq!(first_user, second_user);
assert_eq!(project_entry_count_for_test(), 1);
state.apply_output_event(&OutputEvent::ThinkingSummaryDelta {
text: "first".into(),
});
let cards = project_cards(&state);
assert_eq!(cards[1].body, "first");
let cached_user = cached_card_ptr_for_test(&state, 0);
assert_eq!(first_user, cached_user);
reset_project_entry_count_for_test();
state.apply_output_event(&OutputEvent::ThinkingSummaryDelta {
text: " second".into(),
});
let cards = project_cards(&state);
assert_eq!(cards[1].body, "first second");
assert_eq!(project_entry_count_for_test(), 1);
assert_eq!(first_user, cached_card_ptr_for_test(&state, 0));
}
#[test]
fn topology_change_invalidates_old_ancestor_subagent_card_cache() {
let mut state = MissionControlState::default();
let ancestor = ActivityId::new("cache-batch");
let descendant = ancestor.child("g1");
state
.transcript
.push_back("tool: ⟳ subagents • running".to_string());
state.transcript.link_activity(
0,
TranscriptActivityLink {
activity_id: ancestor.clone(),
tool_name: "subagents".to_string(),
},
);
state.apply_activity_event(ActivityEvent::Started {
id: ancestor.clone(),
parent_id: None,
kind: ActivityKind::SubagentBatch,
status: ActivityStatus::Running,
metadata: ActivityMetadata::new("subagents"),
});
state.apply_activity_event(ActivityEvent::Started {
id: descendant.clone(),
parent_id: Some(ancestor.clone()),
kind: ActivityKind::SubagentTask,
status: ActivityStatus::Running,
metadata: ActivityMetadata::new("g1 old branch"),
});
assert_eq!(project_cards(&state)[0].children.len(), 1);
reset_project_entry_count_for_test();
let old_card = cached_card_ptr_for_test(&state, 0);
assert_eq!(project_entry_count_for_test(), 0);
assert_eq!(old_card, cached_card_ptr_for_test(&state, 0));
state.apply_activity_event(ActivityEvent::Started {
id: descendant.clone(),
parent_id: None,
kind: ActivityKind::SubagentTask,
status: ActivityStatus::Running,
metadata: ActivityMetadata::new("g1 new root"),
});
let cards = project_cards(&state);
assert!(cards[0].children.is_empty(), "old ancestor retained child");
assert_eq!(project_entry_count_for_test(), 1);
}
#[test]
#[ignore = "release-mode transcript projection measurement; run with --release --ignored --nocapture"]
fn transcript_projection_and_thinking_measurement() {
let mut state = MissionControlState::default();
for index in 0..500 {
state.transcript.push_back(format!(
"assistant: entry {index} {}",
"wrapped ".repeat(40)
));
}
let _ = visible_scrollback_visual_lines(&state, 80, 20, 200);
reset_project_entry_count_for_test();
let started = std::time::Instant::now();
for _ in 0..100 {
let _ = visible_scrollback_visual_lines(&state, 80, 20, 200);
}
eprintln!(
"transcript_cache unchanged_frames=100 elapsed_us={} projected_cards={} entry_hash_bytes=0 shared_cards=true backend=projection physical_terminal=false",
started.elapsed().as_micros(),
project_entry_count_for_test()
);
for (label, width, focus) in [
(
"narrow_prompt_focus",
40,
crate::tui::state::TuiFocusPane::Prompt,
),
(
"wide_transcript_focus",
120,
crate::tui::state::TuiFocusPane::Transcript,
),
] {
state.focus_pane = focus;
let started = std::time::Instant::now();
let visible = visible_scrollback_visual_lines(&state, width, 20, 200);
eprintln!(
"transcript_scenario name={} elapsed_us={} visible_lines={} width={} backend=projection physical_terminal=false",
label,
started.elapsed().as_micros(),
visible.lines.len(),
width
);
}
state.start_selection(
crate::tui::layout::TuiPane::Transcript,
crate::tui::selection::TextPosition::new(0, 0, 0),
);
state.update_selection(
crate::tui::layout::TuiPane::Transcript,
crate::tui::selection::TextPosition::new(20, 0, 20),
);
let started = std::time::Instant::now();
let selected = crate::tui::transcript_projection::visible_transcript_lines(&state, 80, 20);
eprintln!(
"transcript_scenario name=selection elapsed_us={} visible_lines={} width=80 backend=projection physical_terminal=false",
started.elapsed().as_micros(),
selected.lines.len()
);
let mut streaming = MissionControlState::default();
let started = std::time::Instant::now();
reset_project_entry_count_for_test();
for _ in 0..100 {
streaming.apply_output_event(&OutputEvent::ThinkingSummaryDelta { text: "x".into() });
let _ = visible_scrollback_visual_lines(&streaming, 80, 20, 0);
}
eprintln!(
"transcript_thinking deltas=100 elapsed_us={} projected_cards={} appended_bytes={} backend=projection physical_terminal=false",
started.elapsed().as_micros(),
project_entry_count_for_test(),
streaming.thinking_append_work_bytes
);
}
#[test]
fn unchanged_scrollback_reuses_visual_blocks_and_wrapped_rows() {
let mut state = MissionControlState::default();
state
.transcript
.extend((0..8).map(|index| format!("session: cached entry {index} wrapped text")));
state.invalidate_transcript_cache();
reset_visual_cache_counters_for_test();
let first = visible_scrollback_visual_lines(&state, 40, 12, 0);
assert!(visual_block_rebuild_count_for_test() > 0);
assert!(visual_row_measurement_count_for_test() > 0);
let first_text = first
.lines
.iter()
.map(|line| line_text(&line.line))
.collect::<Vec<_>>();
reset_visual_cache_counters_for_test();
let second = visible_scrollback_visual_lines(&state, 40, 12, 0);
assert_eq!(visual_block_rebuild_count_for_test(), 0);
assert_eq!(visual_row_measurement_count_for_test(), 0);
assert_eq!(
second
.lines
.iter()
.map(|line| line_text(&line.line))
.collect::<Vec<_>>(),
first_text
);
reset_visual_cache_counters_for_test();
let _ = visible_scrollback_visual_lines(&state, 41, 12, 0);
assert!(visual_block_rebuild_count_for_test() > 0);
assert!(visual_row_measurement_count_for_test() > 0);
state.focus_transcript();
reset_visual_cache_counters_for_test();
let _ = visible_scrollback_visual_lines(&state, 41, 12, 0);
assert!(visual_block_rebuild_count_for_test() > 0);
assert!(visual_row_measurement_count_for_test() > 0);
let themed = MissionControlTheme::from_runtime_appearance(
&crate::appearance::RuntimeAppearance::default(),
1,
);
assert!(state.set_theme(themed, false));
reset_visual_cache_counters_for_test();
let _ = visible_scrollback_visual_lines(&state, 41, 12, 0);
assert!(visual_block_rebuild_count_for_test() > 0);
assert!(visual_row_measurement_count_for_test() > 0);
state
.transcript
.replace(0, "session: changed content".to_string());
state.invalidate_transcript_cache();
reset_visual_cache_counters_for_test();
let changed = visible_scrollback_visual_lines(&state, 41, u16::MAX, 0);
assert!(visual_block_rebuild_count_for_test() > 0);
assert!(
changed
.lines
.iter()
.map(|line| line_text(&line.line))
.collect::<Vec<_>>()
.join("\n")
.contains("changed content")
);
}
#[test]
fn grouped_blocks_and_front_pruning_keep_cached_rows_and_lines_distinct() {
let mut grouped = MissionControlState::default();
grouped.apply_output_event(&OutputEvent::ThinkingSummaryComplete {
text: "first reasoning".to_string(),
});
reset_visual_cache_counters_for_test();
let _ = visible_scrollback_visual_lines(&grouped, 40, 20, 0);
reset_visual_cache_counters_for_test();
grouped.apply_output_event(&OutputEvent::ThinkingSummaryComplete {
text: "second reasoning".to_string(),
});
let expanded = visible_scrollback_visual_lines(&grouped, 40, 20, 0);
assert!(visual_block_rebuild_count_for_test() > 0);
let expanded_text = expanded
.lines
.iter()
.map(|line| line_text(&line.line))
.collect::<Vec<_>>()
.join("\n");
assert!(expanded_text.contains("first reasoning"));
assert!(expanded_text.contains("second reasoning"));
reset_visual_cache_counters_for_test();
let _ = visible_scrollback_visual_lines(&grouped, 40, 20, 0);
assert_eq!(visual_block_rebuild_count_for_test(), 0);
assert_eq!(visual_row_measurement_count_for_test(), 0);
let mut pruning = MissionControlState::default();
pruning
.transcript
.extend((0..500).map(|index| format!("session: entry {index}")));
pruning.invalidate_transcript_cache();
reset_visual_cache_counters_for_test();
let _ = visible_scrollback_visual_lines(&pruning, 40, u16::MAX, 0);
reset_visual_cache_counters_for_test();
pruning.apply_output_event(&OutputEvent::UserPrompt {
text: "after pruning".to_string(),
});
let after_pruning = visible_scrollback_visual_lines(&pruning, 40, u16::MAX, 0);
assert!(visual_block_rebuild_count_for_test() > 0);
assert!(visual_block_rebuild_count_for_test() <= 2);
assert!(
after_pruning
.lines
.iter()
.any(|line| line_text(&line.line).contains("after pruning"))
);
let uncached_after_pruning = {
pruning.invalidate_transcript_cache();
visible_scrollback_visual_lines(&pruning, 40, u16::MAX, 0)
};
assert_eq!(
after_pruning
.lines
.iter()
.map(|line| line_text(&line.line))
.collect::<Vec<_>>(),
uncached_after_pruning
.lines
.iter()
.map(|line| line_text(&line.line))
.collect::<Vec<_>>()
);
assert!(visual_block_width_count_for_test(&pruning) <= 4);
}
#[test]
fn transcript_width_cache_evicts_oldest_width_from_every_cache() {
let mut state = MissionControlState::default();
state.apply_output_event(&OutputEvent::UserPrompt {
text: "width cache prompt".into(),
});
state.apply_output_event(&OutputEvent::AssistantComplete {
text: "width cache response with enough text to wrap".into(),
});
let widths = [12, 24, 36, 48, 60];
let expected_oldest = state
.cached_transcript_lines(widths[0])
.into_iter()
.map(|line| line_text(&line))
.collect::<Vec<_>>();
for &width in &widths {
let _ = state.transcript_visual_rows(width);
let _ = visible_scrollback_visual_lines(&state, width, u16::MAX, 0);
assert_eq!(
visual_width_cache_presence_for_test(&state, width),
[true; 4],
"width {width} was not retained in every cache"
);
}
assert_eq!(
visual_width_cache_presence_for_test(&state, widths[0]),
[false; 4],
"oldest width was not evicted from every cache"
);
for &width in &widths[1..] {
assert_eq!(
visual_width_cache_presence_for_test(&state, width),
[true; 4],
"width {width} was evicted unexpectedly"
);
}
reset_visual_cache_counters_for_test();
let revisited = state
.cached_transcript_lines(widths[0])
.into_iter()
.map(|line| line_text(&line))
.collect::<Vec<_>>();
assert!(visual_block_rebuild_count_for_test() > 0);
assert_eq!(revisited, expected_oldest);
let _ = state.transcript_visual_rows(widths[0]);
let _ = visible_scrollback_visual_lines(&state, widths[0], u16::MAX, 0);
assert_eq!(
visual_width_cache_presence_for_test(&state, widths[0]),
[true; 4]
);
}
#[test]
fn history_index_seeks_cached_heights_without_revisiting_newer_cards() {
use super::super::projection::take_project_span_visits_for_test;
let mut state = MissionControlState::default();
for index in 0..120 {
state.transcript.push_back(format!(
"assistant: row {index} café 界\n{}",
"detail ".repeat(index % 7)
));
}
let width = 28;
let viewport_rows = 7;
take_project_span_visits_for_test();
let _ = visible_scrollback_visual_lines(&state, width, viewport_rows, 200);
assert!(take_project_span_visits_for_test() > 0);
for scrollback in [200, 180, 190, 0] {
let _ = visible_scrollback_visual_lines(&state, width, viewport_rows, scrollback);
assert_eq!(
take_project_span_visits_for_test(),
0,
"cached history seek revisited cards"
);
}
let full = visual_lines(&state, width);
let total: usize = full.iter().map(|line| visual_line_rows(line, width)).sum();
for scrollback in (0..total).step_by(11).chain([usize::MAX, 0, 200]) {
let start = total
.saturating_sub(usize::from(viewport_rows))
.saturating_sub(scrollback);
let expected = super::super::visual_lines::visible_visual_lines_windowed(
&state,
width,
start,
viewport_rows,
);
let actual = visible_scrollback_visual_lines(&state, width, viewport_rows, scrollback);
assert_eq!(actual.scroll_offset, expected.scroll_offset);
assert_eq!(
actual
.lines
.iter()
.map(|line| &line.line)
.collect::<Vec<_>>(),
expected
.lines
.iter()
.map(|line| &line.line)
.collect::<Vec<_>>(),
"scrollback {scrollback}"
);
}
}
fn assert_history_windows_match_forward_projection(state: &MissionControlState) {
let forward_state = MissionControlState {
transcript: state.transcript.clone(),
..MissionControlState::default()
};
for width in [18, 41] {
let full = visual_lines(&forward_state, width);
let total: usize = full.iter().map(|line| visual_line_rows(line, width)).sum();
for viewport_rows in [1, 7] {
for scrollback in [
0,
1,
7,
total / 2,
total.saturating_sub(7),
total.saturating_sub(1),
usize::MAX,
0,
] {
let start = total
.saturating_sub(usize::from(viewport_rows))
.saturating_sub(scrollback);
let actual =
visible_scrollback_visual_lines(state, width, viewport_rows, scrollback);
let mut row = 0;
let mut expected_offset = 0;
let mut expected = Vec::new();
for line in &full {
let next = row + visual_line_rows(line, width);
if next > start && row < start + usize::from(viewport_rows) {
if expected.is_empty() {
expected_offset = start.saturating_sub(row);
}
expected.push(line);
}
row = next;
}
let snapshot = |line: &TranscriptVisualLine| {
(
line.line.clone(),
line.copy_text.clone(),
line.copyable,
line.copy_continuation,
)
};
assert_eq!(
actual.lines.iter().map(snapshot).collect::<Vec<_>>(),
expected.into_iter().map(snapshot).collect::<Vec<_>>(),
"width {width}, viewport {viewport_rows}, scrollback {scrollback}"
);
assert_eq!(actual.scroll_offset, expected_offset);
if scrollback == usize::MAX {
assert_eq!(
actual.scrollbar.content_length,
total.saturating_sub(usize::from(viewport_rows)) + 1
);
assert_eq!(actual.scrollbar.position, 0);
}
}
}
}
}
#[test]
fn history_index_refreshes_group_boundaries_after_identified_update_and_append() {
let mut state = MissionControlState::default();
let summary = |id: &str, text: &str| OutputEvent::ThinkingSummaryCompleteIdentified {
text: text.into(),
item_id: Some(id.into()),
turn_id: Some("turn".into()),
};
state
.transcript
.push_back("user: inspect grouping".to_string());
state.apply_output_event(&summary("first", "first café 界"));
state.apply_output_event(&summary("second", "second"));
assert_history_windows_match_forward_projection(&state);
state.apply_output_event(&summary("second", &"revised café 界 ".repeat(12)));
assert_eq!(
project_cards(&state)[1].body,
format!("first café 界\n\n{}", "revised café 界 ".repeat(12))
);
assert_history_windows_match_forward_projection(&state);
state.apply_output_event(&summary("third", "third member\nwith another row"));
assert_history_windows_match_forward_projection(&state);
state
.transcript
.push_back("assistant: group boundary".to_string());
state.apply_output_event(&summary("fourth", "separate reasoning"));
assert_eq!(project_cards(&state).len(), 4);
assert_history_windows_match_forward_projection(&state);
}
#[test]
fn history_index_refreshes_after_direct_append_and_tail_removal() {
let mut state = MissionControlState::default();
state
.transcript
.push_back("user: initial question".to_string());
assert_history_windows_match_forward_projection(&state);
state
.transcript
.push_back("assistant: appended answer".to_string());
assert_history_windows_match_forward_projection(&state);
state.transcript.pop_back();
assert_history_windows_match_forward_projection(&state);
}
#[test]
fn history_index_refreshes_when_front_pruning_splits_a_reasoning_group() {
let mut state = MissionControlState::default();
for (id, text) in [
("first", "pruned member"),
("second", "retained café 界"),
("third", "retained third"),
] {
state.apply_output_event(&OutputEvent::ThinkingSummaryCompleteIdentified {
text: text.into(),
item_id: Some(id.into()),
turn_id: Some("turn".into()),
});
}
state
.transcript
.extend((0..497).map(|index| format!("session: row {index}")));
assert_eq!(state.transcript.len(), 500);
assert_history_windows_match_forward_projection(&state);
state
.transcript
.push_back("assistant: appended after pruning".to_string());
assert_eq!(state.transcript.len(), 500);
assert!(
state
.transcript
.front()
.unwrap()
.contains("retained café 界")
);
assert_history_windows_match_forward_projection(&state);
assert_eq!(
project_cards(&state)[0].body,
"retained café 界\n\nretained third"
);
}
#[test]
fn selection_pointer_only_projects_blocks_before_the_pointer() {
let mut state = MissionControlState::default();
for index in 0..100 {
state
.transcript
.push_back(format!("assistant: row {index} café 界"));
}
reset_project_entry_count_for_test();
let position = text_position_for_visual_cell(&state, 0, 0, 24).unwrap();
assert_eq!(position.byte, 0);
assert_eq!(project_entry_count_for_test(), 1);
assert!(state.transcript_cache.borrow().visual_lines.is_empty());
let full = visual_lines(&state, 24);
let mut row = 0;
for line in full {
if let Some((start, end)) = line.copy_byte_range {
let left = text_position_for_visual_cell(&state, row, 0, 24).unwrap();
let right = text_position_for_visual_cell(&state, row, 24, 24).unwrap();
assert_eq!(left.byte, start);
assert_eq!(right.byte, end);
}
row += visual_line_rows(&line, 24);
}
}