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("add one to FONT in src/art.rs"), "{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!(
stdout(&saved).contains("mossaic-art --track"),
"it says what is next"
);
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 day(s), 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 commit(s) across 34 day(s)"), "{mid}");
assert!(
mid.contains("latest 2026-08-14"),
"nothing after today: {mid}"
);
assert!(
mid.contains("23 day(s) 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 commit(s) across 57 day(s)"), "{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 day(s) 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}"
);
}
}