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
@@ -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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
prev_match = true;
|
||||
found = true;
|
||||
break;
|
||||
// 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);
|
||||
}
|
||||
prev_match = false;
|
||||
}
|
||||
if !found {
|
||||
return None;
|
||||
before = before.max(best[j]);
|
||||
}
|
||||
best = next;
|
||||
}
|
||||
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
Reference in new issue
Block a user