use std::path::Path;
use std::process::{Command, Output};
fn art(args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_mossaic-art"))
.current_dir(env!("CARGO_MANIFEST_DIR"))
.args(args)
.output()
.expect("the art binary runs")
}
fn scratch(name: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!("mossaic-{}-{name}", std::process::id()))
}
fn stdout(output: &Output) -> String {
String::from_utf8_lossy(&output.stdout).into_owned()
}
#[test]
fn the_font_can_be_looked_at() {
let out = art(&["--font", "--no-colour"]);
assert!(out.status.success());
let text = stdout(&out);
let count: usize = text
.split_whitespace()
.next()
.and_then(|word| word.parse().ok())
.unwrap_or_else(|| panic!("no glyph count in {text}"));
assert!(count >= 39, "{count} glyphs: {text}");
assert!(text.contains("glyphs, 5x5 each"), "{text}");
assert!(
text.contains("write any of them with mossaic-art TEXT"),
"{text}"
);
for character in ['A', 'Z', '0', '9', '-', '.'] {
assert!(
text.contains(character),
"the font view is missing {character}"
);
}
assert!(
text.contains("space"),
"including the one with no glyph: {text}"
);
}
#[test]
fn a_shape_can_be_written_between_colons() {
let out = art(&["I :HEART: RUST", "--year", "2027", "--no-colour"]);
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let text = stdout(&out);
assert!(text.contains("I \u{2665} RUST"), "{text}");
assert!(text.contains("47 of 53 columns"), "{text}");
let lower = art(&["I :heart: RUST", "--year", "2027", "--no-colour"]);
assert_eq!(stdout(&lower), text, "case should not change the drawing");
}
#[test]
fn a_pasted_symbol_draws_the_same_shape_as_its_name() {
let by_name = art(&[":star::heart:", "--year", "2027", "--no-colour"]);
let by_symbol = art(&["\u{2605}\u{2665}", "--year", "2027", "--no-colour"]);
let by_emoji = art(&["\u{2b50}\u{2764}\u{fe0f}", "--year", "2027", "--no-colour"]);
assert!(
by_name.status.success(),
"{}",
String::from_utf8_lossy(&by_name.stderr)
);
assert_eq!(stdout(&by_symbol), stdout(&by_name), "symbol vs name");
assert_eq!(
stdout(&by_emoji),
stdout(&by_name),
"a pasted emoji, variation selector and all, vs name"
);
}
#[test]
fn a_misspelt_shape_says_which_shapes_there_are() {
let unknown = art(&[":wombat:", "--year", "2027"]);
assert!(
!unknown.status.success(),
"it should refuse rather than guess"
);
let error = String::from_utf8_lossy(&unknown.stderr).into_owned();
assert!(error.contains("wombat"), "{error}");
assert!(
error.contains(":star:") && error.contains(":heart:"),
"{error}"
);
let unclosed = art(&["12:30", "--year", "2027"]);
assert!(!unclosed.status.success());
let error = String::from_utf8_lossy(&unclosed.stderr).into_owned();
assert!(error.contains("unclosed"), "{error}");
assert!(error.contains(":name:"), "{error}");
}
#[test]
fn the_font_view_names_its_shapes() {
let out = art(&["--font", "--no-colour"]);
assert!(out.status.success());
let text = stdout(&out);
for named in [
":star:", ":heart:", ":smile:", ":sad:", ":moon:", ":flower:",
] {
assert!(text.contains(named), "the font view is missing {named}");
}
assert!(
!text.contains('\u{2605}'),
"a bare symbol in the headings: {text}"
);
}
#[test]
fn a_shape_round_trips_through_a_saved_plan() {
let dir = scratch("shape-plan");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let run = |args: &[&str]| {
Command::new(env!("CARGO_BIN_EXE_mossaic-art"))
.current_dir(&dir)
.args(args)
.output()
.expect("the art binary runs")
};
let saved = run(&[":heart::star:", "--year", "2027", "--save", "--no-colour"]);
assert!(
saved.status.success(),
"{}",
String::from_utf8_lossy(&saved.stderr)
);
let plan =
std::fs::read_to_string(dir.join(mossaic::plan::DEFAULT_SPEC)).expect("a plan on disk");
assert!(
plan.contains('\u{2665}') && plan.contains('\u{2605}'),
"{plan}"
);
assert!(
!plan.contains(":heart:"),
"the name should not survive: {plan}"
);
let calendar = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("art/vyncint-2027.json")
.to_string_lossy()
.into_owned();
let tracked = run(&[
"--track",
"--merge",
&calendar,
"--no-colour",
"--today",
"2027-06-01",
]);
assert!(
tracked.status.success(),
"{}",
String::from_utf8_lossy(&tracked.stderr)
);
assert!(
stdout(&tracked).contains('\u{2665}'),
"{}",
stdout(&tracked)
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_readme_lists_every_glyph_the_font_has() {
let readme = std::fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("README.md"))
.expect("a README");
let section = readme
.split("## What you can draw")
.nth(1)
.expect("a section listing the font")
.split("\n## ")
.next()
.expect("the section ends");
for (name, _) in mossaic::art::shapes() {
assert!(
section.contains(&format!(":{name}:")),
"the README does not list :{name}:"
);
}
for character in mossaic::art::alphabet() {
if character.is_whitespace() || mossaic::art::shape_name(character).is_some() {
continue;
}
assert!(
section.contains(character),
"the README does not list {character:?}"
);
}
for token in section.split_whitespace() {
let Some(name) = token
.trim_matches('`')
.strip_prefix(':')
.and_then(|rest| rest.strip_suffix(':'))
else {
continue;
};
assert!(
mossaic::art::shape(name).is_some(),
"the README promises :{name}:, which the font does not have"
);
}
let mut rows = 0;
for line in section.lines().filter(|line| line.starts_with('|')) {
let cells: Vec<&str> = line.split('|').map(str::trim).collect();
let [_, symbols, names, pastes, ..] = cells.as_slice() else {
continue;
};
let Some(first) = names.split_whitespace().next() else {
continue;
};
let Some(name) = first
.trim_matches('`')
.strip_prefix(':')
.and_then(|rest| rest.strip_suffix(':'))
else {
continue;
};
let wanted = mossaic::art::bitmap(&format!(":{name}:")).expect("a shape");
rows += 1;
let named: Vec<&str> = names
.split_whitespace()
.filter_map(|token| {
token
.trim_matches('`')
.strip_prefix(':')
.and_then(|rest| rest.strip_suffix(':'))
})
.collect();
for symbol in symbols.chars().filter(|c| !c.is_whitespace()) {
let drawn = mossaic::art::bitmap(&symbol.to_string())
.unwrap_or_else(|error| panic!("the README shows {symbol:?}: {error}"));
assert!(
named
.iter()
.any(|name| drawn == mossaic::art::bitmap(&format!(":{name}:")).unwrap()),
"the README shows {symbol:?} beside {named:?}, which it does not draw"
);
}
for pasted in pastes
.chars()
.filter(|c| {
!c.is_whitespace() && !c.is_ascii() && !matches!(c, '\u{fe0f}' | '\u{fe0e}')
})
{
let drawn = mossaic::art::bitmap(&pasted.to_string())
.unwrap_or_else(|error| panic!("the README offers {pasted:?}: {error}"));
assert_eq!(
drawn, wanted,
"the README offers {pasted:?} for :{name}:, which is not what it draws"
);
}
}
assert!(
rows >= 16,
"only {rows} rows of the shape table were checked"
);
}
#[test]
fn a_shape_reaches_the_commits_it_writes() {
let dir = scratch("shape-write");
let _ = std::fs::remove_dir_all(&dir);
let repo = dir.to_string_lossy().into_owned();
let out = art(&[
":heart:",
"--year",
"2027",
"--commits",
"1",
"--repo",
&repo,
"--write",
"--name",
"Tester",
"--email",
"tester@example.invalid",
"--no-colour",
]);
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
stdout(&out).contains("nothing has been pushed"),
"{}",
stdout(&out)
);
let log = Command::new("git")
.current_dir(&dir)
.args(["log", "--format=%s"])
.output()
.expect("git log runs");
let subjects = String::from_utf8_lossy(&log.stdout).into_owned();
assert!(!subjects.trim().is_empty(), "it wrote commits");
assert!(
subjects.contains("art: \u{2665}"),
"the shape did not reach the commit message: {subjects}"
);
assert!(!subjects.contains(":heart:"), "{subjects}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_shape_survives_the_json_the_action_reads() {
let out = art(&[
":heart:",
"--year",
"2027",
"--track",
"--merge",
"art/vyncint-2027.json",
"--today",
"2027-06-01",
"--format",
"json",
]);
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let text = stdout(&out);
let parsed: serde_json::Value = serde_json::from_str(&text).expect("valid JSON");
assert_eq!(parsed["text"], "\u{2665}", "{text}");
}
#[test]
fn the_font_sheet_in_the_readme_is_the_font() {
let committed = Path::new(env!("CARGO_MANIFEST_DIR")).join("art/font.png");
let fresh = scratch("font-sheet.png");
let _ = std::fs::remove_file(&fresh);
let out = art(&["--font", "--png", &fresh.to_string_lossy()]);
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
assert!(stdout(&out).contains("76 glyphs"), "{}", stdout(&out));
let drawn = std::fs::read(&fresh).expect("the sheet it just wrote");
let shipped = std::fs::read(&committed).expect("art/font.png");
assert_eq!(
drawn.len(),
shipped.len(),
"art/font.png is {} bytes and the font now draws {} — regenerate it:\n \
cargo run --bin mossaic-art -- --font --png art/font.png",
shipped.len(),
drawn.len()
);
assert!(
drawn == shipped,
"art/font.png is not what the font draws — regenerate it:\n \
cargo run --bin mossaic-art -- --font --png art/font.png"
);
assert_eq!(&shipped[..8], b"\x89PNG\r\n\x1a\n");
let readme = std::fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("README.md"))
.expect("a README");
assert!(
readme.contains("](art/font.png)"),
"the README shows the sheet"
);
let _ = std::fs::remove_file(&fresh);
}
#[test]
fn an_unknown_character_says_what_there_is() {
let out = art(&["HI~THERE", "--year", "2027"]);
assert!(!out.status.success(), "it should refuse rather than guess");
let error = String::from_utf8_lossy(&out.stderr).into_owned();
assert!(error.contains('~'), "{error}");
assert!(error.contains("ABCDEFGHIJKLMNOPQRSTUVWXYZ"), "{error}");
}
#[test]
fn tracking_a_finished_year_says_so() {
let out = art(&[
"VYNCINT",
"--year",
"2027",
"--track",
"--merge",
"art/vyncint-2027.json",
"--today",
"2026-08-19",
"--no-colour",
]);
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let text = stdout(&out);
assert!(text.contains("VYNCINT is drawn."), "{text}");
assert!(text.contains("75 of 75 bright"), "{text}");
assert!(!text.contains("cannot be drawn"), "{text}");
assert!(text.contains("2027 has not started"), "{text}");
}
#[test]
fn tracking_a_busy_year_says_why_it_cannot_be_drawn() {
let out = art(&[
"VYNCINT",
"--year",
"2026",
"--track",
"--merge",
"art/vyncint-2026.json",
"--no-colour",
"--today",
"2026-08-19",
"--start-week",
"1",
]);
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let text = stdout(&out);
assert!(
text.contains("VYNCINT cannot be drawn cleanly in 2026."),
"{text}"
);
assert!(
text.contains("inside the letters already have contributions"),
"and it should say why:\n{text}"
);
assert!(
text.contains("a letter day has to reach"),
"and what a day costs:\n{text}"
);
assert_eq!(
text,
stdout(&art(&[
"VYNCINT",
"--year",
"2026",
"--track",
"--merge",
"art/vyncint-2026.json",
"--no-colour",
"--today",
"2026-08-19",
"--start-week",
"1",
]))
);
}
#[test]
fn tracking_without_a_calendar_explains_itself() {
let out = Command::new(env!("CARGO_BIN_EXE_mossaic-art"))
.current_dir(env!("CARGO_MANIFEST_DIR"))
.env("PATH", "/nonexistent")
.args(["VYNCINT", "--year", "2026", "--track"])
.output()
.expect("the art binary runs");
assert!(!out.status.success());
let error = String::from_utf8_lossy(&out.stderr).into_owned();
assert!(
error.contains("--track USER") || error.contains("gh auth login"),
"{error}"
);
}
#[test]
fn a_snapshot_round_trips_through_the_chart() {
let out = scratch("art-cli.json");
let path = out.to_string_lossy().into_owned();
let made = art(&["HI", "--year", "2027", "--snapshot", &path, "--no-colour"]);
assert!(
made.status.success(),
"{}",
String::from_utf8_lossy(&made.stderr)
);
assert!(
stdout(&made).contains("mossaic --file"),
"it says what to do next"
);
assert!(Path::new(&path).exists());
let png = scratch("art-cli.png");
let drawn = Command::new(env!("CARGO_BIN_EXE_mossaic"))
.args(["--file", &path, "--png", &png.to_string_lossy()])
.output()
.expect("the chart runs");
assert!(
drawn.status.success(),
"{}",
String::from_utf8_lossy(&drawn.stderr)
);
assert!(png.exists());
let _ = std::fs::remove_file(&out);
let _ = std::fs::remove_file(&png);
}
#[test]
fn the_help_lists_every_flag_the_parser_takes() {
let source = include_str!("../src/bin/mossaic-art.rs");
let arms: Vec<&str> = source
.lines()
.map(str::trim)
.filter(|line| line.starts_with('"') && line.contains("=>"))
.flat_map(|line| line.split('"').skip(1).step_by(2))
.filter(|token| token.starts_with('-'))
.collect();
assert!(
arms.len() > 15,
"only found {} flags — did the parser move?",
arms.len()
);
let help = stdout(&art(&["--help"]));
for flag in arms {
assert!(help.contains(flag), "--help never mentions {flag}");
}
assert!(
help.contains("examples:"),
"and it should show how, not just what"
);
assert!(help.contains("--save"), "{help}");
assert!(help.contains("mossaic-art --track"), "{help}");
}
#[test]
fn the_report_is_machine_readable() {
let out = art(&[
"VYNCINT",
"--year",
"2026",
"--track",
"--merge",
"art/vyncint-2026.json",
"--start-week",
"1",
"--today",
"2026-08-19",
"--format",
"json",
]);
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let json: serde_json::Value =
serde_json::from_str(&stdout(&out)).expect("--format json emits json");
for field in [
"text",
"year",
"source",
"start_week",
"columns",
"year_total",
"peak",
"need_per_day",
"letters",
"bright",
"owing_days",
"owing_commits",
"holes",
"around",
"verdict",
"headline",
"ahead_days",
"overdue_days",
] {
assert!(!json[field].is_null(), "the report lost `{field}`");
}
assert_eq!(json["text"], "VYNCINT");
assert_eq!(json["year"], 2026);
assert_eq!(json["verdict"], "holed");
assert_eq!(json["letters"], 75);
assert!(json["holes"].as_u64().unwrap() > 0);
let headline = json["headline"].as_str().unwrap();
assert!(headline.contains("VYNCINT"), "{headline}");
assert!(headline.contains("hole"), "{headline}");
let md = stdout(&art(&[
"VYNCINT",
"--year",
"2026",
"--track",
"--merge",
"art/vyncint-2026.json",
"--start-week",
"1",
"--today",
"2026-08-19",
"--format",
"markdown",
]));
assert!(md.starts_with("### VYNCINT · 2026"), "{md}");
assert!(md.contains("Cannot be drawn cleanly"), "{md}");
assert!(md.contains("| letters bright | 75 of 75 |"), "{md}");
assert!(
!md.contains('\x1b'),
"a message body carries no escape codes:\n{md}"
);
}
#[test]
fn an_unknown_format_is_refused() {
let out = art(&["VYNCINT", "--year", "2027", "--track", "--format", "yaml"]);
assert!(!out.status.success());
let error = String::from_utf8_lossy(&out.stderr).into_owned();
assert!(error.contains("text, json or markdown"), "{error}");
}
#[test]
fn a_saved_plan_makes_the_flags_optional() {
let dir = scratch("plan-test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let run = |args: &[&str]| {
Command::new(env!("CARGO_BIN_EXE_mossaic-art"))
.current_dir(&dir)
.args(args)
.output()
.expect("the art binary runs")
};
let lost = run(&["--track"]);
assert!(!lost.status.success());
let hint = String::from_utf8_lossy(&lost.stderr).into_owned();
assert!(hint.contains("--save"), "{hint}");
assert!(hint.contains("no plan at"), "{hint}");
let saved = run(&["VYNCINT", "--year", "2027", "--save", "--no-colour"]);
assert!(
saved.status.success(),
"{}",
String::from_utf8_lossy(&saved.stderr)
);
assert!(
String::from_utf8_lossy(&saved.stderr).contains("mossaic-art --track"),
"it says what is next, on stderr"
);
let spec: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(dir.join("mossaic-plan.json")).unwrap())
.unwrap();
assert_eq!(spec["text"], "VYNCINT");
assert_eq!(spec["year"], 2027);
assert_eq!(spec["start_week"], 6, "the centred column, resolved");
assert_eq!(spec["top"], 1);
let tracked = run(&["--track", "--merge", "../../nonexistent.json"]);
let error = String::from_utf8_lossy(&tracked.stderr).into_owned();
assert!(
error.contains("nonexistent.json"),
"it should have got as far as reading the calendar: {error}"
);
let overridden = run(&["--year", "2028", "--no-colour"]);
assert!(
stdout(&overridden).contains("2028"),
"{}",
stdout(&overridden)
);
assert!(
!stdout(&overridden).contains("· 2027 ·"),
"{}",
stdout(&overridden)
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn every_binary_answers_the_basics() {
for (name, binary) in [
("mossaic", env!("CARGO_BIN_EXE_mossaic")),
("mossaic-art", env!("CARGO_BIN_EXE_mossaic-art")),
("mossaic-glyphs", env!("CARGO_BIN_EXE_mossaic-glyphs")),
] {
for flag in ["--help", "-h", "--version", "-V"] {
let out = Command::new(binary).arg(flag).output().expect("runs");
assert!(out.status.success(), "{name} {flag} failed");
let text = String::from_utf8_lossy(&out.stdout).into_owned();
assert!(!text.trim().is_empty(), "{name} {flag} printed nothing");
if flag.contains("version") || flag == "-V" {
assert!(
text.contains(env!("CARGO_PKG_VERSION")),
"{name} {flag} does not print the version: {text}"
);
assert!(text.starts_with(name), "{name} {flag} says: {text}");
}
}
}
}
#[test]
fn bad_input_is_refused_the_same_way_by_every_binary() {
for binary in [
env!("CARGO_BIN_EXE_mossaic"),
env!("CARGO_BIN_EXE_mossaic-art"),
] {
for bad in ["abc", "999999", "-5", "2101", "0"] {
let out = Command::new(binary)
.args(["HI", "--year", bad])
.output()
.expect("runs");
let error = String::from_utf8_lossy(&out.stderr).into_owned();
assert!(!out.status.success(), "{binary} accepted --year {bad}");
assert!(
error.contains("wants a year between 2000 and 2100"),
"{binary} --year {bad}: {error}"
);
assert!(
!error.contains("panicked"),
"{binary} --year {bad} panicked"
);
}
let out = Command::new(binary)
.args(["--year"])
.output()
.expect("runs");
assert!(String::from_utf8_lossy(&out.stderr).contains("--year needs a value"));
}
}
#[test]
fn colour_follows_the_convention() {
let piped = stdout(&art(&["--font"]));
assert!(!piped.contains('\x1b'), "a pipe should get no colour");
let forced = Command::new(env!("CARGO_BIN_EXE_mossaic-art"))
.current_dir(env!("CARGO_MANIFEST_DIR"))
.args(["--font", "--color", "always"])
.output()
.expect("runs");
assert!(
String::from_utf8_lossy(&forced.stdout).contains('\x1b'),
"--color always should colour a pipe"
);
let no_color = Command::new(env!("CARGO_BIN_EXE_mossaic-art"))
.current_dir(env!("CARGO_MANIFEST_DIR"))
.env("NO_COLOR", "1")
.args(["--font", "--color", "auto"])
.output()
.expect("runs");
assert!(!String::from_utf8_lossy(&no_color.stdout).contains('\x1b'));
let bad = Command::new(env!("CARGO_BIN_EXE_mossaic-art"))
.args(["--font", "--color", "chartreuse"])
.output()
.expect("runs");
assert!(!bad.status.success());
assert!(String::from_utf8_lossy(&bad.stderr).contains("auto, always or never"));
}
#[test]
fn a_background_is_drawn_as_a_shade_and_priced_as_one() {
let out = art(&[
"VYNCINT",
"--year",
"2027",
"--background",
"1",
"--no-colour",
]);
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let text = stdout(&out);
assert!(
text.contains("background level 1 under letters at level 4"),
"{text}"
);
assert!(text.contains("290 background days, 1 each"), "{text}");
assert!(text.contains("clear"), "level 1 under 4 is clear: {text}");
assert!(!text.contains("faint"), "{text}");
assert!(text.contains("590 commits"), "{text}");
assert!(text.contains('░'), "the field: {text}");
assert!(text.contains('█'), "the letters: {text}");
}
#[test]
fn neighbouring_shades_are_drawn_but_said_to_be_faint() {
let out = art(&["VYNCINT", "--year", "2027", "--bg", "3", "--no-colour"]);
assert!(out.status.success(), "faint is a warning, not a refusal");
let text = stdout(&out);
assert!(text.contains("faint"), "{text}");
assert!(
text.contains("neighbouring shades"),
"it should say why: {text}"
);
assert!(
text.contains("--background 2"),
"and what to do instead: {text}"
);
}
#[test]
fn a_background_that_would_hide_the_letters_is_refused() {
let same = art(&["VYNCINT", "--year", "2027", "--background", "4"]);
assert!(!same.status.success());
assert_eq!(same.status.code(), Some(2));
let error = String::from_utf8_lossy(&same.stderr).into_owned();
assert!(error.contains("darker"), "{error}");
let wild = art(&["VYNCINT", "--year", "2027", "--background", "7"]);
assert!(!wild.status.success());
let error = String::from_utf8_lossy(&wild.stderr).into_owned();
assert!(error.contains("between 0 and 4"), "{error}");
let cramped = art(&[
"VYNCINT",
"--year",
"2027",
"--background",
"1",
"--commits",
"1",
]);
assert!(!cramped.status.success());
let error = String::from_utf8_lossy(&cramped.stderr).into_owned();
assert!(error.contains("would not show"), "{error}");
assert!(
error.contains("at least 2 commits"),
"it should quote the fix: {error}"
);
}
#[test]
fn tracking_reports_the_letters_and_the_field_apart() {
let out = art(&[
"VYNCINT",
"--year",
"2027",
"--track",
"--merge",
"art/vyncint-2027.json",
"--background",
"1",
"--today",
"2027-06-01",
"--no-colour",
]);
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let text = stdout(&out);
assert!(
text.contains("75 of 75 bright"),
"the letters are done: {text}"
);
assert!(
text.contains("0 of 290 at level 1"),
"and the field is not: {text}"
);
assert!(
text.contains("the shades"),
"the plan says which two shades it means: {text}"
);
assert!(
text.contains("a background day has to reach 1"),
"and what one costs: {text}"
);
assert!(
!text.contains("VYNCINT is drawn."),
"finished letters are not a finished picture: {text}"
);
assert!(
text.contains("0 for the letters, 290 for the field"),
"the verdict has to count both: {text}"
);
}
#[test]
fn a_saved_plan_remembers_the_background() {
let dir = scratch("plan-background-test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let run = |args: &[&str]| {
Command::new(env!("CARGO_BIN_EXE_mossaic-art"))
.current_dir(&dir)
.args(args)
.output()
.expect("the art binary runs")
};
let saved = run(&[
"VYNCINT",
"--year",
"2027",
"--background",
"2",
"--save",
"--no-colour",
]);
assert!(
saved.status.success(),
"{}",
String::from_utf8_lossy(&saved.stderr)
);
let spec: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(dir.join("mossaic-plan.json")).unwrap())
.unwrap();
assert_eq!(spec["background"], 2, "the shade is part of the plan");
let again = stdout(&run(&["--no-colour"]));
assert!(
again.contains("background level 2 under letters at level 4"),
"{again}"
);
std::fs::write(
dir.join("mossaic-plan.json"),
r#"{"text":"HI","year":2027,"start_week":10,"top":1,"commits":4,"user":null}"#,
)
.unwrap();
let old = run(&["--no-colour"]);
assert!(
old.status.success(),
"{}",
String::from_utf8_lossy(&old.stderr)
);
assert!(
!stdout(&old).contains("background level"),
"an old plan draws no background: {}",
stdout(&old)
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_background_check_meant_for_drawing_does_not_block_tracking() {
let tracking = art(&[
"VYNCINT",
"--year",
"2026",
"--start-week",
"6",
"--background",
"1",
"--commits",
"4",
"--track",
"--merge",
"art/vyncint-2026.json",
"--today",
"2026-08-19",
"--no-colour",
]);
assert!(
tracking.status.success(),
"tracking must not be refused over --commits: {}",
String::from_utf8_lossy(&tracking.stderr)
);
let text = stdout(&tracking);
assert!(
text.contains("letters at level 4, background at level 1"),
"and it reports the shades the plan actually wants: {text}"
);
let drawing = art(&[
"VYNCINT",
"--year",
"2026",
"--start-week",
"6",
"--background",
"1",
"--commits",
"4",
"--merge",
"art/vyncint-2026.json",
"--no-colour",
]);
assert!(
!drawing.status.success(),
"drawing with indistinguishable shades is still refused"
);
let error = String::from_utf8_lossy(&drawing.stderr).into_owned();
assert!(error.contains("would not show"), "{error}");
}
#[test]
fn a_hole_is_called_a_hole_not_a_background_at_level_zero() {
let out = art(&[
"VYNCINT",
"--year",
"2026",
"--track",
"--merge",
"art/vyncint-2026.json",
"--today",
"2026-08-19",
"--no-colour",
]);
assert!(out.status.success());
let text = stdout(&out);
let today = text
.lines()
.find(|line| line.trim_start().starts_with("today"))
.expect("a year under way reports on today");
assert!(
!today.contains("level 0"),
"there is no level-0 background to be over: {today}"
);
assert!(
today.contains("permanent hole"),
"a lit day inside the letters is a hole: {today}"
);
}
#[test]
fn a_day_that_must_stay_dark_says_so_before_it_arrives() {
let args = |format: &'static str| -> Vec<&'static str> {
vec![
"VYNCINT",
"--year",
"2026",
"--track",
"--merge",
"art/vyncint-2026.json",
"--today",
"2026-08-20",
"--no-colour",
"--format",
format,
]
};
let text = stdout(&art(&args("text")));
let today = text
.lines()
.find(|line| line.trim_start().starts_with("today"))
.expect("a year under way reports on today");
assert!(
today.contains("keep it dark"),
"the instruction has to be the instruction: {today}"
);
assert!(
!today.contains("anything committed on it shows"),
"and never the opposite of it: {today}"
);
assert!(
text.contains("Thu Aug 20 keep dark"),
"the schedule names it too:\n{text}"
);
let json: serde_json::Value = serde_json::from_str(&stdout(&art(&args("json")))).expect("json");
assert_eq!(json["today"]["kind"], "keep-dark");
assert_eq!(json["today"]["ceiling"], 0);
assert_eq!(json["today"]["over"], 0, "clean, so not yet a hole");
assert_eq!(json["holes"], 61);
let md = stdout(&art(&args("markdown")));
assert!(
md.contains("| today | inside the letters — keep it dark |"),
"{md}"
);
}
#[test]
fn today_is_an_input_so_a_report_is_reproducible() {
let on = |date: &str| {
stdout(&art(&[
"VYNCINT",
"--year",
"2026",
"--track",
"--merge",
"art/vyncint-2026.json",
"--today",
date,
"--no-colour",
]))
};
let lit = on("2026-08-19");
assert!(
lit.contains("today Wed Aug 19"),
"the date it was told, not the date it is: {lit}"
);
let dark = on("2026-08-20");
assert!(dark.contains("today Thu Aug 20"), "{dark}");
assert_ne!(
lit, dark,
"two days inside the same block, reported differently"
);
assert_eq!(lit, on("2026-08-19"));
let out = art(&[
"VYNCINT",
"--year",
"2026",
"--track",
"--merge",
"art/vyncint-2026.json",
"--today",
"2027-03-01",
"--format",
"json",
]);
let json: serde_json::Value = serde_json::from_str(&stdout(&out)).expect("json");
assert!(json["today"].is_null(), "{}", json["today"]);
assert!(json["tomorrow"].is_null(), "{}", json["tomorrow"]);
let note = String::from_utf8_lossy(&out.stderr).into_owned();
assert!(note.contains("2027-03-01 is after 2026"), "{note}");
assert!(
note.contains("no today or tomorrow"),
"and why that matters: {note}"
);
for bad in ["notadate", "2026-13-01", "1999-01-01"] {
let out = art(&["VYNCINT", "--track", "--today", bad]);
assert!(!out.status.success(), "--today {bad} was accepted");
assert_eq!(out.status.code(), Some(2));
}
}
#[test]
fn numeric_flags_are_bounded_rather_than_cast() {
for (flag, value, wanted) in [
("--commits", "-1", "between 1 and 1000000"),
("--commits", "0", "between 1 and 1000000"),
("--top", "-1", "between 0 and 2"),
("--top", "9", "between 0 and 2"),
("--start-week", "-1", "between 0 and 60"),
("--background", "-1", "between 0 and 4"),
] {
let out = art(&["VYNCINT", "--year", "2027", flag, value]);
let error = String::from_utf8_lossy(&out.stderr).into_owned();
assert!(!out.status.success(), "{flag} {value} was accepted");
assert_eq!(out.status.code(), Some(2), "{flag} {value}");
assert!(error.contains(wanted), "{flag} {value}: {error}");
assert!(!error.contains("panicked"), "{flag} {value}: {error}");
}
let out = art(&["VYNCINT", "--year", "2027", "--start-week", "20"]);
assert!(!out.status.success());
let error = String::from_utf8_lossy(&out.stderr).into_owned();
assert!(error.contains("past the end of 2027"), "{error}");
assert!(error.contains("the last one that fits is 12"), "{error}");
}
#[test]
fn a_saved_plan_remembers_who_to_track() {
let dir = scratch("plan-user-test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("mossaic-plan.json"),
r#"{"text":"HI","year":2026,"start_week":10,"top":1,"commits":4,
"background":0,"user":"octocat"}"#,
)
.unwrap();
let out = Command::new(env!("CARGO_BIN_EXE_mossaic-art"))
.current_dir(&dir)
.env("PATH", "/nonexistent")
.args(["--track"])
.output()
.expect("the art binary runs");
let error = String::from_utf8_lossy(&out.stderr).into_owned();
assert!(
error.contains("octocat"),
"the plan named who to track: {error}"
);
assert!(
!error.contains("could not tell whose"),
"it knew all along — it just threw the answer away: {error}"
);
let typed = Command::new(env!("CARGO_BIN_EXE_mossaic-art"))
.current_dir(&dir)
.env("PATH", "/nonexistent")
.args(["--track", "someone-else"])
.output()
.expect("the art binary runs");
let error = String::from_utf8_lossy(&typed.stderr).into_owned();
assert!(error.contains("someone-else"), "{error}");
assert!(!error.contains("octocat"), "{error}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_calendar_from_another_year_says_so() {
let out = art(&[
"VYNCINT",
"--year",
"2027",
"--track",
"--merge",
"art/vyncint-2026.json",
"--today",
"2027-06-01",
"--no-colour",
]);
assert!(out.status.success(), "a warning, not a refusal");
let error = String::from_utf8_lossy(&out.stderr).into_owned();
assert!(error.contains("holds no contributions in 2027"), "{error}");
assert!(error.contains("it covers 2026"), "{error}");
assert_eq!(
error
.lines()
.filter(|line| line.contains("holds no"))
.count(),
1,
"said once, not once per read: {error}"
);
let fine = art(&[
"VYNCINT",
"--year",
"2026",
"--track",
"--merge",
"art/vyncint-2026.json",
"--today",
"2026-08-19",
"--no-colour",
]);
assert!(!String::from_utf8_lossy(&fine.stderr).contains("holds no"));
}
#[test]
fn backfill_commits_only_what_is_short() {
let dir = scratch("backfill-test");
let _ = std::fs::remove_dir_all(&dir);
let repo = dir.to_string_lossy().into_owned();
let done = art(&[
"VYNCINT",
"--year",
"2027",
"--backfill",
"--merge",
"art/vyncint-2027.json",
"--repo",
&repo,
"--today",
"2027-12-31",
"--no-colour",
]);
assert!(
done.status.success(),
"{}",
String::from_utf8_lossy(&done.stderr)
);
assert!(
stdout(&done).contains("nothing to backfill"),
"{}",
stdout(&done)
);
assert!(!dir.exists(), "and it made no repository to do it in");
let out = art(&[
"HI",
"--year",
"2027",
"--backfill",
"--merge",
"art/vyncint-2027.json",
"--repo",
&repo,
"--write",
"--today",
"2027-12-31",
"--no-colour",
]);
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let text = stdout(&out);
assert!(text.contains("backfilling against"), "{text}");
assert!(text.contains("never a flat count"), "{text}");
assert!(text.contains("nothing has been pushed"), "{text}");
let log = Command::new("git")
.current_dir(&dir)
.args(["log", "--format=%ad", "--date=short"])
.output()
.expect("git log runs");
let dates: Vec<String> = String::from_utf8_lossy(&log.stdout)
.lines()
.map(str::to_string)
.collect();
assert!(!dates.is_empty(), "it wrote commits");
assert!(
dates.iter().all(|date| date.starts_with("2027-")),
"back-dated into the plan's year: {dates:?}"
);
let mut per_day: std::collections::BTreeMap<&String, usize> = std::collections::BTreeMap::new();
for date in &dates {
*per_day.entry(date).or_default() += 1;
}
assert!(
per_day.values().all(|count| *count == 4),
"each short day gets exactly what it lacked of 4: {per_day:?}"
);
let before = dates.len();
let dry = art(&[
"HI",
"--year",
"2027",
"--backfill",
"--merge",
"art/vyncint-2027.json",
"--repo",
&repo,
"--today",
"2027-12-31",
"--no-colour",
]);
assert!(
stdout(&dry).contains("this was a dry run"),
"{}",
stdout(&dry)
);
let after = Command::new("git")
.current_dir(&dir)
.args(["rev-list", "--count", "HEAD"])
.output()
.expect("git rev-list runs");
assert_eq!(
String::from_utf8_lossy(&after.stdout).trim(),
before.to_string(),
"a dry run wrote something"
);
let both = art(&["VYNCINT", "--track", "--backfill"]);
assert!(!both.status.success());
assert!(String::from_utf8_lossy(&both.stderr).contains("pick one"));
let nowhere = art(&["VYNCINT", "--year", "2027", "--backfill"]);
assert!(!nowhere.status.success());
assert!(String::from_utf8_lossy(&nowhere.stderr).contains("--repo"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_plan_file_cannot_smuggle_past_the_flag_bounds() {
let dir = scratch("plan-bounds-test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let run = |plan: &str| {
std::fs::write(dir.join("mossaic-plan.json"), plan).unwrap();
Command::new(env!("CARGO_BIN_EXE_mossaic-art"))
.current_dir(&dir)
.args(["--no-colour"])
.output()
.expect("the art binary runs")
};
let plan = |field: &str, value: &str| {
let mut fields = [
("text", "\"HI\""),
("year", "2027"),
("start_week", "10"),
("top", "1"),
("commits", "4"),
("background", "0"),
("user", "null"),
];
for entry in fields.iter_mut() {
if entry.0 == field {
entry.1 = value;
}
}
let body: Vec<String> = fields
.iter()
.map(|(key, val)| format!("\"{key}\":{val}"))
.collect();
format!("{{{}}}", body.join(","))
};
for (field, value, wanted) in [
("top", "18446744073709551615", "between 0 and 2"),
("commits", "4294967295", "between 1 and 1000000"),
("commits", "0", "between 1 and 1000000"),
("year", "-262143", "between 2000 and 2100"),
("year", "180000", "between 2000 and 2100"),
("start_week", "18446744073709551615", "between 0 and 60"),
("background", "9", "between 0 and 4"),
] {
let out = run(&plan(field, value));
let error = String::from_utf8_lossy(&out.stderr).into_owned();
assert!(!out.status.success(), "plan {field}={value} was accepted");
assert_eq!(out.status.code(), Some(2), "{field}={value}");
assert!(
error.contains("is not a plan these tools can use"),
"{field}={value}: {error}"
);
assert!(error.contains("--save"), "{field}={value}: {error}");
assert!(error.contains(wanted), "{field}={value}: {error}");
assert!(!error.contains("panicked"), "{field}={value}: {error}");
assert!(error.contains(value), "{field}={value}: {error}");
}
let out = run(&plan("text", "\"HI\""));
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
assert!(stdout(&out).contains("HI · 2027"), "{}", stdout(&out));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn backfill_reaches_only_the_days_that_have_gone() {
let args = |today: &'static str| -> Vec<&'static str> {
vec![
"VYNCINT",
"--year",
"2026",
"--backfill",
"--merge",
"art/vyncint-2026.json",
"--repo",
"/nonexistent-dry-run",
"--today",
today,
"--no-colour",
]
};
let mid = stdout(&art(&args("2026-08-19")));
assert!(mid.contains("3,464 commits across 34 days"), "{mid}");
assert!(
mid.contains("latest 2026-08-14"),
"nothing after today: {mid}"
);
assert!(
mid.contains("23 days from 2026-08-19 on are short too, and left alone"),
"and it says what it is leaving: {mid}"
);
let end = stdout(&art(&args("2026-12-31")));
assert!(end.contains("5,994 commits across 57 days"), "{end}");
let early = stdout(&art(&args("2026-01-01")));
assert!(
early.contains("every day the plan is short of is still to come"),
"{early}"
);
assert!(
mid.contains("cannot be drawn cleanly in 2026") && mid.contains("61 days inside the"),
"a warning belongs where the commits do: {mid}"
);
}
#[test]
fn tracking_a_year_that_has_not_started_is_not_a_mistake() {
let out = art(&[
"VYNCINT",
"--year",
"2027",
"--track",
"--merge",
"art/vyncint-2027.json",
"--today",
"2026-08-19",
"--no-colour",
]);
assert!(out.status.success());
let note = String::from_utf8_lossy(&out.stderr).into_owned();
assert!(note.is_empty(), "nothing to warn about: {note}");
assert!(
stdout(&out).contains("2027 has not started"),
"{}",
stdout(&out)
);
let bare = art(&[
"VYNCINT",
"--year",
"2100",
"--track",
"--merge",
"art/vyncint-2027.json",
"--no-colour",
]);
assert!(String::from_utf8_lossy(&bare.stderr)
.lines()
.all(|line| !line.contains("no today or tomorrow")));
let missed = art(&[
"VYNCINT",
"--year",
"2026",
"--track",
"--merge",
"art/vyncint-2026.json",
"--today",
"2027-03-01",
"--no-colour",
]);
assert!(String::from_utf8_lossy(&missed.stderr).contains("2027-03-01 is after 2026"));
}
#[test]
fn tracking_refuses_a_flag_it_would_have_ignored() {
for extra in [
vec!["--snapshot", "/tmp/mossaic-should-not-exist.json"],
vec!["--write"],
vec!["--repo", "/tmp/mossaic-should-not-exist"],
] {
let mut args = vec![
"VYNCINT",
"--year",
"2026",
"--track",
"--merge",
"art/vyncint-2026.json",
"--today",
"2026-08-19",
"--no-colour",
];
args.extend_from_slice(&extra);
let out = art(&args);
let error = String::from_utf8_lossy(&out.stderr).into_owned();
assert!(!out.status.success(), "{extra:?} was accepted");
assert_eq!(out.status.code(), Some(2), "{extra:?}");
assert!(error.contains("writes nothing"), "{extra:?}: {error}");
assert!(error.contains(extra[0]), "{extra:?}: {error}");
}
assert!(
!Path::new("/tmp/mossaic-should-not-exist.json").exists(),
"and nothing was written on the way to refusing"
);
let fine = art(&[
"VYNCINT",
"--year",
"2026",
"--track",
"--merge",
"art/vyncint-2026.json",
"--today",
"2026-08-19",
"--commits",
"4",
"--no-colour",
]);
assert!(
fine.status.success(),
"{}",
String::from_utf8_lossy(&fine.stderr)
);
}
#[test]
fn every_binary_parses_arguments_the_same_way() {
for (name, binary) in [
("mossaic", env!("CARGO_BIN_EXE_mossaic")),
("mossaic-art", env!("CARGO_BIN_EXE_mossaic-art")),
("mossaic-glyphs", env!("CARGO_BIN_EXE_mossaic-glyphs")),
] {
let run = |args: &[&str]| {
Command::new(binary)
.current_dir(env!("CARGO_MANIFEST_DIR"))
.args(args)
.output()
.expect("runs")
};
let out = run(&["--definitely-not-a-flag"]);
let error = String::from_utf8_lossy(&out.stderr).into_owned();
assert_eq!(out.status.code(), Some(2), "{name}: {error}");
assert!(error.starts_with(&format!("{name}: ")), "{name}: {error}");
assert!(error.contains("unknown option"), "{name}: {error}");
let colour_args: Vec<Vec<&str>> = match name {
"mossaic-art" => vec![
vec!["--font", "--color", "always"],
vec!["--font", "--color=always"],
],
"mossaic-glyphs" => vec![vec!["--color", "always"], vec!["--color=always"]],
_ => vec![
vec!["--demo", "--png", "/dev/null", "--theme", "light"],
vec!["--demo", "--png", "/dev/null", "--theme=light"],
],
};
let mut outcomes = Vec::new();
for args in &colour_args {
let out = run(&args.to_vec());
outcomes.push((
out.status.success(),
String::from_utf8_lossy(&out.stdout).contains('\x1b'),
));
}
assert_eq!(
outcomes[0], outcomes[1],
"{name}: the `=` form behaves differently"
);
assert!(outcomes[0].0, "{name}: the spaced form should work at all");
let flag = match name {
"mossaic-glyphs" => "--color",
_ => "--year",
};
let out = run(&[flag]);
let error = String::from_utf8_lossy(&out.stderr).into_owned();
assert_eq!(out.status.code(), Some(2), "{name} {flag}: {error}");
assert!(
error.contains("needs a value") || error.contains("wants a"),
"{name} {flag}: {error}"
);
}
}
#[test]
fn a_flag_that_names_a_companion_is_refused_without_it() {
let png = scratch("refused.png");
let png = png.to_str().unwrap();
let cases: [(&[&str], &str); 7] = [
(
&["--template", "dragon", "--year", "2027", "--png"],
"--font",
),
(
&[
"--matrix",
"art/templates/dragon.art",
"--year",
"2027",
"--png",
],
"--font",
),
(&["VYNCINT", "--year", "2027", "--png"], "--font"),
(&["--list-templates", "--png"], "--font"),
(&["--track", "--png"], "--font"),
(&["--backfill", "--repo", "/tmp/nope", "--png"], "--font"),
(
&["--image", "art/dragon.png", "--year", "2027", "--png"],
"--font",
),
];
for (args, needs) in cases {
let _ = std::fs::remove_file(png);
let mut argv: Vec<&str> = args.to_vec();
argv.push(png);
argv.extend(["--no-colour", "--plan", "/dev/null"]);
let out = art(&argv);
let text = String::from_utf8_lossy(&out.stderr);
assert_eq!(
out.status.code(),
Some(2),
"{args:?} must be refused\n{text}"
);
assert!(
text.contains(needs),
"{args:?}: the message names {needs}\n{text}"
);
assert!(
!Path::new(png).exists(),
"{args:?}: a refused run writes no file"
);
}
let out = art(&["--font", "--png", png, "--no-colour"]);
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
assert!(Path::new(png).exists(), "--font --png still writes it");
let _ = std::fs::remove_file(png);
for (args, needs) in [
(
vec!["-o", "/tmp/nope.art", "VYNCINT", "--year", "2027"],
"--draw",
),
(
vec!["VYNCINT", "--year", "2027", "--format", "json"],
"--track",
),
] {
let mut argv = args.clone();
argv.extend(["--no-colour", "--plan", "/dev/null"]);
let out = art(&argv);
let text = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(2), "{args:?}\n{text}");
assert!(text.contains(needs), "{args:?}\n{text}");
}
}
#[test]
fn a_double_dash_makes_the_rest_text() {
for (text, columns) in [("-", 5), ("-.-", 17), ("A-B", 17)] {
let out = art(&[
"--year",
"2027",
"--no-colour",
"--plan",
"/dev/null",
"--",
text,
]);
let first = String::from_utf8_lossy(&out.stdout)
.lines()
.next()
.unwrap_or_default()
.to_string();
assert!(
out.status.success(),
"{text:?}: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
first.starts_with(&text.to_uppercase()),
"{text:?} is the subject: {first}"
);
assert!(
first.contains(&format!("{columns} of 53 columns")),
"{text:?} draws {columns} columns: {first}"
);
}
let out = art(&["--no-colour", "--plan", "/dev/null", "--", "-h"]);
assert!(
String::from_utf8_lossy(&out.stdout).starts_with("-H"),
"`-- -h` draws rather than printing help"
);
let out = art(&["-A-"]);
let text = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(2));
assert!(text.contains("unknown option"), "{text}");
assert!(text.contains("--"), "the way out is named: {text}");
let out = art(&["--", "-", "--year", "2027"]);
let text = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(2), "{text}");
assert!(text.contains("put the options before it"), "{text}");
}
#[test]
fn saving_a_plan_does_not_write_prose_into_a_machine_document() {
let plan = scratch("saveformat.json");
let merge = Path::new(env!("CARGO_MANIFEST_DIR")).join("art/vyncint-2026.json");
for format in ["json", "markdown"] {
let _ = std::fs::remove_file(&plan);
let out = art(&[
"VYNCINT",
"--year",
"2026",
"--start-week",
"6",
"--track",
"--save",
"--plan",
plan.to_str().unwrap(),
"--merge",
merge.to_str().unwrap(),
"--today",
"2026-08-19",
"--format",
format,
"--no-colour",
]);
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let text = String::from_utf8_lossy(&out.stdout);
match format {
"json" => {
serde_json::from_str::<serde_json::Value>(&text)
.unwrap_or_else(|e| panic!("stdout must be one JSON document: {e}\n{text}"));
}
_ => assert!(
text.starts_with("### "),
"stdout must start with the heading:\n{text}"
),
}
assert!(
String::from_utf8_lossy(&out.stderr).contains("mossaic-art --track"),
"the confirmation is not lost, only moved"
);
assert!(plan.exists(), "and the plan is written");
}
let _ = std::fs::remove_file(&plan);
}
#[test]
fn the_help_prices_commits_the_way_the_report_does() {
let help = String::from_utf8_lossy(&art(&["--help"]).stdout).into_owned();
let line = help
.lines()
.find(|l| l.trim_start().starts_with("--commits"))
.expect("--commits is documented");
assert!(
line.contains("brightest"),
"the help must price the brightest shade, not every lit day: {line}"
);
let report = String::from_utf8_lossy(
&art(&[
"--template",
"dragon",
"--year",
"2027",
"--no-colour",
"--plan",
"/dev/null",
])
.stdout,
)
.into_owned();
let header: u32 = report
.lines()
.next()
.and_then(|l| l.rsplit("·").next())
.and_then(|tail| tail.split_whitespace().next().map(str::to_string))
.and_then(|n| n.replace(',', "").parse().ok())
.expect("the header names a commit total");
let mut table = 0u32;
for line in report.lines() {
let cells: Vec<&str> = line.split_whitespace().collect();
if let [level, days, each] = cells.as_slice() {
if let (Ok(_), Ok(days), Ok(each)) = (
level.parse::<u32>(),
days.replace(',', "").parse::<u32>(),
each.replace(',', "").parse::<u32>(),
) {
table += days * each;
}
}
}
assert_eq!(
table, header,
"the level table must price out to the header:\n{report}"
);
}
#[test]
fn the_cost_table_prices_out_to_the_header() {
let mut rows = vec![vec!['0'; 53]; 7];
rows[0][0] = '4'; rows[6][52] = '4'; rows[5][0] = '4'; rows[3][10] = '2'; let body = format!(
"# name: Edges\n{}\n",
rows.iter()
.map(|row| row.iter().collect::<String>())
.collect::<Vec<_>>()
.join("\n")
);
let path = scratch("edges.art");
std::fs::write(&path, body).unwrap();
let out = art(&[
"--matrix",
path.to_str().unwrap(),
"--year",
"2027",
"--no-colour",
"--plan",
"/dev/null",
]);
let report = String::from_utf8_lossy(&out.stdout).into_owned();
let _ = std::fs::remove_file(&path);
let header = report.lines().next().unwrap_or_default();
let fields: Vec<&str> = header.split('·').map(str::trim).collect();
let header_days: usize = fields[3]
.split_whitespace()
.next()
.unwrap()
.parse()
.unwrap();
let header_commits: u32 = fields[4]
.split_whitespace()
.next()
.unwrap()
.replace(',', "")
.parse()
.unwrap();
let (mut table_days, mut table_commits, mut dark) = (0usize, 0u32, 0usize);
for line in report.lines() {
let cells: Vec<&str> = line.split_whitespace().collect();
if cells.len() == 3 {
if let (Ok(level), Ok(days), Ok(each)) = (
cells[0].parse::<u8>(),
cells[1].replace(',', "").parse::<usize>(),
cells[2].replace(',', "").parse::<u32>(),
) {
if level > 0 {
table_days += days;
table_commits += days as u32 * each;
}
}
}
if cells.len() == 5 && cells[0] == "0" && line.contains("must stay dark") {
dark = cells[1].replace(',', "").parse().unwrap();
}
}
assert_eq!(
table_days, header_days,
"the level rows must sum to the header's day count:\n{report}"
);
assert_eq!(
table_commits, header_commits,
"and price out to its commit total:\n{report}"
);
assert_eq!(
table_days + dark,
365,
"lit rows plus level 0 are the days 2027 has, not 53x7:\n{report}"
);
}
#[test]
fn a_picture_that_draws_nothing_claims_no_legibility() {
let mut rows = vec![vec!['0'; 53]; 7];
rows[0][0] = '4';
rows[6][52] = '4';
let body = format!(
"# name: Outside\n{}\n",
rows.iter()
.map(|row| row.iter().collect::<String>())
.collect::<Vec<_>>()
.join("\n")
);
let path = scratch("outside.art");
std::fs::write(&path, body).unwrap();
let out = art(&[
"--matrix",
path.to_str().unwrap(),
"--year",
"2027",
"--no-colour",
"--plan",
"/dev/null",
]);
let report = String::from_utf8_lossy(&out.stdout).into_owned();
let _ = std::fs::remove_file(&path);
assert!(
!report.contains("closest pair"),
"a drawing with no ink in the year has no shades to compare:\n{report}"
);
assert!(
report.contains("0 days") && report.contains("0 commits"),
"and its header says so:\n{report}"
);
}
#[test]
fn a_broken_template_is_named_rather_than_skipped_in_silence() {
let dir = scratch("tpl");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("templates")).unwrap();
std::fs::write(dir.join("templates/broken.art"), "ZZZZZ\nZZZZ\n").unwrap();
std::fs::write(
dir.join("templates/sixer.art"),
"# name: Sixer\n000\n000\n000\n000\n000\n000\n",
)
.unwrap();
std::fs::write(
dir.join("templates/goodun.art"),
"# name: Goodun\n0000\n0400\n0040\n0004\n0000\n0000\n0000\n",
)
.unwrap();
let in_dir = |args: &[&str]| {
Command::new(env!("CARGO_BIN_EXE_mossaic-art"))
.current_dir(&dir)
.args(args)
.output()
.expect("the art binary runs")
};
let listing = in_dir(&["--list-templates", "--no-colour"]);
let text = String::from_utf8_lossy(&listing.stdout).into_owned();
assert!(
listing.status.success(),
"one broken file must not fail the listing"
);
assert!(text.contains("Goodun"), "the good one still lists:\n{text}");
for (file, why) in [("broken.art", "not a shade"), ("sixer.art", "7 rows")] {
assert!(text.contains(file), "{file} must be named:\n{text}");
assert!(text.contains(why), "and why it was skipped:\n{text}");
}
let miss = in_dir(&[
"--template",
"sixer",
"--year",
"2027",
"--no-colour",
"--plan",
"/dev/null",
]);
let text = String::from_utf8_lossy(&miss.stderr).into_owned();
assert_eq!(miss.status.code(), Some(2), "{text}");
assert!(
text.contains("7 rows"),
"the parse error, not a name miss:\n{text}"
);
assert!(!text.contains("no template named"), "{text}");
std::fs::write(dir.join("templates/dragon.art"), "000\n000\n000\n").unwrap();
let shadowed = in_dir(&[
"--template",
"dragon",
"--year",
"2027",
"--no-colour",
"--plan",
"/dev/null",
]);
let text = String::from_utf8_lossy(&shadowed.stderr).into_owned();
assert_eq!(shadowed.status.code(), Some(2), "{text}");
assert!(text.contains("dragon.art"), "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_plan_with_a_key_it_does_not_know_is_refused() {
let plan = scratch("keys.json");
let good = r#"{"text":"VYNCINT","year":2027,"start_week":6,"top":1,
"commits":4,"background":2,"user":null}"#;
std::fs::write(&plan, good).unwrap();
let out = art(&["--plan", plan.to_str().unwrap(), "--no-colour"]);
assert!(
out.status.success(),
"the control plan loads: {}",
String::from_utf8_lossy(&out.stderr)
);
for (label, body) in [
("a typo", good.replace("background", "backgruond")),
("a case change", good.replace("\"user\"", "\"User\"")),
(
"a key from the future",
good.replace("}", r#","outline":true}"#),
),
] {
std::fs::write(&plan, &body).unwrap();
let out = art(&["--plan", plan.to_str().unwrap(), "--no-colour"]);
let text = String::from_utf8_lossy(&out.stderr).into_owned();
assert_eq!(
out.status.code(),
Some(2),
"{label}: must be refused\n{text}"
);
assert!(text.contains("unknown field"), "{label}: by name\n{text}");
}
std::fs::write(&plan, good.replace("\"background\":2", "\"background\":99")).unwrap();
let out = art(&["--plan", plan.to_str().unwrap(), "--no-colour"]);
let text = String::from_utf8_lossy(&out.stderr).into_owned();
assert_eq!(out.status.code(), Some(2), "{text}");
assert!(text.contains("background"), "{text}");
let _ = std::fs::remove_file(&plan);
}
#[test]
fn a_file_the_user_named_is_not_blamed_on_gh() {
let cases: [(&str, &str); 4] = [
("notjson", "not json"),
("empty", ""),
("nouser", r#"{"data":{}}"#),
("nocalendar", r#"{"data":{"user":{"login":"x"}}}"#),
];
for (label, body) in cases {
let path = scratch(&format!("{label}.json"));
std::fs::write(&path, body).unwrap();
let named = path.to_str().unwrap();
let out = Command::new(env!("CARGO_BIN_EXE_mossaic"))
.current_dir(env!("CARGO_MANIFEST_DIR"))
.args(["--file", named, "--png", "/tmp/mossaic-blame.png"])
.stdin(std::process::Stdio::null())
.output()
.expect("the chart binary runs");
let text = String::from_utf8_lossy(&out.stderr).into_owned();
assert_eq!(out.status.code(), Some(2), "{label}: {text}");
assert!(text.contains(named), "{label}: the path is named\n{text}");
assert!(
!text.contains("gh"),
"{label}: and gh is not blamed\n{text}"
);
let out = art(&[
"VYNCINT",
"--year",
"2026",
"--track",
"--merge",
named,
"--no-colour",
"--plan",
"/dev/null",
]);
let text = String::from_utf8_lossy(&out.stderr).into_owned();
assert!(text.contains(named), "{label} via --merge:\n{text}");
assert!(!text.contains("gh"), "{label} via --merge:\n{text}");
assert_eq!(
text.matches(named).count(),
1,
"{label}: the path appears once\n{text}"
);
let _ = std::fs::remove_file(&path);
}
}
#[test]
fn the_documented_reports_still_read_the_way_the_docs_print_them() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
let m26 = root.join("art/vyncint-2026.json");
let m26 = m26.to_str().unwrap();
let m27 = root.join("art/vyncint-2027.json");
let m27 = m27.to_str().unwrap();
let cases: Vec<(Vec<&str>, Vec<&str>)> = vec![
(
vec![
"VYNCINTNG",
"--year",
"2027",
"--no-colour",
"--plan",
"/dev/null",
],
vec!["3 lit pixels fell outside 2027"],
),
(
vec![
"VYNCINT",
"--year",
"2027",
"--background",
"1",
"--no-colour",
"--plan",
"/dev/null",
],
vec!["290 background days, 1 each"],
),
(
vec![
"VYNCINT",
"--year",
"2026",
"--start-week",
"6",
"--track",
"--merge",
m26,
"--today",
"2026-08-19",
"--no-colour",
"--plan",
"/dev/null",
],
vec![
"owing 57 days short, 5,994 contributions between them",
"holes 61 days are lit inside the letters and cannot be unlit",
"around 23 days outside the text with contributions",
"61 days inside the letters already have contributions, and",
"23 letter days still to come, 2,530 contributions",
"34 letter days already past, 3,464 contributions",
],
),
(
vec![
"--template",
"dragon",
"--year",
"2027",
"--track",
"--merge",
m27,
"--today",
"2027-08-19",
"--no-colour",
"--plan",
"/dev/null",
],
vec!["still owing 104 days · 302 contributions"],
),
];
let docs = ["README.md", "docs/ART.md"]
.iter()
.map(|name| std::fs::read_to_string(root.join(name)).expect("a documented page"))
.collect::<Vec<_>>()
.join("\n");
for (args, quoted) in cases {
let out = art(&args);
let text = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
for line in quoted {
assert!(
text.contains(line),
"the tool no longer prints a documented line.\n wanted: {line}\n from: \
mossaic-art {}\n--- got ---\n{text}",
args.join(" ")
);
assert!(
docs.contains(line),
"the docs no longer quote a line the tool prints: {line}"
);
}
}
assert!(
!docs.contains("day(s)") && !docs.contains("commit(s)") && !docs.contains("pixel(s)"),
"a parenthesized plural is back in the docs"
);
}
#[test]
fn a_holed_picture_is_told_where_it_can_be_drawn() {
let path = scratch("blip.art");
std::fs::write(
&path,
"# name: Blip\n04040\n40404\n04040\n40404\n04040\n40404\n04040\n",
)
.expect("the scratch file is writable");
let art_path = path.to_string_lossy().into_owned();
let run = |format: &str| {
let out = art(&[
"--matrix",
&art_path,
"--year",
"2026",
"--start-week",
"34",
"--track",
"--merge",
"art/vyncint-2026.json",
"--no-colour",
"--today",
"2026-08-19",
"--format",
format,
]);
assert!(
out.status.success(),
"{format}: {}",
String::from_utf8_lossy(&out.stderr)
);
stdout(&out)
};
let text = run("text");
assert!(text.contains("Cannot be drawn cleanly"), "{text}");
assert!(
text.contains("--start-week 41 draws it cleanly."),
"the diagnosis without the way out is the bug:\n{text}"
);
let markdown = run("markdown");
assert!(
markdown.contains("`--start-week 41` draws it cleanly."),
"{markdown}"
);
let json = run("json");
assert!(json.contains("\"suggested_start_week\": 41"), "{json}");
assert!(json.contains("\"suggested_holes\": 0"), "{json}");
assert!(
json.contains("week 41 draws it"),
"the headline has to carry it too:\n{json}"
);
let moved = art(&[
"--matrix",
&art_path,
"--year",
"2026",
"--start-week",
"41",
"--track",
"--merge",
"art/vyncint-2026.json",
"--no-colour",
"--today",
"2026-08-19",
"--format",
"json",
]);
let moved = stdout(&moved);
assert!(moved.contains("\"holes\": 0"), "{moved}");
assert!(moved.contains("\"verdict\": \"reachable\""), "{moved}");
let _ = std::fs::remove_file(&path);
}
#[test]
fn a_picture_on_track_is_not_told_to_move() {
let path = scratch("blip-clean.art");
std::fs::write(
&path,
"# name: Blip\n04040\n40404\n04040\n40404\n04040\n40404\n04040\n",
)
.expect("the scratch file is writable");
let out = art(&[
"--matrix",
&path.to_string_lossy(),
"--year",
"2026",
"--start-week",
"41",
"--track",
"--merge",
"art/vyncint-2026.json",
"--no-colour",
"--today",
"2026-08-19",
"--format",
"markdown",
]);
let text = stdout(&out);
assert!(text.contains("**On track**"), "{text}");
assert!(
!text.contains("--start-week"),
"nothing to suggest, so nothing said:\n{text}"
);
let _ = std::fs::remove_file(&path);
}