use std::time::Duration;
use termlens::{Color, Key, MouseButton, Screen, Scroll, Terminal};
const PREVIEW: [&str; 2] = ["--file", "art/vyncint-2027.json"];
const SIZE: (u16, u16) = (176, 34);
fn chart(args: &[&str]) -> termlens::Result<Terminal> {
spawn(args, SIZE, |builder| builder)
}
fn spawn(
args: &[&str],
size: (u16, u16),
configure: impl FnOnce(termlens::TerminalBuilder) -> termlens::TerminalBuilder,
) -> termlens::Result<Terminal> {
let builder = Terminal::builder()
.size(size.0, size.1)
.env_clear()
.env("COLORTERM", "truecolor")
.current_dir(env!("CARGO_MANIFEST_DIR"))
.timeout(Duration::from_secs(20))
.args(args);
configure(builder).spawn(env!("CARGO_BIN_EXE_mossaic"))
}
fn loaded(screen: &Screen) -> bool {
screen.contains("q quit") && screen.contains("contributions in")
}
fn legend_colours(screen: &Screen) -> Vec<Color> {
let Some(row) = (0..screen.rows()).find(|row| screen.row_text(*row).contains("Less")) else {
return Vec::new();
};
(0..screen.cols())
.filter_map(|col| screen.cell(row, col))
.filter(|cell| cell.contents().contains('\u{1FB2B}'))
.map(|cell| cell.style().fg)
.collect()
}
fn style(screen: &Screen) -> String {
let line = screen
.text()
.lines()
.find(|line| line.contains(" cells"))
.unwrap_or_default()
.to_string();
line.rsplit('·')
.next()
.unwrap_or_default()
.trim()
.trim_end_matches('│')
.trim()
.trim_start_matches("auto: ")
.to_string()
}
#[allow(dead_code)]
fn is_auto(screen: &Screen) -> bool {
screen
.text()
.lines()
.any(|line| line.contains("auto: ") && line.contains(" cells"))
}
fn monday(screen: &Screen) -> u16 {
(0..screen.rows())
.find(|row| {
screen
.row_text(*row)
.trim_start_matches('│')
.starts_with("Mon")
})
.expect("a weekday gutter")
}
const GRID_X: u16 = 1 + 4;
#[test]
fn the_chart_draws_and_quits_cleanly() -> termlens::Result<()> {
let mut t = chart(&PREVIEW)?;
let screen = t.wait_frame(loaded)?;
assert!(screen.alternate_screen(), "the chart owns the whole screen");
assert!(
screen.contains("vyncint · 2027 · 300 contributions in 2027"),
"header should read like github.com's:\n{screen}"
);
assert!(
screen.contains("Less") && screen.contains("More"),
"the legend is github.com's wording:\n{screen}"
);
assert!(screen.contains("preview"), "{screen}");
assert!(!screen.contains("[ ] year"), "{screen}");
t.send(Key::Char('q'))?;
let exit = t.wait_exit()?;
assert!(exit.success(), "clean exit, got {exit:?}");
assert!(
!t.screen().alternate_screen(),
"the terminal is handed back the way it was found"
);
Ok(())
}
#[test]
fn the_cursor_moves_by_day_and_by_week() -> termlens::Result<()> {
let mut t = chart(&PREVIEW)?;
t.wait_frame(loaded)?;
t.send(Key::End)?;
t.wait_frame(|s| s.contains("Fri, Dec 31 2027"))?;
for (key, expected) in [
(Key::Left, "Fri, Dec 24 2027"),
(Key::Up, "Thu, Dec 23 2027"),
(Key::Down, "Fri, Dec 24 2027"),
(Key::Right, "Fri, Dec 31 2027"),
(Key::Char('h'), "Fri, Dec 24 2027"),
(Key::Char('k'), "Thu, Dec 23 2027"),
(Key::Char('j'), "Fri, Dec 24 2027"),
(Key::Char('l'), "Fri, Dec 31 2027"),
(Key::Home, "Fri, Jan 1 2027"),
(Key::End, "Fri, Dec 31 2027"),
] {
t.send(key)?;
t.wait_frame(|s| s.contains(expected))?;
}
t.send(Key::Home)?;
t.wait_frame(|s| s.contains("Fri, Jan 1 2027"))?;
for _ in 0..4 {
t.send(Key::Char('k'))?;
}
t.send(Key::Down)?;
t.wait_frame(|s| s.contains("Sat, Jan 2 2027"))?;
Ok(())
}
#[test]
fn cell_styles_cycle_through_every_shape() -> termlens::Result<()> {
let mut t = chart(&PREVIEW)?;
let first = t.wait_frame(loaded)?;
assert_eq!(style(&first), "rounded cells", "auto picks rounded here");
assert!(is_auto(&first), "and says it chose:\n{first}");
for expected in [
"rounded cells",
"snug cells",
"squares cells",
"grid cells",
"spaced cells",
"blocks cells",
"slim cells",
"compact cells",
] {
t.send(Key::Char('d'))?;
t.wait_frame(|s| style(s) == expected && !is_auto(s))?;
}
t.send(Key::Char('d'))?;
let back = t.wait_frame(is_auto)?;
assert_eq!(style(&back), "rounded cells", "{back}");
t.send(Key::Char('d'))?;
t.wait_frame(|s| style(s) == "rounded cells" && !is_auto(s))?;
Ok(())
}
#[test]
fn the_mouse_hovers_and_clicks() -> termlens::Result<()> {
let mut t = chart(&PREVIEW)?;
let ready = t.wait_frame(|s| loaded(s) && s.mouse_mode() != termlens::MouseMode::None)?;
let row = monday(&ready) + 2;
let stride = 3;
let from = GRID_X + 10 * stride;
let to = GRID_X + 20 * stride;
t.drag(MouseButton::Left, (from, row), (to, row))?;
let screen =
t.wait_frame(|s| s.contains("contributions on ") || s.contains("No contributions on "))?;
let tooltip = screen
.text()
.lines()
.find(|line| line.contains(" on "))
.expect("a tooltip")
.to_string();
assert!(
tooltip.contains('▐') && tooltip.contains('▌'),
"the tooltip is a pill:\n{tooltip}"
);
assert!(
tooltip.ends_with('▌') || tooltip.contains("▌"),
"…closed at both ends:\n{tooltip}"
);
assert!(
screen.text().contains('▼'),
"…with a pointer at the week it describes:\n{screen}"
);
let pointer_row = (0..screen.rows())
.find(|r| screen.row_text(*r).contains('▼'))
.expect("a pointer");
assert_eq!(
pointer_row,
monday(&screen) - 2,
"the pointer sits just above the grid:\n{screen}"
);
t.click(from, row)?;
t.wait_frame(|s| {
s.text()
.lines()
.any(|line| line.starts_with("Wed, ") || line.starts_with("│Wed, "))
})?;
Ok(())
}
#[test]
fn mouse_reporting_can_be_given_back() -> termlens::Result<()> {
let mut t = chart(&PREVIEW)?;
t.wait_frame(|s| loaded(s) && s.mouse_mode() != termlens::MouseMode::None)?;
t.send(Key::Char('m'))?;
t.wait_frame(|s| s.mouse_mode() == termlens::MouseMode::None && s.contains("m mouse on"))?;
t.send(Key::Char('m'))?;
t.wait_frame(|s| s.mouse_mode() != termlens::MouseMode::None && s.contains("m mouse off"))?;
Ok(())
}
#[test]
fn pixel_cells_leave_the_grid_rows_to_the_painter() -> termlens::Result<()> {
let args = [
"--file",
"art/vyncint-2027.json",
"--graphics",
"sixel",
"--cell",
"10x20",
];
let mut t = chart(&args)?;
let screen = t.wait_frame(loaded)?;
assert_eq!(style(&screen), "pixel cells (sixel)", "{screen}");
let top = monday(&screen) - 1;
for row in top..top + 7 {
let text = screen.row_text(row);
let cells = text
.chars()
.skip(usize::from(GRID_X))
.take_while(|c| *c != '│')
.collect::<String>();
assert!(
cells.trim().is_empty(),
"row {row} should be left for the painter: {cells:?}"
);
}
let legend = screen
.text()
.lines()
.find(|line| line.contains("Less"))
.unwrap_or_default()
.to_string();
let gap = legend
.split_once("Less")
.and_then(|(_, rest)| rest.split_once("More"))
.map(|(between, _)| between.to_string())
.unwrap_or_default();
assert!(
gap.len() >= 10 && gap.trim().is_empty(),
"the swatches are an image too, so the text only holds their place: {legend:?}"
);
Ok(())
}
#[test]
fn the_theme_follows_the_terminals_background() -> termlens::Result<()> {
let expect = |levels: [u32; 5]| -> Vec<Color> {
levels
.iter()
.map(|hex| Color::Rgb((hex >> 16) as u8, (hex >> 8) as u8, *hex as u8))
.collect()
};
let mut dark = spawn(&PREVIEW, SIZE, |b| b.background_rgb(0x0d, 0x11, 0x17))?;
let screen = dark.wait_frame(loaded)?;
assert_eq!(
legend_colours(&screen),
expect([0x151b23, 0x033a16, 0x196c2e, 0x2ea043, 0x56d364]),
"a dark terminal gets Primer's dark scale:\n{screen}"
);
let mut light = spawn(&PREVIEW, SIZE, |b| b.background_rgb(0xff, 0xff, 0xff))?;
let screen = light.wait_frame(loaded)?;
assert_eq!(
legend_colours(&screen),
expect([0xeff2f5, 0xaceebb, 0x4ac26b, 0x2da44e, 0x116329]),
"a light terminal gets Primer's light scale:\n{screen}"
);
Ok(())
}
#[test]
fn the_palette_degrades_without_truecolor() -> termlens::Result<()> {
let mut t = Terminal::builder()
.size(SIZE.0, SIZE.1)
.env_clear()
.current_dir(env!("CARGO_MANIFEST_DIR"))
.timeout(Duration::from_secs(20))
.args(PREVIEW)
.spawn(env!("CARGO_BIN_EXE_mossaic"))?;
let screen = t.wait_frame(loaded)?;
let drawn = legend_colours(&screen);
assert!(
drawn.len() == 5 && drawn.iter().all(|c| matches!(c, Color::Indexed(_))),
"without truecolor the levels come from the 256-colour ramp, got {drawn:?}"
);
let mut unique = drawn.clone();
unique.dedup();
assert_eq!(unique.len(), 5, "two levels collapsed: {drawn:?}");
Ok(())
}
#[test]
fn a_narrower_terminal_chooses_a_smaller_cell() -> termlens::Result<()> {
let mut t = chart(&PREVIEW)?;
let screen = t.wait_frame(loaded)?;
assert_eq!(style(&screen), "rounded cells");
t.resize(120, 34)?;
let narrow = t.wait_frame(|s| style(s) == "squares cells")?;
assert!(
narrow.contains("rounded corners need 165 columns"),
"it should say what it wants:\n{narrow}"
);
t.resize(70, 34)?;
t.wait_frame(|s| style(s) == "compact cells")?;
t.resize(SIZE.0, SIZE.1)?;
t.wait_frame(|s| style(s) == "rounded cells")?;
Ok(())
}
#[test]
fn a_missing_file_is_reported_not_swallowed() -> termlens::Result<()> {
let mut t = chart(&["--file", "nope.json"])?;
let screen = t.wait_frame(|s| s.contains("could not read"))?;
assert!(screen.contains("nope.json"), "{screen}");
assert!(
screen.contains("retry"),
"and it offers a way out:\n{screen}"
);
t.send(Key::Char('q'))?;
assert!(t.wait_exit()?.success());
Ok(())
}
#[test]
fn esc_does_not_quit_the_chart() -> termlens::Result<()> {
let mut t = chart(&PREVIEW)?;
t.wait_frame(loaded)?;
t.send(Key::Esc)?;
t.send(Key::End)?;
let after = t.wait_frame(|s| s.contains("Fri, Dec 31 2027"))?;
assert!(
after.alternate_screen(),
"the chart still owns the screen after Esc:\n{after}"
);
t.send(Key::Char('q'))?;
assert!(t.wait_exit()?.success());
Ok(())
}
#[test]
fn the_help_surfaces_do_not_promise_a_key_that_does_nothing() -> termlens::Result<()> {
let mut t = chart(&PREVIEW)?;
t.wait_frame(loaded)?;
t.send(Key::Char('?'))?;
let help = t.wait_frame(|s| s.contains("This terminal"))?;
let quit = help
.text()
.lines()
.find(|line| line.contains("quit"))
.expect("the overlay lists the quit key")
.to_string();
assert!(
!quit.contains("Esc"),
"the overlay still offers Esc as a quit key:\n{quit}"
);
Ok(())
}
#[test]
fn the_help_overlay_answers_what_this_terminal_can_do() -> termlens::Result<()> {
let mut t = chart(&PREVIEW)?;
t.wait_frame(loaded)?;
assert!(t.screen().contains("? help"), "the footer points at it");
t.send(Key::Char('?'))?;
let help = t.wait_frame(|s| s.contains("This terminal"))?;
for wanted in [
"Moving",
"Mouse",
"hover a day",
"kitty graphics",
"sixel",
"character cell",
"drawing with",
"any key closes this",
] {
assert!(help.contains(wanted), "help is missing {wanted:?}:\n{help}");
}
for row in 0..help.rows() {
let line = help.row_text(row);
if let Some((body, _)) = line
.split_once('│')
.and_then(|(_, rest)| rest.rsplit_once('│'))
{
assert!(
!body.ends_with(char::is_alphanumeric),
"row {row} is cut off at the border: {body:?}"
);
}
}
t.send(Key::Char('j'))?;
t.wait_frame(|s| !s.contains("This terminal"))?;
assert!(
t.screen().contains("Less"),
"the chart is back:\n{}",
t.screen()
);
Ok(())
}
#[test]
fn the_demo_needs_no_account_and_no_network() -> termlens::Result<()> {
let mut t = chart(&["--demo"])?;
let screen = t.wait_frame(loaded)?;
assert!(screen.contains("demo · "), "{screen}");
assert!(
screen.contains("contributions in"),
"a full sample year:\n{screen}"
);
assert!(
screen.contains("demo — `mossaic <user>` for a real one"),
"and it says how to get off the demo:\n{screen}"
);
assert!(
!screen.contains("[ ] year"),
"one year only, so no year keys"
);
t.send(Key::Char('q'))?;
assert!(t.wait_exit()?.success());
Ok(())
}
#[test]
#[ignore = "requires gh and network access"]
fn years_users_and_errors() -> termlens::Result<()> {
let mut t = Terminal::builder()
.size(SIZE.0, SIZE.1)
.env("COLORTERM", "truecolor")
.current_dir(env!("CARGO_MANIFEST_DIR"))
.timeout(Duration::from_secs(30))
.spawn(env!("CARGO_BIN_EXE_mossaic"))?;
let screen = t.wait_frame(loaded)?;
let year = chrono_year(&screen);
t.send(Key::Char('['))?;
let earlier = t.wait_frame(|s| chrono_year(s) < year && loaded(s))?;
assert!(earlier.contains("Dec 31"), "{earlier}");
t.send(Key::Char(']'))?;
t.wait_frame(|s| chrono_year(s) == year && loaded(s))?;
let ready = t.wait_frame(|s| s.mouse_mode() != termlens::MouseMode::None)?;
let row = monday(&ready);
t.scroll(GRID_X, row, Scroll::Up)?;
t.wait_frame(|s| chrono_year(s) < year && loaded(s))?;
t.scroll(GRID_X, row, Scroll::Down)?;
t.wait_frame(|s| chrono_year(s) == year && loaded(s))?;
t.send(Key::Char('u'))?;
t.wait_frame(|s| s.contains("esc cancel"))?;
for _ in 0..40 {
t.send(Key::Backspace)?;
}
t.send_str("octocat")?;
t.send(Key::Enter)?;
t.wait_frame(|s| s.contains("octocat") && loaded(s))?;
t.send(Key::Char('u'))?;
t.wait_frame(|s| s.contains("esc cancel"))?;
for _ in 0..40 {
t.send(Key::Backspace)?;
}
t.send_str("zz-no-such-user-9x8")?;
t.send(Key::Enter)?;
t.wait_frame(|s| s.contains("Could not resolve"))?;
t.send(Key::Char('q'))?;
assert!(t.wait_exit()?.success());
Ok(())
}
fn chrono_year(screen: &Screen) -> i32 {
screen
.text()
.lines()
.find_map(|line| {
let (_, rest) = line.split_once(" · ")?;
rest.split(" · ").next()?.trim().parse().ok()
})
.unwrap_or_default()
}
#[test]
fn an_idle_chart_stops_writing() -> termlens::Result<()> {
let mut t = chart(&PREVIEW)?;
t.wait_frame(loaded)?;
let settled = t.screen().repaints();
t.wait_frame(|s| s.repaints() >= settled + 4)?;
let idle: Vec<u32> = t
.frame_timings()
.iter()
.filter(|frame| frame.index() > settled)
.map(|frame| frame.printable_chars())
.collect();
assert!(
idle.len() >= 3,
"expected several idle frames to inspect, got {idle:?}"
);
assert!(
idle.iter().all(|written| *written == 0),
"an idle chart rewrote the screen: printable characters per frame {idle:?}"
);
Ok(())
}
#[test]
fn nothing_rings_the_bell() -> termlens::Result<()> {
let mut t = chart(&PREVIEW)?;
t.wait_frame(loaded)?;
for key in [
Key::Char('d'),
Key::Char('x'),
Key::Char('9'),
Key::Char('Z'),
Key::Tab,
Key::Backspace,
Key::Char('m'),
Key::Char('r'),
Key::Char('?'),
Key::Char('j'),
] {
t.send(key)?;
}
let settled = t.screen().repaints();
let screen = t.wait_frame(|s| s.repaints() > settled + 1)?;
assert_eq!(
screen.bells(),
0,
"mossaic rang the bell {} time(s)",
screen.bells()
);
Ok(())
}
#[test]
fn a_drag_lands_on_the_day_it_ended_on() -> termlens::Result<()> {
let mut t = chart(&PREVIEW)?;
let ready = t.wait_frame(|s| loaded(s) && s.mouse_mode() != termlens::MouseMode::None)?;
let row = monday(&ready) + 2;
let stride = 3;
t.drag(
MouseButton::Left,
(GRID_X + 10 * stride, row),
(GRID_X + 30 * stride, row),
)?;
let screen = t.wait_frame(|s| s.contains(" on "))?;
assert!(
screen.contains("No contributions on July 28th."),
"the tooltip should name the day the drag ended on:\n{screen}"
);
Ok(())
}
#[test]
fn a_small_terminal_still_says_how_to_get_out() -> termlens::Result<()> {
for size in [(80u16, 24u16), (100, 24), (60, 20)] {
let mut t = spawn(&PREVIEW, size, |b| b)?;
let screen = t.wait_frame(loaded)?;
assert!(
screen.contains("q quit"),
"at {size:?} there is no way out on screen:\n{screen}"
);
assert!(screen.contains("? help"), "at {size:?}:\n{screen}");
t.send(Key::Char('?'))?;
let help = t.wait_frame(|s| s.contains("drawing with"))?;
assert!(
help.contains("any key closes this"),
"at {size:?} the overlay dropped its own way out:\n{help}"
);
}
Ok(())
}
#[test]
fn the_advice_line_never_points_at_something_smaller_that_does_not_exist() -> termlens::Result<()> {
let mut t = spawn(&PREVIEW, (50, 22), |b| b)?;
let screen = t.wait_frame(loaded)?;
assert!(
!screen.contains("press d for a smaller style"),
"nothing smaller exists here:\n{screen}"
);
assert!(
screen.contains("at its narrowest"),
"it should say what a year actually needs:\n{screen}"
);
Ok(())
}
#[test]
fn a_click_that_misses_a_day_puts_the_tooltip_away() -> termlens::Result<()> {
let mut t = chart(&PREVIEW)?;
let ready = t.wait_frame(|s| loaded(s) && s.mouse_mode() != termlens::MouseMode::None)?;
let row = monday(&ready) + 2;
t.click(GRID_X + 10 * 3, row)?;
t.wait_frame(|s| s.text().contains(" on "))?;
t.click(40, 1)?;
let after = t.wait_frame(|s| !s.text().contains(" on "))?;
assert!(
!after.text().contains(" on "),
"the tooltip outlived the day it described:\n{after}"
);
Ok(())
}
#[test]
fn the_wheel_does_nothing_behind_the_help_overlay() -> termlens::Result<()> {
let mut t = chart(&["--demo"])?;
let ready = t.wait_frame(|s| loaded(s) && s.mouse_mode() != termlens::MouseMode::None)?;
let year = chrono_year(&ready);
let row = monday(&ready);
t.send(Key::Char('?'))?;
let help = t.wait_frame(|s| s.contains("This terminal"))?;
t.scroll(GRID_X, row, Scroll::Up)?;
let settled = help.repaints();
let after = t.wait_frame(|s| s.repaints() > settled + 2)?;
assert!(
after.contains("This terminal"),
"the overlay should still be up:\n{after}"
);
assert_eq!(
chrono_year(&after),
year,
"the wheel changed the year behind it:\n{after}"
);
Ok(())
}
#[test]
fn the_documented_window_sizes_are_the_ones_a_terminal_reports() -> termlens::Result<()> {
for (size, expected) in [
((112u16, 19u16), "squares cells"),
((111, 19), "compact cells"),
((112, 18), "compact cells"),
((165, 19), "rounded cells"),
((164, 19), "squares cells"),
] {
let mut t = spawn(&PREVIEW, size, |b| b)?;
let screen = t.wait_frame(|s| s.contains(" cells") && s.contains("contributions in"))?;
assert_eq!(
style(&screen),
expected,
"at {}x{}:\n{screen}",
size.0,
size.1
);
t.send(Key::Char('q'))?;
let _ = t.wait_exit()?;
}
Ok(())
}
#[test]
fn the_advice_line_names_the_window_size_not_the_drawable_area() -> termlens::Result<()> {
let mut t = spawn(&PREVIEW, (112, 18), |b| b)?;
let screen = t.wait_frame(|s| s.contains("needs") && s.contains("rows"))?;
assert!(
screen.contains("the chart needs 19 rows — this window has 18"),
"the numbers a terminal reports, not the ones inside the border:\n{screen}"
);
t.send(Key::Char('q'))?;
let _ = t.wait_exit()?;
Ok(())
}