From 82a58263734ccb7c3437dca114a3f3113b28ca0b Mon Sep 17 00:00:00 2001 From: Vyncint Ng <115854244+vyncint@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:33:28 +0700 Subject: [PATCH] =?UTF-8?q?release:=20v0.6.3=20=E2=80=94=20the=20things=20?= =?UTF-8?q?a=20new=20user=20hits=20first?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A review of mossaic from the outside: install it, run it, read what it prints. Five things it got wrong, all of them on the paths somebody meets in the first minute. The one shipped template warned on every year. `--template dragon` printed `note: 6 cell(s) fell outside 2027 and were dropped` for 2025 through 2029, and all six were blank margin — Canvas::place counted every cell that fell outside the year rather than every cell that would have been drawn. The flagship path, warning about nothing, teaching people that notes here are noise. It now counts only lit cells. Counts did not read as English. Twenty-nine of them: `1 template(s)`, `6 cell(s)`, `59 day(s)` — the first line of `--list-templates` and the markdown the GitHub Action posts to a pull request. `plural` takes both forms rather than appending an `s`, so verbs agree too: "1 day is short", "2 days are short", and the number's own formatting stays at the call site where the widths and separators live. `mossaic-art --help` was thirty flags in one flat list, with a paragraph of colour theory inside `--background`. Grouped now — what to draw, where it goes, tracking, making the commits, output — with usage and seven examples first. The colour theory moved to docs/ART.md, which had room for it already. 93 lines against 113, and no flag dropped. A missing `--file` in a script blamed the terminal. The chart claims the terminal before it reads the calendar, so a typo'd path in CI came back as `needs an interactive terminal`: true, and no help in finding the typo. Only that case changed — with a terminal the error is still shown *in* the chart with `r` to retry, which is the better answer, and the first attempt at this broke that. `--font` told everyone who ran it to edit `src/art.rs`. The catalogue was a catalogue of one, which is what `--template NAME` offered. Three more: wave, pulse, invader — 51 columns each, so no lit day lands in a partial week, and `{0, 2, 4}` each, the only three shades that are all clearly distinct. That last part is why two gates come with them, and they matter now that #57 invites contributed art. `every_template_reads_clearly` measures the closest pair of shades in every template: art using 2 and 3 looks two-toned in the file, because the digits differ, and reads as one flat colour on github.com. `no_template_overhangs_the_year` fails a template that loses a lit day off either end of any year. Both were run against art planted to break them, and both did. Also: the description never said mossaic was a terminal program, and the action refs in the docs had been pinned at v0.6.0 for two releases. 213 tests, fmt, clippy and rustdoc clean. Signed-off-by: Vyncint Ng <115854244+vyncint@users.noreply.github.com> --- CHANGELOG.md | 58 ++++++++- Cargo.lock | 2 +- Cargo.toml | 9 +- README.md | 3 + action/README.md | 6 +- action/action.yml | 2 +- action/track.example.yml | 4 +- art/templates/invader.art | 11 ++ art/templates/pulse.art | 11 ++ art/templates/wave.art | 11 ++ docs/ART.md | 15 ++- src/art.rs | 29 +++-- src/bin/mossaic-art.rs | 267 ++++++++++++++++++++------------------ src/draw.rs | 9 +- src/lib.rs | 16 +++ src/main.rs | 21 ++- src/plan.rs | 31 +++-- src/render_tests.rs | 51 +++++++- tests/art_cli.rs | 15 ++- tests/canvas_pty.rs | 45 ++++--- tests/chart_cli.rs | 68 ++++++++++ 21 files changed, 490 insertions(+), 194 deletions(-) create mode 100644 art/templates/invader.art create mode 100644 art/templates/pulse.art create mode 100644 art/templates/wave.art create mode 100644 tests/chart_cli.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 507f88a..c1edd75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,63 @@ listed under a **Changed** or **Removed** heading. ## [Unreleased] +## [0.6.3] - 2026-08-24 + ### Added +- **Three more templates**, so the catalogue is a catalogue: `wave` (one clean + sine across the year), `pulse` (a heartbeat trace, a spike every few weeks) + and `invader` (a space invader, centred). Each is 51 columns, which keeps + every lit day clear of the two partial weeks at the ends of a year, and each + is drawn in `{0, 2, 4}` — the only three shades no reader has to squint at. + +- **Two gates on template art**, which matter now that + [#57](https://github.com/vyncint/mossaic/issues/57) invites contributions. + `every_template_reads_clearly` measures the closest pair of shades in every + template and fails anything that is not *clear*: a picture using `2` and `3` + looks two-toned in the `.art` file, because the digits differ, and reads as + one flat colour on github.com. `no_template_overhangs_the_year` fails a + template that loses a lit day off either end of any year. Both were checked + against art planted to break them. + +### Changed + +- **`mossaic-art --help` is grouped rather than a list of thirty flags.** The + options now sit under *what to draw*, *where it goes*, *tracking*, *making + the commits* and *output*, with the usage and seven examples first. The + colour-theory paragraph that lived inside `--background` moved to + [docs/ART.md](docs/ART.md), where there is room to say it properly. Shorter + overall (93 lines against 113) and no flag was dropped. + +- **Counts read as English.** Every user-facing count said `1 template(s)`, + `6 cell(s)`, `59 day(s)` — twenty-nine of them, on the first lines a new + user reads and in the markdown the GitHub Action posts. They now agree with + their number, verb included: *1 day is short*, *2 days are short*. + +- **The description says what mossaic is.** It never mentioned the terminal. + +### Fixed + +- **The one shipped template no longer warns on every single year.** Drawing + `--template dragon` printed `note: 6 cell(s) fell outside 2027 and were + dropped` for 2025 through 2029 — and all six were *blank* margin, so nothing + was lost. `Canvas::place` counted every cell that fell outside the year, + lit or not. It now counts only the cells that would have been drawn, so the + note appears when a shade is genuinely lost and stays quiet otherwise. + + Found by running the flagship path and reading what it printed. + +- **A missing `--file` in a script names the file, not the terminal.** The + chart claims the terminal before it reads the calendar, so + `mossaic --file typo.json` in CI reported `needs an interactive terminal` — + true, and no help at all in finding the typo. With a terminal the behaviour + is unchanged: the error is shown *in* the chart with `r` to retry, which is + better than exiting. + +- **`--font` no longer tells its readers to edit `src/art.rs`.** It printed a + note meant for contributors to everyone who ran it. + + - **`brew install vyncint/tap/mossaic`.** The formula is generated by the release itself from the checksums the archives produced, so it cannot describe an archive that was never built, and a hand-edit in the tap is @@ -861,7 +916,8 @@ there was none. [termlens]: https://github.com/vyncint/termlens -[Unreleased]: https://github.com/vyncint/mossaic/compare/v0.6.2...HEAD +[Unreleased]: https://github.com/vyncint/mossaic/compare/v0.6.3...HEAD +[0.6.3]: https://github.com/vyncint/mossaic/compare/v0.6.2...v0.6.3 [0.6.2]: https://github.com/vyncint/mossaic/compare/v0.6.1...v0.6.2 [0.6.1]: https://github.com/vyncint/mossaic/compare/v0.6.0...v0.6.1 [0.6.0]: https://github.com/vyncint/mossaic/compare/v0.5.0...v0.6.0 diff --git a/Cargo.lock b/Cargo.lock index 4a317ba..dffea9c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -788,7 +788,7 @@ dependencies = [ [[package]] name = "mossaic" -version = "0.6.2" +version = "0.6.3" dependencies = [ "chrono", "libc", diff --git a/Cargo.toml b/Cargo.toml index 9d32ce3..61e0218 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mossaic" -version = "0.6.2" +version = "0.6.3" edition = "2021" # Minimum supported Rust version. Verified by the `msrv` CI job, which reads this # field; bumping it is a minor (not patch) change. @@ -8,8 +8,11 @@ edition = "2021" # 1.88 is dictated by ratatui 0.30. Everything else in the tree needs less: the # graphics and colour code is plain std. rust-version = "1.88" -description = "Plan and track GitHub contribution art — what today owes to draw your name by December, and whether the year can still be drawn at all" -keywords = ["github", "contributions", "contribution-art", "sixel", "kitty"] +description = "Draw pixel art in your GitHub contribution graph — a terminal chart, an editor, and a planner that says what today owes" +# `tui` earns its place over `contributions`, which `contribution-art` already +# covers: the chart, the designer and the glyph viewer are all terminal UIs, and +# that is how someone looking for one searches. +keywords = ["github", "contribution-art", "tui", "sixel", "kitty"] categories = ["command-line-utilities", "visualization"] license = "MIT OR Apache-2.0" repository = "https://github.com/vyncint/mossaic" diff --git a/README.md b/README.md index 8d5419e..0c59936 100644 --- a/README.md +++ b/README.md @@ -289,6 +289,9 @@ That is `--template dragon` on 2027 — 146 days, 442 commits — drawn by the s rasteriser that draws the chart, so it is what the graph will actually look like rather than an impression of it. +Four templates ship with it — `dragon`, `wave`, `pulse` and `invader` — and +each is a seven-line text file you can copy and edit. + ```sh mossaic-art --list-templates # what there is, with thumbnails mossaic-art --template dragon --year 2027 # draw one, and see what it costs diff --git a/action/README.md b/action/README.md index 3d8619f..77f2f50 100644 --- a/action/README.md +++ b/action/README.md @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - id: art - uses: vyncint/mossaic/action@v0.6.0 + uses: vyncint/mossaic/action@v0.6.3 with: text: VYNCINT year: "2027" @@ -84,7 +84,7 @@ drawn. The rest are the same either way. ```yaml - id: art - uses: vyncint/mossaic/action@v0.6.0 + uses: vyncint/mossaic/action@v0.6.3 with: template: dragon year: "2027" @@ -216,7 +216,7 @@ on 290 days of the year is a job nobody reads. ## Notes -- **Two knobs, two jobs.** The ref you pin (`@v0.6.0`, `@main`) chooses the +- **Two knobs, two jobs.** The ref you pin (`@v0.6.3`, `@main`) chooses the *action's steps* — the glue that runs the tracker and shapes the outputs. The `version` input chooses the *tracker itself*, straight from crates.io. The default, `latest`, is fine for a daily report; pin a number when you diff --git a/action/action.yml b/action/action.yml index b30ecbd..6ff2555 100644 --- a/action/action.yml +++ b/action/action.yml @@ -118,7 +118,7 @@ inputs: default: "true" version: description: >- - Which mossaic release runs the tracking, e.g. "0.6.0". The default, + Which mossaic release runs the tracking, e.g. "0.6.3". The default, `latest`, installs the newest release on crates.io each run — fine for tracking, since the report format is versioned with the crate. Pin a number if you want the tracker to change only when you say so. diff --git a/action/track.example.yml b/action/track.example.yml index e08cadc..1bc06b2 100644 --- a/action/track.example.yml +++ b/action/track.example.yml @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-latest steps: - id: art - uses: vyncint/mossaic/action@v0.6.0 + uses: vyncint/mossaic/action@v0.6.3 with: text: VYNCINT year: "2027" @@ -39,7 +39,7 @@ jobs: # the failure notification you already get does the reminding. fail-on: never # Which mossaic release does the tracking. The default, latest, - # follows crates.io; pin a number ("0.6.0") to freeze it. + # follows crates.io; pin a number ("0.6.3") to freeze it. # version: latest # ----------------------------------------------------------------- issue diff --git a/art/templates/invader.art b/art/templates/invader.art new file mode 100644 index 0000000..b12fbfb --- /dev/null +++ b/art/templates/invader.art @@ -0,0 +1,11 @@ +# name: Invader +# author: @vyncint +# description: A space invader, centred on the year + +000000000000000000000040000400000000000000000000000 +000000000000000000000004444000000000000000000000000 +000000000000000000000044044400000000000000000000000 +000000000000000000004444444444000000000000000000000 +000000000000000000004044444404000000000000000000000 +000000000000000000004040000404000000000000000000000 +000000000000000000000004004000000000000000000000000 diff --git a/art/templates/pulse.art b/art/templates/pulse.art new file mode 100644 index 0000000..809042b --- /dev/null +++ b/art/templates/pulse.art @@ -0,0 +1,11 @@ +# name: Pulse +# author: @vyncint +# description: A heartbeat trace across the year: a quiet line, a spike every few weeks + +000000004400000000000000044000000000000000440000000 +000000004400000000000000044000000000000000440000000 +000004204404200000000042044042000000000420440420000 +222224244424222222222242444242222222222424442422222 +000000044420000000000000444200000000000004442000000 +000000000420000000000000004200000000000000042000000 +000000000000000000000000000000000000000000000000000 diff --git a/art/templates/wave.art b/art/templates/wave.art new file mode 100644 index 0000000..0f86def --- /dev/null +++ b/art/templates/wave.art @@ -0,0 +1,11 @@ +# name: Wave +# author: @vyncint +# description: One clean sine wave rolling across the whole year + +000000000444444444000000000000000000000000000000000 +000004444400000004444400000000000000000000000000000 +004444000000000000000444400000000000000000000000000 +444000000000000000000000444400000000000000000000044 +000000000000000000000000000444400000000000000044440 +000000000000000000000000000000444440000000444440000 +000000000000000000000000000000000044444444400000000 diff --git a/docs/ART.md b/docs/ART.md index 1a01890..6c33a02 100644 --- a/docs/ART.md +++ b/docs/ART.md @@ -211,6 +211,19 @@ mossaic-art --list-templates # what there is, with thumbnails mossaic-art --template dragon --year 2027 # draw one ``` +Four ship with it, and every one of them is a file in +[`art/templates/`](../art/templates): + +| name | what it is | +|---|---| +| `dragon` | a serpentine dragon coiling across the whole year | +| `wave` | one clean sine rolling across the year | +| `pulse` | a heartbeat trace: a quiet line, a spike every few weeks | +| `invader` | a space invader, centred | + +They are worth reading as much as drawing: each is seven lines of digits, and +copying one into your own file is the quickest way to start. + ``` Dragon · 2027 · 53 of 53 columns · 146 days · 442 commits @@ -494,7 +507,7 @@ arrive rather than be asked for: ```yaml - id: art - uses: vyncint/mossaic/action@v0.6.0 + uses: vyncint/mossaic/action@v0.6.3 with: text: VYNCINT year: "2027" diff --git a/src/art.rs b/src/art.rs index a725b7f..339ed95 100644 --- a/src/art.rs +++ b/src/art.rs @@ -916,10 +916,15 @@ impl Canvas { /// Lay the canvas on a year and report the shade each day should end at. /// /// `start` is the calendar column the canvas's first column lands on. - /// Columns that fall outside the year are counted in the returned total - /// rather than silently dropped — the first and last calendar columns are - /// partial weeks, so a full-width picture always loses a few days at the - /// ends and the caller should be able to say so. + /// The returned total counts the cells that fell outside the year and + /// **would have been drawn** — the first and last calendar columns are + /// partial weeks, so a full-width picture overhangs them. + /// + /// Blank cells are not counted, because losing one costs the picture + /// nothing. Counting them meant a 53-column template warned about six + /// dropped cells on every year, all six of them empty margin: a `note:` + /// on the path everybody takes, which teaches people to stop reading + /// notes. What is worth interrupting for is a *shade* that will not fit. /// /// Returns the level for **every** day it covers, including the level-0 /// ones. That is the difference between a canvas and text: a dark day @@ -930,19 +935,19 @@ impl Canvas { let mut levels = BTreeMap::new(); let mut skipped = 0; for (offset, column) in self.columns.iter().enumerate() { - let Some(week) = start.checked_add(offset) else { - skipped += CANVAS_ROWS; - continue; + let week = match start.checked_add(offset) { + Some(week) if week < grid.weeks => week, + // Past the end of the year: nothing in this column lands. + _ => { + skipped += column.iter().filter(|level| **level > 0).count(); + continue; + } }; - if week >= grid.weeks { - skipped += CANVAS_ROWS; - continue; - } for (row, level) in column.iter().enumerate() { let date = grid.date_at(week, row); if grid.holds(date) { levels.insert(date, *level); - } else { + } else if *level > 0 { skipped += 1; } } diff --git a/src/bin/mossaic-art.rs b/src/bin/mossaic-art.rs index f2946a0..a89775c 100644 --- a/src/bin/mossaic-art.rs +++ b/src/bin/mossaic-art.rs @@ -16,121 +16,101 @@ use chrono::{Datelike, Local, NaiveDate}; use mossaic::art::{self, Grid}; use mossaic::cli::{Args, YEARS}; use mossaic::primer::{Appearance, Palette, Season}; -use mossaic::{github, graphics, plan, png, primer, templates, thousands, Colour}; +use mossaic::{github, graphics, plan, plural, png, primer, templates, thousands, Colour}; -const HELP: &str = "\ -mossaic-art — write text into a GitHub contribution graph by dating commits +const HELP: &str = r#"mossaic-art — draw on a GitHub contribution graph by dating commits usage: - mossaic-art TEXT [options] - mossaic-art --template NAME [options] - mossaic-art --matrix FILE [options] + mossaic-art TEXT [options] write words through a 5x5 font + mossaic-art --template NAME draw a picture from the catalogue + mossaic-art --matrix FILE draw a picture of your own + mossaic-art --draw draw one by hand, in an editor - TEXT what to draw, e.g. VYNCINT. Letters, digits, punctuation - and shapes: `I :heart: RUST`. A shape is written between - colons, or pasted as the symbol or emoji it depicts. - --font lists everything - - Text is drawn through a 5x5 font on Mon-Fri. The three flags below draw a - *picture* instead: seven rows by up to 53 columns, any of GitHub's five - shades per day, using the whole week and the whole year. - - --template NAME draw a template from the catalogue. --list-templates - shows what there is +examples: + mossaic-art --draw a blank year, by hand + mossaic-art --list-templates the pictures on offer + mossaic-art --template dragon --year 2027 a picture across next year + mossaic-art VYNCINT --year 2027 what it looks like, and costs + mossaic-art VYNCINT --year 2027 --save remember it + mossaic-art --track am I on pace? + mossaic-art --backfill --repo ../art --write commit what the plan is short + +WHAT TO DRAW + TEXT letters, digits, punctuation and shapes: `I :heart: RUST`. + Drawn Mon-Fri through a 5x5 font; --font lists every glyph + --template NAME a picture from the catalogue --list-templates list the templates, with a thumbnail of each, and exit - --matrix FILE (--file) draw a .art file: 7 rows of the shades 0-4, or - of the blocks ░ ▒ ▓ █ with a space for 0. - Lines starting with # are comments, and `# name:`, - `# author:` and `# description:` are read as the - picture's own. With --commits N a level-4 day costs N - and every darker level is priced against it - --image FILE turn a PNG into a picture: shrunk to fit the calendar, - keeping its aspect ratio, and quantised to five shades. + --matrix FILE (--file) a .art file: 7 rows of the shades 0-4, or of the + blocks ░ ▒ ▓ █ with a space for 0. `#` starts a comment + --image FILE a PNG, shrunk to fit the year and quantised to five shades. A dark pixel is a busy day, the way ink reads on paper - --invert with --image, map bright pixels to busy days instead - --dither with --image, spread the rounding error into - neighbouring days (Floyd-Steinberg), so a gradient - reads as one rather than as four bands - --draw open the editor and draw on the year by hand: arrows or - hjkl to move, 0-4 to paint a shade, space to cycle one, - the mouse to paint directly, u to undo and s to save. - With --template or --matrix it opens that picture; on - its own it starts a blank year - --output FILE (-o) where --draw saves, as a .art file - (default: the picture's name, or drawing.art) + --invert with --image, bright pixels become the busy days instead + --dither with --image, spread the rounding error into neighbouring + days (Floyd-Steinberg), so a gradient reads as one + --draw open the editor: arrows or hjkl to move, 0-4 to paint a + shade, space to cycle one, the mouse to paint, u to undo, + s to save. With --template or --matrix it opens that picture + --output FILE (-o) where --draw saves (default: the picture's name) + + A picture is seven rows by up to 53 columns, any of GitHub's five shades per + day, using the whole week and the whole year. Text uses Mon-Fri and one shade. + +WHERE IT GOES --year YEAR which year's calendar (default: this one) - --commits N commits per lit day (default 4); keep it uniform for one - flat shade - --background LEVEL (--bg) draw the background as a shade instead of leaving - it empty, 0-3 (default 0). The letters stay at level 4, so - --background 1 draws them on a light green field rather - than on nothing — which is how you draw art without - going dark for most of the year. Levels two or more - apart are legible in every palette GitHub ships; one - apart is not - --top ROW first calendar row used, 0 = Sunday (default 1, so Mon-Fri) - --start-week N left edge in weeks (default: centred) - --merge PATH a saved `gh api graphql` calendar to draw on top of, so the - preview accounts for contributions already there - --snapshot PATH write a calendar file for `mossaic --file PATH` - --repo DIR where --write puts the commits - --write actually create the commits in --repo (local only) - --backfill commit what each day already past is still short of the - plan, measured against the real calendar — the flag for - catching up. Unlike a plain --write it never adds to a day - that is already bright, which over an active year is what - stops the art raising the very peak it is measured - against, and it leaves days still to come alone. --today - is what \"past\" means. Needs --repo, and --write to commit - --login NAME name shown in the snapshot (default: preview) + --start-week N left edge, in weeks (default: centred) + --commits N commits per lit day (default 4) + --top ROW first calendar row, 0 = Sunday (default 1, so Mon-Fri). + Text only; a picture uses all seven rows + --background LEVEL (--bg) draw the background as a shade 0-3 rather than + leaving it empty, so the whole year stays green. Text + only. Keep it two levels clear of the letters, or the + two greens are too close to tell apart + +TRACKING + --track [USER] how far along, what today owes, and whether the picture can + still be drawn at all. Reads --merge if given, otherwise + asks gh for USER (default: whoever gh is) + --today DATE what counts as today, as YYYY-MM-DD (default: the clock), + so a report is repeatable and a future day can be asked + --merge PATH a saved calendar to draw on top of, so the preview accounts + for contributions already there + --plan PATH where the plan lives (default: mossaic-plan.json) + --save write the plan, so later runs need no flags at all + +MAKING THE COMMITS + --repo DIR the repository --write commits into (local only) + --write actually create the commits + --backfill commit only what each past day is still short of the plan, + never adding to a day that is already bright, and leaving + days still to come alone. Needs --repo, and --write to commit --name NAME commit author name (default: git config) --email ADDRESS commit author email (default: git config) + +OUTPUT + --snapshot PATH write a calendar file for `mossaic --file PATH` + --login NAME the name that snapshot carries (default: preview) + --format F text (default), json or markdown — what --track prints. + The GitHub Action reads json and markdown --color WHEN (--colour) auto (default), always or never. auto means colour when stdout is a terminal and NO_COLOR is unset --no-color (--no-colour) the same as --color never - --format F text (default), json or markdown — what --track prints. - json and markdown are what the GitHub Action reads - --plan PATH where the plan is read from and written to - (default: mossaic-plan.json) - --save write the plan, so later runs need no flags at all --font print every glyph the font has, and exit - --png PATH with --font, write the glyphs to a PNG instead of the - terminal — the sheet the README shows - --track [USER] compare the plan with what has actually been contributed: - how far along, what today owes, and whether the text can - still be drawn at all. Reads --merge if given, otherwise - asks gh for USER (default: whoever gh is) - --today DATE what \"today\" means, as YYYY-MM-DD (default: the clock). - A report is then reproducible, and a day that has not - arrived can be asked what it will owe + --png PATH with --font, write the glyph sheet to a PNG instead + -V, --version print the version -h, --help show this help -examples: - mossaic-art --draw draw a year by hand - mossaic-art --template dragon --draw -o mine.art start from one, save as mine - mossaic-art --list-templates see the pictures on offer - mossaic-art --template dragon --year 2027 draw one across the year - mossaic-art --matrix mine.art --year 2027 --save draw your own, and keep it - mossaic-art VYNCINT --year 2027 what it would look like, and cost - mossaic-art VYNCINT --year 2027 --background 1 letters on a field, not on nothing - mossaic-art VYNCINT --year 2027 --track am I getting there, and what today owes - mossaic-art VYNCINT --year 2027 --snapshot a.json then: mossaic --file a.json - mossaic-art VYNCINT --year 2027 --repo ../art --write local commits, never pushed - mossaic-art --backfill --repo ../art --write commit just what the plan is short - mossaic-art --track --today 2027-06-01 what that day will owe - mossaic-art \"I :heart: RUST\" --year 2027 a shape among the letters - mossaic-art --font every glyph, side by side - mossaic-art --font --png art/font.png the same sheet, as an image - Save the plan once and later runs need no flags at all: mossaic-art VYNCINT --year 2027 --start-week 6 --save mossaic-art --track # reads mossaic-plan.json +Pictures, templates and the .art format: docs/ART.md + https://github.com/vyncint/mossaic/blob/main/docs/ART.md + also installed: mossaic the chart itself - mossaic-glyphs what this terminal makes of the fallback cells"; + mossaic-glyphs what this terminal makes of the fallback cells"#; fn main() { let Some(options) = parse_args() else { @@ -223,11 +203,13 @@ fn main() { .unwrap_or_else(|error| fail(&error)); if placed.skipped > 0 { eprintln!( - "note: {} pixel(s) fell outside {} and were dropped — the first and \ + "note: {} lit {} fell outside {} and {} dropped — the first and \ last calendar columns are partial weeks, so {} of {} columns hold a \ whole letter", placed.skipped, + plural(placed.skipped, "pixel", "pixels"), grid.year, + plural(placed.skipped, "was", "were"), grid.usable_weeks(), grid.weeks ); @@ -301,11 +283,12 @@ fn main() { if shades.field > 0 { let (legibility, delta) = drawn.worst(); println!( - "background level {} under letters at level {} · {} background day(s), \ + "background level {} under letters at level {} · {} background {}, \ {} each · ΔE {delta:.0} at worst, {legibility}", drawn.field, drawn.ink, thousands(placed.field.len() as u32), + plural(placed.field.len(), "day", "days"), ink.field, ); match legibility { @@ -338,13 +321,15 @@ fn main() { }) .count(); println!( - "drawing over {} day(s) already active (busiest {})", + "drawing over {} {} already active (busiest {})", existing.len(), + plural(existing.len(), "day", "days"), existing.values().copied().max().unwrap_or(0) ); println!( "peak after merge {peak} · letters land at level {art_level}/4 · \ - {rivals} existing day(s) as bright or brighter" + {rivals} existing {} as bright or brighter", + plural(rivals, "day", "days") ); if art_level < 4 { let days: Vec = placed.lit.keys().copied().collect(); @@ -527,7 +512,11 @@ fn show_templates(colour: bool) { .max() .unwrap_or(0); - println!("{} template(s):\n", catalogue.len()); + println!( + "{} {}:\n", + catalogue.len(), + plural(catalogue.len(), "template", "templates") + ); for template in &catalogue { let author = template .author() @@ -598,8 +587,10 @@ fn run_canvas(options: &Options, grid: &Grid, name: &str, canvas: &art::Canvas) } if skipped > 0 { eprintln!( - "note: {skipped} cell(s) fell outside {} and were dropped — the first \ - and last calendar columns are partial weeks", + "note: {skipped} {} of the picture fell outside {} — the first and \ + last calendar columns are partial weeks, so a full-width picture \ + overhangs them. Try --start-week, or a narrower picture.", + plural(skipped, "shade", "shades"), grid.year ); } @@ -799,12 +790,14 @@ fn track_canvas( match plan.verdict() { plan::Verdict::Done => println!(" Drawn. Every day is the shade the picture asks for."), plan::Verdict::Reachable => println!( - " On track — {drawn} of {wanted} shaded day(s) are right, \ - {owing_days} to go." + " On track — {drawn} of {wanted} shaded {} right, \ + {owing_days} to go.", + plural(wanted, "day is", "days are") ), plan::Verdict::Holed { holes } => println!( - " Cannot be drawn cleanly — {holes} day(s) are brighter than the \ - picture wants,\n and nothing takes a contribution away." + " Cannot be drawn cleanly — {holes} {} brighter than the \ + picture wants,\n and nothing takes a contribution away.", + plural(holes, "day is", "days are") ), } @@ -845,9 +838,11 @@ fn track_canvas( ); println!( - "\n still owing {} day(s) · {} contributions", + "\n still owing {} {} · {} {}", thousands(owing_days as u32), - thousands(owing_commits) + plural(owing_days, "day", "days"), + thousands(owing_commits), + plural(owing_commits, "contribution", "contributions") ); // What a day is owed turns on `want`, not on whether `need` is zero. Both @@ -895,7 +890,11 @@ fn track_canvas( let holes = plan.holes(); if !holes.is_empty() { - println!("\n {} day(s) already too bright:", holes.len()); + println!( + "\n {} {} already too bright:", + holes.len(), + plural(holes.len(), "day", "days") + ); for day in holes.iter().take(8) { println!( " {} has {}, wants at most {}", @@ -931,8 +930,9 @@ fn backfill_canvas(options: &Options, grid: &Grid, levels: &BTreeMap 0 { let (field_days, field_commits) = plan.field_owing(); println!( - " background {field_days} day(s) short, {} commits", + " background {field_days} {} short, {} commits", + plural(field_days, "day", "days"), thousands(field_commits) ); } @@ -1067,8 +1069,10 @@ fn backfill( println!(" reaching days before {today}, which are the ones only back-dating reaches"); if ahead > 0 { println!( - " {ahead} day(s) from {today} on are short too, and left alone — \ - contribute on those as they come" + " {ahead} {} from {today} on {} short too, and left alone — \ + contribute on those as they come", + plural(ahead, "day", "days"), + plural(ahead, "is", "are") ); } } @@ -1077,11 +1081,13 @@ fn backfill( // commits changes that. if let plan::Verdict::Holed { holes } = plan.verdict() { println!( - "\n warning: {} cannot be drawn cleanly in {} — {holes} day(s) inside the\n \ - letters are already lit, and nothing takes those away. Backfilling will\n \ + "\n warning: {} cannot be drawn cleanly in {} — {holes} {} inside the\n \ + letters already lit, and nothing takes those away. Backfilling will\n \ brighten the letters, and the text will still read with holes in it.\n \ `mossaic-art --track` sweeps --start-week for a placement with fewer.", - plan.text, plan.year + plan.text, + plan.year, + plural(holes, "day", "days") ); } @@ -1095,9 +1101,11 @@ fn backfill( return; } println!( - "\n {} commit(s) across {} day(s), earliest {}, latest {}", + "\n {} {} across {} {}, earliest {}, latest {}", thousands(total), + plural(total, "commit", "commits"), owed.len(), + plural(owed.len(), "day", "days"), owed.keys().next().expect("not empty"), owed.keys().next_back().expect("not empty"), ); @@ -1273,7 +1281,8 @@ fn track_progress( ); if owing_days > 0 { println!( - " owing {owing_days} day(s) short, {} contributions between them", + " owing {owing_days} {} short, {} contributions between them", + plural(owing_days, "day", "days"), thousands(owing_commits) ); } @@ -1295,7 +1304,8 @@ fn track_progress( let (field_owing_days, field_owing_commits) = plan.field_owing(); if field_owing_days > 0 { println!( - " owing {field_owing_days} background day(s) short, {} contributions", + " owing {field_owing_days} background {} short, {} contributions", + plural(field_owing_days, "day", "days"), thousands(field_owing_commits) ); } @@ -1305,8 +1315,9 @@ fn track_progress( " holes {}", paint( &format!( - "{} day(s) inside the letters are lit and cannot be unlit", - holes.len() + "{} {} lit inside the letters and cannot be unlit", + holes.len(), + plural(holes.len(), "day is", "days are") ), danger() ) @@ -1314,8 +1325,9 @@ fn track_progress( } if plan.around() > 0 { println!( - " around {} day(s) outside the text have contributions", - plan.around() + " around {} {} outside the text with contributions", + plan.around(), + plural(plan.around(), "day", "days") ); } @@ -1346,8 +1358,9 @@ fn track_progress( ) ); println!( - " {holes} day(s) inside the letters already have contributions, and\n \ - nothing takes those away — the text would read with holes in it." + " {holes} {} inside the letters already have contributions, and\n \ + nothing takes those away — the text would read with holes in it.", + plural(holes, "day", "days") ); match plan::best_start_week( grid, @@ -1487,7 +1500,8 @@ fn track_progress( } println!("\n the rest of the year"); println!( - " {future} letter day(s) still to come, {} contributions", + " {future} letter {} still to come, {} contributions", + plural(future, "day", "days"), thousands(future_commits) ); if past > 0 { @@ -1497,8 +1511,9 @@ fn track_progress( // would not even read the plan, so the placement could differ from the // one just reported on. println!( - " {past} letter day(s) already past, {} contributions — only back-dated\n \ + " {past} letter {} already past, {} contributions — only back-dated\n \ commits reach those:\n\n mossaic-art {}--backfill --repo ../art --write\n", + plural(past, "day", "days"), thousands(past_commits), match options.plan_loaded { true => String::new(), @@ -1548,7 +1563,7 @@ fn show_font(colour: bool) { let characters: Vec = art::alphabet().collect(); println!( - "{} glyphs, {}x{} each — add one to FONT in src/art.rs\n", + "{} glyphs, {}x{} each — write any of them with mossaic-art TEXT\n", characters.len(), art::GLYPH_COLS, art::GLYPH_ROWS diff --git a/src/draw.rs b/src/draw.rs index 8bce818..ec39c1c 100644 --- a/src/draw.rs +++ b/src/draw.rs @@ -20,6 +20,7 @@ use ratatui::widgets::{Block, Borders, Paragraph}; use ratatui::Frame; use crate::art::{self, Canvas, Grid, CANVAS_ROWS}; +use crate::plural; use crate::primer::{Appearance, Legibility, Palette, Season}; use crate::thousands; @@ -181,7 +182,8 @@ impl Editor { Some(previous) => { self.canvas = previous; self.dirty = true; - self.status = format!("undone — {} step(s) left", self.undo.len()); + let left = self.undo.len(); + self.status = format!("undone — {left} {} left", plural(left, "step", "steps")); true } None => { @@ -515,7 +517,10 @@ pub fn render(frame: &mut Frame<'_>, editor: &Editor, palette: &Palette) { let rgb = palette.levels[usize::from(level)]; let bar = "█".repeat(count * 30 / (editor.canvas.width() * CANVAS_ROWS).max(1)); stats.push(Line::from(vec![ - Span::raw(format!(" level {level} {count:>4} day(s) ")), + Span::raw(format!( + " level {level} {count:>4} {:<6} ", + plural(count, "day", "days") + )), Span::styled(bar, Style::default().fg(Color::Rgb(rgb.0, rgb.1, rgb.2))), Span::raw(if level == 0 { " (must stay dark)".to_string() diff --git a/src/lib.rs b/src/lib.rs index baa4661..d3abfd3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -173,6 +173,22 @@ pub fn thousands(n: u32) -> String { out } +/// The singular or the plural of a word, chosen by a count. +/// +/// Here because the alternative is what mossaic used to print: `1 template(s)`, +/// `6 cell(s)`, `59 day(s)` — on the first lines a new user reads. Taking both +/// forms rather than appending an `s` keeps the irregular cases honest, and +/// leaves the number's own formatting (widths, thousands separators) to the +/// call site, which is where it belongs. +#[must_use] +pub fn plural<'a, N: PartialEq + From>(n: N, one: &'a str, many: &'a str) -> &'a str { + if n == N::from(1) { + one + } else { + many + } +} + pub mod app; pub mod art; pub mod calendar; diff --git a/src/main.rs b/src/main.rs index 3113a8a..8506b5e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ //! library beside it. use std::io; +use std::path::Path; use std::time::Duration; use chrono::{Datelike, Local, NaiveDate}; @@ -104,8 +105,24 @@ fn main() { // try_init rather than init/run so a non-tty (a pipe, CI) gets a readable // message instead of a panic. It still installs the terminal-restoring hook. - let mut terminal = ratatui::try_init() - .unwrap_or_else(|error| fail(&format!("needs an interactive terminal ({error})"))); + let mut terminal = ratatui::try_init().unwrap_or_else(|error| { + // With a terminal, a `--file` that cannot be read is shown *in* the + // chart, with `r` to retry — better than exiting, because the fix is + // usually to write the file in another pane. Without one there is no + // chart to show it in, and reporting only the missing terminal names + // the wrong problem: a typo'd path in a script used to come back as + // "needs an interactive terminal", which is true and useless. + if let Source::File(path) = &source { + if !Path::new(path).is_file() { + fail(&format!( + "no calendar file at {path}\n\n \ + mossaic-art TEXT --snapshot {path} write one\n \ + mossaic --demo a sample year instead" + )); + } + } + fail(&format!("needs an interactive terminal ({error})")) + }); // Ask now: raw mode is on, so the replies come back unbuffered and unechoed, // and anything a terminal prints instead of answering lands on the alternate diff --git a/src/plan.rs b/src/plan.rs index ade64c0..c3de168 100644 --- a/src/plan.rs +++ b/src/plan.rs @@ -21,7 +21,7 @@ use std::collections::{BTreeMap, BTreeSet}; use chrono::{Datelike, NaiveDate}; use crate::art::{self, Grid, GLYPH_ROWS}; -use crate::thousands; +use crate::{plural, thousands}; /// What the art wants of a day. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -913,8 +913,13 @@ impl Report { match self.verdict { "drawn" => format!("{} · {} — drawn", self.text, self.year), "holed" => format!( - "{} · {} — {} of {} bright, {} hole(s) that cannot be unlit", - self.text, self.year, self.bright, self.letters, self.holes + "{} · {} — {} of {} bright, {} {} that cannot be unlit", + self.text, + self.year, + self.bright, + self.letters, + self.holes, + plural(self.holes, "hole", "holes") ), // The background is work too, and saying "0 to go" while three // hundred field days are bare is the kind of confidently wrong a @@ -941,13 +946,15 @@ impl Report { out.push_str(&match self.verdict { "drawn" => format!("**{} is drawn.**\n\n", self.text), "holed" => format!( - "**Cannot be drawn cleanly** — {} day(s) inside the letters are already \ + "**Cannot be drawn cleanly** — {} {} inside the letters already \ lit, and nothing takes those away.\n\n", - self.holes + self.holes, + plural(self.holes, "day is", "days are") ), _ => format!( - "**On track** — {} contribution(s) to go{}.\n\n", + "**On track** — {} {} to go{}.\n\n", thousands(self.owing_commits), + plural(self.owing_commits, "contribution", "contributions"), match self.field_owing_commits { 0 => String::new(), owed => format!(", and {} for the background", thousands(owed)), @@ -962,8 +969,9 @@ impl Report { )); if self.owing_days > 0 { out.push_str(&format!( - "| still owing | {} day(s) · {} contributions |\n", + "| still owing | {} {} · {} contributions |\n", self.owing_days, + plural(self.owing_days, "day", "days"), thousands(self.owing_commits) )); } @@ -978,8 +986,9 @@ impl Report { )); if self.field_owing_days > 0 { out.push_str(&format!( - "| background owing | {} day(s) · {} contributions |\n", + "| background owing | {} {} · {} contributions |\n", self.field_owing_days, + plural(self.field_owing_days, "day", "days"), thousands(self.field_owing_commits) )); } @@ -1032,8 +1041,9 @@ impl Report { } if self.overdue_days > 0 { out.push_str(&format!( - "| already past | {} day(s) · {} contributions, back-dating only |\n", + "| already past | {} {} · {} contributions, back-dating only |\n", self.overdue_days, + plural(self.overdue_days, "day", "days"), thousands(self.overdue_commits) )); } @@ -1041,7 +1051,8 @@ impl Report { if let (Some(week), Some(holes)) = (self.suggested_start_week, self.suggested_holes) { if holes < self.holes { out.push_str(&format!( - "\n`--start-week {week}` would leave {holes} hole(s) instead of {}.\n", + "\n`--start-week {week}` would leave {holes} {} instead of {}.\n", + plural(holes, "hole", "holes"), self.holes )); } diff --git a/src/render_tests.rs b/src/render_tests.rs index 5311cf4..cdca357 100644 --- a/src/render_tests.rs +++ b/src/render_tests.rs @@ -3916,17 +3916,54 @@ fn every_template_file_is_a_valid_canvas() { #[test] fn the_reference_template_reads_clearly() { let dragon = crate::templates::find("dragon").expect("the reference template"); - let (low, high, legibility, delta) = dragon.canvas.closest_pair().expect("more than one shade"); - assert_eq!( - legibility, - crate::primer::Legibility::Clear, - "the reference template's closest pair is {low} and {high} at ΔE {delta:.0}, \ - which is not something a reader can rely on seeing" - ); assert_eq!(dragon.canvas.width(), crate::art::CANVAS_COLS); assert_eq!(dragon.author(), Some("@vyncint")); } +/// Every template in the catalogue, not only the reference one. +/// +/// This is the gate on contributed art, and it cannot be replaced by reading +/// the file: a picture whose two closest shades are nine ΔE apart looks +/// obviously two-toned in the `.art` source, because `2` and `3` are different +/// characters — and reads as one flat colour on github.com. Only measuring +/// catches it. +#[test] +fn every_template_reads_clearly() { + for (name, source) in crate::templates::builtin_sources() { + let canvas = + crate::art::Canvas::parse(source).unwrap_or_else(|error| panic!("{name}: {error}")); + // One shade has nothing to tell apart from anything. + let Some((low, high, legibility, delta)) = canvas.closest_pair() else { + continue; + }; + assert_eq!( + legibility, + crate::primer::Legibility::Clear, + "{name}: its closest pair is {low} and {high} at ΔE {delta:.0}, \ + which is not something a reader can rely on seeing" + ); + } +} + +/// No template loses a shade off the end of the year it is drawn across. +/// +/// The `note:` about a picture overhanging the calendar is for pictures that +/// genuinely do not fit. Shipping one that trips it on every year — as the +/// reference template did, on six blank cells — trains people to ignore the +/// note, which costs more than the note is worth. +#[test] +fn no_template_overhangs_the_year() { + for year in [2026, 2027, 2028, 2029] { + let grid = crate::art::Grid::new(year).expect("a real year"); + for (name, source) in crate::templates::builtin_sources() { + let canvas = + crate::art::Canvas::parse(source).unwrap_or_else(|error| panic!("{name}: {error}")); + let (_, skipped) = canvas.place(&grid, canvas.centred(&grid)); + assert_eq!(skipped, 0, "{name} loses {skipped} shades off {year}"); + } + } +} + /// The fact the rule above rests on, checked rather than asserted in prose: /// three shades is the most that can be drawn with nothing faint between any /// two of them, and `{0, 2, 4}` is the only such set. diff --git a/tests/art_cli.rs b/tests/art_cli.rs index 1f64562..b0e7268 100644 --- a/tests/art_cli.rs +++ b/tests/art_cli.rs @@ -45,7 +45,10 @@ fn the_font_can_be_looked_at() { .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}"); + assert!( + text.contains("write any of them with mossaic-art TEXT"), + "{text}" + ); // Every character it claims, drawn. for character in ['A', 'Z', '0', '9', '-', '.'] { assert!( @@ -903,7 +906,7 @@ fn a_background_is_drawn_as_a_shade_and_priced_as_one() { text.contains("background level 1 under letters at level 4"), "{text}" ); - assert!(text.contains("290 background day(s), 1 each"), "{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}"); @@ -1678,19 +1681,19 @@ fn backfill_reaches_only_the_days_that_have_gone() { let mid = stdout(&art(&args("2026-08-19"))); // The same 34 days and 3,464 commits `--track` calls "already past": both // answers come from one plan and one date, so they agree by construction. - assert!(mid.contains("3,464 commit(s) across 34 day(s)"), "{mid}"); + 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 day(s) from 2026-08-19 on are short too, and left alone"), + mid.contains("23 days from 2026-08-19 on are short too, and left alone"), "and it says what it is leaving: {mid}" ); // Later in the year, more of it is reachable. let end = stdout(&art(&args("2026-12-31"))); - assert!(end.contains("5,994 commit(s) across 57 day(s)"), "{end}"); + assert!(end.contains("5,994 commits across 57 days"), "{end}"); // Before the year starts, none of it is — and it says why rather than // claiming the plan is finished. @@ -1702,7 +1705,7 @@ fn backfill_reaches_only_the_days_that_have_gone() { // A year that cannot be drawn cleanly says so before an irreversible write. assert!( - mid.contains("cannot be drawn cleanly in 2026") && mid.contains("61 day(s) inside the"), + mid.contains("cannot be drawn cleanly in 2026") && mid.contains("61 days inside the"), "a warning belongs where the commits do: {mid}" ); } diff --git a/tests/canvas_pty.rs b/tests/canvas_pty.rs index 2a25b94..c01ee4f 100644 --- a/tests/canvas_pty.rs +++ b/tests/canvas_pty.rs @@ -53,26 +53,37 @@ fn list_templates_shows_a_picture_and_not_just_a_name() -> termlens::Result<()> // satisfied by a screen the rest of it has not reached yet. Every // assertion below is then a race, and it loses on a loaded runner. terminal.wait_exit()?; - let screen = terminal.screen(); - - assert!(screen.contains("dragon"), "the name:\n{screen}"); - assert!(screen.contains("Dragon"), "the title:\n{screen}"); - assert!(screen.contains("@vyncint"), "the author:\n{screen}"); - assert!(screen.contains("53 columns"), "the size:\n{screen}"); - assert!(screen.contains("built in"), "where it came from:\n{screen}"); + // History *and* screen. The catalogue outgrew one screenful when it stopped + // being a catalogue of one, so the first entry has scrolled off by the time + // the process exits — and "did this reach the terminal" is the question the + // test is actually asking, not "is it still visible". + let text = terminal.screen().full_text(); + + assert!(text.contains("dragon"), "the name:\n{text}"); + assert!(text.contains("Dragon"), "the title:\n{text}"); + assert!(text.contains("@vyncint"), "the author:\n{text}"); + assert!(text.contains("53 columns"), "the size:\n{text}"); + assert!(text.contains("built in"), "where it came from:\n{text}"); + + // Every template in the catalogue, not just the first: a listing that + // silently stopped after one would satisfy the assertions above. + for name in ["dragon", "invader", "pulse", "wave"] { + assert!( + text.contains(name), + "{name} is missing from the catalogue:\n{text}" + ); + } // The thumbnail is the reason to run this rather than read a directory // listing, and a name with no picture beside it would satisfy every // assertion above. - let blocks = (0..screen.rows()) - .filter(|row| { - let text = screen.row_text(*row); - text.contains('\u{2588}') || text.contains('\u{2593}') - }) + let blocks = text + .lines() + .filter(|line| line.contains('\u{2588}') || line.contains('\u{2593}')) .count(); assert!( blocks >= 5, - "expected a seven-row thumbnail, found {blocks} row(s) of blocks:\n{screen}" + "expected a seven-row thumbnail, found {blocks} rows of blocks:\n{text}" ); Ok(()) } @@ -181,7 +192,7 @@ fn painting_shows_up_on_screen_and_in_the_numbers() -> termlens::Result<()> { terminal.send(Key::Char('4'))?; terminal.send(Key::Char('l'))?; } - let screen = terminal.wait_frame(|screen| screen.contains("level 4 4 day(s)"))?; + let screen = terminal.wait_frame(|screen| screen.contains("level 4 4 days"))?; // Four days at level 4 cost four commits each at the default --commits. assert!( @@ -203,10 +214,10 @@ fn undo_takes_the_drawing_back_and_says_when_there_is_no_more() -> termlens::Res terminal.wait_until(ready)?; terminal.send(Key::Char('3'))?; - terminal.wait_until(|screen| screen.contains("level 3 1 day(s)"))?; + terminal.wait_until(|screen| screen.contains("level 3 1 day "))?; terminal.send(Key::Char('u'))?; let screen = terminal.wait_frame(|screen| screen.contains("undone"))?; - assert!(screen.contains("level 3 0 day(s)"), "{screen}"); + assert!(screen.contains("level 3 0 days"), "{screen}"); terminal.send(Key::Char('u'))?; terminal.wait_until(|screen| screen.contains("nothing to undo"))?; @@ -279,7 +290,7 @@ fn an_unsaved_drawing_is_not_lost_quietly() -> termlens::Result<()> { let mut terminal = spawn(&["--draw", "--year", "2027", "--plan", "/dev/null"])?; terminal.wait_until(ready)?; terminal.send(Key::Char('4'))?; - terminal.wait_until(|screen| screen.contains("level 4 1 day(s)"))?; + terminal.wait_until(|screen| screen.contains("level 4 1 day "))?; terminal.send(Key::Char('q'))?; terminal.wait_exit()?; diff --git a/tests/chart_cli.rs b/tests/chart_cli.rs new file mode 100644 index 0000000..d8ee8fa --- /dev/null +++ b/tests/chart_cli.rs @@ -0,0 +1,68 @@ +//! The `mossaic` chart binary run *without* a terminal — a script, a pipe, CI. +//! +//! No PTY here on purpose: this file is about what the chart says when it +//! cannot open one at all. `smoke.rs` covers everything it does once it can. + +use std::process::{Command, Output}; + +fn chart(args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_mossaic")) + .current_dir(env!("CARGO_MANIFEST_DIR")) + .args(args) + .stdin(std::process::Stdio::null()) + .output() + .expect("the chart binary runs") +} + +fn stderr(output: &Output) -> String { + String::from_utf8_lossy(&output.stderr).into_owned() +} + +/// The chart shows a missing `--file` *inside* the chart, with `r` to retry +/// (`smoke.rs::a_missing_file_is_reported_not_swallowed`). Without a terminal +/// there is no chart to show it in, and it used to report only the missing +/// terminal — true, and no help at all to someone with a typo in a script. +#[test] +fn without_a_terminal_a_missing_file_is_named_rather_than_the_terminal() { + let out = chart(&["--file", "/no/such/calendar.json"]); + let text = stderr(&out); + assert!( + text.contains("no calendar file at /no/such/calendar.json"), + "the file is the problem, and it should say so:\n{text}" + ); + assert!( + !text.contains("interactive terminal"), + "and it should not blame the terminal:\n{text}" + ); + assert!( + text.contains("--snapshot") && text.contains("--demo"), + "and it should say how to get one:\n{text}" + ); +} + +/// The other half, which keeps the message above specific rather than blanket: +/// a file that *is* there leaves the terminal as the only thing wrong. +#[test] +fn without_a_terminal_an_existing_file_still_reports_the_terminal() { + let out = chart(&["--file", "Cargo.toml"]); + let text = stderr(&out); + assert!( + text.contains("interactive terminal"), + "nothing is wrong with the path, so the terminal is the problem:\n{text}" + ); + assert!( + !text.contains("no calendar file"), + "and the file must not be blamed:\n{text}" + ); +} + +/// `--png` is the documented way to get a chart out of a machine with no +/// terminal, so it must not need one. +#[test] +fn png_needs_no_terminal() { + let path = std::env::temp_dir().join(format!("mossaic-{}-headless.png", std::process::id())); + let out = chart(&["--demo", "--png", path.to_str().expect("utf-8 temp path")]); + assert!(out.status.success(), "{}", stderr(&out)); + assert!(path.is_file(), "it wrote {}", path.display()); + let _ = std::fs::remove_file(&path); +}