mirror of
https://github.com/storytold/craft-ai-integrations.git
synced 2026-11-02 18:06:49 +00:00
659 lines
21 KiB
Rust
659 lines
21 KiB
Rust
//! The widgets driven headlessly: real egui layout, real clicks, no window and no GPU.
|
||
|
||
use craft_ai_client::{Choice, RoleReady, RoleStatus};
|
||
use craft_ai_core::consent::DisclosureItem;
|
||
use craft_ai_core::{
|
||
AiError, AppId, Billing, CapabilityId, ConnectionId, CostEstimate, Disclosure, ErrorKind,
|
||
InputRole, JobState, Locality, ModelId, PayloadKind,
|
||
};
|
||
use craft_ai_egui::{
|
||
ADD_CONNECTION, ALLOW, CANCEL, ConsentChoice, ConsentPromptState, DENY, OPEN_LAUNCHER,
|
||
ProgressAction, StatusAction, StatusView, TimedConsentChoice, USE_DEFAULT, ai_status,
|
||
consent_prompt, consent_prompt_with_terms, consent_term_label, job_progress,
|
||
};
|
||
use egui::accesskit::Role;
|
||
use egui_kittest::Harness;
|
||
use egui_kittest::kittest::{NodeT, Queryable};
|
||
|
||
/// Synthetic Photocraft cloud-or-local disclosure with one image input and one parameter.
|
||
fn disclosure(locality: Locality) -> Disclosure {
|
||
Disclosure {
|
||
app: AppId::new("photocraft").unwrap(),
|
||
connection: ConnectionId::new("cloud").unwrap(),
|
||
capability: CapabilityId::parse("image.inpaint").unwrap(),
|
||
locality,
|
||
destination: "https://api.example.com".into(),
|
||
items: vec![DisclosureItem {
|
||
role: InputRole::Source,
|
||
kind: PayloadKind::Image,
|
||
bytes: Some(2_500_000),
|
||
}],
|
||
param_names: vec!["size".into()],
|
||
}
|
||
}
|
||
|
||
fn choice(conn: &str, model: &str, label: &str, locality: Locality) -> Choice {
|
||
Choice {
|
||
connection: ConnectionId::new(conn).unwrap(),
|
||
connection_label: format!("Conn {conn}"),
|
||
model: ModelId::new(model).unwrap(),
|
||
model_label: label.into(),
|
||
locality,
|
||
}
|
||
}
|
||
|
||
fn ready(conn: &str, model: &str, label: &str) -> RoleStatus {
|
||
RoleStatus::Ready(RoleReady {
|
||
connection: ConnectionId::new(conn).unwrap(),
|
||
connection_label: format!("Conn {conn}"),
|
||
model: ModelId::new(model).unwrap(),
|
||
model_label: label.into(),
|
||
locality: Locality::Local,
|
||
billing: Billing::Free,
|
||
})
|
||
}
|
||
|
||
// ---- consent ----
|
||
|
||
fn consent_harness(d: Disclosure) -> Harness<'static, Option<ConsentChoice>> {
|
||
Harness::new_ui_state(
|
||
move |ui, state: &mut Option<ConsentChoice>| {
|
||
if let Some(c) = consent_prompt(ui, &d, "PhotoCraft") {
|
||
*state = Some(c);
|
||
}
|
||
},
|
||
None,
|
||
)
|
||
}
|
||
|
||
#[test]
|
||
fn consent_shows_destination_kinds_sizes_and_the_remote_badge() {
|
||
let mut h = consent_harness(disclosure(Locality::Remote));
|
||
h.run();
|
||
h.get_by_label("Leaves this computer");
|
||
h.get_by_label("PhotoCraft will send the following to https://api.example.com");
|
||
h.get_by_label("\u{2022} image (source), 2.5 MB");
|
||
h.get_by_label("\u{2022} settings: size");
|
||
assert_eq!(
|
||
*h.state(),
|
||
None,
|
||
"nothing is chosen until a button is pressed"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn consent_allow_and_deny_report_exactly_one_choice() {
|
||
let mut h = consent_harness(disclosure(Locality::Remote));
|
||
h.run();
|
||
h.get_by_label(ALLOW).click();
|
||
h.run();
|
||
assert_eq!(*h.state(), Some(ConsentChoice::Allow));
|
||
|
||
let mut h = consent_harness(disclosure(Locality::Remote));
|
||
h.run();
|
||
h.get_by_label(DENY).click();
|
||
h.run();
|
||
assert_eq!(*h.state(), Some(ConsentChoice::Deny));
|
||
}
|
||
|
||
#[test]
|
||
fn consent_for_a_local_model_says_it_stays_here() {
|
||
let mut h = consent_harness(disclosure(Locality::Local));
|
||
h.run();
|
||
h.get_by_label("Stays on this computer");
|
||
assert!(h.query_by_label("Leaves this computer").is_none());
|
||
}
|
||
|
||
// ---- status ----
|
||
|
||
struct StatusState {
|
||
status: RoleStatus,
|
||
choices: Vec<Choice>,
|
||
has_override: bool,
|
||
can_add_here: bool,
|
||
last: Option<StatusAction>,
|
||
}
|
||
|
||
/// A status harness at the default size whose state keeps the last reported action.
|
||
fn status_harness(s: StatusState) -> Harness<'static, StatusState> {
|
||
Harness::new_ui_state(
|
||
|ui, st: &mut StatusState| {
|
||
let view = StatusView {
|
||
label: "Generative Fill",
|
||
status: &st.status,
|
||
choices: &st.choices,
|
||
has_override: st.has_override,
|
||
can_add_here: st.can_add_here,
|
||
};
|
||
if let Some(a) = ai_status(ui, &view) {
|
||
st.last = Some(a);
|
||
}
|
||
},
|
||
s,
|
||
)
|
||
}
|
||
|
||
/// A status harness at a fixed size whose state keeps the last reported action.
|
||
fn status_harness_sized(size: egui::Vec2, s: StatusState) -> Harness<'static, StatusState> {
|
||
Harness::builder().with_size(size).build_ui_state(
|
||
|ui, st: &mut StatusState| {
|
||
let view = StatusView {
|
||
label: "Generative Fill",
|
||
status: &st.status,
|
||
choices: &st.choices,
|
||
has_override: st.has_override,
|
||
can_add_here: st.can_add_here,
|
||
};
|
||
if let Some(a) = ai_status(ui, &view) {
|
||
st.last = Some(a);
|
||
}
|
||
},
|
||
s,
|
||
)
|
||
}
|
||
|
||
/// An unavailable status explains its reason, offers the launcher, and hides Reset when there is
|
||
/// no override.
|
||
#[test]
|
||
fn unavailable_feature_explains_itself_and_offers_the_launcher() {
|
||
let mut h = status_harness(StatusState {
|
||
status: RoleStatus::Unavailable {
|
||
reason: "no model is chosen for 'fill'".into(),
|
||
},
|
||
choices: vec![],
|
||
has_override: false,
|
||
can_add_here: false,
|
||
last: None,
|
||
});
|
||
h.run();
|
||
h.get_by_label("Generative Fill is off: no model is chosen for 'fill'");
|
||
assert!(
|
||
h.query_by_label(USE_DEFAULT).is_none(),
|
||
"no override, no reset button"
|
||
);
|
||
h.get_by_label(OPEN_LAUNCHER).click();
|
||
h.run();
|
||
assert_eq!(h.state().last, Some(StatusAction::OpenLauncher));
|
||
}
|
||
|
||
#[test]
|
||
fn ready_feature_shows_its_model_and_no_launcher_button() {
|
||
let mut h = status_harness(StatusState {
|
||
status: ready("local", "llama3", "Llama 3"),
|
||
choices: vec![choice("local", "llama3", "Llama 3", Locality::Local)],
|
||
has_override: false,
|
||
can_add_here: false,
|
||
last: None,
|
||
});
|
||
h.run();
|
||
h.get_by_label("Generative Fill: Llama 3 on Conn local (stays on this computer, free)");
|
||
assert!(h.query_by_label(OPEN_LAUNCHER).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn picking_another_model_reports_it_and_the_current_one_does_not() {
|
||
let mut h = status_harness(StatusState {
|
||
status: ready("local", "llama3", "Llama 3"),
|
||
choices: vec![
|
||
choice("local", "llama3", "Llama 3", Locality::Local),
|
||
choice("cloud", "gpt-4o", "GPT-4o", Locality::Remote),
|
||
],
|
||
has_override: false,
|
||
can_add_here: false,
|
||
last: None,
|
||
});
|
||
h.run();
|
||
h.get_by_role(Role::ComboBox).click();
|
||
h.run();
|
||
// Choosing what is already selected changes nothing.
|
||
h.query_all_by_label_contains("Llama 3 \u{2014} Conn local")
|
||
.last()
|
||
.unwrap()
|
||
.click();
|
||
h.run();
|
||
assert_eq!(h.state().last, None);
|
||
|
||
let mut h = status_harness(StatusState {
|
||
status: ready("local", "llama3", "Llama 3"),
|
||
choices: vec![
|
||
choice("local", "llama3", "Llama 3", Locality::Local),
|
||
choice("cloud", "gpt-4o", "GPT-4o", Locality::Remote),
|
||
],
|
||
has_override: false,
|
||
can_add_here: false,
|
||
last: None,
|
||
});
|
||
h.run();
|
||
h.get_by_role(Role::ComboBox).click();
|
||
h.run();
|
||
h.get_by_label_contains("GPT-4o \u{2014} Conn cloud (leaves this computer)")
|
||
.click();
|
||
h.run();
|
||
assert_eq!(
|
||
h.state().last,
|
||
Some(StatusAction::Pick {
|
||
connection: ConnectionId::new("cloud").unwrap(),
|
||
model: ModelId::new("gpt-4o").unwrap(),
|
||
})
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn use_default_appears_only_with_an_override() {
|
||
let mut h = status_harness(StatusState {
|
||
status: ready("local", "llama3", "Llama 3"),
|
||
choices: vec![],
|
||
has_override: true,
|
||
can_add_here: false,
|
||
last: None,
|
||
});
|
||
h.run();
|
||
h.get_by_label(USE_DEFAULT).click();
|
||
h.run();
|
||
assert_eq!(h.state().last, Some(StatusAction::UseDefault));
|
||
}
|
||
|
||
#[test]
|
||
fn adding_a_connection_here_is_offered_only_when_asked_and_only_when_unavailable() {
|
||
let unavailable = || RoleStatus::Unavailable {
|
||
reason: "no connection".into(),
|
||
};
|
||
// Off by default: the launcher is the only way.
|
||
let mut h = status_harness(StatusState {
|
||
status: unavailable(),
|
||
choices: vec![],
|
||
has_override: false,
|
||
can_add_here: false,
|
||
last: None,
|
||
});
|
||
h.run();
|
||
assert!(h.query_by_label(ADD_CONNECTION).is_none());
|
||
h.get_by_label(OPEN_LAUNCHER);
|
||
// On: offered next to the launcher button, not instead of it.
|
||
let mut h = status_harness(StatusState {
|
||
status: unavailable(),
|
||
choices: vec![],
|
||
has_override: false,
|
||
can_add_here: true,
|
||
last: None,
|
||
});
|
||
h.run();
|
||
h.get_by_label(OPEN_LAUNCHER);
|
||
h.get_by_label(ADD_CONNECTION).click();
|
||
h.run();
|
||
assert_eq!(h.state().last, Some(StatusAction::AddConnection));
|
||
// A feature that already works has nothing to add.
|
||
let mut h = status_harness(StatusState {
|
||
status: ready("local", "llama3", "Llama 3"),
|
||
choices: vec![],
|
||
has_override: false,
|
||
can_add_here: true,
|
||
last: None,
|
||
});
|
||
h.run();
|
||
assert!(h.query_by_label(ADD_CONNECTION).is_none());
|
||
}
|
||
|
||
// ---- progress ----
|
||
|
||
struct ProgressState {
|
||
job: JobState,
|
||
cost: Option<CostEstimate>,
|
||
cancelled: bool,
|
||
}
|
||
|
||
/// A progress harness at the default size for `job`, with optional cost, recording cancellation.
|
||
fn progress_harness(job: JobState, cost: Option<CostEstimate>) -> Harness<'static, ProgressState> {
|
||
Harness::new_ui_state(
|
||
|ui, st: &mut ProgressState| {
|
||
if job_progress(ui, &st.job, st.cost.as_ref()) == Some(ProgressAction::Cancel) {
|
||
st.cancelled = true;
|
||
}
|
||
},
|
||
ProgressState {
|
||
job,
|
||
cost,
|
||
cancelled: false,
|
||
},
|
||
)
|
||
}
|
||
|
||
/// A progress harness at a fixed size for `job`, with optional cost, recording cancellation.
|
||
fn progress_harness_sized(
|
||
size: egui::Vec2,
|
||
job: JobState,
|
||
cost: Option<CostEstimate>,
|
||
) -> Harness<'static, ProgressState> {
|
||
Harness::builder().with_size(size).build_ui_state(
|
||
|ui, st: &mut ProgressState| {
|
||
if job_progress(ui, &st.job, st.cost.as_ref()) == Some(ProgressAction::Cancel) {
|
||
st.cancelled = true;
|
||
}
|
||
},
|
||
ProgressState {
|
||
job,
|
||
cost,
|
||
cancelled: false,
|
||
},
|
||
)
|
||
}
|
||
|
||
/// The same widget under a right-to-left host layout, at a narrow width.
|
||
fn progress_harness_rtl_sized(
|
||
size: egui::Vec2,
|
||
job: JobState,
|
||
cost: Option<CostEstimate>,
|
||
) -> Harness<'static, ProgressState> {
|
||
Harness::builder().with_size(size).build_ui_state(
|
||
|ui, st: &mut ProgressState| {
|
||
ui.with_layout(egui::Layout::right_to_left(egui::Align::TOP), |ui| {
|
||
if job_progress(ui, &st.job, st.cost.as_ref()) == Some(ProgressAction::Cancel) {
|
||
st.cancelled = true;
|
||
}
|
||
});
|
||
},
|
||
ProgressState {
|
||
job,
|
||
cost,
|
||
cancelled: false,
|
||
},
|
||
)
|
||
}
|
||
|
||
/// A running job shows its percent and formatted cost, and Cancel reports.
|
||
#[test]
|
||
fn a_running_job_shows_percent_cost_and_can_be_cancelled() {
|
||
let cost = CostEstimate {
|
||
micros: 40_000,
|
||
currency: "USD".into(),
|
||
billing: Billing::Metered,
|
||
};
|
||
let mut h = progress_harness(JobState::Running { percent: Some(40) }, Some(cost));
|
||
h.run();
|
||
h.get_by_label_contains("Working, 40%");
|
||
h.get_by_label("about 0.04 USD (billed per use)");
|
||
h.get_by_label(CANCEL).click();
|
||
h.run();
|
||
assert!(h.state().cancelled);
|
||
}
|
||
|
||
#[test]
|
||
fn a_job_with_no_percentage_still_shows_it_is_working() {
|
||
let mut h = progress_harness(JobState::Running { percent: None }, None);
|
||
// A spinner repaints forever, so step a few frames instead of running to rest.
|
||
h.step();
|
||
h.step();
|
||
h.get_by_label("Working");
|
||
h.get_by_label(CANCEL);
|
||
}
|
||
|
||
/// Succeeded, Cancelled and Failed show the right text and offer no Cancel.
|
||
#[test]
|
||
fn finished_jobs_say_how_they_ended_and_cannot_be_cancelled() {
|
||
for (state, text) in [
|
||
(JobState::Succeeded, "Done".to_owned()),
|
||
(JobState::Cancelled, "Cancelled".to_owned()),
|
||
(
|
||
JobState::Failed {
|
||
error: AiError::new(ErrorKind::Quota, "out of credits"),
|
||
},
|
||
"Failed: out of credits".to_owned(),
|
||
),
|
||
] {
|
||
let mut h = progress_harness(state, None);
|
||
h.run();
|
||
h.get_by_label_contains(&text);
|
||
assert!(h.query_by_label(CANCEL).is_none(), "{text}");
|
||
}
|
||
}
|
||
|
||
/// The term radios expose their checked state; choosing One hour and pressing Allow returns that
|
||
/// term.
|
||
#[test]
|
||
fn timed_consent_radios_expose_their_selected_state_and_duration() {
|
||
struct State {
|
||
prompt: ConsentPromptState,
|
||
choice: Option<TimedConsentChoice>,
|
||
}
|
||
let d = disclosure(Locality::Remote);
|
||
let mut h = Harness::new_ui_state(
|
||
move |ui, state: &mut State| {
|
||
if let Some(choice) = consent_prompt_with_terms(ui, &d, "PhotoCraft", &mut state.prompt)
|
||
{
|
||
state.choice = Some(choice);
|
||
}
|
||
},
|
||
State {
|
||
prompt: ConsentPromptState::default(),
|
||
choice: None,
|
||
},
|
||
);
|
||
h.run();
|
||
let this_time = h.get_by_label(consent_term_label(craft_ai_core::ConsentTerm::ThisTime));
|
||
assert_eq!(this_time.accesskit_node().role(), Role::RadioButton);
|
||
assert_eq!(
|
||
this_time.accesskit_node().toggled(),
|
||
Some(egui::accesskit::Toggled::True)
|
||
);
|
||
let one_hour = h.get_by_label(consent_term_label(craft_ai_core::ConsentTerm::OneHour));
|
||
assert_eq!(
|
||
one_hour.accesskit_node().toggled(),
|
||
Some(egui::accesskit::Toggled::False)
|
||
);
|
||
one_hour.click();
|
||
h.run();
|
||
assert_eq!(
|
||
h.get_by_label(consent_term_label(craft_ai_core::ConsentTerm::OneHour))
|
||
.accesskit_node()
|
||
.toggled(),
|
||
Some(egui::accesskit::Toggled::True)
|
||
);
|
||
h.get_by_label(ALLOW).click();
|
||
h.run();
|
||
assert_eq!(
|
||
h.state().choice,
|
||
Some(TimedConsentChoice::Allow(
|
||
craft_ai_core::ConsentTerm::OneHour
|
||
))
|
||
);
|
||
}
|
||
|
||
/// The status model combo is labelled for its feature, and no widget is unnamed.
|
||
#[test]
|
||
fn the_status_model_picker_has_a_field_name_not_just_its_value() {
|
||
let mut h = status_harness(StatusState {
|
||
status: ready("local", "llama3", "Llama 3"),
|
||
choices: vec![
|
||
choice("local", "llama3", "Llama 3", Locality::Local),
|
||
choice("cloud", "gpt-4o", "GPT-4o", Locality::Remote),
|
||
],
|
||
has_override: false,
|
||
can_add_here: false,
|
||
last: None,
|
||
});
|
||
h.run();
|
||
let combo = h.get_by_role(Role::ComboBox);
|
||
assert_eq!(
|
||
combo.accesskit_node().label().as_deref(),
|
||
Some("Model for Generative Fill")
|
||
);
|
||
assert!(h.unnamed_widgets().is_empty());
|
||
}
|
||
|
||
/// At 180×360 Launcher and Add connection stay inside, and Add reports.
|
||
#[test]
|
||
fn status_commands_stay_inside_a_narrow_header() {
|
||
let mut h = status_harness_sized(
|
||
egui::vec2(180.0, 360.0),
|
||
StatusState {
|
||
status: RoleStatus::Unavailable {
|
||
reason: "no connection".into(),
|
||
},
|
||
choices: vec![],
|
||
has_override: false,
|
||
can_add_here: true,
|
||
last: None,
|
||
},
|
||
);
|
||
h.run();
|
||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(180.0, 360.0));
|
||
for label in [OPEN_LAUNCHER, ADD_CONNECTION] {
|
||
let node = h.get_by_label(label);
|
||
assert!(
|
||
screen.contains_rect(node.rect()),
|
||
"{label} is outside {screen:?}: {:?}",
|
||
node.rect()
|
||
);
|
||
}
|
||
h.get_by_label(ADD_CONNECTION).click();
|
||
h.run();
|
||
assert_eq!(h.state().last, Some(StatusAction::AddConnection));
|
||
}
|
||
|
||
/// At 320px the bar and Cancel are contained, do not overlap, and Cancel reports.
|
||
#[test]
|
||
fn cancel_stays_visible_and_does_not_overlap_progress_at_320() {
|
||
let mut h = progress_harness_sized(
|
||
egui::vec2(320.0, 480.0),
|
||
JobState::Running { percent: Some(40) },
|
||
None,
|
||
);
|
||
h.run();
|
||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(320.0, 480.0));
|
||
let bar = h.get_by_label_contains("Working, 40%");
|
||
let cancel = h.get_by_label(CANCEL);
|
||
assert!(screen.contains_rect(bar.rect()));
|
||
assert!(screen.contains_rect(cancel.rect()));
|
||
assert!(
|
||
!bar.rect().intersects(cancel.rect()),
|
||
"bar {:?} overlaps Cancel {:?}",
|
||
bar.rect(),
|
||
cancel.rect()
|
||
);
|
||
cancel.click();
|
||
h.run();
|
||
assert!(h.state().cancelled);
|
||
}
|
||
|
||
/// At 120px Cancel wraps below the bar, stays inside and still reports.
|
||
#[test]
|
||
fn narrow_cancel_wraps_below_the_bar_and_stays_clickable() {
|
||
let mut h = progress_harness_sized(
|
||
egui::vec2(120.0, 480.0),
|
||
JobState::Running { percent: Some(40) },
|
||
None,
|
||
);
|
||
h.run();
|
||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(120.0, 480.0));
|
||
let bar = h.get_by_label_contains("Working, 40%");
|
||
let cancel = h.get_by_label(CANCEL);
|
||
assert!(screen.contains_rect(cancel.rect()), "Cancel is clipped");
|
||
assert!(
|
||
cancel.rect().top() >= bar.rect().bottom(),
|
||
"Cancel should wrap below the bar: bar {:?}, cancel {:?}",
|
||
bar.rect(),
|
||
cancel.rect()
|
||
);
|
||
cancel.click();
|
||
h.run();
|
||
assert!(h.state().cancelled);
|
||
}
|
||
|
||
/// Indeterminate progress at 120px keeps Cancel inside the view and clickable.
|
||
#[test]
|
||
fn narrow_spinner_job_also_keeps_cancel_reachable() {
|
||
let mut h = progress_harness_sized(
|
||
egui::vec2(120.0, 480.0),
|
||
JobState::Running { percent: None },
|
||
None,
|
||
);
|
||
h.step();
|
||
h.step();
|
||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(120.0, 480.0));
|
||
let cancel = h.get_by_label(CANCEL);
|
||
assert!(screen.contains_rect(cancel.rect()));
|
||
cancel.click();
|
||
// The spinner keeps requesting repaints, so one step is enough to see the click.
|
||
h.step();
|
||
assert!(h.state().cancelled);
|
||
}
|
||
|
||
/// At 320px right-to-left, Cancel precedes the bar, does not overlap it and reports.
|
||
#[test]
|
||
fn rtl_narrow_progress_keeps_cancel_visible_and_apart_from_the_bar() {
|
||
let mut h = progress_harness_rtl_sized(
|
||
egui::vec2(320.0, 480.0),
|
||
JobState::Running { percent: Some(40) },
|
||
None,
|
||
);
|
||
h.run();
|
||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(320.0, 480.0));
|
||
let bar = h.get_by_label_contains("Working, 40%");
|
||
let cancel = h.get_by_label(CANCEL);
|
||
assert!(screen.contains_rect(bar.rect()));
|
||
assert!(screen.contains_rect(cancel.rect()));
|
||
assert!(
|
||
!bar.rect().intersects(cancel.rect()),
|
||
"bar {:?} overlaps Cancel {:?}",
|
||
bar.rect(),
|
||
cancel.rect()
|
||
);
|
||
assert!(
|
||
cancel.rect().right() <= bar.rect().left() + 0.5,
|
||
"right-to-left order puts Cancel before the bar: bar {:?}, cancel {:?}",
|
||
bar.rect(),
|
||
cancel.rect()
|
||
);
|
||
cancel.click();
|
||
h.run();
|
||
assert!(h.state().cancelled);
|
||
}
|
||
|
||
/// At 320px right-to-left, Cancel precedes the label, does not overlap it and reports.
|
||
#[test]
|
||
fn rtl_narrow_spinner_job_keeps_cancel_visible_and_clickable() {
|
||
let mut h = progress_harness_rtl_sized(
|
||
egui::vec2(320.0, 480.0),
|
||
JobState::Running { percent: None },
|
||
None,
|
||
);
|
||
// The spinner keeps asking for repaints, so step instead of running to rest.
|
||
h.step();
|
||
h.step();
|
||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(320.0, 480.0));
|
||
let label = h.get_by_label("Working");
|
||
let cancel = h.get_by_label(CANCEL);
|
||
assert!(screen.contains_rect(label.rect()));
|
||
assert!(screen.contains_rect(cancel.rect()));
|
||
assert!(
|
||
!label.rect().intersects(cancel.rect()),
|
||
"label {:?} overlaps Cancel {:?}",
|
||
label.rect(),
|
||
cancel.rect()
|
||
);
|
||
assert!(
|
||
cancel.rect().right() <= label.rect().left() + 0.5,
|
||
"right-to-left order puts Cancel before the label: label {:?}, cancel {:?}",
|
||
label.rect(),
|
||
cancel.rect()
|
||
);
|
||
cancel.click();
|
||
h.step();
|
||
assert!(h.state().cancelled);
|
||
}
|
||
|
||
/// Draws the fixture bullet, ellipsis, dash and middle dot; the harness fails if the test font
|
||
/// lacks a glyph.
|
||
#[test]
|
||
fn the_fixed_fixture_glyphs_are_in_the_test_fonts() {
|
||
let mut h = Harness::new_ui_state(
|
||
|ui, _| {
|
||
ui.label("\u{2022} bullet \u{2026} ellipsis \u{2014} dash \u{00b7} middle dot");
|
||
},
|
||
(),
|
||
);
|
||
// The harness panics on a missing glyph, which is the assertion here.
|
||
h.run();
|
||
assert!(h.unnamed_widgets().is_empty());
|
||
}
|