Files
wanderbooks/crates/ledger-gui/tests/text_documents.rs
T
swaitsandClaude Opus 5.5 a9de57d6cd
check / check (push) Canceled after 0s
feat(viewer): thumbnails drawn at load, kept on disk by hash; no picture crashes it
Tiles draw a thumbnail, not the file: the top of the first page, 560
pixels wide (never scaled up) and at most 420 tall, rendered by two
workers of their own so opening a file never waits behind them. Every
file's thumbnail starts when the books load, before the documents page
is opened, and the live viewer keeps each as <sha>.png in
$XDG_CACHE_HOME (else ~/.cache)/wanderbooks/thumbs/v1-560 (private to
the user, written whole and renamed into place), so the next launch
reads small PNGs, not PDFs. A file's name is its content's hash, so a
thumbnail always matches the file the books hold; the blob is still read
through the verifying store each time, so a corrupt or missing file is
an error, never a kept picture.

From review: a picture or page past the device's largest texture is
scaled to fit (egui panicked on it, in the full view too); a renderer
panic is that file's error, not the end of its worker; a thumbnail asked
for before the cache moved is dropped, never shown over a newer one;
text thumbnails keep 2 KiB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-23 09:58:25 -06:00

221 lines
8.0 KiB
Rust

//! Text documents in the viewer: a Markdown file is drawn as formatted
//! text, a plain-text file as its text, both on the document's page and
//! on its tile; a text file that isn't UTF-8 says so rather than show
//! half of it.
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use ledger_core::Currency;
use ledger_gui::nav::Route;
use ledger_gui::{App, Source, data};
use ledger_store::Store;
use std::path::PathBuf;
struct TempDir(PathBuf);
impl TempDir {
fn new(tag: &str) -> TempDir {
let d = std::env::temp_dir().join(format!("ledger-gui-text-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
TempDir(d)
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
const PLAN: &str = "\u{feff}# Studio plan\n\nThe studio's goal is **ten apps** that earn.\n\n\
- Chess openings\n- Route planner\n";
fn open(dir: &TempDir, route: Route) -> Harness<'static, App> {
let books = data::load_once(&Source {
data_dir: dir.0.join("books-data"),
today: Some(chrono::NaiveDate::from_ymd_opt(2026, 10, 23).unwrap()),
})
.unwrap();
let mut h = Harness::builder()
.with_size(egui::vec2(1100.0, 900.0))
.build_eframe(|cc| App::with_books(&cc.egui_ctx, books, false, route));
h.run_steps(3);
let ctx = h.ctx.clone();
h.state_mut().settle(&ctx);
h.run_steps(3);
h
}
/// SHOT_DIR set: the page as drawn, to look at.
fn shot(h: &mut Harness<'static, App>, name: &str) {
if let Ok(out) = std::env::var("SHOT_DIR") {
let out = PathBuf::from(out);
std::fs::create_dir_all(&out).unwrap();
h.render()
.unwrap()
.save(out.join(format!("{name}.png")))
.unwrap();
}
}
#[test]
fn a_markdown_document_is_drawn_as_formatted_text() {
let dir = TempDir::new("md");
let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap();
let (md, _, _) = s
.file_document(
"other",
"plan.md",
"text/markdown",
PLAN.as_bytes(),
Some("Plan for the studio"),
)
.unwrap();
drop(s);
let mut h = open(&dir, Route::Document(md));
shot(&mut h, "markdown-document");
// The heading is its own text, without the "#" or the byte-order mark;
// the list items are there; nothing says it can't be shown.
assert!(h.query_by_label("Studio plan").is_some());
assert!(h.query_by_label_contains("# Studio").is_none());
assert!(h.query_by_label_contains("Chess openings").is_some());
assert!(h.query_by_label_contains("Route planner").is_some());
assert!(h.query_by_label_contains("can't be previewed").is_none());
assert!(h.query_by_label_contains("Can't show").is_none());
}
#[test]
fn a_plain_text_document_is_its_text_and_bad_text_says_so() {
let dir = TempDir::new("txt");
let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap();
let (txt, _, _) = s
.file_document(
"other",
"notes.txt",
"text/plain; charset=utf-8",
b"Meeting notes\nall agreed",
None,
)
.unwrap();
let (bad, _, _) = s
.file_document("other", "bad.md", "text/markdown", b"caf\xe9", None)
.unwrap();
drop(s);
let h = open(&dir, Route::Document(txt));
assert!(h.query_by_label_contains("Meeting notes").is_some());
assert!(h.query_by_label_contains("Can't show").is_none());
let h = open(&dir, Route::Document(bad));
assert!(h.query_by_label_contains("the text isn't UTF-8").is_some());
}
#[test]
fn the_documents_page_draws_text_tiles() {
let dir = TempDir::new("tiles");
let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap();
s.file_document("other", "plan.md", "text/markdown", PLAN.as_bytes(), None)
.unwrap();
drop(s);
let mut h = open(&dir, Route::Documents);
h.run_steps(2);
shot(&mut h, "text-tiles");
assert_eq!(h.state_mut().route(), &Route::Documents);
}
/// A PNG `w` by `h` pixels.
fn png(w: u32, h: u32) -> Vec<u8> {
let img = image::RgbaImage::from_pixel(w, h, image::Rgba([40, 90, 160, 255]));
let mut out = Vec::new();
image::DynamicImage::ImageRgba8(img)
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
.unwrap();
out
}
/// receipts.rs: every file's thumbnail is started when the books load,
/// before its page is opened; it is kept on disk as `<sha>.png`, at most
/// 560 pixels wide (a smaller picture is never scaled up), and the next
/// launch draws what was kept instead of the file.
#[test]
fn thumbnails_are_drawn_at_load_and_kept_by_hash() {
let dir = TempDir::new("thumbs");
let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap();
// The second of what filing gives is the file's SHA-256.
let (_, big_sha, _) = s
.file_document("photo", "wide.png", "image/png", &png(1200, 600), None)
.unwrap();
let (_, small_sha, _) = s
.file_document("photo", "small.png", "image/png", &png(300, 200), None)
.unwrap();
drop(s);
let thumbs = dir.0.join("thumbs");
// The overview, not the documents page: the thumbnails come anyway.
let mut h = open(&dir, Route::Overview);
h.state_mut().keep_thumbs_in(thumbs.clone());
let ctx = h.ctx.clone();
h.state_mut().settle(&ctx);
h.run_steps(2);
assert_eq!(h.state().thumb_size(&big_sha), Some([560, 280]));
assert_eq!(h.state().thumb_size(&small_sha), Some([300, 200]));
let kept = thumbs.join(format!("{big_sha}.png"));
let img = image::open(&kept).unwrap();
assert_eq!((img.width(), img.height()), (560, 280));
assert!(thumbs.join(format!("{small_sha}.png")).exists());
// What was kept is what the next launch draws.
std::fs::write(&kept, png(7, 5)).unwrap();
let mut h = open(&dir, Route::Documents);
h.state_mut().keep_thumbs_in(thumbs.clone());
let ctx = h.ctx.clone();
h.state_mut().settle(&ctx);
assert_eq!(h.state().thumb_size(&big_sha), Some([7, 5]));
}
/// receipts.rs: a long picture's thumbnail is its top, at most 420
/// pixels tall; a picture past a texture's side opens scaled to fit
/// rather than crash the viewer; a file whose blob is gone shows as an
/// error even with its thumbnail kept.
#[test]
fn long_and_huge_pictures_are_cut_or_scaled_and_a_lost_file_is_an_error() {
let dir = TempDir::new("long");
let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap();
let (_, long_sha, _) = s
.file_document("photo", "long.png", "image/png", &png(400, 9000), None)
.unwrap();
let (huge, huge_sha, _) = s
.file_document("photo", "huge.png", "image/png", &png(9000, 300), None)
.unwrap();
drop(s);
let thumbs = dir.0.join("thumbs");
let mut h = open(&dir, Route::Document(huge));
h.state_mut().keep_thumbs_in(thumbs.clone());
let ctx = h.ctx.clone();
h.state_mut().settle(&ctx);
h.run_steps(2);
assert_eq!(h.state().thumb_size(&long_sha), Some([400, 420]));
assert_eq!(h.state().thumb_size(&huge_sha), Some([560, 18]));
assert!(h.query_by_label_contains("Can't show").is_none());
// The blob gone: the kept thumbnail is not shown in its place.
let blob = walk(&dir.0.join("books-data"), &long_sha).expect("the blob's file");
std::fs::remove_file(blob).unwrap();
let mut h = open(&dir, Route::Documents);
h.state_mut().keep_thumbs_in(thumbs);
let ctx = h.ctx.clone();
h.state_mut().settle(&ctx);
assert_eq!(h.state().thumb_size(&long_sha), None);
}
/// The file under `dir` named `name`.
fn walk(dir: &std::path::Path, name: &str) -> Option<PathBuf> {
for e in std::fs::read_dir(dir).ok()?.flatten() {
let p = e.path();
if p.is_dir() {
if let Some(f) = walk(&p, name) {
return Some(f);
}
} else if p.file_name().and_then(|n| n.to_str()) == Some(name) {
return Some(p);
}
}
None
}