gui: spec-blind suite; search finds the best match, axis ticks never overflow
check / check (push) Canceled after 0s
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:
1 parent
1291547fc4
commit
9110cd888d
7 files changed
+1328
-53
No files matched your search
Generated
+1
@@ -3631,6 +3631,7 @@ dependencies = [
|
|||||||
"ledger-import",
|
"ledger-import",
|
||||||
"ledger-store",
|
"ledger-store",
|
||||||
"notify",
|
"notify",
|
||||||
|
"proptest",
|
||||||
"rust_decimal",
|
"rust_decimal",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ serde_json = "1"
|
|||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
egui_kittest = { version = "0.36", features = ["wgpu", "snapshot", "eframe"] }
|
egui_kittest = { version = "0.36", features = ["wgpu", "snapshot", "eframe"] }
|
||||||
|
proptest = "1"
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
@@ -17,30 +17,51 @@ fn f(d: Decimal) -> f64 {
|
|||||||
|
|
||||||
/// ```text
|
/// ```text
|
||||||
/// INVARIANTS (finite lo <= hi, n >= 1):
|
/// 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
|
/// 2. The first tick is at or below lo and the last at or above hi, so
|
||||||
/// no data falls outside the gridlines.
|
/// no data falls outside the gridlines.
|
||||||
/// 3. At most n + 2 ticks.
|
/// 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].
|
/// "Nice" axis ticks covering [lo, hi].
|
||||||
fn ticks(lo: f64, hi: f64, n: usize) -> Vec<f64> {
|
pub fn ticks(lo: f64, hi: f64, n: usize) -> Vec<f64> {
|
||||||
let span = (hi - lo).max(1e-9);
|
let n = n.max(1);
|
||||||
let raw = span / n as f64;
|
let fallback = || if lo == hi { vec![lo] } else { vec![lo, hi] };
|
||||||
let mag = 10f64.powf(raw.log10().floor());
|
let span = (hi - lo).max(f64::MIN_POSITIVE);
|
||||||
let step = [1.0, 2.0, 2.5, 5.0, 10.0]
|
if !span.is_finite() {
|
||||||
.iter()
|
return fallback();
|
||||||
.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);
|
|
||||||
}
|
}
|
||||||
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 {
|
fn money_label(v: f64) -> String {
|
||||||
|
|||||||
@@ -4,16 +4,16 @@
|
|||||||
#![allow(missing_docs)]
|
#![allow(missing_docs)]
|
||||||
|
|
||||||
pub mod app;
|
pub mod app;
|
||||||
mod attention;
|
pub mod attention;
|
||||||
pub mod books;
|
pub mod books;
|
||||||
mod charts;
|
pub mod charts;
|
||||||
pub mod data;
|
pub mod data;
|
||||||
pub mod demo;
|
pub mod demo;
|
||||||
mod export;
|
pub mod export;
|
||||||
mod fmt;
|
pub mod fmt;
|
||||||
pub mod nav;
|
pub mod nav;
|
||||||
pub mod pages;
|
pub mod pages;
|
||||||
mod palette;
|
pub mod palette;
|
||||||
mod receipts;
|
mod receipts;
|
||||||
mod theme;
|
mod theme;
|
||||||
mod widgets;
|
mod widgets;
|
||||||
|
|||||||
@@ -157,42 +157,58 @@ impl Palette {
|
|||||||
/// query matches everything with score 0.
|
/// query matches everything with score 0.
|
||||||
/// 2. A match's score is at least the number of non-space query
|
/// 2. A match's score is at least the number of non-space query
|
||||||
/// characters; consecutive matches and matches at a word start score
|
/// characters; consecutive matches and matches at a word start score
|
||||||
/// more, so a word-start prefix scores at least as high as any
|
/// more. The score is the best over every way to match (not the
|
||||||
/// other match of the same query.
|
/// 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> {
|
pub fn fuzzy(hay: &str, query: &str) -> Option<i32> {
|
||||||
if query.is_empty() {
|
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);
|
return Some(0);
|
||||||
}
|
}
|
||||||
let hay: Vec<char> = hay.to_lowercase().chars().collect();
|
// The best score over every way to match (a greedy left-most match
|
||||||
let mut score = 0;
|
// can miss a word start further on): best[j] is the top score for the
|
||||||
let mut hi = 0;
|
// query so far with its last character at hay[j].
|
||||||
let mut prev_match = false;
|
let word_start = |j: usize| j == 0 || !hay[j - 1].is_alphanumeric();
|
||||||
for qc in query.to_lowercase().chars() {
|
let mut best: Vec<Option<i32>> = hay
|
||||||
if qc == ' ' {
|
.iter()
|
||||||
prev_match = false;
|
.enumerate()
|
||||||
continue;
|
.map(|(j, &c)| (c == q[0].0).then(|| 1 + if word_start(j) { 4 } else { 0 }))
|
||||||
}
|
.collect();
|
||||||
let mut found = false;
|
for &(qc, gap) in &q[1..] {
|
||||||
while hi < hay.len() {
|
let mut next = vec![None; hay.len()];
|
||||||
let c = hay[hi];
|
// The best score ending strictly before j.
|
||||||
hi += 1;
|
let mut before: Option<i32> = None;
|
||||||
if c == qc {
|
for j in 0..hay.len() {
|
||||||
score += if prev_match { 6 } else { 1 };
|
if hay[j] == qc {
|
||||||
if hi == 1 || !hay[hi - 2].is_alphanumeric() {
|
let bonus = if word_start(j) { 4 } else { 0 };
|
||||||
score += 4;
|
let run = if gap {
|
||||||
}
|
None
|
||||||
prev_match = true;
|
} else {
|
||||||
found = true;
|
j.checked_sub(1)
|
||||||
break;
|
.and_then(|p| best[p])
|
||||||
|
.map(|s| s + 6 + bonus)
|
||||||
|
};
|
||||||
|
let jump = before.map(|s| s + 1 + bonus);
|
||||||
|
next[j] = run.max(jump);
|
||||||
}
|
}
|
||||||
prev_match = false;
|
before = before.max(best[j]);
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
return None;
|
|
||||||
}
|
}
|
||||||
|
best = next;
|
||||||
}
|
}
|
||||||
Some(score)
|
best.into_iter().flatten().max()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn search(b: &Books, query: &str) -> Vec<Item> {
|
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
Reference in new issue
Block a user