check / check (push) Canceled after 0s
The owner's viewer at 1479x519 (scale 1) showed Overview through half of
Accounts above the status card; Assets, Parties, Projects, Documents,
Company and Journal sat in a scroll area with no sign that it scrolled,
so the links seemed to go behind the status box.
The sidebar now gives way in steps, each only when the one before is not
enough, so a window where everything fit looks exactly as it did
(pixel-checked against main at 1440x920, tests/goldens):
1. The status card folds to its one summary line, "● Live · event 559 ·
✓ verified". Hovering it shows the three checks; right-click keeps the
card's copy menu. The line is the fullest wording that fits the card
(summaries), never cut off: a failed check is named ("⊘ Database
differs", "⊘ 2 files damaged") before the event and the word "Live"
are kept, and only then shortened to a count. Whatever the line
leaves out, its tooltip says.
2. The rows tighten: the footer's menus, the gap under the header, the
spacing between links, then the rows' height, down to 24 points, in
the same font. The rail tightens the same way, also to 24 points.
3. Only then do the links scroll, with a soft fade and a chevron at each
clipped edge (the rail's chevron sits centred on its own pill, clear
of the icons), and the current page's link is scrolled into view
whenever the page changes.
Links that overrun their room by under half a pixel count as fitting, so
the card folds where main's links began to lose a visible sliver, not a
point sooner at fractional scales.
At 1479x519 and scale 1 all twelve links show unscrolled in the sidebar,
and at 900x519 in the rail, in every footer state (Live, Snapshot, each
database state, damaged files, no Books menu).
Tests (tests/sidenav.rs, fixture books with a footer like the live one):
every height from 300 to 920 px at scales 1, 1.25, 1.5 and 2, sidebar and
rail, for each footer state: no link under the footer, links cut off only
when they scroll, and nothing scrolls from 519 px up at scale 1; the
owner's window in sidebar and rail; the folded line fits its card and
names a failed check, its tooltip the three checks; the scroll cue and
scrolling to the current link; the tall sidebar unchanged. Unit tests for
fit, its order of steps, the footer's memo and the folded line's
wordings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
759 lines
25 KiB
Rust
759 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, where this machine can render (CI has no GPU).
|
|
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();
|
|
}
|
|
}
|
|
}
|