Files
craft-ai-integrations/crates/ai-egui/tests/history_view.rs
T
skylar taylor-barrick 0e55340771 Migrate shared AI widgets to egui 0.37 (#105)
* refactor(ai-egui): migrate shared widgets to egui 0.37

* egui037: qualify shared AI widgets and downstream dependency pins

* docs(ai-egui): record the committed migration qualification

* fix(ci): provide Vulkan snapshots and allow pinned fork sources

* test: make cancellation-before-load coverage deterministic
2026-10-25 16:23:02 +00:00

435 lines
13 KiB
Rust

//! The history view driven like a person would: filter, switch tables, export, clear.
use craft_ai_core::history::{EndRecord, HISTORY_SCHEMA, StartRecord, merge};
use craft_ai_core::job::Usage;
use craft_ai_core::{ErrorKind, HistoryLog, HistoryRecord, Locality, Outcome};
use craft_ai_egui::history_text::entry_line;
use craft_ai_egui::{
CLEAR, CLEAR_NO, CLEAR_YES, EMPTY, EXPORT, HistoryAction, HistoryViewState, NOTHING_MATCHES,
REFRESH, Range, TAB_DAYS, TAB_JOBS, TAB_MONTHS, Tab, history_view,
};
use egui_kittest::Harness;
use egui_kittest::kittest::{NodeT, Queryable};
const T0: u64 = 1_760_000_000_000; // 2025-10-09T08:53:20Z
const DAY: u64 = 86_400_000;
struct Job {
run: &'static str,
at: u64,
app: &'static str,
model: &'static str,
outcome: Option<Outcome>,
cost: Option<u64>,
}
fn job(run: &'static str, at: u64, app: &'static str, model: &'static str) -> Job {
Job {
run,
at,
app,
model,
outcome: Some(Outcome::Done),
cost: None,
}
}
fn log(jobs: &[Job], skipped: usize) -> HistoryLog {
let mut records = Vec::new();
for j in jobs {
records.push(HistoryRecord::Start(StartRecord {
v: HISTORY_SCHEMA,
run: j.run.into(),
at_ms: j.at,
app: j.app.into(),
role_id: "fill".into(),
role_label: "Generative Fill".into(),
capability: "image.inpaint".into(),
connection: "c".into(),
connection_label: "Hosted".into(),
model: j.model.into(),
locality: Locality::Remote,
destination: "https://api.example.com".into(),
sent: Vec::new(),
params: Vec::new(),
provider_job: None,
estimate: None,
prompt: None,
}));
if let Some(outcome) = &j.outcome {
records.push(HistoryRecord::End(EndRecord {
v: HISTORY_SCHEMA,
run: j.run.into(),
at_ms: j.at + 1000,
outcome: outcome.clone(),
usage: j.cost.map(|c| Usage {
cost_micros: Some(c),
currency: Some("USD".into()),
..Usage::default()
}),
}));
}
}
merge(records, skipped)
}
struct State {
view: HistoryViewState,
log: HistoryLog,
now: u64,
action: Option<HistoryAction>,
}
fn harness(view: HistoryViewState, log: HistoryLog, now: u64) -> Harness<'static, State> {
Harness::new_ui_state(
|ui, s: &mut State| {
if let Some(a) = history_view(ui, &mut s.view, &s.log, s.now) {
s.action = Some(a);
}
},
State {
view,
log,
now,
action: None,
},
)
}
fn two_apps() -> HistoryLog {
log(
&[
Job {
cost: Some(31_000),
..job("a", T0, "photocraft", "flux")
},
Job {
outcome: Some(Outcome::Failed {
kind: ErrorKind::Quota,
}),
..job("b", T0 + 1000, "wordcraft", "gpt-4o")
},
Job {
outcome: None,
..job("c", T0 + 2000, "photocraft", "flux")
},
],
0,
)
}
#[test]
fn an_empty_history_says_so_and_offers_no_clear_or_export() {
let mut h = harness(HistoryViewState::default(), log(&[], 0), T0);
h.run();
h.get_by_label(EMPTY);
assert!(h.get_by_label(CLEAR).accesskit_node().is_disabled());
assert!(h.get_by_label(EXPORT).accesskit_node().is_disabled());
}
#[test]
fn jobs_show_as_lines_with_counts_and_only_reported_cost() {
let log = two_apps();
let line_a = entry_line(&log.entries[2], 0);
let mut h = harness(HistoryViewState::default(), log, T0 + 5000);
h.run();
h.get_by_label("3 jobs: 1 done, 1 failed, 1 not finished.");
h.get_by_label("Reported cost: 0.03 USD.");
h.get_by_label(&line_a);
assert!(line_a.contains("0.03 USD"), "{line_a}");
}
#[test]
fn filtering_by_app_hides_the_others_and_export_follows_the_filter() {
let log = two_apps();
let word_line = entry_line(&log.entries[1], 0);
let mut view = HistoryViewState::default();
view.app = Some("photocraft".into());
let mut h = harness(view, log, T0 + 5000);
h.run();
h.get_by_label("2 jobs: 1 done, 1 not finished.");
assert!(h.query_by_label(&word_line).is_none());
h.get_by_label(EXPORT).click();
h.run();
let Some(HistoryAction::Export(csv)) = h.state().action.clone() else {
panic!("no export: {:?}", h.state().action);
};
assert!(csv.starts_with("started_utc,app,feature"));
assert!(csv.contains("photocraft"));
assert!(!csv.contains("wordcraft"), "{csv}");
assert_eq!(csv.matches("\r\n").count(), 3, "header and two rows");
}
#[test]
fn a_filter_left_on_something_no_longer_in_the_log_falls_back_to_everything() {
let mut view = HistoryViewState::for_app("gone-app");
view.model = Some("gone-model".into());
let mut h = harness(view, two_apps(), T0 + 5000);
h.run();
h.run();
h.get_by_label("3 jobs: 1 done, 1 failed, 1 not finished.");
assert_eq!(h.state().view.app, None);
assert_eq!(h.state().view.model, None);
}
#[test]
fn a_range_with_nothing_in_it_says_no_jobs_match() {
let mut view = HistoryViewState::default();
view.range = Range::Week;
let mut h = harness(view, two_apps(), T0 + 40 * DAY);
h.run();
h.get_by_label(NOTHING_MATCHES);
assert!(h.get_by_label(EXPORT).accesskit_node().is_disabled());
}
#[test]
fn today_starts_at_midnight_in_the_chosen_offset() {
// 08:53Z on 9 October is 10:53 at UTC+2; "today" there began at 22:00Z on the 8th.
let log = log(
&[
job("old", T0 - 12 * 3_600_000, "photocraft", "flux"),
job("new", T0, "photocraft", "flux"),
],
0,
);
let mut view = HistoryViewState::default();
view.range = Range::Today;
view.offset_minutes = 120;
let mut h = harness(view, log, T0 + 1000);
h.run();
h.get_by_label("1 job: 1 done.");
}
#[test]
fn the_day_and_month_tables_total_per_group() {
let log = log(
&[
Job {
cost: Some(10_000),
..job("a", T0, "photocraft", "flux")
},
Job {
cost: Some(20_000),
..job("b", T0 + 1000, "photocraft", "flux")
},
Job {
cost: Some(5_000),
..job("c", T0 + 30 * DAY, "photocraft", "flux")
},
],
0,
);
let mut h = harness(HistoryViewState::default(), log, T0 + 31 * DAY);
h.run();
h.get_by_label(TAB_DAYS).click();
h.run();
h.get_by_label("2025-11-08 \u{b7} 1 job \u{b7} 0.0050 USD");
h.get_by_label("2025-10-09 \u{b7} 2 jobs \u{b7} 0.03 USD");
h.get_by_label(TAB_MONTHS).click();
h.run();
h.get_by_label("2025-11 \u{b7} 1 job \u{b7} 0.0050 USD");
h.get_by_label("2025-10 \u{b7} 2 jobs \u{b7} 0.03 USD");
assert_eq!(h.state().view.tab, Tab::Months);
}
#[test]
fn clearing_needs_a_second_yes_and_keep_cancels_it() {
let mut h = harness(HistoryViewState::default(), two_apps(), T0 + 5000);
h.run();
h.get_by_label(CLEAR).click();
h.run();
assert_eq!(h.state().action, None, "one click is only a question");
h.get_by_label("Delete the history for every app?");
h.get_by_label(CLEAR_NO).click();
h.run();
assert_eq!(h.state().action, None);
h.get_by_label(CLEAR).click();
h.run();
h.get_by_label(CLEAR_YES).click();
h.run();
assert_eq!(h.state().action, Some(HistoryAction::Clear));
h.run();
assert!(
h.query_by_label(CLEAR_YES).is_none(),
"the question goes away"
);
}
#[test]
fn refresh_is_reported_and_unreadable_lines_are_mentioned() {
let mut h = harness(
HistoryViewState::default(),
log(&[job("a", T0, "p", "m")], 2),
T0,
);
h.run();
h.get_by_label("2 lines in the history could not be read and were left out.");
h.get_by_label(REFRESH).click();
h.run();
assert_eq!(h.state().action, Some(HistoryAction::Refresh));
}
#[test]
fn a_long_log_draws_only_the_rows_in_view() {
let jobs: Vec<Job> = (0..3000u64)
.map(|i| {
let run: &'static str = Box::leak(format!("r{i}").into_boxed_str());
job(run, T0 + i * 60_000, "photocraft", "flux")
})
.collect();
let log = log(&jobs, 0);
let oldest = entry_line(&log.entries[2999], 0);
let newest = entry_line(&log.entries[0], 0);
let mut h = harness(HistoryViewState::default(), log, T0 + 4_000_000);
h.run();
h.get_by_label(&newest);
assert!(h.query_by_label(&oldest).is_none());
h.get_by_label("3000 jobs: 3000 done.");
}
struct SizedState {
view: HistoryViewState,
log: HistoryLog,
now: u64,
action: Option<HistoryAction>,
row_height: f32,
spacing: f32,
}
fn large_font_harness() -> Harness<'static, SizedState> {
let jobs: Vec<Job> = (0..40u64)
.map(|i| {
let run: &'static str = Box::leak(format!("r{i}").into_boxed_str());
let model: &'static str = Box::leak(format!("m{i:02}").into_boxed_str());
Job {
run,
at: T0 + i * 60_000,
app: "photocraft",
model,
outcome: Some(Outcome::Done),
cost: None,
}
})
.collect();
let log = log(&jobs, 0);
let mut h = Harness::builder()
.with_size(egui::vec2(320.0, 700.0))
.build_ui_state(
|ui, state: &mut SizedState| {
ui.style_mut()
.text_styles
.insert(egui::TextStyle::Body, egui::FontId::proportional(26.0));
state.row_height = ui.text_style_height(&egui::TextStyle::Body);
state.spacing = ui.spacing().item_spacing.y;
if let Some(action) = history_view(ui, &mut state.view, &state.log, state.now) {
state.action = Some(action);
}
},
SizedState {
view: HistoryViewState::default(),
log,
now: T0 + 40 * 60_000,
action: None,
row_height: 0.0,
spacing: 0.0,
},
);
h.run();
h
}
/// The visible job rows, in screen order, with the model index parsed from each line.
fn visible_rows(h: &Harness<'_, SizedState>) -> Vec<(usize, egui::Rect)> {
let mut rows: Vec<(usize, egui::Rect)> = h
.query_all_by_label_contains("photocraft")
.filter_map(|node| {
// egui puts a Label's text in AccessKit's value; `By::label` handles this too.
let accessibility = node.accesskit_node();
let label = accessibility.value().or_else(|| accessibility.label())?;
let part = label.split(" \u{b7} ").nth(3)?;
let model = part.split(' ').next()?;
let index = model.strip_prefix('m')?.parse().ok()?;
Some((index, node.rect()))
})
.collect();
rows.sort_by(|a, b| a.1.top().total_cmp(&b.1.top()));
rows
}
fn scroll_rows(h: &mut Harness<'_, SizedState>, times: usize) {
for _ in 0..times {
let node = h
.query_all_by_label_contains("photocraft")
.last()
.expect("a visible row to scroll from");
node.scroll_down();
for _ in 0..12 {
h.step();
}
}
}
#[test]
fn rows_use_one_text_height_without_double_spacing_at_a_larger_font() {
let mut h = large_font_harness();
let first = visible_rows(&h);
assert!(
first.len() > 1,
"the fixture has visible rows; labels: {:?}",
h.root()
);
// History is newest first: the fixture's model indices descend as we scroll.
assert_eq!(first.first().unwrap().0, 39);
assert!(first.last().unwrap().0 > 0);
let expected = h.state().row_height + h.state().spacing;
assert!(
expected > 20.0,
"the test uses a really larger font: {expected}"
);
for pair in first.windows(2) {
assert_eq!(
pair[0].0,
pair[1].0 + 1,
"adjacent jobs remain in history order"
);
let delta = pair[1].1.top() - pair[0].1.top();
assert!(
(delta - expected).abs() < 1.5,
"row spacing is {delta}, expected one text height plus item spacing ({expected})"
);
}
scroll_rows(&mut h, 4);
let middle = visible_rows(&h);
assert!(!middle.is_empty());
assert!(middle.first().unwrap().0 < 39, "the newest row is gone");
assert!(
middle.last().unwrap().0 > 0,
"the oldest row is not reached yet"
);
scroll_rows(&mut h, 40);
let end = visible_rows(&h);
assert_eq!(end.last().unwrap().0, 0);
assert!(end.first().unwrap().0 < 39);
}
#[test]
fn each_filter_is_named_by_what_it_filters_not_by_its_current_value() {
let mut h = harness(HistoryViewState::default(), two_apps(), T0 + 5000);
h.run();
for name in ["Filter by app", "Filter by model", "Date range"] {
let node = h.get_by_label(name);
assert_eq!(
node.accesskit_node().role(),
egui::accesskit::Role::ComboBox
);
}
assert!(h.unnamed_widgets().is_empty());
h.get_by_label(TAB_DAYS).click();
h.run();
h.get_by_label(TAB_JOBS).click();
h.run();
assert_eq!(h.state().view.tab, Tab::Jobs);
}