check / check (push) Canceled after 0s
CI's software renderer antialiases the same shapes differently (4635 pixels at 1440x920), so a_tall_sidebar_is_unchanged failed there. The shape hash stays the check everywhere; the pixels are a local check with SIDENAV_PIXELS=1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
765 lines
25 KiB
Rust
765 lines
25 KiB
Rust
//! The sidebar's links fit the window (owner, 2026-10-22: at 1479x519
|
|
//! half the links hid under the status box with no sign that they
|
|
//! scroll). As the window gets shorter the sidebar gives way in three
|
|
//! steps, each only when the one before isn't enough: the status card
|
|
//! folds to its summary line, then the rows tighten, then the links
|
|
//! scroll, with a fade and a chevron at the clipped edge.
|
|
//!
|
|
//! ```text
|
|
//! GOAL: every link reachable and never under the footer, at any height
|
|
//! and scale, sidebar and rail; a window where everything fits
|
|
//! looks exactly as it did.
|
|
//! SOURCE: app.rs (Fit, fit, sidebar, rail, live_status).
|
|
//! ```
|
|
//!
|
|
//! `SHOT_DIR` keeps pictures for eyes (`sidenav_screenshots`).
|
|
//! `SIDENAV_BLESS=1` rewrites the goldens of the 1440x920 sidebar
|
|
//! (`tests/goldens/`): only from a commit whose sidebar is the one meant.
|
|
|
|
use chrono::NaiveDate;
|
|
use egui::{Rect, vec2};
|
|
use egui_kittest::Harness;
|
|
use egui_kittest::kittest::Queryable;
|
|
use ledger_core::{AccountType, Currency, Money, Side};
|
|
use ledger_gui::books::Books;
|
|
use ledger_gui::nav::{Route, Section};
|
|
use ledger_gui::owner::{Agent, Owner, RestorePlan};
|
|
use ledger_gui::{App, Source, data};
|
|
use ledger_store::{DiskCheck, Store};
|
|
use rust_decimal::Decimal;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::{Arc, OnceLock};
|
|
|
|
/// An owner that never gets asked anything here: it only makes the
|
|
/// viewer show its Books menu, as the live one does.
|
|
struct Quiet;
|
|
|
|
impl Owner for Quiet {
|
|
fn close_books(&self) -> Result<String, String> {
|
|
Err("not here".into())
|
|
}
|
|
fn new_books(&self, _: &str, _: &str) -> Result<String, String> {
|
|
Err("not here".into())
|
|
}
|
|
fn back_up_now(&self, _: &str) -> Result<Vec<String>, String> {
|
|
Err("not here".into())
|
|
}
|
|
fn plan_restore(&self, _: &str, _: Option<String>, _: bool) -> Result<RestorePlan, String> {
|
|
Err("not here".into())
|
|
}
|
|
fn restore(&self, _: &RestorePlan) -> Result<Vec<String>, String> {
|
|
Err("not here".into())
|
|
}
|
|
fn connect(&self, _: Agent, _: &str) -> Result<Vec<String>, String> {
|
|
Err("not here".into())
|
|
}
|
|
fn upgrade_format(&self, _: &str, _: &str) -> Result<Vec<String>, String> {
|
|
Err("not here".into())
|
|
}
|
|
}
|
|
|
|
fn owner() {
|
|
static ONCE: OnceLock<()> = OnceLock::new();
|
|
ONCE.get_or_init(|| ledger_gui::owner::set_owner(Arc::new(Quiet)));
|
|
}
|
|
|
|
fn today() -> NaiveDate {
|
|
NaiveDate::from_ymd_opt(2026, 10, 22).unwrap()
|
|
}
|
|
|
|
/// What the sidebar's footer can show: the live viewer's (a Books menu,
|
|
/// Live, every check passing) and each way it differs.
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
enum Footer {
|
|
/// The owner's: Books menu, Live, every check passing.
|
|
Owners,
|
|
/// Not following a journal: "Snapshot".
|
|
Snapshot,
|
|
Behind,
|
|
Ahead,
|
|
/// A copy of the books: no server database.
|
|
Missing,
|
|
Differs,
|
|
Unreadable,
|
|
/// Stored files damaged.
|
|
Damaged,
|
|
/// Books outside a data root: no Books menu.
|
|
NoMenu,
|
|
}
|
|
|
|
const FOOTERS: [Footer; 9] = [
|
|
Footer::Owners,
|
|
Footer::Snapshot,
|
|
Footer::Behind,
|
|
Footer::Ahead,
|
|
Footer::Missing,
|
|
Footer::Differs,
|
|
Footer::Unreadable,
|
|
Footer::Damaged,
|
|
Footer::NoMenu,
|
|
];
|
|
|
|
/// Books with a footer like the live one's: a data root's (its Books
|
|
/// menu), about 560 events and 110 stored receipts. Removed when dropped.
|
|
struct Fixture {
|
|
_dir: tempfile::TempDir,
|
|
root: PathBuf,
|
|
plain: PathBuf,
|
|
}
|
|
|
|
fn usd(c: i64) -> Money {
|
|
Money::new(Decimal::new(c, 2), Currency::USD)
|
|
}
|
|
|
|
fn fill(book: &Path) {
|
|
let mut s = Store::create(book, Currency::USD).unwrap();
|
|
let mut add = |code: &str, ty| s.add_account(code, code, ty, None, false).unwrap();
|
|
let cash = add("1000", AccountType::Asset);
|
|
let capital = add("3000", AccountType::Equity);
|
|
let rent = add("6000", AccountType::Expense);
|
|
for i in 0..110i64 {
|
|
let (dr, cr) = if i == 0 {
|
|
(cash, capital)
|
|
} else {
|
|
(rent, cash)
|
|
};
|
|
let e = s
|
|
.create_entry(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(), "test")
|
|
.unwrap();
|
|
s.add_line(e, dr, Side::Debit, usd(100 + i)).unwrap();
|
|
s.add_line(e, cr, Side::Credit, usd(100 + i)).unwrap();
|
|
s.attach_receipt(
|
|
Some(e),
|
|
"r.pdf",
|
|
"application/pdf",
|
|
format!("%PDF {i}").as_bytes(),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
s.post_entry(e).unwrap();
|
|
}
|
|
}
|
|
|
|
impl Fixture {
|
|
fn new() -> Fixture {
|
|
owner();
|
|
let dir = tempfile::Builder::new()
|
|
.prefix("wb-gui-sidenav-")
|
|
.tempdir()
|
|
.unwrap();
|
|
std::fs::write(dir.path().join(ledger_store::root::MARKER_FILE), b"").unwrap();
|
|
let root = dir.path().join("books").join("wanderland");
|
|
std::fs::create_dir_all(&root).unwrap();
|
|
fill(&root);
|
|
// The same books outside the root: no Books menu.
|
|
let plain = tempfile::Builder::new()
|
|
.prefix("wb-gui-sidenav-plain-")
|
|
.tempdir()
|
|
.unwrap()
|
|
.keep();
|
|
let plain_book = plain.join("books-data");
|
|
copy_dir(&root, &plain_book);
|
|
Fixture {
|
|
_dir: dir,
|
|
root,
|
|
plain: plain_book,
|
|
}
|
|
}
|
|
|
|
fn books(&self, footer: Footer) -> Books {
|
|
let data_dir = if footer == Footer::NoMenu {
|
|
self.plain.clone()
|
|
} else {
|
|
self.root.clone()
|
|
};
|
|
let mut b = data::load_once(&Source {
|
|
data_dir,
|
|
today: Some(today()),
|
|
})
|
|
.unwrap();
|
|
match footer {
|
|
Footer::Behind => {
|
|
b.integrity.disk = DiskCheck::Behind {
|
|
applied: 550,
|
|
journal: 559,
|
|
}
|
|
}
|
|
Footer::Ahead => {
|
|
b.integrity.disk = DiskCheck::Ahead {
|
|
applied: 561,
|
|
journal: 559,
|
|
}
|
|
}
|
|
Footer::Missing => b.integrity.disk = DiskCheck::Missing,
|
|
Footer::Differs => {
|
|
b.integrity.disk = DiskCheck::Differs("the entries table differs".into())
|
|
}
|
|
Footer::Unreadable => {
|
|
b.integrity.disk = DiskCheck::Unreadable("database disk image is malformed".into())
|
|
}
|
|
Footer::Damaged => {
|
|
b.integrity.file_failures = vec![(3, "missing".into()), (9, "hash differs".into())]
|
|
}
|
|
_ => {}
|
|
}
|
|
b
|
|
}
|
|
|
|
/// The viewer on `route`, `w`x`h` points at `scale`.
|
|
fn harness(
|
|
&self,
|
|
footer: Footer,
|
|
route: Route,
|
|
w: f32,
|
|
h: f32,
|
|
scale: f32,
|
|
) -> Harness<'static, App> {
|
|
let b = self.books(footer);
|
|
let mut h = Harness::builder()
|
|
.with_size(vec2(w, h))
|
|
.with_pixels_per_point(scale)
|
|
.build_eframe(move |cc| {
|
|
let mut a = App::with_books(&cc.egui_ctx, b, true, route);
|
|
a.set_scheme(&cc.egui_ctx, "Everforest/Dark");
|
|
if footer != Footer::Snapshot {
|
|
a.show_live_label();
|
|
}
|
|
a
|
|
});
|
|
h.run_steps(6);
|
|
h
|
|
}
|
|
}
|
|
|
|
impl Drop for Fixture {
|
|
fn drop(&mut self) {
|
|
if let Some(p) = self.plain.parent() {
|
|
let _ = std::fs::remove_dir_all(p);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn copy_dir(from: &Path, to: &Path) {
|
|
std::fs::create_dir_all(to).unwrap();
|
|
for e in std::fs::read_dir(from).unwrap() {
|
|
let p = e.unwrap().path();
|
|
let dest = to.join(p.file_name().unwrap());
|
|
if p.is_dir() {
|
|
copy_dir(&p, &dest);
|
|
} else {
|
|
std::fs::copy(&p, &dest).unwrap();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Sidebar or rail: which one the window's width shows.
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
enum Pane {
|
|
Sidebar,
|
|
Rail,
|
|
}
|
|
|
|
impl Pane {
|
|
/// Its width.
|
|
fn panel(self) -> f32 {
|
|
match self {
|
|
Pane::Sidebar => 224.0,
|
|
Pane::Rail => 60.0,
|
|
}
|
|
}
|
|
/// A window width (points) that shows it.
|
|
fn width(self) -> f32 {
|
|
match self {
|
|
Pane::Sidebar => 1440.0,
|
|
Pane::Rail => 900.0,
|
|
}
|
|
}
|
|
fn key(self) -> &'static str {
|
|
match self {
|
|
Pane::Sidebar => "sidebar",
|
|
Pane::Rail => "rail",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The link of section `s` (the leftmost button of its name).
|
|
fn link(h: &Harness<'_, App>, s: Section) -> Rect {
|
|
h.get_all_by_role_and_label(egui::accesskit::Role::Button, s.title())
|
|
.map(|n| n.rect())
|
|
.min_by(|a, b| a.left().total_cmp(&b.left()))
|
|
.unwrap_or_else(|| panic!("no {} link", s.title()))
|
|
}
|
|
|
|
/// What the sidebar says it drew: the links' viewport, the footer's
|
|
/// rect, and whether the links scroll.
|
|
fn drawn(h: &Harness<'_, App>, side: Pane) -> (Rect, Rect, bool) {
|
|
let k = side.key();
|
|
h.ctx.data(|d| {
|
|
(
|
|
d.get_temp::<Rect>(egui::Id::new(format!("{k}_links_view")))
|
|
.expect("the links' viewport"),
|
|
d.get_temp::<Rect>(egui::Id::new(format!("{k}_footer_rect")))
|
|
.expect("the footer's rect"),
|
|
d.get_temp::<bool>(egui::Id::new(format!("{k}_links_scroll")))
|
|
.expect("whether the links scroll"),
|
|
)
|
|
})
|
|
}
|
|
|
|
const EPS: f32 = 0.01;
|
|
|
|
/// `r` shows top to bottom inside `view`. Links are laid on whole
|
|
/// pixels and the viewport needn't be, so at a fractional scale they may
|
|
/// differ by under a pixel (half a point at most here); across, a link may
|
|
/// overrun the viewport likewise.
|
|
fn whole_in(view: Rect, r: Rect) -> bool {
|
|
r.top() >= view.top() - 0.5 && r.bottom() <= view.bottom() + 0.5
|
|
}
|
|
|
|
/// No link shows over or under the footer: the part of each link inside
|
|
/// the links' viewport ends above the footer and above every footer
|
|
/// widget the tree names; without scrolling every link shows whole.
|
|
fn assert_clear(h: &Harness<'_, App>, side: Pane, tag: &str) {
|
|
let (view, footer, scrolls) = drawn(h, side);
|
|
assert!(
|
|
view.bottom() <= footer.top() + EPS,
|
|
"{tag}: the links' viewport {view:?} reaches the footer {footer:?}"
|
|
);
|
|
// The footer's own widgets, found in the tree, not by the hook.
|
|
let mut tops = vec![footer.top()];
|
|
for label in ["Export", "event "] {
|
|
for n in h.query_all_by_label_contains(label) {
|
|
if n.rect().left() < side.panel() && n.rect().top() > view.top() {
|
|
tops.push(n.rect().top());
|
|
}
|
|
}
|
|
}
|
|
let footer_top = tops.into_iter().fold(f32::INFINITY, f32::min);
|
|
let mut whole = 0;
|
|
for s in Section::ALL {
|
|
let r = link(h, s);
|
|
let seen = r.intersect(view);
|
|
if seen.is_positive() {
|
|
assert!(
|
|
seen.bottom() <= footer_top + EPS,
|
|
"{tag}: {} shows to {} under the footer at {footer_top}",
|
|
s.title(),
|
|
seen.bottom()
|
|
);
|
|
}
|
|
if whole_in(view, r) {
|
|
whole += 1;
|
|
} else {
|
|
assert!(
|
|
scrolls,
|
|
"{tag}: {} at {r:?} cut off with no scrolling in {view:?}",
|
|
s.title()
|
|
);
|
|
}
|
|
}
|
|
assert!(
|
|
whole >= 1,
|
|
"{tag}: no link shows whole in {view:?} (footer {footer:?}): {:?}",
|
|
Section::ALL.map(|s| link(h, s))
|
|
);
|
|
}
|
|
|
|
/// Every height from 300 to 920 pixels in steps of 20, at scales 1 to 2,
|
|
/// sidebar and rail, with each footer the live viewer can show.
|
|
fn grid(scale: f32) {
|
|
let f = Fixture::new();
|
|
for footer in FOOTERS {
|
|
for side in [Pane::Sidebar, Pane::Rail] {
|
|
let mut h = f.harness(footer, Route::Projects, side.width(), 920.0, scale);
|
|
for px in (300..=920).step_by(20) {
|
|
let pt = px as f32 / scale;
|
|
h.set_size(vec2(side.width(), pt));
|
|
h.run_steps(4);
|
|
let tag = format!("{footer:?} {side:?} {px}px @{scale}");
|
|
assert_clear(&h, side, &tag);
|
|
// The owner's complaint: in a window as tall as theirs
|
|
// (519) or taller, no link is out of sight, sidebar or rail.
|
|
if scale == 1.0 && px >= 519 {
|
|
assert!(!drawn(&h, side).2, "{tag}: the links scroll");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn no_link_is_ever_under_the_footer_at_scale_1() {
|
|
grid(1.0);
|
|
}
|
|
|
|
#[test]
|
|
fn no_link_is_ever_under_the_footer_at_scale_1_25() {
|
|
grid(1.25);
|
|
}
|
|
|
|
#[test]
|
|
fn no_link_is_ever_under_the_footer_at_scale_1_5() {
|
|
grid(1.5);
|
|
}
|
|
|
|
#[test]
|
|
fn no_link_is_ever_under_the_footer_at_scale_2() {
|
|
grid(2.0);
|
|
}
|
|
|
|
/// The owner's window: every link shows whole, nothing scrolls, and the
|
|
/// status card is its summary line, the checks in its tooltip.
|
|
#[test]
|
|
fn at_1479x519_every_link_shows_unscrolled() {
|
|
let f = Fixture::new();
|
|
for footer in FOOTERS {
|
|
let mut h = f.harness(footer, Route::Projects, 1479.0, 519.0, 1.0);
|
|
let (view, _, scrolls) = drawn(&h, Pane::Sidebar);
|
|
assert!(!scrolls, "{footer:?}: the links scroll");
|
|
for s in Section::ALL {
|
|
let r = link(&h, s);
|
|
assert!(
|
|
whole_in(view, r) && r.bottom() <= 519.0,
|
|
"{footer:?}: {} at {r:?} isn't whole in {view:?}",
|
|
s.title()
|
|
);
|
|
assert!(r.height() >= 24.0, "{footer:?}: {} too tight", s.title());
|
|
}
|
|
assert_clear(&h, Pane::Sidebar, &format!("{footer:?}"));
|
|
// Nothing moves when the last link is asked into view.
|
|
let first = link(&h, Section::Overview);
|
|
h.get_all_by_role_and_label(egui::accesskit::Role::Button, "Journal")
|
|
.min_by(|a, b| a.rect().left().total_cmp(&b.rect().left()))
|
|
.unwrap()
|
|
.scroll_to_me();
|
|
h.run_steps(4);
|
|
assert_eq!(
|
|
link(&h, Section::Overview),
|
|
first,
|
|
"{footer:?}: it scrolled"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Folded, the card is one line that fits the card whole (never cut to
|
|
/// "Datab…"), naming the check that failed; hovered, it shows the three
|
|
/// checks.
|
|
#[test]
|
|
fn the_folded_card_shows_its_checks_on_hover() {
|
|
let f = Fixture::new();
|
|
for (footer, verdict, checks) in [
|
|
(
|
|
Footer::Owners,
|
|
"verified",
|
|
[
|
|
"Journal chain verified",
|
|
"Database matches journal",
|
|
"110 files verified",
|
|
],
|
|
),
|
|
(
|
|
Footer::Snapshot,
|
|
// Its line is "Snapshot · event 555 · (seal)": no room for
|
|
// "verified", which the seal says.
|
|
"Snapshot",
|
|
[
|
|
"Journal chain verified",
|
|
"Database matches journal",
|
|
"110 files verified",
|
|
],
|
|
),
|
|
(
|
|
Footer::Differs,
|
|
"Database differs",
|
|
[
|
|
"Journal chain verified",
|
|
"Database differs",
|
|
"110 files verified",
|
|
],
|
|
),
|
|
(
|
|
Footer::Unreadable,
|
|
"Database unreadable",
|
|
[
|
|
"Journal chain verified",
|
|
"Database unreadable",
|
|
"110 files verified",
|
|
],
|
|
),
|
|
(
|
|
Footer::Damaged,
|
|
"2 files damaged",
|
|
[
|
|
"Journal chain verified",
|
|
"Database matches journal",
|
|
"2 files damaged",
|
|
],
|
|
),
|
|
] {
|
|
for scale in [1.0, 1.25] {
|
|
let tag = format!("{footer:?} @{scale}");
|
|
let mut h = f.harness(
|
|
footer,
|
|
Route::Projects,
|
|
1479.0 / scale,
|
|
519.0 / scale,
|
|
scale,
|
|
);
|
|
assert!(
|
|
h.query_by_label_contains("files verified").is_none()
|
|
&& h.query_by_label_contains("Journal chain verified")
|
|
.is_none(),
|
|
"{tag}: the card is folded"
|
|
);
|
|
let (_, footer_rect, _) = drawn(&h, Pane::Sidebar);
|
|
let line = h.get_by_label_contains(verdict);
|
|
let card = line.rect();
|
|
// Every piece of the line lies inside the card's margins: a
|
|
// piece cut off or running out would reach past them.
|
|
for piece in ["Live", "Snapshot", "event 5", verdict] {
|
|
for n in h.query_all_by_label_contains(piece) {
|
|
let r = n.rect();
|
|
if r.left() < Pane::Sidebar.panel() && r.top() >= footer_rect.top() {
|
|
assert!(
|
|
r.right() <= footer_rect.right() - 10.0 + 0.5,
|
|
"{tag}: {piece:?} at {r:?} runs out of the card {footer_rect:?}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
h.hover_at(card.center());
|
|
h.run_steps(30);
|
|
for c in checks {
|
|
assert!(
|
|
h.query_all_by_label_contains(c).count() >= 1,
|
|
"{tag}: hovered, the card shows {c}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The owner's height in the rail too: every link shows, unscrolled.
|
|
#[test]
|
|
fn the_rail_at_519_shows_every_link_unscrolled() {
|
|
let f = Fixture::new();
|
|
for footer in FOOTERS {
|
|
for route in [Route::Projects, Route::Overview, Route::Journal] {
|
|
let h = f.harness(footer, route.clone(), 900.0, 519.0, 1.0);
|
|
let tag = format!("{footer:?} {route:?}");
|
|
let (view, _, scrolls) = drawn(&h, Pane::Rail);
|
|
assert!(!scrolls, "{tag}: the rail's links scroll");
|
|
for s in Section::ALL {
|
|
let r = link(&h, s);
|
|
assert!(
|
|
whole_in(view, r) && r.bottom() <= 519.0,
|
|
"{tag}: {} at {r:?} isn't whole in {view:?}",
|
|
s.title()
|
|
);
|
|
}
|
|
assert_clear(&h, Pane::Rail, &tag);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Where everything fits, the card shows its checks, unfolded.
|
|
#[test]
|
|
fn a_tall_window_keeps_the_whole_card() {
|
|
let f = Fixture::new();
|
|
let h = f.harness(Footer::Owners, Route::Projects, 1440.0, 920.0, 1.0);
|
|
for c in [
|
|
"Journal chain verified",
|
|
"Database matches journal",
|
|
"110 files verified",
|
|
] {
|
|
assert!(h.query_by_label_contains(c).is_some(), "{c}");
|
|
}
|
|
let (_, _, scrolls) = drawn(&h, Pane::Sidebar);
|
|
assert!(!scrolls);
|
|
}
|
|
|
|
/// Too short even for tight rows: the links scroll, the current page's
|
|
/// link is scrolled into view, and a chevron says more links are there.
|
|
#[test]
|
|
fn scrolling_links_show_the_current_one_and_a_cue() {
|
|
let f = Fixture::new();
|
|
for side in [Pane::Sidebar, Pane::Rail] {
|
|
let h = f.harness(Footer::Owners, Route::Journal, side.width(), 300.0, 1.0);
|
|
let (view, _, scrolls) = drawn(&h, side);
|
|
assert!(scrolls, "{side:?}: 300 points can't hold every link");
|
|
let journal = link(&h, Section::Journal);
|
|
assert!(
|
|
whole_in(view, journal),
|
|
"{side:?}: Journal at {journal:?} not in {view:?}"
|
|
);
|
|
assert!(
|
|
h.query_by_label("More links above").is_some(),
|
|
"{side:?}: the cue above"
|
|
);
|
|
let mut h = f.harness(Footer::Owners, Route::Overview, side.width(), 300.0, 1.0);
|
|
assert!(
|
|
h.query_by_label("More links below").is_some(),
|
|
"{side:?}: the cue below"
|
|
);
|
|
assert!(h.query_by_label("More links above").is_none(), "{side:?}");
|
|
// The chevron scrolls the links.
|
|
let before = link(&h, Section::Overview);
|
|
h.get_by_label("More links below").click();
|
|
h.run_steps(6);
|
|
assert!(
|
|
link(&h, Section::Overview).top() < before.top(),
|
|
"{side:?}: the chevron scrolled"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Going to a page scrolls its link into view (the palette, a crumb).
|
|
#[test]
|
|
fn going_somewhere_scrolls_its_link_into_view() {
|
|
let f = Fixture::new();
|
|
let mut h = f.harness(Footer::Owners, Route::Overview, 1440.0, 300.0, 1.0);
|
|
for (page, s) in [
|
|
("journal", Section::Journal),
|
|
("overview", Section::Overview),
|
|
("documents", Section::Documents),
|
|
] {
|
|
h.state_mut()
|
|
.show(ledger_store::ops::ViewerTarget::Page { page: page.into() });
|
|
h.run_steps(6);
|
|
let (view, _, _) = drawn(&h, Pane::Sidebar);
|
|
let r = link(&h, s);
|
|
assert!(whole_in(view, r), "{} at {r:?} in {view:?}", s.title());
|
|
}
|
|
}
|
|
|
|
/// 1440x920, where everything always fit: pixel for pixel the sidebar
|
|
/// main drew (2d7b12a), and the same shapes.
|
|
#[test]
|
|
fn a_tall_sidebar_is_unchanged() {
|
|
let f = Fixture::new();
|
|
let mut h = f.harness(Footer::Owners, Route::Overview, 1440.0, 920.0, 1.0);
|
|
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/goldens");
|
|
let shapes = sidebar_shapes(&h);
|
|
let bless = std::env::var("SIDENAV_BLESS").is_ok();
|
|
let shapes_file = dir.join("sidenav-1440x920.shapes");
|
|
if bless {
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
std::fs::write(&shapes_file, format!("{shapes:016x}\n")).unwrap();
|
|
} else {
|
|
let want = std::fs::read_to_string(&shapes_file).unwrap();
|
|
assert_eq!(
|
|
format!("{shapes:016x}"),
|
|
want.trim(),
|
|
"the sidebar's shapes at 1440x920 changed"
|
|
);
|
|
}
|
|
// Pixels too, only on the machine that blessed them (SIDENAV_PIXELS=1):
|
|
// another renderer rasterizes the same shapes differently (CI's
|
|
// software renderer differs in antialiasing), so the shapes above are
|
|
// the check everywhere and the pixels a local one.
|
|
if !bless && std::env::var("SIDENAV_PIXELS").is_err() {
|
|
return;
|
|
}
|
|
let png = dir.join("sidenav-1440x920.png");
|
|
match h.render() {
|
|
Ok(img) => {
|
|
let side = image::imageops::crop_imm(&img, 0, 0, 224, img.height()).to_image();
|
|
if bless {
|
|
side.save(&png).unwrap();
|
|
} else {
|
|
let want = image::open(&png).unwrap().to_rgba8();
|
|
assert_eq!(side.dimensions(), want.dimensions());
|
|
let diff = side
|
|
.pixels()
|
|
.zip(want.pixels())
|
|
.filter(|(a, b)| a != b)
|
|
.count();
|
|
assert_eq!(diff, 0, "{diff} sidebar pixels differ from main's");
|
|
}
|
|
}
|
|
Err(e) => eprintln!("no renderer ({e}): pixels not compared, shapes were"),
|
|
}
|
|
}
|
|
|
|
/// A hash of every shape painted in the sidebar's column (FNV-1a over
|
|
/// their debug form): the same shapes paint the same pixels.
|
|
fn sidebar_shapes(h: &Harness<'_, App>) -> u64 {
|
|
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
|
|
for s in &h.output().shapes {
|
|
if s.clip_rect.max.x <= 224.5 {
|
|
for b in format!("{:?}", s.shape).bytes() {
|
|
hash ^= b as u64;
|
|
hash = hash.wrapping_mul(0x100_0000_01b3);
|
|
}
|
|
}
|
|
}
|
|
hash
|
|
}
|
|
|
|
/// Pictures for eyes: set `SHOT_DIR`.
|
|
#[test]
|
|
fn sidenav_screenshots() {
|
|
let Ok(out) = std::env::var("SHOT_DIR") else {
|
|
return;
|
|
};
|
|
let out = PathBuf::from(out);
|
|
std::fs::create_dir_all(&out).unwrap();
|
|
let f = Fixture::new();
|
|
let shots: [(&str, Route, f32, f32, f32); 11] = [
|
|
("sidebar-1479x519", Route::Projects, 1479.0, 519.0, 1.0),
|
|
(
|
|
"sidebar-1479x519-overview",
|
|
Route::Overview,
|
|
1479.0,
|
|
519.0,
|
|
1.0,
|
|
),
|
|
(
|
|
"sidebar-1479x519-journal",
|
|
Route::Journal,
|
|
1479.0,
|
|
519.0,
|
|
1.0,
|
|
),
|
|
("sidebar-1479x420", Route::Projects, 1479.0, 420.0, 1.0),
|
|
("sidebar-1479x340", Route::Projects, 1479.0, 340.0, 1.0),
|
|
(
|
|
"sidebar-1479x340-journal",
|
|
Route::Journal,
|
|
1479.0,
|
|
340.0,
|
|
1.0,
|
|
),
|
|
("sidebar-1440x920", Route::Overview, 1440.0, 920.0, 1.0),
|
|
// 986x346 points: narrow enough for the rail.
|
|
("rail-1479x519-scale1.5", Route::Projects, 986.0, 346.0, 1.5),
|
|
("rail-900x519", Route::Projects, 900.0, 519.0, 1.0),
|
|
("rail-900x340", Route::Projects, 900.0, 340.0, 1.0),
|
|
("rail-900x920", Route::Projects, 900.0, 920.0, 1.0),
|
|
];
|
|
for (name, route, w, hgt, scale) in shots {
|
|
let mut h = f.harness(Footer::Owners, route, w, hgt, scale);
|
|
if let Ok(img) = h.render() {
|
|
img.save(out.join(format!("{name}.png"))).unwrap();
|
|
}
|
|
}
|
|
// The folded card in each footer, at the owner's size.
|
|
for footer in FOOTERS {
|
|
let mut h = f.harness(footer, Route::Projects, 1479.0, 519.0, 1.0);
|
|
if let Ok(img) = h.render() {
|
|
img.save(out.join(format!("folded-{footer:?}.png")))
|
|
.unwrap();
|
|
}
|
|
}
|
|
// The folded card's tooltip.
|
|
let mut h = f.harness(Footer::Owners, Route::Projects, 1479.0, 519.0, 1.0);
|
|
if let Some(n) = h.query_by_label_contains("event 5") {
|
|
let c = n.rect().center();
|
|
h.hover_at(c);
|
|
h.run_steps(30);
|
|
if let Ok(img) = h.render() {
|
|
img.save(out.join("sidebar-1479x519-hover.png")).unwrap();
|
|
}
|
|
}
|
|
}
|