use ansi_to_tui::IntoText;
use ratatui::{
Frame,
buffer::Buffer,
layout::Rect,
style::Style,
widgets::{Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget},
};
use repon_core::{
ActionReceipt, CaptureElision, DefaultBranch, DefaultBranchStopped, Diagnostics, DirtyCounts,
EntityState, Head, InProgressOperation, Kind, OwnWork, RunningStep, Settled, StepOutcome,
StepResult, SyncState, Timestamp, Unknown,
};
use super::list::{
base_meaning, dirty_meaning, name_cell_meaning, spinner_frame, state_meaning,
worktree_state_word, write_cell_runs,
};
use crate::{
elapsed::format_seconds_elapsed,
glyphs::{BorderScratch, FULL_SPINNER_INTERVAL, GlyphSet},
keys::Action,
scroll::scroll_after,
theme::{Meaning, Role, Theme},
};
const BORDER_WIDTH: u16 = 2;
#[derive(Default)]
pub struct Detail {
scroll: u16,
}
impl Detail {
pub fn apply(&mut self, action: Action, content_len: usize, viewport_height: u16) {
self.scroll = scroll_after(self.scroll, action, content_len, viewport_height);
}
pub fn content_len(entity: &EntityState, area_width: u16, glyphs: &'static GlyphSet) -> usize {
content_lines(entity, interior_width(area_width), glyphs).len()
}
pub fn draw(
&self,
frame: &mut Frame,
area: Rect,
entity: &EntityState,
glyphs: &'static GlyphSet,
focused: bool,
theme: &Theme,
) {
let role = if focused {
Role::BorderFocused
} else {
Role::Border
};
let mut scratch = BorderScratch::new();
let block = glyphs
.bordered_block(&mut scratch)
.border_style(theme.style_for(role))
.title(format!(" {} ", entity.name))
.title_bottom(ratatui::text::Line::from(crate::warnings::CLOSE_HINT).right_aligned());
let interior = block.inner(area);
frame.render_widget(block, area);
let lines = styled_content_lines(entity, interior.width, glyphs);
let buf = frame.buffer_mut();
draw_lines(buf, interior, &lines, self.scroll, theme);
draw_scrollbar(
buf,
area,
interior,
lines.len(),
self.scroll,
glyphs,
theme.style_for(role),
);
}
}
fn interior_width(area_width: u16) -> u16 {
area_width.saturating_sub(BORDER_WIDTH)
}
type Span = (String, Role);
type StyledLine = Vec<Span>;
#[derive(Debug)]
enum ContentLine {
Styled(StyledLine),
Raw(Vec<(String, Style)>),
}
impl ContentLine {
#[cfg(test)]
fn spans(&self) -> &StyledLine {
match self {
ContentLine::Styled(spans) => spans,
ContentLine::Raw(_) => panic!("expected a Styled content line, got {self:?}"),
}
}
#[cfg(test)]
fn first_text(&self) -> Option<&str> {
match self {
ContentLine::Styled(spans) => spans.first().map(|(text, _)| text.as_str()),
ContentLine::Raw(runs) => runs.first().map(|(text, _)| text.as_str()),
}
}
}
fn plain(text: String) -> StyledLine {
vec![(text, Role::Text)]
}
fn labelled(label: &str, value: StyledLine) -> StyledLine {
let mut line = vec![(label.to_string(), Role::Dim)];
line.extend(value);
line
}
fn draw_lines(buf: &mut Buffer, area: Rect, lines: &[ContentLine], scroll: u16, theme: &Theme) {
for (row, line) in lines
.iter()
.skip(scroll as usize)
.take(area.height as usize)
.enumerate()
{
let runs: Vec<(String, Style)> = match line {
ContentLine::Styled(spans) => spans
.iter()
.map(|(text, role)| (text.clone(), theme.style_for(*role)))
.collect(),
ContentLine::Raw(runs) => runs.clone(),
};
write_cell_runs(buf, area, area.x, area.y + row as u16, area.width, &runs);
}
}
fn draw_scrollbar(
buf: &mut Buffer,
area: Rect,
interior: Rect,
content_len: usize,
scroll: u16,
glyphs: &'static GlyphSet,
style: Style,
) {
let viewport = interior.height as usize;
if viewport == 0 || area.width < BORDER_WIDTH || content_len <= viewport {
return;
}
let mut track = [0u8; 4];
let mut thumb = [0u8; 4];
let bar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.track_symbol(Some(glyphs.scrollbar_track.encode_utf8(&mut track)))
.thumb_symbol(glyphs.scrollbar_thumb.encode_utf8(&mut thumb))
.begin_symbol(None)
.end_symbol(None)
.track_style(style)
.thumb_style(style);
let mut state = ScrollbarState::new(content_len - viewport + 1).position(scroll as usize);
bar.render(
Rect::new(area.right() - 1, interior.y, 1, interior.height),
buf,
&mut state,
);
}
fn styled_content_lines(
entity: &EntityState,
interior_width: u16,
glyphs: &'static GlyphSet,
) -> Vec<ContentLine> {
let EntityState {
key,
name,
common_dir: _,
kind,
branch,
sync,
base,
dirty,
state,
default_branch,
diagnostics,
last_action,
presence: _,
excluded: _,
in_progress_operation,
recent_commits,
} = entity;
let freshness = freshness_row(&[
cell_freshness("branch", branch.settled(), branch.is_in_flight()),
cell_freshness("sync", sync.settled(), sync.is_in_flight()),
cell_freshness("base", base.settled(), base.is_in_flight()),
cell_freshness("dirty", dirty.settled(), dirty.is_in_flight()),
cell_freshness("state", state.settled(), state.is_in_flight()),
cell_freshness(
"default branch",
default_branch.settled(),
default_branch.is_in_flight(),
),
]);
let mut lines: Vec<ContentLine> = Vec::new();
lines.push(ContentLine::Styled(vec![
(name.to_string(), name_cell_meaning(*kind).role()),
(format!(" {}", kind_word(*kind)), Role::Dim),
]));
lines.push(ContentLine::Styled(plain(key.path().display().to_string())));
lines.push(ContentLine::Styled(plain(String::new())));
lines.push(ContentLine::Styled(labelled(
"branch ",
describe_cell_spans(branch.settled(), head_word, |_| Meaning::FreshValue),
)));
lines.push(ContentLine::Styled(labelled(
"sync ",
describe_cell_spans(sync.settled(), sync_word, sync_meaning),
)));
lines.push(ContentLine::Styled(labelled(
"base ",
describe_cell_spans(base.settled(), base_word, base_meaning),
)));
lines.push(ContentLine::Styled(labelled(
"dirty ",
describe_cell_spans(dirty.settled(), dirty_word, dirty_meaning),
)));
lines.push(ContentLine::Styled(labelled(
"state ",
describe_cell_spans(
state.settled(),
|value| worktree_state_word(value).to_string(),
state_meaning,
),
)));
lines.push(ContentLine::Styled(labelled(
"default branch ",
describe_cell_spans(default_branch.settled(), default_branch_word, |_| {
Meaning::FreshValue
}),
)));
for diagnostic_line in default_branch_diagnostics_lines(diagnostics) {
lines.push(ContentLine::Styled(plain(format!(
" {diagnostic_line}"
))));
}
if let Some(freshness) = freshness {
let (label, values, role) = match freshness {
FreshnessRow::Loading(value) => {
("loading", vec![value], Meaning::LoadingSpinner.role())
}
FreshnessRow::Refreshed(values) => ("refreshed", values, Meaning::Age.role()),
};
for (index, value) in values.into_iter().enumerate() {
let label_text = if index == 0 {
format!("{label:<16}")
} else {
" ".repeat(16)
};
lines.push(ContentLine::Styled(labelled(
&label_text,
vec![(value, role)],
)));
}
}
if let Some(reason) = row_level_failure(diagnostics, last_action) {
lines.push(ContentLine::Styled(plain(String::new())));
lines.push(ContentLine::Styled(vec![(
reason,
Meaning::FailedProvenance.role(),
)]));
}
if let Some(operation) = in_progress_operation {
lines.push(ContentLine::Styled(plain(String::new())));
lines.push(ContentLine::Styled(plain(format!(
"in progress: {}",
in_progress_word(*operation)
))));
}
lines.push(ContentLine::Styled(plain(String::new())));
lines.push(ContentLine::Styled(vec![(
"recent".to_string(),
Meaning::ColumnHeader.role(),
)]));
if recent_commits.is_empty() {
lines.push(ContentLine::Styled(plain(
" no commits read yet".to_string(),
)));
} else {
for commit in recent_commits {
lines.push(ContentLine::Styled(plain(format!(
" {} {}",
commit.short_id, commit.summary
))));
}
}
lines.push(ContentLine::Styled(plain(String::new())));
lines.push(ContentLine::Styled(labelled(
"last action ",
last_action_spans(last_action),
)));
if let Some(receipt) = last_action {
lines.extend(action_run_lines(receipt, interior_width, glyphs));
}
lines
}
fn content_lines(
entity: &EntityState,
interior_width: u16,
glyphs: &'static GlyphSet,
) -> Vec<String> {
styled_content_lines(entity, interior_width, glyphs)
.into_iter()
.map(|line| match line {
ContentLine::Styled(spans) => spans.into_iter().map(|(text, _)| text).collect(),
ContentLine::Raw(runs) => runs.into_iter().map(|(text, _)| text).collect(),
})
.collect()
}
fn last_action_spans(last_action: &Option<ActionReceipt>) -> StyledLine {
match last_action {
Some(receipt) if receipt.failed() => {
vec![("failed".to_string(), Meaning::FailedActionStep.role())]
}
Some(receipt) if receipt.refused() => vec![(
"refused".to_string(),
Meaning::ActionStepNotRunOrCancelled.role(),
)],
Some(_) => vec![("ok".to_string(), Meaning::SucceededActionStep.role())],
None => vec![(
"none yet".to_string(),
Meaning::ActionStepNotRunOrCancelled.role(),
)],
}
}
fn action_run_lines(
receipt: &ActionReceipt,
interior_width: u16,
glyphs: &'static GlyphSet,
) -> Vec<ContentLine> {
let mut lines = Vec::new();
for (index, step) in receipt.steps.iter().enumerate() {
lines.push(finished_step_line(index, step));
lines.extend(captured_output_lines(
&step.output,
step.elision,
interior_width,
glyphs,
));
}
if let Some(running) = &receipt.running {
lines.push(running_step_line(receipt.steps.len(), running, glyphs));
}
lines
}
fn step_outcome_word(outcome: &StepOutcome) -> String {
match outcome {
StepOutcome::Ok => "ok".to_string(),
StepOutcome::Failed(code) => format!("failed exit {code}"),
StepOutcome::NotRun => "not run".to_string(),
StepOutcome::Cancelled => "cancelled".to_string(),
StepOutcome::OwnWork(work) => work.said().to_string(),
}
}
fn step_outcome_meaning(outcome: &StepOutcome) -> Meaning {
match outcome {
StepOutcome::Ok | StepOutcome::OwnWork(OwnWork::Did(_)) => Meaning::SucceededActionStep,
StepOutcome::Failed(_) | StepOutcome::OwnWork(OwnWork::CouldNotAct(_)) => {
Meaning::FailedActionStep
}
StepOutcome::NotRun
| StepOutcome::Cancelled
| StepOutcome::OwnWork(OwnWork::Refused(_)) => Meaning::ActionStepNotRunOrCancelled,
}
}
fn finished_step_line(index: usize, step: &StepResult) -> ContentLine {
match &step.outcome {
StepOutcome::Ok | StepOutcome::Failed(_) | StepOutcome::NotRun | StepOutcome::Cancelled => {
child_step_line(index, step)
}
StepOutcome::OwnWork(_) => own_work_line(step),
}
}
fn shell_tag(shell: bool, interactive: bool) -> &'static str {
match (shell, interactive) {
(true, true) => "[shell -ic] ",
(true, false) => "[shell] ",
(false, _) => "",
}
}
fn child_step_line(index: usize, step: &StepResult) -> ContentLine {
ContentLine::Styled(vec![
(format!(" step {} ", index + 1), Role::Dim),
(
step_outcome_word(&step.outcome),
step_outcome_meaning(&step.outcome).role(),
),
(
format!(
" {}{} {}",
shell_tag(step.shell, step.interactive),
step.label,
format_seconds_elapsed(step.elapsed)
),
Role::Dim,
),
])
}
fn own_work_line(step: &StepResult) -> ContentLine {
ContentLine::Styled(vec![
(format!(" {} ", step.label), Role::Dim),
(
step_outcome_word(&step.outcome),
step_outcome_meaning(&step.outcome).role(),
),
(
format!(" {}", format_seconds_elapsed(step.elapsed)),
Role::Dim,
),
])
}
fn running_step_line(
index: usize,
running: &RunningStep,
glyphs: &'static GlyphSet,
) -> ContentLine {
let elapsed = running.started_at.elapsed();
let frame = spinner_frame(glyphs.loading, FULL_SPINNER_INTERVAL, elapsed);
ContentLine::Styled(vec![
(
format!("{frame} step {} ", index + 1),
Meaning::LoadingSpinner.role(),
),
("running".to_string(), Meaning::LoadingSpinner.role()),
(
format!(
" {}{} {}",
shell_tag(running.shell, running.interactive),
running.label,
format_seconds_elapsed(elapsed)
),
Role::Dim,
),
])
}
const CAPTURED_OUTPUT_INDENT: &str = " ";
fn elision_row(elision: CaptureElision, glyphs: &'static GlyphSet) -> String {
let CaptureElision {
dropped_lines,
kept_head_lines: _,
} = elision;
let mark = glyphs.capture_elision;
format!("{mark} {dropped_lines} lines elided {mark}")
}
fn captured_output_lines(
output: &[u8],
elision: Option<CaptureElision>,
interior_width: u16,
glyphs: &'static GlyphSet,
) -> Vec<ContentLine> {
if output.is_empty() {
return Vec::new();
}
let wrap_width = (interior_width as usize).saturating_sub(CAPTURED_OUTPUT_INDENT.len());
let mut parsed = parse_output_lines(output);
if let Some(elision) = elision {
let CaptureElision {
dropped_lines: _,
kept_head_lines,
} = elision;
let at = kept_head_lines.min(parsed.len());
parsed.insert(at, ratatui::text::Line::raw(elision_row(elision, glyphs)));
}
let mut lines = Vec::new();
for line in parsed {
let expanded = expand_tabs(&line);
for row in wrap_output_line(&expanded, wrap_width) {
let mut runs = vec![(CAPTURED_OUTPUT_INDENT.to_string(), Style::default())];
runs.extend(row);
lines.push(ContentLine::Raw(runs));
}
}
lines
}
const TAB_STOP: usize = 8;
fn expand_tabs(line: &ratatui::text::Line<'static>) -> ratatui::text::Line<'static> {
use unicode_segmentation::UnicodeSegmentation;
let mut runs: Vec<(String, Style)> = Vec::new();
let mut column = 0usize;
for span in &line.spans {
let style = Style::default().patch(line.style).patch(span.style);
for grapheme in span.content.as_ref().graphemes(true) {
if grapheme == "\t" {
let width = TAB_STOP - (column % TAB_STOP);
column += width;
push_run(&mut runs, &" ".repeat(width), style);
} else {
column += ratatui::text::Span::raw(grapheme).width();
push_run(&mut runs, grapheme, style);
}
}
}
ratatui::text::Line::from(
runs.into_iter()
.map(|(text, style)| ratatui::text::Span::styled(text, style))
.collect::<Vec<_>>(),
)
}
fn push_run(runs: &mut Vec<(String, Style)>, text: &str, style: Style) {
match runs.last_mut() {
Some((existing_text, existing_style)) if *existing_style == style => {
existing_text.push_str(text)
}
_ => runs.push((text.to_string(), style)),
}
}
fn parse_output_lines(output: &[u8]) -> Vec<ratatui::text::Line<'static>> {
match output.into_text() {
Ok(text) => text.lines,
Err(_) => String::from_utf8_lossy(output)
.lines()
.map(|line| ratatui::text::Line::raw(line.to_string()))
.collect(),
}
}
fn wrap_output_line(
line: &ratatui::text::Line<'static>,
width: usize,
) -> Vec<Vec<(String, Style)>> {
let mut rows: Vec<Vec<(String, Style)>> = vec![Vec::new()];
let mut row_width = 0usize;
for grapheme in line.styled_graphemes(Style::default()) {
let symbol_width = ratatui::text::Span::raw(grapheme.symbol).width();
if row_width > 0 && row_width + symbol_width > width {
rows.push(Vec::new());
row_width = 0;
}
let style = strip_colour_if_disabled(grapheme.style);
let row = rows.last_mut().expect("rows always holds at least one row");
push_run(row, grapheme.symbol, style);
row_width += symbol_width;
}
rows
}
fn strip_colour_if_disabled(style: Style) -> Style {
if crossterm::style::Colored::ansi_color_disabled_memoized() {
Style {
fg: None,
bg: None,
underline_color: None,
..style
}
} else {
style
}
}
fn kind_word(kind: Kind) -> &'static str {
match kind {
Kind::Repo => "repo",
Kind::Worktree => "worktree",
Kind::Submodule => "submodule",
}
}
fn sync_meaning(value: &SyncState) -> Meaning {
match value {
SyncState::Tracking(counts) if counts.ahead > 0 => Meaning::AheadCount,
SyncState::Tracking(counts) if counts.behind > 0 => Meaning::BehindCount,
SyncState::Tracking(_) => Meaning::KnownZero,
SyncState::NoUpstream | SyncState::NoRemote => Meaning::FreshValue,
}
}
#[allow(dead_code)] fn describe_cell<T>(
settled: Option<&Settled<T>>,
format_value: impl FnOnce(&T) -> String,
) -> String {
describe_cell_spans(settled, format_value, |_| Meaning::FreshValue)
.into_iter()
.map(|(text, _)| text)
.collect()
}
fn describe_cell_spans<T>(
settled: Option<&Settled<T>>,
format_value: impl FnOnce(&T) -> String,
meaning_for_value: impl FnOnce(&T) -> Meaning,
) -> StyledLine {
match settled {
Some(Settled::Known {
value,
at: _,
stale: _,
}) => {
vec![(format_value(value), meaning_for_value(value).role())]
}
Some(Settled::Unknown(reason)) => vec![(
format!("unknown: {}", describe_unknown(*reason)),
Meaning::StaleOrUnknownGutterMark.role(),
)],
Some(Settled::Failed(error)) => {
vec![(error.to_string(), Meaning::FailedProvenance.role())]
}
Some(Settled::NotApplicable) => vec![("not applicable".to_string(), Role::Text)],
None => vec![("loading".to_string(), Meaning::LoadingSpinner.role())],
}
}
#[allow(dead_code)] fn age_annotation(at: Timestamp, stale: bool) -> String {
let word = if stale { "stale" } else { "refreshed" };
format!("{word} {}", format_age(at))
}
fn freshness_text(at: Timestamp, stale: bool) -> String {
if stale {
format!("stale {}", format_age(at))
} else {
format_age(at)
}
}
struct CellFreshness {
label: &'static str,
age: Option<String>,
in_flight: bool,
}
fn cell_freshness<T>(
label: &'static str,
settled: Option<&Settled<T>>,
in_flight: bool,
) -> CellFreshness {
CellFreshness {
label,
age: match settled {
Some(Settled::Known {
value: _,
at,
stale,
}) => Some(freshness_text(*at, *stale)),
_ => None,
},
in_flight,
}
}
enum FreshnessRow {
Loading(String),
Refreshed(Vec<String>),
}
fn freshness_row(cells: &[CellFreshness; 6]) -> Option<FreshnessRow> {
let in_flight_labels: Vec<&str> = cells
.iter()
.filter(|cell| cell.in_flight)
.map(|cell| cell.label)
.collect();
if !in_flight_labels.is_empty() {
return Some(FreshnessRow::Loading(in_flight_labels.join(", ")));
}
let known: Vec<&CellFreshness> = cells.iter().filter(|cell| cell.age.is_some()).collect();
let mut tally: Vec<(&str, usize)> = Vec::new();
for cell in &known {
let age = cell.age.as_deref().expect("filtered to Known above");
match tally.iter_mut().find(|(text, _)| *text == age) {
Some(entry) => entry.1 += 1,
None => tally.push((age, 1)),
}
}
let majority_count = tally.iter().map(|(_, count)| *count).max()?;
if majority_count == known.len() {
return Some(FreshnessRow::Refreshed(vec![
known[0].age.clone().expect("filtered to Known above"),
]));
}
let majority_age = (majority_count * 2 > known.len()).then(|| {
tally
.iter()
.find(|(_, count)| *count == majority_count)
.expect("the tallied max came from this same tally")
.0
});
let breakdown = known
.iter()
.filter(|cell| majority_age.is_none() || cell.age.as_deref() != majority_age)
.map(|cell| {
format!(
"{}, {}",
cell.label,
cell.age.as_deref().expect("filtered to Known above")
)
})
.collect();
Some(FreshnessRow::Refreshed(breakdown))
}
fn describe_unknown(reason: Unknown) -> &'static str {
match reason {
Unknown::TimedOut => "timed out",
Unknown::NoDefaultBranch => "no default branch found",
Unknown::SubmoduleUninitialized => "not yet initialised",
}
}
fn format_age(at: Timestamp) -> String {
let elapsed = at.elapsed();
let secs = elapsed.as_secs();
if secs < 10 {
"just now".to_string()
} else if secs < 60 {
format!("{}s ago", secs / 10 * 10)
} else if secs < 3_600 {
format!("{}m ago", secs / 60)
} else if secs < 86_400 {
format!("{}h ago", secs / 3_600)
} else {
format!("{}d ago", secs / 86_400)
}
}
fn head_word(value: &Head) -> String {
match value {
Head::Branch { name, .. } => name.to_string(),
Head::Unborn(name) => format!("{name} (no commits yet)"),
Head::Detached(oid) => format!("detached at {oid}"),
}
}
fn sync_word(value: &SyncState) -> String {
match value {
SyncState::Tracking(counts) => format!("{} ahead, {} behind", counts.ahead, counts.behind),
SyncState::NoUpstream => "no upstream configured".to_string(),
SyncState::NoRemote => "no remote configured".to_string(),
}
}
fn base_word(value: &u32) -> String {
if *value == 0 {
"level with the default branch".to_string()
} else {
format!("{value} behind the default branch")
}
}
fn dirty_word(value: &DirtyCounts) -> String {
let total = value.total();
if total == 0 {
"clean".to_string()
} else {
format!("{total} changed")
}
}
fn default_branch_word(value: &DefaultBranch) -> String {
value.name().to_string()
}
fn in_progress_word(operation: InProgressOperation) -> &'static str {
match operation {
InProgressOperation::ApplyMailbox => "applying a mailbox",
InProgressOperation::ApplyMailboxRebase => "rebasing while applying a mailbox",
InProgressOperation::Bisect => "bisecting",
InProgressOperation::CherryPick => "cherry-picking",
InProgressOperation::CherryPickSequence => "cherry-picking a sequence",
InProgressOperation::Merge => "merging",
InProgressOperation::Rebase => "rebasing",
InProgressOperation::RebaseInteractive => "rebasing interactively",
InProgressOperation::Revert => "reverting",
InProgressOperation::RevertSequence => "reverting a sequence",
}
}
fn default_branch_diagnostics_lines(diagnostics: &Diagnostics) -> Vec<String> {
let mut lines = Vec::new();
if diagnostics.default_branch_rung == Some(3) {
lines.push("resolved by the name list, not origin/HEAD".to_string());
}
if diagnostics.default_branch_rung_disagreement {
lines.push(
"origin/HEAD and the name list disagree; origin/HEAD's answer is used".to_string(),
);
}
if diagnostics.default_branch_rung_two_stale {
lines.push("origin/HEAD named a target that no longer resolves".to_string());
}
if let Some(stopped) = diagnostics.default_branch_stopped {
lines.push(format!("no default branch: {}", stopped_word(stopped)));
}
lines
}
fn stopped_word(stopped: DefaultBranchStopped) -> &'static str {
match stopped {
DefaultBranchStopped::NoRemote => "no remote is configured",
DefaultBranchStopped::AmbiguousRemote => "two or more remotes and none named origin",
DefaultBranchStopped::NameListExhausted => "origin/HEAD and the name list found no match",
}
}
fn row_level_failure(
diagnostics: &Diagnostics,
last_action: &Option<ActionReceipt>,
) -> Option<String> {
if let Some(reason) = &diagnostics.gitmodules_failed {
return Some(format!("failed to read .gitmodules: {reason}"));
}
if last_action.as_ref().is_some_and(ActionReceipt::failed) {
return Some("the last Action failed".to_string());
}
None
}
#[cfg(test)]
mod tests {
use std::{path::Path, process::Command, sync::Arc, time::Duration};
use repon_core::{
CaptureElision, Cell, Core, CoreSpec, EntityKey, ProbeError, RecentCommit, SetSpec,
StepOutcome, StepResult, WorktreeState, liveness::wait_for,
};
use super::*;
use crate::theme;
fn entity(name: &str) -> EntityState {
EntityState::new(
EntityKey::new(Arc::from(Path::new(name))),
Arc::from(name),
Arc::from(Path::new(name)),
Kind::Worktree,
)
}
const WIDE: u16 = 104;
fn full_glyphs() -> &'static GlyphSet {
GlyphSet::for_config(crate::config::document::Glyphs::default())
}
fn receipt(outcome: StepOutcome) -> ActionReceipt {
ActionReceipt {
label: Arc::from("action"),
steps: Arc::from(vec![StepResult {
label: Arc::from("step"),
outcome,
output: Arc::from(&b""[..]),
elapsed: Duration::from_millis(1),
elision: None,
shell: false,
interactive: false,
}]),
skip: None,
finished_at: Timestamp::now(),
running: None,
}
}
fn step_result(
label: &str,
outcome: StepOutcome,
output: &[u8],
elapsed: Duration,
) -> StepResult {
StepResult {
label: Arc::from(label),
outcome,
output: Arc::from(output),
elapsed,
elision: None,
shell: false,
interactive: false,
}
}
fn action_receipt(
label: &str,
steps: Vec<StepResult>,
running: Option<RunningStep>,
) -> ActionReceipt {
ActionReceipt {
label: Arc::from(label),
steps: Arc::from(steps),
skip: None,
finished_at: Timestamp::now(),
running,
}
}
static COLOUR_CAPABILITY_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn no_source_file_writes_an_action_receipt_to_disk() {
let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let core_src = manifest_dir.join("../repon-core/src");
let repon_src = manifest_dir.join("src");
let receipt_markers = ["ActionReceipt", "StepResult", "last_action"];
let disk_write_markers = [
"fs::write",
"File::create",
"toml::to_string",
"OpenOptions::new",
"serde_json::to",
];
let mut offending = Vec::new();
for path in crate::test_support::rust_source_files(&core_src)
.into_iter()
.chain(crate::test_support::rust_source_files(&repon_src))
{
let production = crate::test_support::production_source_at(&path);
let mentions_receipt = receipt_markers
.iter()
.any(|marker| production.contains(marker));
let writes_to_disk = disk_write_markers
.iter()
.any(|marker| production.contains(marker));
if mentions_receipt && writes_to_disk {
offending.push(path);
}
}
assert!(
offending.is_empty(),
"found a file whose production source both mentions an Action receipt and \
writes to disk: {offending:?}"
);
}
#[test]
fn a_known_refreshed_value_reads_its_word_and_age_in_words() {
let text = age_annotation(Timestamp::now(), false);
assert!(text.starts_with("refreshed"), "got {text:?}");
assert!(
text.contains("ago") || text.contains("just now"),
"got {text:?}"
);
}
#[test]
fn a_known_stale_value_reads_stale_rather_than_refreshed() {
let text = age_annotation(Timestamp::now(), true);
assert!(text.starts_with("stale"), "got {text:?}");
assert!(!text.contains("refreshed"), "got {text:?}");
}
#[test]
fn age_is_computed_from_the_settled_timestamp_not_a_fixed_epoch() {
let recent = Timestamp::at(std::time::SystemTime::now() - Duration::from_secs(45));
let old = Timestamp::at(std::time::SystemTime::now() - Duration::from_secs(7_200));
assert!(
age_annotation(recent, false).ends_with("40s ago"),
"got {:?}",
age_annotation(recent, false)
);
assert!(
age_annotation(old, false).ends_with("2h ago"),
"got {:?}",
age_annotation(old, false)
);
}
#[test]
fn a_settled_timestamp_in_the_future_reads_as_just_now_with_no_clamp_defence() {
let backward_clock_jump =
Timestamp::at(std::time::SystemTime::now() + Duration::from_secs(3_600));
assert!(
age_annotation(backward_clock_jump, false).ends_with("just now"),
"got {:?}",
age_annotation(backward_clock_jump, false)
);
}
#[test]
fn elapsed_under_ten_seconds_reads_just_now() {
for secs in [0, 1, 5, 9] {
let at = Timestamp::at(std::time::SystemTime::now() - Duration::from_secs(secs));
assert_eq!(format_age(at), "just now", "elapsed {secs}s");
}
}
#[test]
fn between_ten_seconds_and_a_minute_rounds_down_to_the_nearest_ten() {
let cases = [
(19, "10s ago"),
(59, "50s ago"),
(10, "10s ago"),
(20, "20s ago"),
];
for (secs, expected) in cases {
let at = Timestamp::at(std::time::SystemTime::now() - Duration::from_secs(secs));
assert_eq!(format_age(at), expected, "elapsed {secs}s");
}
}
#[test]
fn a_minute_or_more_keeps_the_existing_minute_hour_and_day_rungs() {
let cases = [
(60, "1m ago"),
(3_599, "59m ago"),
(3_600, "1h ago"),
(86_399, "23h ago"),
(86_400, "1d ago"),
];
for (secs, expected) in cases {
let at = Timestamp::at(std::time::SystemTime::now() - Duration::from_secs(secs));
assert_eq!(format_age(at), expected, "elapsed {secs}s");
}
}
#[test]
fn two_elapsed_values_in_the_same_ten_second_bucket_render_identically() {
for bucket_start in [0, 10, 20, 30, 40, 50] {
for (offset_a, offset_b) in [(0, 9), (1, 8), (3, 6)] {
let a = Timestamp::at(
std::time::SystemTime::now() - Duration::from_secs(bucket_start + offset_a),
);
let b = Timestamp::at(
std::time::SystemTime::now() - Duration::from_secs(bucket_start + offset_b),
);
assert_eq!(
format_age(a),
format_age(b),
"bucket starting at {bucket_start}s: offsets {offset_a}s and {offset_b}s diverged"
);
}
}
}
#[test]
fn a_never_probed_cell_reads_loading() {
let settled: Option<&Settled<u32>> = None;
assert_eq!(describe_cell(settled, |value| value.to_string()), "loading");
}
#[test]
fn a_not_applicable_cell_reads_not_applicable_in_words() {
let settled: Settled<u32> = Settled::NotApplicable;
assert_eq!(
describe_cell(Some(&settled), |value| value.to_string()),
"not applicable"
);
}
#[test]
fn a_failed_cells_probe_message_reads_as_words_not_a_debug_dump() {
let settled: Settled<u32> = Settled::Failed(ProbeError::Read(Arc::from("boom")));
let text = describe_cell(Some(&settled), |value| value.to_string());
assert!(!text.contains("ProbeError"), "got {text:?}");
assert!(text.contains("failed to read HEAD"), "got {text:?}");
}
#[test]
fn the_three_unknown_reasons_read_as_distinct_words() {
let reasons = [
Unknown::TimedOut,
Unknown::NoDefaultBranch,
Unknown::SubmoduleUninitialized,
];
for (index, a) in reasons.iter().enumerate() {
for b in &reasons[index + 1..] {
assert_ne!(describe_unknown(*a), describe_unknown(*b));
}
}
assert_eq!(describe_unknown(Unknown::TimedOut), "timed out");
assert_eq!(
describe_unknown(Unknown::NoDefaultBranch),
"no default branch found"
);
assert_eq!(
describe_unknown(Unknown::SubmoduleUninitialized),
"not yet initialised"
);
}
#[test]
fn a_gitmodules_parse_failure_and_a_failed_last_action_read_as_distinct_words() {
let mut gitmodules_row = entity("a");
gitmodules_row.diagnostics.gitmodules_failed = Some(Arc::from("bad syntax"));
let mut action_row = entity("b");
action_row.last_action = Some(receipt(StepOutcome::Failed(1)));
let gitmodules_reason =
row_level_failure(&gitmodules_row.diagnostics, &gitmodules_row.last_action)
.expect("expected a row-level failure reason");
let action_reason = row_level_failure(&action_row.diagnostics, &action_row.last_action)
.expect("expected a row-level failure reason");
assert_ne!(gitmodules_reason, action_reason);
assert!(gitmodules_reason.contains(".gitmodules"));
assert!(action_reason.contains("Action"));
}
#[test]
fn a_row_with_neither_failure_cause_has_no_row_level_failure_reason() {
let clean_row = entity("c");
assert_eq!(
row_level_failure(&clean_row.diagnostics, &clean_row.last_action),
None
);
}
#[test]
fn a_rung_three_default_branch_is_marked_resolved_by_the_name_list() {
let mut rung_three = entity("a");
rung_three.diagnostics.default_branch_rung = Some(3);
let lines = default_branch_diagnostics_lines(&rung_three.diagnostics).join("\n");
assert!(lines.contains("name list"), "got {lines:?}");
}
#[test]
fn a_rung_two_default_branch_carries_no_name_list_mark() {
let mut rung_two = entity("a");
rung_two.diagnostics.default_branch_rung = Some(2);
let lines = default_branch_diagnostics_lines(&rung_two.diagnostics);
assert!(lines.is_empty(), "got {lines:?}");
}
#[test]
fn a_recorded_disagreement_says_origin_head_still_wins() {
let mut disagreeing = entity("a");
disagreeing.diagnostics.default_branch_rung_disagreement = true;
let lines = default_branch_diagnostics_lines(&disagreeing.diagnostics).join("\n");
assert!(lines.contains("disagree"), "got {lines:?}");
assert!(lines.contains("origin/HEAD"), "got {lines:?}");
}
#[test]
fn no_disagreement_recorded_carries_no_disagreement_line() {
let agreeing = entity("a");
let lines = default_branch_diagnostics_lines(&agreeing.diagnostics);
assert!(lines.is_empty(), "got {lines:?}");
}
#[test]
fn a_stale_origin_head_target_is_named() {
let mut stale = entity("a");
stale.diagnostics.default_branch_rung_two_stale = true;
let lines = default_branch_diagnostics_lines(&stale.diagnostics).join("\n");
assert!(lines.contains("no longer resolves"), "got {lines:?}");
}
#[test]
fn a_resolvable_origin_head_target_carries_no_stale_line() {
let resolvable = entity("a");
let lines = default_branch_diagnostics_lines(&resolvable.diagnostics);
assert!(lines.is_empty(), "got {lines:?}");
}
#[test]
fn every_stopped_reason_reads_as_its_own_distinct_words() {
let words = [
stopped_word(DefaultBranchStopped::NoRemote),
stopped_word(DefaultBranchStopped::AmbiguousRemote),
stopped_word(DefaultBranchStopped::NameListExhausted),
];
for (index, word) in words.iter().enumerate() {
for (other_index, other) in words.iter().enumerate() {
if index != other_index {
assert_ne!(word, other, "got duplicate stopped words: {words:?}");
}
}
}
}
#[test]
fn a_stopped_reason_is_named_only_once_recorded() {
let mut exhausted = entity("a");
exhausted.diagnostics.default_branch_stopped =
Some(DefaultBranchStopped::NameListExhausted);
let lines = default_branch_diagnostics_lines(&exhausted.diagnostics).join("\n");
assert!(lines.contains(stopped_word(DefaultBranchStopped::NameListExhausted)));
}
#[test]
fn a_rung_that_answered_carries_no_stopped_reason_line() {
let answered = entity("a");
let lines = default_branch_diagnostics_lines(&answered.diagnostics);
assert!(lines.is_empty(), "got {lines:?}");
}
#[test]
fn the_default_branchs_diagnostics_fields_are_read_nowhere_outside_this_file() {
let needles = ["default_branch_rung", "default_branch_stopped"];
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let mut offending_locations = Vec::new();
for path in crate::test_support::rust_source_files(&manifest_dir.join("src")) {
if path.file_name().is_some_and(|name| name == "detail.rs") {
continue;
}
let production = crate::test_support::production_source_at(&path);
for (number, line) in production.lines().enumerate() {
if line.trim_start().starts_with("//") {
continue;
}
if needles.iter().any(|needle| line.contains(needle)) {
offending_locations.push(format!("{}:{}", path.display(), number + 1));
}
}
}
assert!(
offending_locations.is_empty(),
"a default branch diagnostics field was read outside detail.rs, the one place \
`default-branch.md` allows it, at: {offending_locations:?}"
);
}
#[test]
fn head_word_carries_a_detached_heads_full_object_id_not_the_lists_abbreviation() {
use repon_core::{Core, CoreSpec, SetSpec};
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path().canonicalize().expect("canonicalize temp dir");
let status = Command::new("git")
.arg("init")
.args(["--quiet", "--initial-branch", "main"])
.arg(&root)
.status()
.expect("run git init");
assert!(status.success());
git(&root, &["commit", "--allow-empty", "-m", "first"]);
let sha_output = Command::new("git")
.arg("-C")
.arg(&root)
.args(["rev-parse", "HEAD"])
.output()
.expect("run git rev-parse");
assert!(sha_output.status.success());
let full_id = String::from_utf8(sha_output.stdout)
.expect("utf8 sha")
.trim()
.to_string();
assert_eq!(full_id.len(), 40, "expected a full sha1 hex id");
let status = Command::new("git")
.arg("-C")
.arg(&root)
.args(["checkout", "--quiet", "--detach", &full_id])
.status()
.expect("run git checkout --detach");
assert!(status.success());
let core = Core::start_discovered(CoreSpec {
set: SetSpec {
name: "test".to_string(),
roots: vec![root],
include: Vec::new(),
exclude: Vec::new(),
},
overrides: Vec::new(),
poll_interval: Duration::from_secs(3600),
status_stale_after: Duration::from_secs(3600),
generation_deadline: Duration::from_secs(3600),
show_submodules: false,
fetch: repon_core::FetchSpec {
enabled: false,
interval: std::time::Duration::from_secs(3600),
concurrency: 4,
},
auto_update: repon_core::AutoUpdateSpec { enabled: false },
});
let keys: Vec<_> = core
.snapshot()
.entities
.iter()
.map(|entity| entity.key.clone())
.collect();
core.refresh(&keys);
let settled = core.settle();
let lines = content_lines(&settled.entities[0], WIDE, full_glyphs());
let branch_line = line_labelled(&lines, "branch");
assert!(
branch_line.contains(&full_id),
"expected the full forty-character id in the pane, got {branch_line:?}"
);
}
#[test]
fn content_lines_opens_with_the_entitys_name_kind_and_path() {
let lines = content_lines(&entity("acquiring-gateway"), WIDE, full_glyphs());
assert!(lines[0].contains("acquiring-gateway"));
assert!(lines[0].contains("worktree"));
assert_eq!(lines[1], "acquiring-gateway");
}
#[test]
fn content_lines_carries_one_line_per_cell_even_before_any_probe() {
let lines = content_lines(&entity("a"), WIDE, full_glyphs()).join("\n");
for label in ["branch", "sync", "base", "dirty", "state", "default branch"] {
assert!(
lines.contains(label),
"expected a {label} line, got {lines:?}"
);
}
}
fn settled_known_at<T>(value: T, at: Timestamp, stale: bool) -> Cell<T> {
Cell::already_settled(Settled::Known { value, at, stale })
}
#[test]
fn a_known_cells_value_line_never_carries_its_own_inline_age() {
let at = Timestamp::now();
let two_hours_ago =
Timestamp::at(std::time::SystemTime::now() - Duration::from_secs(7_200));
let mut row = entity("a");
row.branch = settled_known_at(Head::Unborn(Arc::from("main")), at, false);
row.dirty = settled_known_at(DirtyCounts::default(), at, true);
row.sync = settled_known_at(SyncState::NoUpstream, two_hours_ago, false);
let lines = content_lines(&row, WIDE, full_glyphs());
for label in ["branch", "sync", "base", "dirty", "state", "default branch"] {
let line = line_labelled(&lines, label);
assert!(
!line.contains("ago")
&& !line.contains("just now")
&& !line.contains("refreshed")
&& !line.contains("stale"),
"expected no inline age on the {label} row, got {line:?}"
);
}
}
#[test]
fn a_rows_agreeing_known_cells_print_one_refreshed_line_with_their_shared_age() {
let at = Timestamp::now();
let mut row = entity("a");
row.branch = settled_known_at(Head::Unborn(Arc::from("main")), at, false);
row.sync = settled_known_at(SyncState::NoUpstream, at, false);
row.dirty = settled_known_at(DirtyCounts::default(), at, false);
row.default_branch =
settled_known_at(DefaultBranch::new(Arc::from("origin/main")), at, false);
let lines = content_lines(&row, WIDE, full_glyphs());
let freshness_line = line_labelled(&lines, "refreshed");
assert_eq!(
freshness_line, "refreshed just now",
"got {freshness_line:?}"
);
assert_eq!(
lines
.iter()
.filter(|line| line.starts_with("refreshed"))
.count(),
1,
"expected exactly one refreshed line, got {lines:?}"
);
}
#[test]
fn a_rows_disagreeing_known_cells_print_one_refreshed_line_with_one_label_and_age_per_line() {
let now = Timestamp::now();
let ten_seconds_ago = Timestamp::at(std::time::SystemTime::now() - Duration::from_secs(12));
let twenty_seconds_ago =
Timestamp::at(std::time::SystemTime::now() - Duration::from_secs(25));
let mut row = entity("a");
row.branch = settled_known_at(Head::Unborn(Arc::from("main")), now, false);
row.base = settled_known_at(0u32, now, false);
row.default_branch =
settled_known_at(DefaultBranch::new(Arc::from("origin/main")), now, false);
row.sync = settled_known_at(SyncState::NoUpstream, ten_seconds_ago, false);
row.dirty = settled_known_at(DirtyCounts::default(), twenty_seconds_ago, false);
let lines = content_lines(&row, WIDE, full_glyphs());
let freshness_index = lines
.iter()
.position(|line| line.starts_with("refreshed"))
.expect("expected a refreshed line");
assert_eq!(
lines[freshness_index], "refreshed sync, 10s ago",
"got {lines:?}"
);
assert_eq!(
lines[freshness_index + 1],
" dirty, 20s ago",
"expected the second breakdown entry on its own line, indented under the label, \
got {lines:?}"
);
assert_eq!(
lines
.iter()
.filter(|line| line.starts_with("refreshed"))
.count(),
1,
"expected exactly one line to start with the refreshed label, got {lines:?}"
);
}
#[test]
fn a_disagreement_breakdown_draws_each_label_age_line_as_its_own_screen_row() {
use ratatui::{Terminal, backend::TestBackend};
let now = Timestamp::now();
let ten_seconds_ago = Timestamp::at(std::time::SystemTime::now() - Duration::from_secs(12));
let twenty_seconds_ago =
Timestamp::at(std::time::SystemTime::now() - Duration::from_secs(25));
let mut row = entity("a");
row.branch = settled_known_at(Head::Unborn(Arc::from("main")), now, false);
row.base = settled_known_at(0u32, now, false);
row.default_branch =
settled_known_at(DefaultBranch::new(Arc::from("origin/main")), now, false);
row.sync = settled_known_at(SyncState::NoUpstream, ten_seconds_ago, false);
row.dirty = settled_known_at(DirtyCounts::default(), twenty_seconds_ago, false);
let glyphs = full_glyphs();
let detail = Detail::default();
let backend = TestBackend::new(60, 20);
let mut terminal = Terminal::new(backend).expect("create test terminal");
terminal
.draw(|frame| {
detail.draw(frame, frame.area(), &row, glyphs, true, &theme::DEFAULT);
})
.expect("draw the frame");
let buf = terminal.backend().buffer();
let rows: Vec<String> = (1..buf.area.height.saturating_sub(1))
.map(|y| {
(1..buf.area.width.saturating_sub(1))
.map(|x| buf[(x, y)].symbol())
.collect::<String>()
.trim_end()
.to_string()
})
.collect();
let refreshed_row = rows
.iter()
.position(|line| line.starts_with("refreshed"))
.unwrap_or_else(|| panic!("no refreshed row drawn, got {rows:?}"));
assert!(
rows[refreshed_row].contains("sync, 10s ago") && !rows[refreshed_row].contains("dirty"),
"expected only the first breakdown entry on the refreshed row, got {:?}",
rows[refreshed_row]
);
assert!(
rows[refreshed_row + 1].contains("dirty, 20s ago")
&& !rows[refreshed_row + 1].contains("refreshed"),
"expected the second breakdown entry on its own row below, got {:?}",
rows[refreshed_row + 1]
);
}
#[test]
fn a_three_three_split_names_every_cell_rather_than_hide_half_behind_an_arbitrary_majority() {
let group_a = Timestamp::now();
let group_b = Timestamp::at(std::time::SystemTime::now() - Duration::from_secs(15));
let mut row = entity("a");
row.branch = settled_known_at(Head::Unborn(Arc::from("main")), group_a, false);
row.sync = settled_known_at(SyncState::NoUpstream, group_a, false);
row.base = settled_known_at(0u32, group_a, false);
row.dirty = settled_known_at(DirtyCounts::default(), group_b, false);
row.state = settled_known_at(WorktreeState::Active, group_b, false);
row.default_branch =
settled_known_at(DefaultBranch::new(Arc::from("origin/main")), group_b, false);
let lines = content_lines(&row, WIDE, full_glyphs());
let freshness_index = lines
.iter()
.position(|line| line.starts_with("refreshed"))
.expect("expected a refreshed line");
let breakdown = &lines[freshness_index..freshness_index + 6];
for label in ["branch", "sync", "base", "dirty", "state", "default branch"] {
assert!(
breakdown.iter().any(|line| line.contains(label)),
"expected {label} named in a 3-3 split with no majority, got {breakdown:?}"
);
}
}
#[test]
fn a_stale_cell_beside_a_fresh_one_still_counts_as_a_disagreement_and_gets_its_own_breakdown_line()
{
let at = Timestamp::now();
let mut row = entity("a");
row.branch = settled_known_at(Head::Unborn(Arc::from("main")), at, false);
row.base = settled_known_at(0u32, at, false);
row.default_branch =
settled_known_at(DefaultBranch::new(Arc::from("origin/main")), at, false);
row.dirty = settled_known_at(DirtyCounts::default(), at, true);
let lines = content_lines(&row, WIDE, full_glyphs());
let freshness_line = line_labelled(&lines, "refreshed");
assert_eq!(
freshness_line, "refreshed dirty, stale just now",
"a stale cell beside fresh, same-instant neighbours must still disagree, got \
{freshness_line:?}"
);
assert_eq!(
lines
.iter()
.filter(|line| line.starts_with("refreshed"))
.count(),
1,
"expected exactly one refreshed line, got {lines:?}"
);
}
fn settled_known_and_in_flight<T>(value: T, at: Timestamp, stale: bool) -> Cell<T> {
Cell::already_settled_and_in_flight(Settled::Known { value, at, stale })
}
#[test]
fn a_cell_being_reprobed_reads_loading_and_names_only_the_in_flight_labels() {
let at = Timestamp::now();
let mut row = entity("a");
row.branch = settled_known_at(Head::Unborn(Arc::from("main")), at, false);
row.sync = settled_known_at(SyncState::NoUpstream, at, false);
row.base = settled_known_at(0u32, at, false);
row.default_branch =
settled_known_at(DefaultBranch::new(Arc::from("origin/main")), at, false);
row.dirty = settled_known_and_in_flight(DirtyCounts::default(), at, false);
row.state = settled_known_and_in_flight(WorktreeState::Active, at, false);
let lines = content_lines(&row, WIDE, full_glyphs());
let freshness_line = line_labelled(&lines, "loading");
assert_eq!(
freshness_line, "loading dirty, state",
"got {freshness_line:?}"
);
assert!(
!lines.iter().any(|line| line.starts_with("refreshed")),
"a cell being reprobed must never also show a refreshed line, got {lines:?}"
);
}
#[test]
fn the_refreshed_lines_position_is_the_same_line_index_whether_loading_or_settled() {
let at = Timestamp::now();
let mut settled = entity("a");
settled.branch = settled_known_at(Head::Unborn(Arc::from("main")), at, false);
let mut loading = entity("a");
loading.branch = settled_known_and_in_flight(Head::Unborn(Arc::from("main")), at, false);
let settled_lines = content_lines(&settled, WIDE, full_glyphs());
let loading_lines = content_lines(&loading, WIDE, full_glyphs());
let default_branch_index = settled_lines
.iter()
.position(|line| line.starts_with("default branch"))
.expect("a default branch line");
let settled_freshness_index = settled_lines
.iter()
.position(|line| line.starts_with("refreshed"))
.expect("settled Known cells print a refreshed line");
let loading_freshness_index = loading_lines
.iter()
.position(|line| line.starts_with("loading"))
.expect("an in-flight cell prints a loading line");
assert_eq!(
settled_freshness_index,
default_branch_index + 1,
"got {settled_lines:?}"
);
assert_eq!(
loading_freshness_index,
default_branch_index + 1,
"got {loading_lines:?}"
);
}
#[test]
fn a_row_with_no_known_cell_yet_prints_no_refreshed_line_at_all() {
let row = entity("a");
let lines = content_lines(&row, WIDE, full_glyphs());
assert!(
!lines
.iter()
.any(|line| line.starts_with("refreshed") || line.starts_with("loading")),
"a row with nothing Known yet has nothing to report, got {lines:?}"
);
}
#[test]
fn a_single_known_cell_still_gets_its_own_refreshed_line_rather_than_no_line_at_all() {
let mut row = entity("a");
row.dirty = settled_known_at(DirtyCounts::default(), Timestamp::now(), false);
let lines = content_lines(&row, WIDE, full_glyphs());
let freshness_line = line_labelled(&lines, "refreshed");
assert!(
freshness_line.contains("just now"),
"got {freshness_line:?}"
);
assert_eq!(
lines
.iter()
.filter(|line| line.starts_with("refreshed"))
.count(),
1,
"one Known cell has nothing to disagree with, so it still gets the row, got \
{lines:?}"
);
}
#[test]
fn content_lines_carries_the_default_branchs_own_diagnostics_lines() {
let mut disagreeing_at_rung_three = entity("a");
disagreeing_at_rung_three.diagnostics.default_branch_rung = Some(3);
disagreeing_at_rung_three
.diagnostics
.default_branch_rung_disagreement = true;
let lines = content_lines(&disagreeing_at_rung_three, WIDE, full_glyphs()).join("\n");
assert!(lines.contains("name list"), "got {lines:?}");
assert!(lines.contains("disagree"), "got {lines:?}");
}
#[test]
fn content_lines_shows_the_in_progress_operation_only_when_one_is_set() {
let mut idle = entity("a");
idle.in_progress_operation = None;
let idle_lines = content_lines(&idle, WIDE, full_glyphs()).join("\n");
assert!(!idle_lines.contains("in progress"));
let mut rebasing = entity("b");
rebasing.in_progress_operation = Some(InProgressOperation::Rebase);
let rebasing_lines = content_lines(&rebasing, WIDE, full_glyphs()).join("\n");
assert!(rebasing_lines.contains("in progress: rebasing"));
}
#[test]
fn content_lines_lists_recent_commits_most_recent_first() {
let mut with_commits = entity("a");
with_commits.recent_commits = vec![
RecentCommit {
short_id: Arc::from("abc1234"),
summary: Arc::from("second commit"),
},
RecentCommit {
short_id: Arc::from("def5678"),
summary: Arc::from("first commit"),
},
];
let lines = content_lines(&with_commits, WIDE, full_glyphs());
let second_index = lines
.iter()
.position(|line| line.contains("second commit"))
.expect("second commit line");
let first_index = lines
.iter()
.position(|line| line.contains("first commit"))
.expect("first commit line");
assert!(second_index < first_index);
}
#[test]
fn content_lines_shows_the_last_actions_own_outcome() {
let mut ok_run = entity("a");
ok_run.last_action = Some(receipt(StepOutcome::Ok));
assert!(
content_lines(&ok_run, WIDE, full_glyphs())
.join("\n")
.contains("last action ok")
);
let mut failed_run = entity("b");
failed_run.last_action = Some(receipt(StepOutcome::Failed(1)));
assert!(
content_lines(&failed_run, WIDE, full_glyphs())
.join("\n")
.contains("last action failed")
);
let mut no_run = entity("c");
no_run.last_action = None;
assert!(
content_lines(&no_run, WIDE, full_glyphs())
.join("\n")
.contains("last action none yet")
);
}
fn git(path: &Path, args: &[&str]) {
let status = Command::new("git")
.arg("-C")
.arg(path)
.args(["-c", "user.email=test@example.com", "-c", "user.name=Test"])
.args(args)
.status()
.expect("run git");
assert!(status.success(), "git {args:?} failed");
}
fn init_repo_with_a_resolvable_default_branch(path: &Path, branch: &str) {
std::fs::create_dir_all(path).expect("create repo dir");
let status = Command::new("git")
.arg("init")
.args(["--quiet", "--initial-branch", branch])
.arg(path)
.status()
.expect("run git init");
assert!(status.success());
git(path, &["commit", "--allow-empty", "-m", "first"]);
git(
path,
&[
"remote",
"add",
"origin",
"https://example.invalid/repo.git",
],
);
let sha_output = Command::new("git")
.arg("-C")
.arg(path)
.args(["rev-parse", "HEAD"])
.output()
.expect("run git rev-parse");
assert!(sha_output.status.success());
let sha = String::from_utf8(sha_output.stdout)
.expect("utf8 sha")
.trim()
.to_string();
git(path, &["update-ref", "refs/remotes/origin/main", &sha]);
let remote_refs_dir = path
.join(".git")
.join("refs")
.join("remotes")
.join("origin");
std::fs::create_dir_all(&remote_refs_dir).expect("create refs/remotes/origin dir");
std::fs::write(
remote_refs_dir.join("HEAD"),
"ref: refs/remotes/origin/main\n",
)
.expect("write refs/remotes/origin/HEAD");
}
fn line_labelled<'a>(lines: &'a [String], label: &str) -> &'a str {
lines
.iter()
.find(|line| line.starts_with(label))
.unwrap_or_else(|| panic!("no {label:?} line in {lines:?}"))
}
#[test]
fn content_lines_never_reads_one_cells_line_from_a_different_cell() {
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path().canonicalize().expect("canonicalize temp dir");
let outer = root.join("outer");
let submodule_path = outer.join("vendor").join("lib");
std::fs::create_dir_all(&outer).expect("create outer dir");
let status = Command::new("git")
.arg("init")
.args(["--quiet", "--initial-branch", "outer-main"])
.arg(&outer)
.status()
.expect("run git init");
assert!(status.success());
git(&outer, &["commit", "--allow-empty", "-m", "first"]);
std::fs::write(
outer.join(".gitmodules"),
"[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.invalid/lib.git\n",
)
.expect("write .gitmodules");
init_repo_with_a_resolvable_default_branch(&submodule_path, "feature-distinct-branch");
let core = Core::start_discovered(CoreSpec {
set: SetSpec {
name: "test".to_string(),
roots: vec![root],
include: Vec::new(),
exclude: Vec::new(),
},
overrides: Vec::new(),
poll_interval: Duration::from_secs(3600),
status_stale_after: Duration::from_secs(3600),
generation_deadline: Duration::from_secs(3600),
show_submodules: true,
fetch: repon_core::FetchSpec {
enabled: false,
interval: std::time::Duration::from_secs(3600),
concurrency: 4,
},
auto_update: repon_core::AutoUpdateSpec { enabled: false },
});
let keys: Vec<_> = core
.snapshot()
.entities
.iter()
.map(|entity| entity.key.clone())
.collect();
core.refresh(&keys);
let settled = core.settle();
let submodule = settled
.entities
.iter()
.find(|entity| matches!(entity.kind, Kind::Submodule))
.expect("submodule entity present");
let lines = content_lines(submodule, WIDE, full_glyphs());
let branch_line = line_labelled(&lines, "branch");
let sync_line = line_labelled(&lines, "sync");
let base_line = line_labelled(&lines, "base");
let dirty_line = line_labelled(&lines, "dirty");
let state_line = line_labelled(&lines, "state");
let default_branch_line = line_labelled(&lines, "default branch");
assert!(
branch_line.contains("feature-distinct-branch") && !branch_line.contains("refreshed"),
"got {branch_line:?}"
);
assert!(
default_branch_line.contains("origin/main")
&& !default_branch_line.contains("refreshed"),
"got {default_branch_line:?}"
);
assert!(
sync_line.contains("no upstream configured") && !sync_line.contains("refreshed"),
"the submodule's own branch has a remote but no upstream configured for it, got \
{sync_line:?}"
);
assert!(
dirty_line.contains("clean") && !dirty_line.contains("refreshed"),
"the submodule's own working tree is freshly committed and clean, got \
{dirty_line:?}"
);
assert!(
base_line.ends_with("unknown: no default branch found"),
"got {base_line:?}"
);
assert!(
state_line.ends_with("unknown: no default branch found"),
"got {state_line:?}"
);
assert!(
lines.iter().any(|line| line.contains("refreshed")
&& (line.contains("ago") || line.contains("just now"))),
"expected the four agreeing Known cells' shared age on its own line, got {lines:?}"
);
assert_ne!(
base_line, dirty_line,
"base and dirty must never read alike: {base_line:?} vs {dirty_line:?}"
);
}
#[test]
fn describe_cell_spans_gives_a_known_values_own_meaning_its_role_and_no_other_span() {
let settled = Settled::Known {
value: 5u32,
at: Timestamp::now(),
stale: false,
};
let spans = describe_cell_spans(
Some(&settled),
|value| value.to_string(),
|_| Meaning::Dirty,
);
assert_eq!(
spans,
vec![("5".to_string(), Meaning::Dirty.role())],
"a Known value carries no age span of its own, got {spans:?}"
);
}
#[test]
fn describe_cell_spans_colours_unknown_dim_failed_danger_not_applicable_text_and_loading_accent()
{
let unknown: Settled<u32> = Settled::Unknown(Unknown::TimedOut);
let failed: Settled<u32> = Settled::Failed(ProbeError::Read(Arc::from("boom")));
let not_applicable: Settled<u32> = Settled::NotApplicable;
assert_eq!(
describe_cell_spans(Some(&unknown), |v: &u32| v.to_string(), |_| Meaning::Dirty)[0].1,
Meaning::StaleOrUnknownGutterMark.role()
);
assert_eq!(
describe_cell_spans(Some(&failed), |v: &u32| v.to_string(), |_| Meaning::Dirty)[0].1,
Meaning::FailedProvenance.role()
);
assert_eq!(
describe_cell_spans(
Some(¬_applicable),
|v: &u32| v.to_string(),
|_| { Meaning::Dirty }
)[0]
.1,
Role::Text
);
assert_eq!(
describe_cell_spans(
None::<&Settled<u32>>,
|v: &u32| v.to_string(),
|_| Meaning::Dirty
)[0]
.1,
Meaning::LoadingSpinner.role()
);
}
fn own_work_receipt(operation: &str, work: OwnWork) -> ActionReceipt {
ActionReceipt {
label: Arc::from(operation),
steps: Arc::from(vec![StepResult {
label: Arc::from(operation),
outcome: StepOutcome::OwnWork(work),
output: Arc::from(&b""[..]),
elapsed: Duration::from_millis(3),
elision: None,
shell: false,
interactive: false,
}]),
skip: None,
finished_at: Timestamp::now(),
running: None,
}
}
#[test]
fn the_pane_names_the_operation_and_what_repon_did() {
let mut row = entity("repo-a");
row.last_action = Some(own_work_receipt(
"delete",
OwnWork::Did(Arc::from("working tree removed, `[[repo]]` entry removed")),
));
let lines = content_lines(&row, WIDE, full_glyphs()).join("\n");
assert!(lines.contains("delete"), "got {lines:?}");
assert!(
lines.contains("working tree removed, `[[repo]]` entry removed"),
"got {lines:?}"
);
}
#[test]
fn the_pane_names_why_a_row_was_refused_and_calls_the_run_refused_rather_than_ok() {
let mut row = entity("sidecar");
row.last_action = Some(own_work_receipt(
"delete",
OwnWork::Refused(Arc::from(
"refused, removing a linked Worktree is `git worktree remove`'s job",
)),
));
let lines = content_lines(&row, WIDE, full_glyphs()).join("\n");
assert!(
lines.contains("refused, removing a linked Worktree"),
"got {lines:?}"
);
assert_eq!(
last_action_spans(&row.last_action)[0].0,
"refused",
"a refusal is neither ok nor failed"
);
assert_eq!(
last_action_spans(&row.last_action)[0].1,
Meaning::ActionStepNotRunOrCancelled.role(),
"and takes the dim role rather than danger, since nothing went wrong"
);
}
#[test]
fn an_own_work_row_carries_no_step_number_and_no_exit_code() {
for work in [
OwnWork::Did(Arc::from("ignored")),
OwnWork::Refused(Arc::from("refused, already ignored")),
OwnWork::CouldNotAct(Arc::from("failed, permission denied")),
] {
let mut row = entity("repo-a");
row.last_action = Some(own_work_receipt("ignore", work));
let lines = content_lines(&row, WIDE, full_glyphs()).join("\n");
assert!(!lines.contains("step 1"), "got {lines:?}");
assert!(!lines.contains("exit"), "got {lines:?}");
assert!(!lines.contains("not run"), "got {lines:?}");
assert!(!lines.contains("cancelled"), "got {lines:?}");
}
}
#[test]
fn each_grade_of_own_work_takes_its_own_role_and_only_could_not_act_reads_as_a_failure() {
let did = StepOutcome::OwnWork(OwnWork::Did(Arc::from("ignored")));
let refused = StepOutcome::OwnWork(OwnWork::Refused(Arc::from("already ignored")));
let could_not = StepOutcome::OwnWork(OwnWork::CouldNotAct(Arc::from("boom")));
assert_eq!(step_outcome_meaning(&did), Meaning::SucceededActionStep);
assert_eq!(
step_outcome_meaning(&refused),
Meaning::ActionStepNotRunOrCancelled
);
assert_eq!(step_outcome_meaning(&could_not), Meaning::FailedActionStep);
assert_eq!(step_outcome_word(&did), "ignored");
assert_eq!(step_outcome_word(&refused), "already ignored");
assert_eq!(step_outcome_word(&could_not), "boom");
let mut refused_row = entity("a");
refused_row.last_action = Some(own_work_receipt(
"ignore",
OwnWork::Refused(Arc::from("already ignored")),
));
assert_eq!(
row_level_failure(&refused_row.diagnostics, &refused_row.last_action),
None,
"a refusal must not widen the row summary fold"
);
let mut could_not_row = entity("b");
could_not_row.last_action = Some(own_work_receipt(
"delete",
OwnWork::CouldNotAct(Arc::from("boom")),
));
assert!(
row_level_failure(&could_not_row.diagnostics, &could_not_row.last_action).is_some(),
"work Repon could not finish is a failure and does widen it"
);
}
#[test]
fn sync_meaning_gives_an_ahead_a_behind_a_known_zero_and_a_settled_absence_their_own_role() {
assert_eq!(
sync_meaning(&SyncState::Tracking(repon_core::AheadBehind {
ahead: 2,
behind: 0
})),
Meaning::AheadCount
);
assert_eq!(
sync_meaning(&SyncState::Tracking(repon_core::AheadBehind {
ahead: 0,
behind: 3
})),
Meaning::BehindCount
);
assert_eq!(
sync_meaning(&SyncState::Tracking(repon_core::AheadBehind {
ahead: 0,
behind: 0
})),
Meaning::KnownZero
);
assert_eq!(sync_meaning(&SyncState::NoUpstream), Meaning::FreshValue);
assert_eq!(sync_meaning(&SyncState::NoRemote), Meaning::FreshValue);
}
#[test]
fn last_action_spans_names_ok_failed_and_none_yet_through_their_own_role() {
assert_eq!(
last_action_spans(&Some(receipt(StepOutcome::Ok)))[0].1,
Meaning::SucceededActionStep.role()
);
assert_eq!(
last_action_spans(&Some(receipt(StepOutcome::Failed(1))))[0].1,
Meaning::FailedActionStep.role()
);
assert_eq!(
last_action_spans(&None)[0].1,
Meaning::ActionStepNotRunOrCancelled.role()
);
}
#[test]
fn styled_content_lines_gives_every_labels_own_span_the_dim_role() {
let lines = styled_content_lines(&entity("a"), WIDE, full_glyphs());
let labelled_lines = [3, 4, 5, 6, 7, 8];
for index in labelled_lines {
assert_eq!(
lines[index].spans()[0].1,
Role::Dim,
"line {index} {:?} must open with a dim label",
lines[index]
);
}
}
#[test]
fn styled_content_lines_gives_the_header_name_its_kind_cell_meaning_and_the_kind_word_dim() {
let repo = EntityState::new(
EntityKey::new(Arc::from(Path::new("r"))),
Arc::from("r"),
Arc::from(Path::new("r")),
Kind::Repo,
);
let worktree = entity("wt");
let repo_header = &styled_content_lines(&repo, WIDE, full_glyphs())[0];
let worktree_header = &styled_content_lines(&worktree, WIDE, full_glyphs())[0];
assert_eq!(repo_header.spans()[0].1, Meaning::FreshValue.role());
assert_eq!(worktree_header.spans()[0].1, Meaning::WorktreeName.role());
assert_eq!(
worktree_header.spans()[1].1,
Role::Dim,
"the kind word is dim"
);
}
#[test]
fn styled_content_lines_colours_the_recent_header_as_a_column_header_and_a_row_level_failure_danger()
{
let lines = styled_content_lines(&entity("a"), WIDE, full_glyphs());
let recent_line = lines
.iter()
.find(|line| line.first_text() == Some("recent"))
.expect("expected a 'recent' section header line");
assert_eq!(recent_line.spans()[0].1, Meaning::ColumnHeader.role());
let mut failing = entity("b");
failing.diagnostics.gitmodules_failed = Some(Arc::from("bad syntax"));
let failing_lines = styled_content_lines(&failing, WIDE, full_glyphs());
let failure_line = failing_lines
.iter()
.find(|line| {
line.first_text()
.is_some_and(|text| text.contains(".gitmodules"))
})
.expect("expected the row-level failure line");
assert_eq!(failure_line.spans()[0].1, Meaning::FailedProvenance.role());
}
#[test]
fn draw_titles_the_top_border_with_the_entitys_own_name() {
use ratatui::{Terminal, backend::TestBackend};
let glyphs = full_glyphs();
let detail = Detail::default();
let backend = TestBackend::new(60, 10);
let mut terminal = Terminal::new(backend).expect("create test terminal");
terminal
.draw(|frame| {
detail.draw(
frame,
frame.area(),
&entity("distinctive-repo-name"),
glyphs,
true,
&theme::DEFAULT,
);
})
.expect("draw the frame");
let top_row: String = (0..60)
.map(|x| terminal.backend().buffer()[(x, 0)].symbol())
.collect();
assert!(
top_row.contains("distinctive-repo-name"),
"expected the entity's own name in the top border, got: {top_row:?}"
);
}
#[test]
fn draw_paints_the_border_from_the_live_theme_not_the_compiled_default() {
use ratatui::{Terminal, backend::TestBackend};
let live_theme = Theme {
border_focused: ratatui::style::Color::Rgb(9, 8, 7),
..theme::DEFAULT
};
let glyphs = GlyphSet::for_config(crate::config::document::Glyphs::default());
let detail = Detail::default();
let backend = TestBackend::new(40, 10);
let mut terminal = Terminal::new(backend).expect("create test terminal");
terminal
.draw(|frame| {
detail.draw(frame, frame.area(), &entity("a"), glyphs, true, &live_theme);
})
.expect("draw the frame");
let buf = terminal.backend().buffer();
assert_eq!(
buf[(0, 0)].fg,
ratatui::style::Color::Rgb(9, 8, 7),
"expected the focused border painted in the live theme's own colour"
);
}
#[test]
fn draw_frames_the_pane_with_the_active_glyph_tables_own_border() {
use ratatui::{Terminal, backend::TestBackend};
for glyphs in [&crate::glyphs::FULL, &crate::glyphs::ASCII] {
let detail = Detail::default();
let backend = TestBackend::new(40, 30);
let mut terminal = Terminal::new(backend).expect("create test terminal");
terminal
.draw(|frame| {
detail.draw(
frame,
frame.area(),
&entity("a"),
glyphs,
true,
&theme::DEFAULT,
);
})
.expect("draw the frame");
crate::test_support::assert_bordered_frame_and_top_title_drawn_with(
terminal.backend().buffer(),
Rect::new(0, 0, 40, 30),
glyphs.border,
" a ",
"the detail pane's frame",
);
let bottom_row: String = (0..40)
.map(|x| terminal.backend().buffer()[(x, 29)].symbol())
.collect();
let expected_tail = format!(
"{}{}",
crate::warnings::CLOSE_HINT,
glyphs.border.bottom_right
);
assert!(
bottom_row.ends_with(&expected_tail),
"expected the close hint right-aligned against the bottom-right corner, got \
{bottom_row:?}"
);
}
}
fn right_border_interior(buf: &Buffer, area: Rect) -> String {
((area.y + 1)..(area.bottom() - 1))
.map(|y| buf[(area.right() - 1, y)].symbol())
.collect()
}
fn entity_with_commits(count: usize) -> EntityState {
let mut entity = entity("a");
entity.recent_commits = (0..count)
.map(|index| RecentCommit {
short_id: Arc::from(format!("{index:07}")),
summary: Arc::from("a commit summary"),
})
.collect();
entity
}
fn drawn_scrollbar(
entity: &EntityState,
glyphs: &'static GlyphSet,
theme: &Theme,
focused: bool,
scroll: u16,
width: u16,
height: u16,
) -> (String, ratatui::buffer::Buffer) {
use ratatui::{Terminal, backend::TestBackend};
let area = Rect::new(0, 0, width, height);
let detail = Detail { scroll };
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).expect("create test terminal");
terminal
.draw(|frame| detail.draw(frame, frame.area(), entity, glyphs, focused, theme))
.expect("draw the frame");
let buf = terminal.backend().buffer().clone();
(right_border_interior(&buf, area), buf)
}
#[test]
fn draw_leaves_the_right_border_bare_when_the_content_fits_the_pane() {
let glyphs = full_glyphs();
let (bar, _) = drawn_scrollbar(&entity("a"), glyphs, &theme::DEFAULT, true, 0, 40, 40);
assert_eq!(
bar,
glyphs
.border
.vertical
.to_string()
.repeat(bar.chars().count()),
"expected an unscrollable pane's right border untouched, got {bar:?}"
);
}
#[test]
fn draw_marks_the_top_of_the_right_border_when_a_scrollable_pane_shows_its_first_line() {
let glyphs = full_glyphs();
let (bar, _) = drawn_scrollbar(
&entity_with_commits(30),
glyphs,
&theme::DEFAULT,
true,
0,
40,
10,
);
assert!(
bar.starts_with(glyphs.scrollbar_thumb),
"expected the thumb against the top of the track, got {bar:?}"
);
assert!(
bar.ends_with(glyphs.scrollbar_track),
"expected track below the thumb at the first line, got {bar:?}"
);
}
#[test]
fn draw_marks_the_bottom_of_the_right_border_when_a_scrollable_pane_shows_its_last_line() {
let glyphs = full_glyphs();
let entity = entity_with_commits(30);
let mut detail = Detail::default();
detail.apply(Action::Bottom, Detail::content_len(&entity, 40, glyphs), 8);
let (bar, buf) = drawn_scrollbar(
&entity,
glyphs,
&theme::DEFAULT,
true,
detail.scroll,
40,
10,
);
assert!(
bar.ends_with(glyphs.scrollbar_thumb),
"expected the thumb against the bottom of the track at the last line, got {bar:?}"
);
assert!(
bar.starts_with(glyphs.scrollbar_track),
"expected track above the thumb at the last line, got {bar:?}"
);
assert_eq!(
buf[(39, 0)].symbol(),
glyphs.border.top_right.to_string(),
"the bar must not reach the pane's own top-right corner"
);
assert_eq!(
buf[(39, 9)].symbol(),
glyphs.border.bottom_right.to_string(),
"the bar must not reach the pane's own bottom-right corner"
);
}
#[test]
fn draw_takes_the_scrollbars_characters_from_the_active_glyph_table() {
for glyphs in [&crate::glyphs::FULL, &crate::glyphs::ASCII] {
let (bar, _) = drawn_scrollbar(
&entity_with_commits(30),
glyphs,
&theme::DEFAULT,
true,
0,
40,
10,
);
assert!(
bar.contains(glyphs.scrollbar_thumb),
"expected the active table's own thumb on the border, got {bar:?}"
);
assert!(
bar.contains(glyphs.scrollbar_track),
"expected the active table's own track on the border, got {bar:?}"
);
}
}
#[test]
fn draw_paints_the_scrollbar_in_the_role_of_the_border_it_sits_in() {
let live_theme = Theme {
border: ratatui::style::Color::Rgb(1, 2, 3),
border_focused: ratatui::style::Color::Rgb(9, 8, 7),
..theme::DEFAULT
};
let glyphs = full_glyphs();
for (focused, expected) in [
(true, ratatui::style::Color::Rgb(9, 8, 7)),
(false, ratatui::style::Color::Rgb(1, 2, 3)),
] {
let (_, buf) = drawn_scrollbar(
&entity_with_commits(30),
glyphs,
&live_theme,
focused,
0,
40,
10,
);
assert_eq!(
buf[(39, 1)].fg,
expected,
"expected the thumb painted in the same role as the border, focused: {focused}"
);
}
}
#[test]
fn draw_paints_the_header_lines_name_in_its_own_meaning_role() {
use ratatui::{Terminal, backend::TestBackend};
let glyphs = GlyphSet::for_config(crate::config::document::Glyphs::default());
let detail = Detail::default();
let backend = TestBackend::new(40, 10);
let mut terminal = Terminal::new(backend).expect("create test terminal");
let worktree = entity("wt");
terminal
.draw(|frame| {
detail.draw(
frame,
frame.area(),
&worktree,
glyphs,
true,
&theme::DEFAULT,
);
})
.expect("draw the frame");
let buf = terminal.backend().buffer();
assert_eq!(
buf[(1, 1)].fg,
theme::DEFAULT.role_color(Meaning::WorktreeName.role()),
"expected the Worktree name painted in its own meaning's role, not left uncoloured"
);
}
#[test]
fn a_finished_steps_own_label_and_output_survive_the_run() {
let mut row = entity("a");
row.last_action = Some(action_receipt(
"reinstall",
vec![
step_result(
"rm -rf node_modules",
StepOutcome::Ok,
b"",
Duration::from_millis(300),
),
step_result(
"pnpm install",
StepOutcome::Ok,
b"added 42 packages\n",
Duration::from_secs(9),
),
],
None,
));
let lines = content_lines(&row, WIDE, full_glyphs()).join("\n");
assert!(
lines.contains("rm -rf node_modules"),
"expected the first step's own label, got: {lines}"
);
assert!(
lines.contains("pnpm install"),
"expected the second step's own label, got: {lines}"
);
assert!(
lines.contains("added 42 packages"),
"expected the second step's own captured output, still present after the run, \
got: {lines}"
);
}
#[test]
fn each_finished_steps_own_elapsed_time_is_shown() {
let mut row = entity("a");
row.last_action = Some(action_receipt(
"reinstall",
vec![
step_result(
"rm -rf node_modules",
StepOutcome::Ok,
b"",
Duration::from_millis(300),
),
step_result("pnpm test", StepOutcome::Ok, b"", Duration::from_secs(75)),
],
None,
));
let lines = content_lines(&row, WIDE, full_glyphs()).join("\n");
assert!(
lines.contains("0.3s"),
"expected the first step's own elapsed time, got: {lines}"
);
assert!(
lines.contains("1m15s"),
"expected the second step's own elapsed time past a minute, got: {lines}"
);
}
#[test]
fn a_running_step_carries_the_spinner_in_the_outcome_position_and_a_finished_step_never_does() {
let glyphs = full_glyphs();
let mut row = entity("a");
row.last_action = Some(action_receipt(
"reinstall",
vec![step_result(
"rm -rf node_modules",
StepOutcome::Ok,
b"",
Duration::from_millis(300),
)],
Some(RunningStep {
label: Arc::from("pnpm install"),
started_at: Timestamp::now(),
shell: false,
interactive: false,
}),
));
let lines = styled_content_lines(&row, WIDE, glyphs);
let finished_line = lines
.iter()
.find(|line| {
line.first_text()
.is_some_and(|text| text.contains("step 1"))
})
.expect("expected the finished step's own line");
let running_line = lines
.iter()
.find(|line| {
line.first_text()
.is_some_and(|text| text.contains("step 2"))
})
.expect("expected the running step's own line");
assert_eq!(
finished_line.spans()[0].1,
Role::Dim,
"a finished step's own leading span must never carry the spinner's role"
);
assert_eq!(
running_line.spans()[0].1,
Meaning::LoadingSpinner.role(),
"the running step's own leading span must carry the spinner's role"
);
let running_text = running_line.first_text().expect("running line has text");
assert!(
glyphs
.loading
.iter()
.any(|frame| running_text.starts_with(*frame)),
"expected the running line to open with one of the glyph set's own spinner \
frames, got: {running_text:?}"
);
}
#[test]
fn a_finished_step_that_ran_through_a_shell_is_marked_and_an_argv_step_is_not() {
let mut row = entity("a");
row.last_action = Some(action_receipt(
"deploy",
vec![
step_result(
"rm -rf node_modules",
StepOutcome::Ok,
b"",
Duration::from_millis(1),
),
StepResult {
shell: true,
..step_result(
"echo $(pwd)",
StepOutcome::Ok,
b"",
Duration::from_millis(1),
)
},
],
None,
));
let lines = content_lines(&row, WIDE, full_glyphs()).join("\n");
let argv_line = lines
.lines()
.find(|line| line.contains("rm -rf node_modules"))
.expect("expected the argv step's own line");
let shell_line = lines
.lines()
.find(|line| line.contains("echo $(pwd)"))
.expect("expected the shell step's own line");
assert!(
!argv_line.contains("[shell]"),
"an argv step must carry no shell mark, got: {argv_line:?}"
);
assert!(
shell_line.contains("[shell]"),
"a step that ran through a shell must carry its mark, got: {shell_line:?}"
);
}
#[test]
fn a_finished_step_that_ran_interactively_is_marked_shell_ic() {
let mut row = entity("a");
row.last_action = Some(action_receipt(
"deploy",
vec![StepResult {
shell: true,
interactive: true,
..step_result("gff", StepOutcome::Ok, b"", Duration::from_millis(1))
}],
None,
));
let lines = content_lines(&row, WIDE, full_glyphs()).join("\n");
let line = lines
.lines()
.find(|line| line.contains("gff"))
.expect("expected the interactive step's own line");
assert!(
line.contains("[shell -ic]"),
"an interactive step must carry the -ic mark, got: {line:?}"
);
}
#[test]
fn a_running_step_that_is_shelling_out_is_marked_before_it_finishes() {
let mut row = entity("a");
row.last_action = Some(action_receipt(
"deploy",
Vec::new(),
Some(RunningStep {
label: Arc::from("echo $(pwd)"),
started_at: Timestamp::now(),
shell: true,
interactive: false,
}),
));
let lines = content_lines(&row, WIDE, full_glyphs()).join("\n");
let running_line = lines
.lines()
.find(|line| line.contains("echo $(pwd)"))
.expect("expected the running step's own line");
assert!(
running_line.contains("[shell]"),
"a running shell step must carry its mark before it finishes, got: {running_line:?}"
);
}
fn elided_step(dropped_lines: usize, kept_head: usize, kept_tail: usize) -> StepResult {
let mut output = String::new();
for n in 0..kept_head {
output.push_str(&format!("head {n}\n"));
}
for n in 0..kept_tail {
output.push_str(&format!("tail {n}\n"));
}
StepResult {
label: Arc::from("pnpm install"),
outcome: StepOutcome::Ok,
output: Arc::from(output.as_bytes()),
elapsed: Duration::from_millis(1),
elision: Some(CaptureElision {
dropped_lines,
kept_head_lines: kept_head,
}),
shell: false,
interactive: false,
}
}
fn elided_step_content_lines(
set: &'static GlyphSet,
dropped_lines: usize,
kept_head: usize,
kept_tail: usize,
) -> Vec<String> {
let mut row = entity("a");
row.last_action = Some(action_receipt(
"reinstall",
vec![elided_step(dropped_lines, kept_head, kept_tail)],
None,
));
content_lines(&row, WIDE, set)
}
fn rendered_elision_row(lines: &[String], label: &str) -> String {
lines[rendered_elision_index(lines, label)]
.trim()
.to_string()
}
fn rendered_elision_index(lines: &[String], label: &str) -> usize {
let matches: Vec<usize> = lines
.iter()
.enumerate()
.filter(|(_, line)| line.contains("lines elided"))
.map(|(index, _)| index)
.collect();
let [index] = matches.as_slice() else {
panic!("expected exactly one elision row under {label}, got {matches:?}");
};
*index
}
fn spec_capture_head_lines() -> usize {
let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let spec = std::fs::read_to_string(manifest_dir.join("../../docs/spec/actions.md"))
.expect("read the actions specification");
spec.split("Capture is bounded to the head ")
.nth(1)
.expect("actions.md states the capture bound")
.split(' ')
.next()
.expect("a head line count")
.parse()
.expect("actions.md's head bound is a whole number of lines")
}
fn spec_capture_elision_marks() -> (String, String) {
let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let spec = std::fs::read_to_string(manifest_dir.join("../../docs/spec/theming.md"))
.expect("read the theming specification");
let rows: Vec<Vec<String>> = spec
.lines()
.map(str::trim)
.filter(|line| line.starts_with('|'))
.map(|line| {
line.trim_matches('|')
.split('|')
.map(|cell| cell.trim().trim_matches('`').to_string())
.collect()
})
.filter(|cells: &Vec<String>| {
cells
.first()
.is_some_and(|first| first == "capture elision")
})
.collect();
let [row] = rows.as_slice() else {
panic!(
"expected exactly one `capture elision` row in theming.md's glyph table, got {rows:?}"
);
};
let [_, full, ascii] = row.as_slice() else {
panic!("theming.md's `capture elision` row does not have exactly three cells: {row:?}");
};
(full.clone(), ascii.clone())
}
#[test]
fn an_elided_steps_mark_is_the_one_theming_mds_glyph_table_names_for_the_live_set() {
let (spec_full, spec_ascii) = spec_capture_elision_marks();
assert_ne!(
spec_full, spec_ascii,
"theming.md's two sets must name different capture elision marks, or this test \
cannot tell them apart"
);
for (label, set, mark) in [
("full", &crate::glyphs::FULL, &spec_full),
("ascii", &crate::glyphs::ASCII, &spec_ascii),
] {
assert_eq!(
rendered_elision_row(&elided_step_content_lines(set, 212, 3, 2), label),
format!("{mark} 212 lines elided {mark}"),
"the {label} set's elision row must be drawn with the mark theming.md's own \
glyph table gives it"
);
}
}
#[test]
fn an_elided_steps_mark_sits_after_exactly_the_kept_head_lines_it_names() {
let kept_head = spec_capture_head_lines();
let kept_tail = kept_head / 4 + 1;
assert_ne!(
kept_head, kept_tail,
"the two kept runs must differ, or this test cannot tell an index counted from \
the head apart from one counted from the tail"
);
let lines = elided_step_content_lines(&crate::glyphs::FULL, 100, kept_head, kept_tail);
let position = |needle: &str| {
lines
.iter()
.position(|line| line.trim() == needle)
.unwrap_or_else(|| panic!("expected a line reading {needle:?}: {lines:?}"))
};
let elided = rendered_elision_index(&lines, "full");
assert_eq!(
elided,
position("head 0") + kept_head,
"the mark must sit {kept_head} kept lines after the first, not merely somewhere \
between the head and the tail"
);
assert_eq!(lines[elided - 1].trim(), format!("head {}", kept_head - 1));
assert_eq!(lines[elided + 1].trim(), "tail 0");
}
fn bold_elided_step(dropped_lines: usize, kept_head: usize, kept_tail: usize) -> StepResult {
let mut output = String::new();
for n in 0..kept_head {
output.push_str(&format!("\u{1b}[1mhead {n}\u{1b}[0m\n"));
}
for n in 0..kept_tail {
output.push_str(&format!("\u{1b}[1mtail {n}\u{1b}[0m\n"));
}
StepResult {
label: Arc::from("pnpm install"),
outcome: StepOutcome::Ok,
output: Arc::from(output.as_bytes()),
elapsed: Duration::from_millis(1),
elision: Some(CaptureElision {
dropped_lines,
kept_head_lines: kept_head,
}),
shell: false,
interactive: false,
}
}
fn bold_elided_step_rendered_rows(set: &'static GlyphSet) -> Vec<Vec<(String, Style)>> {
let mut row = entity("a");
row.last_action = Some(action_receipt(
"reinstall",
vec![bold_elided_step(212, 3, 2)],
None,
));
styled_content_lines(&row, WIDE, set)
.into_iter()
.filter_map(|line| match line {
ContentLine::Styled(_) => None,
ContentLine::Raw(runs) => Some(runs),
})
.collect()
}
#[test]
fn an_elided_steps_mark_renders_unstyled_between_the_childs_own_styled_rows() {
for (label, set) in [
("full", &crate::glyphs::FULL),
("ascii", &crate::glyphs::ASCII),
] {
let rows = bold_elided_step_rendered_rows(set);
let (elided, child): (Vec<_>, Vec<_>) = rows
.iter()
.partition(|runs| runs.iter().any(|(text, _)| text.contains("lines elided")));
let [elided] = elided.as_slice() else {
panic!("expected exactly one elision row under {label}, got {elided:?}");
};
for (text, style) in elided.iter() {
assert_eq!(
*style,
Style::default(),
"the {label} set's elision row must render unstyled, but {text:?} carries \
{style:?}"
);
}
assert!(
child.iter().flat_map(|runs| runs.iter()).any(|(_, style)| {
style.add_modifier.contains(ratatui::style::Modifier::BOLD)
}),
"the child's own rows must reach the pane styled under {label}, or this test \
cannot tell an unstyled elision row from an unstyled pane"
);
}
}
#[test]
fn a_step_whose_own_output_prints_the_elision_text_is_not_treated_as_elided() {
let mut row = entity("a");
row.last_action = Some(action_receipt(
"reinstall",
vec![step_result(
"echo",
StepOutcome::Ok,
"\u{b7}\u{b7}\u{b7} 212 lines elided \u{b7}\u{b7}\u{b7}\n".as_bytes(),
Duration::from_millis(1),
)],
None,
));
let lines = content_lines(&row, WIDE, &crate::glyphs::ASCII);
let row = rendered_elision_row(&lines, "ascii");
assert_eq!(
row, "\u{b7}\u{b7}\u{b7} 212 lines elided \u{b7}\u{b7}\u{b7}",
"a step's own output is a quotation of another program's screen and must reach \
the pane unrewritten, even under the ascii table"
);
}
#[test]
fn the_full_tables_elision_row_matches_actions_mds_own_detail_pane_mock() {
let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let spec = std::fs::read_to_string(manifest_dir.join("../../docs/spec/actions.md"))
.expect("read the actions specification");
let mock: Vec<String> = spec
.lines()
.filter(|line| line.contains("lines elided"))
.map(|line| line.trim_matches(['│', ' ']).to_string())
.collect();
let [mock] = mock.as_slice() else {
panic!("expected exactly one elision line in actions.md's own mocks, got {mock:?}");
};
let dropped: usize = mock
.split_whitespace()
.nth(1)
.expect("the mock's dropped count")
.parse()
.expect("the mock's dropped count is a number");
assert_eq!(
rendered_elision_row(
&elided_step_content_lines(&crate::glyphs::FULL, dropped, 3, 2),
"full"
),
*mock
);
}
fn elision_glyph_spellings() -> Vec<String> {
let mut spellings = vec!["\u{b7}".to_string()];
for leading_zeros in 0..=4 {
let zeros = "0".repeat(leading_zeros);
for hex in ["b7", "B7"] {
spellings.push(format!("u{{{zeros}{hex}}}"));
}
}
spellings
}
#[test]
fn repon_core_names_no_elision_glyph() {
let core_src = Path::new(env!("CARGO_MANIFEST_DIR")).join("../repon-core/src");
for needle in elision_glyph_spellings() {
let offending = crate::test_support::production_lines_under_containing(
std::slice::from_ref(&core_src),
&needle,
);
assert!(
offending.is_empty(),
"repon-core names {needle:?}, the mark the consumer's glyph set owns, at: \
{offending:?}"
);
}
}
#[test]
fn captured_output_wraps_a_line_longer_than_the_pane_without_losing_any_character() {
let long_line: String = (0..300)
.map(|index| char::from(b'a' + (index % 26) as u8))
.collect();
let output = format!("{long_line}\n").into_bytes();
let area_width = 40u16;
let wrap_width = (interior_width(area_width) as usize) - CAPTURED_OUTPUT_INDENT.len();
let wrapped =
captured_output_lines(&output, None, interior_width(area_width), full_glyphs());
assert!(
wrapped.len() > 1,
"a 300-character line at a {wrap_width}-column wrap width must wrap into more \
than one row"
);
let mut reconstructed = String::new();
for line in &wrapped {
let ContentLine::Raw(runs) = line else {
panic!("expected every captured-output row to be Raw, got {line:?}");
};
assert_eq!(
runs[0].0, CAPTURED_OUTPUT_INDENT,
"expected every row to open with the captured-output indent"
);
let row_text: String = runs[1..].iter().map(|(text, _)| text.as_str()).collect();
assert!(
row_text.chars().count() <= wrap_width,
"expected row {row_text:?} to fit the {wrap_width}-column wrap width"
);
reconstructed.push_str(&row_text);
}
assert_eq!(
reconstructed, long_line,
"expected every character of the original line preserved across the wrap"
);
}
#[test]
fn a_tab_advances_to_the_next_eight_column_stop_not_a_fixed_width() {
let output = b"ab\tcdefghijkl\tmn\n".to_vec();
let lines = captured_output_lines(&output, None, interior_width(104), full_glyphs());
assert_eq!(
lines.len(),
1,
"expected the one output line to stay one rendered row"
);
let ContentLine::Raw(runs) = &lines[0] else {
panic!("expected a Raw content line, got {:?}", lines[0]);
};
let text: String = runs.iter().map(|(text, _)| text.as_str()).collect();
assert_eq!(
text,
format!("{CAPTURED_OUTPUT_INDENT}ab cdefghijkl mn"),
"expected each tab to reach the next 8-column stop counted from the line's own \
start, got {text:?}"
);
}
#[test]
fn a_tabs_inserted_spaces_keep_the_style_of_the_run_the_tab_came_from() {
use ratatui::style::Color;
let _guard = COLOUR_CAPABILITY_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
crossterm::style::force_color_output(true);
let output = b"a\x1b[31m\t\x1b[0mb\n".to_vec();
let lines = captured_output_lines(&output, None, interior_width(104), full_glyphs());
let ContentLine::Raw(runs) = &lines[0] else {
panic!("expected a Raw content line, got {:?}", lines[0]);
};
let (tab_run_text, tab_run_style) = runs[1..]
.iter()
.find(|(text, _)| text.chars().all(|ch| ch == ' ') && !text.is_empty())
.expect("expected a run of spaces from the expanded tab");
assert_eq!(
tab_run_text.len(),
7,
"expected the tab (starting at column 1, after 'a') to reach column 8 with 7 \
spaces, got {tab_run_text:?}"
);
assert_eq!(
tab_run_style.fg,
Some(Color::Red),
"expected the tab's own inserted spaces to carry the tab's own colour"
);
for (text, style) in runs {
if text == "a" || text == "b" {
assert_ne!(
style.fg,
Some(Color::Red),
"expected the untouched letters either side of the tab to keep their own \
default style, not the tab's"
);
}
}
}
const REAL_LS_PTY_CAPTURE: &[u8] =
b"ab\t\t\tabcdefghij\t\tlonglonglonglongname\tmid1234\t\t\tx\r\n";
#[test]
fn real_ls_output_under_a_pty_keeps_its_columns_tab_stop_aligned() {
use ratatui::{Terminal, backend::TestBackend};
let mut row = entity("a");
row.last_action = Some(action_receipt(
"list",
vec![step_result(
"ls",
StepOutcome::Ok,
REAL_LS_PTY_CAPTURE,
Duration::from_millis(1),
)],
None,
));
let glyphs = full_glyphs();
let detail = Detail::default();
let backend = TestBackend::new(150, 20);
let mut terminal = Terminal::new(backend).expect("create test terminal");
terminal
.draw(|frame| {
detail.draw(frame, frame.area(), &row, glyphs, true, &theme::DEFAULT);
})
.expect("draw the frame");
let buf = terminal.backend().buffer();
let names = ["ab", "abcdefghij", "longlonglonglongname", "mid1234", "x"];
let mut positions = Vec::new();
let mut row_y = None;
for name in names {
let (x, y) = find_text(buf, buf.area, name).unwrap_or_else(|| {
panic!("expected to find {name:?} rendered somewhere in the pane")
});
match row_y {
Some(expected_y) => assert_eq!(
y, expected_y,
"expected every name on the same rendered row, {name:?} landed on a \
different one"
),
None => row_y = Some(y),
}
positions.push(x);
}
let gaps: Vec<u16> = positions.windows(2).map(|pair| pair[1] - pair[0]).collect();
assert_eq!(
gaps,
vec![24, 24, 24, 24],
"expected each name's start column to match real tab-stop arithmetic, got \
positions {positions:?}"
);
let last_x = positions.last().expect("at least one position") + 1;
let border_x = buf.area.width - 1;
let trailing: String = (last_x..border_x)
.map(|x| buf[(x, row_y.expect("a row was found"))].symbol())
.collect();
assert_eq!(
trailing.trim(),
"",
"expected nothing but blank cells after the last name, got {trailing:?}"
);
}
#[test]
fn a_step_whose_output_is_elided_says_so_on_screen() {
use ratatui::{Terminal, backend::TestBackend};
use repon_core::{ActionSpec, Core, CoreSpec, FetchSpec, SetSpec, Step};
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path().canonicalize().expect("canonicalize temp dir");
let status = Command::new("git")
.arg("init")
.args(["--quiet", "--initial-branch", "main"])
.arg(&root)
.status()
.expect("run git init");
assert!(status.success());
git(&root, &["commit", "--allow-empty", "-m", "first"]);
let core = Core::start_discovered(CoreSpec {
set: SetSpec {
name: "test".to_string(),
roots: vec![root.clone()],
include: Vec::new(),
exclude: Vec::new(),
},
overrides: Vec::new(),
poll_interval: Duration::from_secs(3600),
status_stale_after: Duration::from_secs(3600),
generation_deadline: Duration::from_secs(3600),
show_submodules: false,
fetch: FetchSpec {
enabled: false,
interval: Duration::from_secs(3600),
concurrency: 4,
},
auto_update: repon_core::AutoUpdateSpec { enabled: false },
});
let key = core.snapshot().entities[0].key.clone();
let steps = vec![Step {
argv: vec![
"sh".to_string(),
"-c".to_string(),
"i=1; while [ \"$i\" -le 3000 ]; do echo \"line $i\"; i=$((i+1)); done".to_string(),
],
shell: false,
interactive: false,
env: Vec::new(),
}];
let started = core.run_action(
ActionSpec {
label: Arc::from("flood"),
name: None,
steps,
concurrency: 1,
when: None,
},
std::slice::from_ref(&key),
);
assert!(started, "expected the flooding Action to start");
wait_for("the flooding step to finish", || !core.action_running());
let entity = core.snapshot().entities[0].clone();
let glyphs = full_glyphs();
let detail = Detail::default();
let backend = TestBackend::new(120, 450);
let mut terminal = Terminal::new(backend).expect("create test terminal");
terminal
.draw(|frame| {
detail.draw(frame, frame.area(), &entity, glyphs, true, &theme::DEFAULT);
})
.expect("draw the frame");
let buf = terminal.backend().buffer();
let screen = dump_screen(buf, buf.area);
let elision_line = screen
.lines()
.find(|line| line.contains("lines elided"))
.unwrap_or_else(|| {
panic!("expected an elision line somewhere on screen, got:\n{screen}")
});
let dropped_count: usize = elision_line
.split_whitespace()
.find_map(|word| word.parse::<usize>().ok())
.unwrap_or_else(|| {
panic!("expected a number naming the dropped count in {elision_line:?}")
});
assert!(
dropped_count > 0 && dropped_count < 3_000,
"expected a plausible dropped-line count between 0 and 3,000, got {dropped_count}"
);
}
#[test]
fn captured_output_colour_survives_into_the_rendered_buffer() {
use ratatui::{Terminal, backend::TestBackend, style::Color};
let _guard = COLOUR_CAPABILITY_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
crossterm::style::force_color_output(true);
let mut row = entity("a");
row.last_action = Some(action_receipt(
"reinstall",
vec![step_result(
"pnpm install",
StepOutcome::Ok,
b"\x1b[31mZEBRA\x1b[0m",
Duration::from_millis(1),
)],
None,
));
let glyphs = full_glyphs();
let detail = Detail::default();
let backend = TestBackend::new(60, 20);
let mut terminal = Terminal::new(backend).expect("create test terminal");
terminal
.draw(|frame| {
detail.draw(frame, frame.area(), &row, glyphs, true, &theme::DEFAULT);
})
.expect("draw the frame");
let buf = terminal.backend().buffer();
let (x, y) = find_text(buf, buf.area, "ZEBRA")
.expect("expected to find the captured word ZEBRA rendered somewhere in the pane");
assert_eq!(
buf[(x, y)].fg,
Color::Red,
"expected the captured word's own literal colour, not left uncoloured or lost"
);
}
#[test]
fn captured_colour_renders_with_it_on_and_is_stripped_with_it_off_but_the_text_never_changes() {
use ratatui::{Terminal, backend::TestBackend, style::Color};
let _guard = COLOUR_CAPABILITY_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let mut row = entity("a");
row.last_action = Some(action_receipt(
"reinstall",
vec![step_result(
"pnpm install",
StepOutcome::Ok,
b"\x1b[31mZEBRA\x1b[0m plain",
Duration::from_millis(1),
)],
None,
));
let glyphs = full_glyphs();
let detail = Detail::default();
let render = || {
let backend = TestBackend::new(60, 20);
let mut terminal = Terminal::new(backend).expect("create test terminal");
terminal
.draw(|frame| {
detail.draw(frame, frame.area(), &row, glyphs, true, &theme::DEFAULT);
})
.expect("draw the frame");
terminal.backend().buffer().clone()
};
crossterm::style::force_color_output(true);
let coloured = render();
crossterm::style::force_color_output(false);
let monochrome = render();
crossterm::style::force_color_output(true);
let area = coloured.area;
assert_eq!(area, monochrome.area);
for y in area.top()..area.bottom() {
for x in area.left()..area.right() {
assert_eq!(
coloured[(x, y)].symbol(),
monochrome[(x, y)].symbol(),
"expected identical text at ({x}, {y}) regardless of colour capability"
);
}
}
let (x, y) = find_text(&coloured, area, "ZEBRA")
.expect("expected to find the captured word ZEBRA rendered somewhere in the pane");
assert_eq!(
coloured[(x, y)].fg,
Color::Red,
"expected the captured word's own colour with colour on"
);
assert_ne!(
monochrome[(x, y)].fg,
Color::Red,
"expected the captured word's own colour stripped with colour off"
);
}
fn find_text(buf: &Buffer, area: Rect, text: &str) -> Option<(u16, u16)> {
let needle: Vec<char> = text.chars().collect();
for y in area.top()..area.bottom() {
for x in area.left()..area.right() {
if x + needle.len() as u16 > area.right() {
continue;
}
let found = needle
.iter()
.enumerate()
.all(|(offset, ch)| buf[(x + offset as u16, y)].symbol() == ch.to_string());
if found {
return Some((x, y));
}
}
}
None
}
fn dump_screen(buf: &Buffer, area: Rect) -> String {
let mut screen = String::new();
for y in area.top()..area.bottom() {
let mut row = String::new();
for x in area.left()..area.right() {
row.push_str(buf[(x, y)].symbol());
}
screen.push_str(row.trim_end());
screen.push('\n');
}
screen
}
}