gui: spec-blind suite; search finds the best match, axis ticks never overflow
check / check (push) Canceled after 0s

tests/spec_gui.rs (31 spec-blind tests): exact money text round-trips,
file names never escape the folder or hit a Windows device name, history
against a browser model, fuzzy search against a subsequence model, axis
ticks, the view model against ledger-core, and the attention list.

Found and fixed:
- fuzzy matched greedily, so "ac c" scored 1 for "c" (the "c" inside
  "ac") instead of 5 for the word "c"; it now scores the best alignment.
- ticks accumulated float error (a last tick at -5.55e-17 for a top of
  0), gave more than n + 2 ticks at large magnitudes with tiny spans,
  and overflowed for spans near f64's limit. Ticks are now whole
  multiples of the step, checked, with [lo, hi] when f64 has no nice step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-14 11:39:41 -06:00
1 parent 1291547fc4
commit 9110cd888d
7 files changed
+1328 -53

No files matched your search

Generated
+1
View File
@@ -3631,6 +3631,7 @@ dependencies = [
"ledger-import",
"ledger-store",
"notify",
"proptest",
"rust_decimal",
"serde",
"serde_json",
+1
View File
@@ -31,6 +31,7 @@ serde_json = "1"
[dev-dependencies]
chrono = "0.4"
egui_kittest = { version = "0.36", features = ["wgpu", "snapshot", "eframe"] }
proptest = "1"
[lints]
workspace = true
+39 -18
View File
@@ -17,30 +17,51 @@ fn f(d: Decimal) -> f64 {
/// ```text
/// INVARIANTS (finite lo <= hi, n >= 1):
/// 1. Evenly spaced by a step of 1, 2, 2.5, or 5 times a power of ten.
/// 1. Evenly spaced by a step of 1, 2, 2.5, or 5 times a power of ten
/// (each tick a whole multiple of the step), all finite.
/// 2. The first tick is at or below lo and the last at or above hi, so
/// no data falls outside the gridlines.
/// 3. At most n + 2 ticks.
/// When no such step exists in f64 (nice ticks past f64's range, within
/// a factor of ten of it, or a span too small for the magnitude), the
/// ticks are just [lo, hi] (or [lo]).
/// ```
/// "Nice" axis ticks covering [lo, hi].
fn ticks(lo: f64, hi: f64, n: usize) -> Vec<f64> {
let span = (hi - lo).max(1e-9);
let raw = span / n as f64;
let mag = 10f64.powf(raw.log10().floor());
let step = [1.0, 2.0, 2.5, 5.0, 10.0]
.iter()
.map(|m| m * mag)
.find(|s| span / s <= n as f64)
.unwrap_or(10.0 * mag);
let start = (lo / step).floor() * step;
let mut v = vec![start];
// Up to the first tick at or above the top, so the data never pokes
// past the last gridline.
while *v.last().expect("non-empty") < hi - step * 1e-9 {
let next = v.last().expect("non-empty") + step;
v.push(next);
pub fn ticks(lo: f64, hi: f64, n: usize) -> Vec<f64> {
let n = n.max(1);
let fallback = || if lo == hi { vec![lo] } else { vec![lo, hi] };
let span = (hi - lo).max(f64::MIN_POSITIVE);
if !span.is_finite() {
return fallback();
}
v
let raw = span / n as f64;
let mut mag = 10f64.powf(raw.log10().floor());
// Try the nice steps from the smallest that fits n, coarser as needed
// (rounding at large magnitudes can cost a tick).
for _ in 0..4 {
for m in [1.0, 2.0, 2.5, 5.0] {
let step = m * mag;
if !step.is_finite() || step <= 0.0 || span / step > n as f64 {
continue;
}
let (k0, k1) = ((lo / step).floor(), (hi / step).ceil());
if !(k0.is_finite() && k1.is_finite()) || k1 - k0 + 1.0 > (n + 2) as f64 {
continue;
}
let v: Vec<f64> = (0..=(k1 - k0) as i64)
.map(|k| (k0 + k as f64) * step)
.collect();
let ok = v.iter().all(|x| x.is_finite())
&& v.windows(2).all(|w| w[1] > w[0])
&& v.first().is_some_and(|f| *f <= lo)
&& v.last().is_some_and(|l| *l >= hi);
if ok {
return v;
}
}
mag *= 10.0;
}
fallback()
}
fn money_label(v: f64) -> String {
+5 -5
View File
@@ -4,16 +4,16 @@
#![allow(missing_docs)]
pub mod app;
mod attention;
pub mod attention;
pub mod books;
mod charts;
pub mod charts;
pub mod data;
pub mod demo;
mod export;
mod fmt;
pub mod export;
pub mod fmt;
pub mod nav;
pub mod pages;
mod palette;
pub mod palette;
mod receipts;
mod theme;
mod widgets;
+46 -30
View File
@@ -157,42 +157,58 @@ impl Palette {
/// query matches everything with score 0.
/// 2. A match's score is at least the number of non-space query
/// characters; consecutive matches and matches at a word start score
/// more, so a word-start prefix scores at least as high as any
/// other match of the same query.
/// more. The score is the best over every way to match (not the
/// first match found), so a hay holding the query whole at a word
/// start scores at least as high as any hay's match of it.
/// ```
fn fuzzy(hay: &str, query: &str) -> Option<i32> {
if query.is_empty() {
pub fn fuzzy(hay: &str, query: &str) -> Option<i32> {
let hay: Vec<char> = hay.to_lowercase().chars().collect();
// Query characters, each marked whether a space came just before it
// (a space ends a run: the next match earns no run bonus).
let mut q: Vec<(char, bool)> = Vec::new();
let mut after_space = false;
for c in query.to_lowercase().chars() {
if c == ' ' {
after_space = true;
} else {
q.push((c, after_space));
after_space = false;
}
}
if q.is_empty() {
return Some(0);
}
let hay: Vec<char> = hay.to_lowercase().chars().collect();
let mut score = 0;
let mut hi = 0;
let mut prev_match = false;
for qc in query.to_lowercase().chars() {
if qc == ' ' {
prev_match = false;
continue;
// The best score over every way to match (a greedy left-most match
// can miss a word start further on): best[j] is the top score for the
// query so far with its last character at hay[j].
let word_start = |j: usize| j == 0 || !hay[j - 1].is_alphanumeric();
let mut best: Vec<Option<i32>> = hay
.iter()
.enumerate()
.map(|(j, &c)| (c == q[0].0).then(|| 1 + if word_start(j) { 4 } else { 0 }))
.collect();
for &(qc, gap) in &q[1..] {
let mut next = vec![None; hay.len()];
// The best score ending strictly before j.
let mut before: Option<i32> = None;
for j in 0..hay.len() {
if hay[j] == qc {
let bonus = if word_start(j) { 4 } else { 0 };
let run = if gap {
None
} else {
j.checked_sub(1)
.and_then(|p| best[p])
.map(|s| s + 6 + bonus)
};
let jump = before.map(|s| s + 1 + bonus);
next[j] = run.max(jump);
}
let mut found = false;
while hi < hay.len() {
let c = hay[hi];
hi += 1;
if c == qc {
score += if prev_match { 6 } else { 1 };
if hi == 1 || !hay[hi - 2].is_alphanumeric() {
score += 4;
before = before.max(best[j]);
}
prev_match = true;
found = true;
break;
best = next;
}
prev_match = false;
}
if !found {
return None;
}
}
Some(score)
best.into_iter().flatten().max()
}
fn search(b: &Books, query: &str) -> Vec<Item> {
@@ -0,0 +1,12 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc e1abb99e086123434884a3986a83c6a04e095d9f4a0d6b0b8f9490c3ef7a3fb4 # shrinks to center = 721350534.2385265, span_exp = -2, n = 1
cc 67ed971061efe8d67c42fac1e63e6d1943358315b8d447482fd8e723d2850577 # shrinks to query = "c", head = "ac", tail = "", other = "c"
cc 2f8bab974c3fe9cadfdc5f457bec479125025199050e9ad7d2a868750951e1d8 # shrinks to a = 0.0, b = -0.4626437454001001, n = 3, same = false
cc a358fb90f0d14b99605d509ebbe00e178285158223ab165a54676d70a1a01287 # shrinks to plan = BooksPlan { threshold: "0.00", entries: [EntryPlan { day: 0, debits: [(0, 1)], credit_accounts: [0], split_pct: 1, scale: 3, post: false, receipt: false }], reverse: None }
cc 3b788cd1184019b4cf33c3150a5368e446d20bdc52afe98b6d3094516ac7e5ac # shrinks to a = 1.2726536570066655e-11, b = -0.44869856490793697, n = 1, same = false
cc adafa1178ead699437f63287c9be531c043a0a9de3ee34f908e4ca3ef81bc6c3 # shrinks to center = 79655376.47358137, span_exp = -6, n = 5
File diff suppressed because it is too large. Load diff