mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-28 03:58:45 -06:00
refactor(ui-egui): move control.rs's lifecycle tests to control/lifecycle_tests.rs
This commit is contained in:
@@ -2145,930 +2145,7 @@ mod transport_tests {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod lifecycle_tests {
|
||||
use super::*;
|
||||
use egui::Plugin as _;
|
||||
use egui_kittest::Harness;
|
||||
|
||||
/// A request is answered after accepted frames only: a discarded layout pass is not a frame
|
||||
/// the agent can see, so it must not advance the count.
|
||||
#[test]
|
||||
fn discarded_passes_are_not_counted_as_accepted_frames() {
|
||||
let shared = Arc::new(Mutex::new(Shared::default()));
|
||||
let mut plugin = ControlPlugin { shared: shared.clone() };
|
||||
let ctx = egui::Context::default();
|
||||
let mut accepted = egui::FullOutput::default();
|
||||
plugin.output_hook(&ctx, &mut accepted);
|
||||
let mut discarded = egui::FullOutput::default();
|
||||
discarded.platform_output.request_discard_reasons.push(egui::RepaintCause::new_reason("control test discard"));
|
||||
plugin.output_hook(&ctx, &mut discarded);
|
||||
assert_eq!(shared.lock().unwrap().passes, 1, "only the presented pass counts");
|
||||
}
|
||||
|
||||
/// A frame that requests discard on every pass and hits `max_passes` still presents its
|
||||
/// last pass: that presented pass must count, or `Pending::Frames` waits for a frame that
|
||||
/// never arrives. This runs the real multi-pass loop rather than a hand-built FullOutput.
|
||||
#[test]
|
||||
fn a_final_capped_pass_is_counted_as_presented() {
|
||||
let shared = Arc::new(Mutex::new(Shared::default()));
|
||||
let ctx = egui::Context::default();
|
||||
ctx.add_plugin(ControlPlugin { shared: shared.clone() });
|
||||
ctx.options_mut(|o| o.max_passes = 2.try_into().expect("two is non-zero"));
|
||||
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| ui.ctx().request_discard("control test must discard"));
|
||||
output.textures_delta.clear();
|
||||
assert_eq!(output.platform_output.num_completed_passes, 2, "the cap was reached");
|
||||
assert!(!output.platform_output.request_discard_reasons.is_empty(), "the last pass still requested a discard");
|
||||
assert_eq!(shared.lock().unwrap().passes, 1, "the presented final pass counts once");
|
||||
}
|
||||
|
||||
/// `max_passes` above two still counts a frame that settles after earlier discarded passes:
|
||||
/// egui clears the previous pass's discard reasons at the start of each pass, so the merged
|
||||
/// output of a presented final pass is not mistaken for a discarded one.
|
||||
#[test]
|
||||
fn a_frame_that_settles_before_a_higher_pass_cap_is_counted_once() {
|
||||
let shared = Arc::new(Mutex::new(Shared::default()));
|
||||
let ctx = egui::Context::default();
|
||||
ctx.add_plugin(ControlPlugin { shared: shared.clone() });
|
||||
ctx.options_mut(|o| o.max_passes = 3.try_into().expect("three is non-zero"));
|
||||
let mut pass = 0;
|
||||
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
pass += 1;
|
||||
if pass < 3 {
|
||||
ui.ctx().request_discard("control test must discard the first two passes");
|
||||
}
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
assert_eq!(output.platform_output.num_completed_passes, 3, "the third pass settled at the higher cap");
|
||||
assert!(output.platform_output.request_discard_reasons.is_empty(), "earlier discard reasons do not survive the settling pass");
|
||||
assert_eq!(shared.lock().unwrap().passes, 1, "the one presented frame counts once");
|
||||
}
|
||||
|
||||
/// `ui.key` delivers held modifiers through `ModifiersChanged`, because egui does not derive
|
||||
/// `InputState.modifiers` from a key event's own field. Shift+Tab and ⌘Enter handlers read it.
|
||||
#[test]
|
||||
fn key_commands_deliver_modifier_state() {
|
||||
let held = egui::Modifiers { command: true, ctrl: true, ..egui::Modifiers::NONE };
|
||||
let batches = key_batches(egui::Key::Enter, held);
|
||||
let key = |pressed| egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed, repeat: false, modifiers: held };
|
||||
assert_eq!(
|
||||
batches,
|
||||
vec![vec![egui::Event::ModifiersChanged(held), key(true)], vec![key(false), egui::Event::ModifiersChanged(egui::Modifiers::NONE)]]
|
||||
);
|
||||
let ctx = egui::Context::default();
|
||||
let mut press_seen = None;
|
||||
ctx.run_ui(egui::RawInput { events: batches[0].clone(), ..Default::default() }, |ui| press_seen = Some(ui.input(|i| i.modifiers)))
|
||||
.textures_delta
|
||||
.clear();
|
||||
let mut release_seen = None;
|
||||
ctx.run_ui(egui::RawInput { events: batches[1].clone(), ..Default::default() }, |ui| release_seen = Some(ui.input(|i| i.modifiers)))
|
||||
.textures_delta
|
||||
.clear();
|
||||
assert_eq!(press_seen, Some(held), "the press sees the modifiers it was sent with");
|
||||
assert_eq!(release_seen, Some(egui::Modifiers::NONE), "the modifier release follows the key-up");
|
||||
}
|
||||
|
||||
/// A batch admitted for one document/focus generation must not apply after that generation
|
||||
/// changes; the request is reported as stale instead of replaying the input.
|
||||
#[test]
|
||||
fn a_stale_synthetic_batch_is_dropped_and_reported() {
|
||||
let shared = Arc::new(Mutex::new(Shared::default()));
|
||||
let mut plugin = ControlPlugin { shared: shared.clone() };
|
||||
{
|
||||
let mut s = shared.lock().unwrap();
|
||||
s.epoch = 2;
|
||||
s.inject.push_back(RequestBatches {
|
||||
request: 41,
|
||||
epoch: 1,
|
||||
started: false,
|
||||
since: web_time::Instant::now(),
|
||||
release: Vec::new(),
|
||||
batches: VecDeque::from([vec![egui::Event::PointerMoved(egui::pos2(1.0, 1.0))]]),
|
||||
});
|
||||
}
|
||||
let mut raw = egui::RawInput::default();
|
||||
plugin.input_hook(&egui::Context::default(), &mut raw);
|
||||
assert!(raw.events.is_empty(), "stale input must not reach the new document");
|
||||
let s = shared.lock().unwrap();
|
||||
assert_eq!(s.dropped, vec![41]);
|
||||
assert!(s.inject.is_empty());
|
||||
}
|
||||
|
||||
/// A stale request is dropped alone. A request admitted later for the current generation
|
||||
/// behind it still runs: one agent's outdated input must not fail another's.
|
||||
#[test]
|
||||
fn a_stale_request_does_not_drop_a_later_current_one() {
|
||||
let shared = Arc::new(Mutex::new(Shared::default()));
|
||||
let mut plugin = ControlPlugin { shared: shared.clone() };
|
||||
{
|
||||
let mut s = shared.lock().unwrap();
|
||||
s.epoch = 2;
|
||||
s.inject.push_back(RequestBatches {
|
||||
request: 41,
|
||||
epoch: 1,
|
||||
started: false,
|
||||
since: web_time::Instant::now(),
|
||||
release: Vec::new(),
|
||||
batches: VecDeque::from([vec![egui::Event::PointerMoved(egui::pos2(1.0, 1.0))]]),
|
||||
});
|
||||
s.inject.push_back(RequestBatches {
|
||||
request: 42,
|
||||
epoch: 2,
|
||||
started: false,
|
||||
since: web_time::Instant::now(),
|
||||
release: Vec::new(),
|
||||
batches: VecDeque::from([vec![egui::Event::PointerMoved(egui::pos2(2.0, 2.0))]]),
|
||||
});
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
let mut first = egui::RawInput::default();
|
||||
plugin.input_hook(&ctx, &mut first);
|
||||
assert!(first.events.is_empty(), "the stale request is not replayed");
|
||||
let mut second = egui::RawInput::default();
|
||||
plugin.input_hook(&ctx, &mut second);
|
||||
assert_eq!(second.events, [egui::Event::PointerMoved(egui::pos2(2.0, 2.0))], "the current request still applies");
|
||||
let s = shared.lock().unwrap();
|
||||
assert_eq!(s.dropped, vec![41]);
|
||||
assert!(s.inject.is_empty());
|
||||
}
|
||||
|
||||
/// Once a request has started, the focus/dialog change caused by its own first batch must not
|
||||
/// drop its release: a key press without its release leaves `keys_down` stuck.
|
||||
#[test]
|
||||
fn a_started_request_finishes_its_batches_after_focus_moves() {
|
||||
let shared = Arc::new(Mutex::new(Shared::default()));
|
||||
let mut plugin = ControlPlugin { shared: shared.clone() };
|
||||
{
|
||||
let mut s = shared.lock().unwrap();
|
||||
s.epoch = 7;
|
||||
s.inject.push_back(RequestBatches {
|
||||
request: 9,
|
||||
epoch: 7,
|
||||
started: false,
|
||||
since: web_time::Instant::now(),
|
||||
release: Vec::new(),
|
||||
batches: VecDeque::from([
|
||||
vec![egui::Event::Key {
|
||||
key: egui::Key::Tab,
|
||||
physical_key: None,
|
||||
pressed: true,
|
||||
repeat: false,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
}],
|
||||
vec![egui::Event::Key {
|
||||
key: egui::Key::Tab,
|
||||
physical_key: None,
|
||||
pressed: false,
|
||||
repeat: false,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
}],
|
||||
]),
|
||||
});
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
let mut press = egui::RawInput::default();
|
||||
plugin.input_hook(&ctx, &mut press);
|
||||
assert!(matches!(press.events.as_slice(), [egui::Event::Key { pressed: true, .. }]));
|
||||
// The press moved focus: the next pass sees another input-target generation.
|
||||
shared.lock().unwrap().epoch = 8;
|
||||
let mut release = egui::RawInput::default();
|
||||
plugin.input_hook(&ctx, &mut release);
|
||||
assert!(matches!(release.events.as_slice(), [egui::Event::Key { pressed: false, .. }]), "the release still arrives");
|
||||
let s = shared.lock().unwrap();
|
||||
assert!(s.dropped.is_empty(), "a started request is not reported as stale");
|
||||
assert!(s.inject.is_empty());
|
||||
}
|
||||
|
||||
/// A host for timeout tests: the control layer must not need a real app.
|
||||
struct NoopHost;
|
||||
|
||||
impl Host for NoopHost {
|
||||
fn state(&self) -> Value {
|
||||
json!({})
|
||||
}
|
||||
fn command(&mut self, _id: &str) -> Reply {
|
||||
Err("no commands in this test".into())
|
||||
}
|
||||
fn panel(&mut self, _reset: bool, _params: &Value) -> Reply {
|
||||
Err("no side panels in this test".into())
|
||||
}
|
||||
fn commands(&self) -> Value {
|
||||
json!([])
|
||||
}
|
||||
fn set(&mut self, _key: &str, _value: &str) -> Reply {
|
||||
Err("no settings in this test".into())
|
||||
}
|
||||
fn open(&mut self, _path: &str) -> Reply {
|
||||
Err("no files in this test".into())
|
||||
}
|
||||
fn quit(&mut self, _ctx: &egui::Context) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// A host that starts unready (fonts not installed) and counts dispatched state reads.
|
||||
/// `state` takes `&self`, so the count sits in a `Cell`.
|
||||
struct ReadinessHost {
|
||||
ready: bool,
|
||||
states: std::cell::Cell<usize>,
|
||||
/// Methods the host was asked to run, in dispatch order.
|
||||
calls: std::cell::RefCell<Vec<&'static str>>,
|
||||
}
|
||||
|
||||
impl ReadinessHost {
|
||||
fn new(ready: bool) -> Self {
|
||||
Self { ready, states: std::cell::Cell::new(0), calls: std::cell::RefCell::new(Vec::new()) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Host for ReadinessHost {
|
||||
fn state(&self) -> Value {
|
||||
self.states.set(self.states.get() + 1);
|
||||
self.calls.borrow_mut().push("ui.state");
|
||||
json!({ "ready": self.ready })
|
||||
}
|
||||
fn command(&mut self, _id: &str) -> Reply {
|
||||
Err("no commands in this test".into())
|
||||
}
|
||||
fn panel(&mut self, _reset: bool, _params: &Value) -> Reply {
|
||||
Err("no side panels in this test".into())
|
||||
}
|
||||
fn commands(&self) -> Value {
|
||||
self.calls.borrow_mut().push("ui.commands");
|
||||
json!([])
|
||||
}
|
||||
fn set(&mut self, _key: &str, _value: &str) -> Reply {
|
||||
Err("no settings in this test".into())
|
||||
}
|
||||
fn open(&mut self, _path: &str) -> Reply {
|
||||
Err("no files in this test".into())
|
||||
}
|
||||
fn quit(&mut self, _ctx: &egui::Context) -> bool {
|
||||
false
|
||||
}
|
||||
fn accepts_input(&self) -> bool {
|
||||
self.ready
|
||||
}
|
||||
}
|
||||
|
||||
/// The admission timestamp of the one pending request, whichever kind it is.
|
||||
fn pending_since(control: &Control) -> Option<web_time::Instant> {
|
||||
let first = control.pending.first()?;
|
||||
match first {
|
||||
Pending::Frames { since, .. } | Pending::Screenshot { since, .. } => Some(*since),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_expired_in_the_channel_is_never_dispatched_or_deferred() {
|
||||
for ready in [false, true] {
|
||||
let ctx = egui::Context::default();
|
||||
let (mut control, client) = attach(&ctx);
|
||||
let mut host = ReadinessHost::new(ready);
|
||||
let (reply, waiting) = channel();
|
||||
let since = web_time::Instant::now().checked_sub(std::time::Duration::from_secs(10)).unwrap();
|
||||
client.tx.send(ControlRequest { since, method: "ui.state".into(), params: json!({}), reply }).unwrap();
|
||||
control.tick(&ctx, &mut host);
|
||||
let error = waiting.try_recv().expect("expired channel request replies on the first tick").expect_err("expired request cannot dispatch");
|
||||
assert!(error.contains(if ready { "finished starting" } else { "still starting" }), "{error}");
|
||||
assert_eq!(host.states.get(), 0);
|
||||
assert!(control.deferred.is_empty() && control.pending.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
/// A request held during startup keeps its arrival time. Once the app accepts input, one
|
||||
/// older than the control deadline fails instead of being dispatched into a later UI.
|
||||
#[test]
|
||||
fn a_deferred_request_expires_instead_of_dispatching_after_the_deadline() {
|
||||
let ctx = egui::Context::default();
|
||||
let (mut control, client) = attach(&ctx);
|
||||
let mut host = ReadinessHost::new(false);
|
||||
let waiting = client.send("ui.state", json!({}));
|
||||
control.tick(&ctx, &mut host);
|
||||
assert_eq!(control.deferred.len(), 1, "unready host defers the request");
|
||||
assert!(waiting.try_recv().is_err(), "it is not answered while starting");
|
||||
let past = web_time::Instant::now().checked_sub(std::time::Duration::from_secs(10)).unwrap();
|
||||
for deferred in &mut control.deferred {
|
||||
deferred.since = past;
|
||||
}
|
||||
// It expires even while the app is still not ready, so the client is not left waiting.
|
||||
control.tick(&ctx, &mut host);
|
||||
let error = waiting.try_recv().expect("expired deferred request receives a reply").expect_err("it must not dispatch");
|
||||
assert!(error.contains("still starting"), "{error}");
|
||||
assert!(control.deferred.is_empty());
|
||||
// And readiness arriving later cannot revive it.
|
||||
host.ready = true;
|
||||
control.tick(&ctx, &mut host);
|
||||
assert_eq!(host.states.get(), 0, "an expired request is never dispatched");
|
||||
}
|
||||
|
||||
/// A request deferred only briefly still runs once fonts are ready.
|
||||
#[test]
|
||||
fn a_fresh_deferred_request_still_dispatches_when_the_app_becomes_ready() {
|
||||
let ctx = egui::Context::default();
|
||||
let (mut control, client) = attach(&ctx);
|
||||
let mut host = ReadinessHost::new(false);
|
||||
let waiting = client.send("ui.state", json!({}));
|
||||
control.tick(&ctx, &mut host);
|
||||
assert!(waiting.try_recv().is_err());
|
||||
host.ready = true;
|
||||
control.tick(&ctx, &mut host);
|
||||
let reply = waiting.try_recv().expect("fresh deferred request replies").expect("it dispatches");
|
||||
assert_eq!(reply["ready"], true);
|
||||
assert_eq!(host.states.get(), 1, "the fresh deferred request is dispatched once");
|
||||
}
|
||||
|
||||
/// A request deferred during startup keeps its one arrival timestamp. Dispatching it into
|
||||
/// synthetic input, a deferred frame reply or a screenshot reservation must not start a fresh
|
||||
/// 5 s in that phase: every later phase measures the same deadline from arrival.
|
||||
#[test]
|
||||
fn a_startup_wait_counts_against_one_admission_deadline_in_every_phase() {
|
||||
let params = [("ui.type", json!({ "text": "x" })), ("ui.resize", json!({ "width": 800, "height": 600 })), ("ui.screenshot", json!({}))];
|
||||
for (method, params) in params {
|
||||
// `ui.resize` needs a native window; the other phases are checked on every target.
|
||||
if method == "ui.resize" && cfg!(target_arch = "wasm32") {
|
||||
continue;
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
// A real window size, so an admitted screenshot has capturable geometry.
|
||||
ctx.run_ui(
|
||||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(640.0, 480.0))), ..Default::default() },
|
||||
|_| {},
|
||||
)
|
||||
.drop_without_applying_deltas();
|
||||
let (mut control, client) = attach(&ctx);
|
||||
let mut host = ReadinessHost::new(false);
|
||||
let waiting = client.send(method, params);
|
||||
control.tick(&ctx, &mut host);
|
||||
assert_eq!(control.deferred.len(), 1, "{method}: deferred while starting");
|
||||
// Three of the five seconds were spent starting: the request is still inside its
|
||||
// deadline, but only just.
|
||||
let arrived = web_time::Instant::now().checked_sub(std::time::Duration::from_secs(3)).unwrap();
|
||||
for deferred in &mut control.deferred {
|
||||
deferred.since = arrived;
|
||||
}
|
||||
host.ready = true;
|
||||
control.tick(&ctx, &mut host);
|
||||
assert!(control.deferred.is_empty(), "{method}: dispatched once the app accepts input");
|
||||
assert!(waiting.try_recv().is_err(), "{method}: a preserved deadline does not answer early");
|
||||
assert_eq!(pending_since(&control), Some(arrived), "{method}: the pending reply keeps the arrival deadline");
|
||||
if method == "ui.type" {
|
||||
let shared = control.shared.lock().unwrap();
|
||||
assert_eq!(shared.inject.front().map(|request| request.since), Some(arrived), "{method}: queued input keeps the arrival deadline");
|
||||
}
|
||||
// Move that same shared timestamp past its own deadline (five seconds after arrival).
|
||||
// Expiry measures the one admission instant, so nothing waits for a fresh five.
|
||||
let past = arrived.checked_sub(timeout()).expect("a representable instant");
|
||||
for pending in &mut control.pending {
|
||||
match pending {
|
||||
Pending::Frames { since, .. } | Pending::Screenshot { since, .. } => *since = past,
|
||||
}
|
||||
}
|
||||
if let Ok(mut shared) = control.shared.lock() {
|
||||
for request in &mut shared.inject {
|
||||
request.since = past;
|
||||
}
|
||||
}
|
||||
control.finish_pending(&ctx, &mut host);
|
||||
let error = waiting.try_recv().expect("the expired request replies").expect_err("it must not succeed");
|
||||
assert!(error.contains("not being drawn"), "{method}: {error}");
|
||||
assert!(control.pending.is_empty(), "{method}: the expired request is not retried");
|
||||
assert_eq!(control.reserved_bytes(), 0, "{method}: any reservation is released");
|
||||
}
|
||||
}
|
||||
|
||||
/// One client's requests keep their arrival order: a request deferred during startup runs
|
||||
/// before a newer request that was still waiting in the channel when the app became ready.
|
||||
#[test]
|
||||
fn a_deferred_request_runs_before_a_newer_queued_one() {
|
||||
let ctx = egui::Context::default();
|
||||
let (mut control, client) = attach(&ctx);
|
||||
let mut host = ReadinessHost::new(false);
|
||||
let older = client.send("ui.state", json!({}));
|
||||
control.tick(&ctx, &mut host);
|
||||
assert_eq!(control.deferred.len(), 1, "the older request is deferred while starting");
|
||||
let newer = client.send("ui.commands", json!({}));
|
||||
host.ready = true;
|
||||
control.tick(&ctx, &mut host);
|
||||
assert_eq!(*host.calls.borrow(), ["ui.state", "ui.commands"], "the older deferred request is dispatched first");
|
||||
assert_eq!(older.try_recv().expect("the older request replies").expect("it dispatches")["ready"], true);
|
||||
assert_eq!(newer.try_recv().expect("the newer request replies").expect("it dispatches"), json!([]));
|
||||
}
|
||||
|
||||
/// A request whose startup wait used up the deadline is refused once the app is ready, and the
|
||||
/// message says the app finished starting instead of claiming it is still starting.
|
||||
#[test]
|
||||
fn a_request_that_outlasted_startup_reports_that_the_app_finished_starting() {
|
||||
let ctx = egui::Context::default();
|
||||
let (mut control, client) = attach(&ctx);
|
||||
let mut host = ReadinessHost::new(false);
|
||||
let waiting = client.send("ui.state", json!({}));
|
||||
control.tick(&ctx, &mut host);
|
||||
assert_eq!(control.deferred.len(), 1);
|
||||
let past = web_time::Instant::now().checked_sub(std::time::Duration::from_secs(10)).unwrap();
|
||||
for deferred in &mut control.deferred {
|
||||
deferred.since = past;
|
||||
}
|
||||
host.ready = true;
|
||||
control.tick(&ctx, &mut host);
|
||||
let error = waiting.try_recv().expect("the expired request replies").expect_err("it is never dispatched");
|
||||
assert!(error.contains("finished starting"), "{error}");
|
||||
assert!(!error.contains("was still starting"), "the app is ready now: {error}");
|
||||
assert_eq!(host.states.get(), 0, "an expired request is never dispatched");
|
||||
assert!(control.deferred.is_empty() && control.pending.is_empty());
|
||||
}
|
||||
|
||||
/// Keep key/modifier cleanup, but a canceled pointer must never produce a release event.
|
||||
#[test]
|
||||
fn a_timed_out_started_request_cancels_pointer_and_releases_keys() {
|
||||
let ctx = egui::Context::default();
|
||||
let (mut control, _client) = attach(&ctx);
|
||||
let held = egui::Modifiers { command: true, ctrl: true, ..egui::Modifiers::NONE };
|
||||
let pos = egui::pos2(24.0, 32.0);
|
||||
let key = |pressed| egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed, repeat: false, modifiers: held };
|
||||
let ids = control
|
||||
.inject(
|
||||
vec![
|
||||
vec![
|
||||
egui::Event::ModifiersChanged(held),
|
||||
key(true),
|
||||
egui::Event::PointerMoved(pos),
|
||||
egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: held },
|
||||
],
|
||||
vec![egui::Event::PointerMoved(egui::pos2(40.0, 48.0))],
|
||||
vec![key(false), egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: held }],
|
||||
],
|
||||
web_time::Instant::now(),
|
||||
)
|
||||
.unwrap();
|
||||
let (reply, rx) = channel();
|
||||
control.pending.push(Pending::Frames { until_pass: u64::MAX, batches: ids, since: web_time::Instant::now(), reply, value: json!({}) });
|
||||
ctx.run_ui(egui::RawInput::default(), |_| {}).drop_without_applying_deltas();
|
||||
assert!(ctx.input(|i| i.keys_down.contains(&egui::Key::Enter) && i.pointer.primary_down()));
|
||||
assert_eq!(ctx.input(|i| i.modifiers), held);
|
||||
age_input(&mut control);
|
||||
let next = egui::pos2(80.0, 90.0);
|
||||
control.inject(vec![vec![egui::Event::PointerMoved(next)]], web_time::Instant::now()).unwrap();
|
||||
control.finish_pending(&ctx, &mut NoopHost);
|
||||
assert!(rx.try_recv().unwrap().unwrap_err().contains("not being drawn"));
|
||||
assert!(control.pending.is_empty());
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
assert!(!ui.input(|i| i.keys_down.contains(&egui::Key::Enter)));
|
||||
assert_eq!(ui.input(|i| i.modifiers), egui::Modifiers::NONE);
|
||||
assert_eq!(ui.input(|i| i.pointer.hover_pos()), Some(next), "another live request survives");
|
||||
assert!(!ui.input(|i| i.pointer.any_down() || i.pointer.any_released()));
|
||||
assert!(ui.ctx().dragged_id().is_none() && ui.ctx().drag_stopped_id().is_none());
|
||||
})
|
||||
.drop_without_applying_deltas();
|
||||
assert!(control.shared.lock().unwrap().inject.is_empty());
|
||||
assert!(rx.try_recv().is_err(), "only one timeout reply");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_deadline_crossing_inside_an_admitted_pass_cancels_before_the_next_interaction() {
|
||||
let ctx = egui::Context::default();
|
||||
let (mut control, _client) = attach(&ctx);
|
||||
let pos = egui::pos2(24.0, 32.0);
|
||||
let ids = control
|
||||
.inject(
|
||||
vec![
|
||||
vec![
|
||||
egui::Event::PointerMoved(pos),
|
||||
egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE },
|
||||
],
|
||||
vec![egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }],
|
||||
],
|
||||
web_time::Instant::now(),
|
||||
)
|
||||
.unwrap();
|
||||
let (reply, rx) = channel();
|
||||
control.pending.push(Pending::Frames { until_pass: u64::MAX, batches: ids, since: web_time::Instant::now(), reply, value: json!({}) });
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
assert!(ui.input(|i| i.pointer.primary_down()), "the press was admitted before expiry");
|
||||
age_input(&mut control);
|
||||
control.finish_pending(ui.ctx(), &mut NoopHost);
|
||||
assert!(rx.try_recv().is_err(), "do not cancel an already-calculated interaction mid-pass");
|
||||
})
|
||||
.drop_without_applying_deltas();
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
control.finish_pending(ui.ctx(), &mut NoopHost);
|
||||
assert!(!ui.input(|i| i.pointer.any_down() || i.pointer.any_released()));
|
||||
assert!(ui.ctx().drag_stopped_id().is_none());
|
||||
})
|
||||
.drop_without_applying_deltas();
|
||||
assert!(rx.try_recv().unwrap().unwrap_err().contains("not being drawn"));
|
||||
assert!(control.shared.lock().unwrap().inject.is_empty());
|
||||
}
|
||||
|
||||
/// Deterministic elapsed-time input: age the same admission in both halves, without sleeping
|
||||
/// or fabricating any egui pointer/widget state.
|
||||
fn age_input(control: &mut Control) {
|
||||
let past = web_time::Instant::now().checked_sub(std::time::Duration::from_secs(10)).unwrap();
|
||||
for request in &mut control.shared.lock().unwrap().inject {
|
||||
request.since = past;
|
||||
}
|
||||
for pending in &mut control.pending {
|
||||
if let Pending::Frames { since, .. } = pending {
|
||||
*since = past;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The public gestures this test drives through real UI passes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Gesture {
|
||||
AtomicClick,
|
||||
HeldClick,
|
||||
Marquee,
|
||||
}
|
||||
|
||||
fn gesture_request(gesture: Gesture, from: egui::Pos2, to: egui::Pos2) -> (&'static str, Value) {
|
||||
match gesture {
|
||||
Gesture::AtomicClick => ("ui.click", json!({"x": from.x, "y": from.y})),
|
||||
Gesture::HeldClick => ("ui.drag", json!({"from": [from.x, from.y], "to": [from.x, from.y], "steps": 6})),
|
||||
Gesture::Marquee => ("ui.drag", json!({"from": [from.x, from.y], "to": [to.x, to.y], "steps": 6})),
|
||||
}
|
||||
}
|
||||
|
||||
fn marquee_harness() -> (Harness<'static, crate::PdfCraftApp>, ControlClient) {
|
||||
let client_slot: Arc<Mutex<Option<ControlClient>>> = Arc::new(Mutex::new(None));
|
||||
let slot = client_slot.clone();
|
||||
let h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).with_step_dt(0.01).build_eframe(move |cc| {
|
||||
let mut app = crate::PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("synthetic-form.pdf", None, include_bytes!("../tests/data/form.pdf").to_vec()).unwrap();
|
||||
app.set_option("quick", "marquee-zoom").unwrap();
|
||||
*slot.lock().unwrap() = Some(app.attach_control(&cc.egui_ctx));
|
||||
app
|
||||
});
|
||||
let client = client_slot.lock().unwrap().take().unwrap();
|
||||
(h, client)
|
||||
}
|
||||
|
||||
fn marquee_step(h: &mut Harness<'static, crate::PdfCraftApp>) {
|
||||
let ctx = h.ctx.clone();
|
||||
let mut raw = std::mem::take(h.input_mut());
|
||||
h.state_mut().prepare_raw_input(&ctx, &mut raw);
|
||||
*h.input_mut() = raw;
|
||||
h.step();
|
||||
}
|
||||
|
||||
/// The actual laid-out page rect and a short point gesture inside it.
|
||||
fn gesture_target(h: &Harness<'static, crate::PdfCraftApp>) -> (egui::Pos2, egui::Pos2) {
|
||||
let page = h.state().views[0].page_screen_rect(0).expect("the actual PDF page was laid out").intersect(h.state().views[0].viewport_rect());
|
||||
assert!(page.width() > 100.0 && page.height() > 100.0, "a visible hit target: {page:?}");
|
||||
let from = page.center();
|
||||
let to = from + egui::vec2(40.0, 50.0);
|
||||
assert!(page.contains(to));
|
||||
(from, to)
|
||||
}
|
||||
|
||||
/// Step until the public request has really reached egui and can still be interrupted.
|
||||
fn await_gesture_admission(h: &mut Harness<'static, crate::PdfCraftApp>, gesture: Gesture) -> bool {
|
||||
let mut admitted = false;
|
||||
for _ in 0..8 {
|
||||
marquee_step(h);
|
||||
admitted = if gesture == Gesture::AtomicClick {
|
||||
let shared = h.state().control.as_ref().unwrap().shared.lock().unwrap();
|
||||
shared.inject.front().is_some_and(|request| request.started && request.batches.len() == 1)
|
||||
} else {
|
||||
h.ctx.input(|i| i.pointer.primary_down()) && (gesture != Gesture::Marquee || h.state().views[0].marquee.is_some())
|
||||
};
|
||||
if admitted {
|
||||
break;
|
||||
}
|
||||
}
|
||||
admitted
|
||||
}
|
||||
|
||||
/// The request is queued exactly once, with the expected terminal batch not yet applied.
|
||||
fn assert_interruptible_admission(h: &Harness<'static, crate::PdfCraftApp>, gesture: Gesture, from: egui::Pos2) {
|
||||
{
|
||||
let shared = h.state().control.as_ref().unwrap().shared.lock().unwrap();
|
||||
assert_eq!(shared.inject.len(), 1, "only the actual public request is queued");
|
||||
let pending = shared.inject.front().unwrap();
|
||||
assert!(pending.started);
|
||||
let terminal = pending.batches.back().expect("the terminal batch is still pending");
|
||||
if gesture == Gesture::AtomicClick {
|
||||
assert_eq!(pending.batches.len(), 1);
|
||||
assert_eq!(terminal.len(), 2);
|
||||
assert!(
|
||||
matches!(terminal[0], egui::Event::PointerButton { pressed: true, button: egui::PointerButton::Primary, pos, .. } if pos == from)
|
||||
);
|
||||
assert!(
|
||||
matches!(terminal[1], egui::Event::PointerButton { pressed: false, button: egui::PointerButton::Primary, pos, .. } if pos == from)
|
||||
);
|
||||
assert!(!h.ctx.input(|i| i.pointer.primary_down()), "atomic click has only hovered so far");
|
||||
} else {
|
||||
assert!(h.ctx.input(|i| i.pointer.primary_down()), "a real pass consumed the press");
|
||||
assert_eq!(terminal.len(), 1);
|
||||
assert!(matches!(terminal[0], egui::Event::PointerButton { pressed: false, button: egui::PointerButton::Primary, .. }));
|
||||
}
|
||||
}
|
||||
assert!(!h.ctx.input(|i| i.pointer.any_released()), "the terminal release has not run");
|
||||
assert!(h.ctx.drag_stopped_id().is_none());
|
||||
if gesture == Gesture::Marquee {
|
||||
assert!(h.ctx.dragged_id().is_some() && h.state().views[0].marquee.is_some(), "the app has a real drag preview");
|
||||
} else {
|
||||
assert!(h.state().views[0].marquee.is_none() && h.state().views[0].marquee_done.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
/// Expire the admission both through hidden logic and through the next input hook, then
|
||||
/// assert that cancellation left no gesture, zoom or edit behind.
|
||||
fn assert_timed_out_gesture(h: &mut Harness<'static, crate::PdfCraftApp>, reply: &Receiver<Reply>, hidden_logic: bool, zoom: f32, bytes: &[u8]) {
|
||||
age_input(h.state_mut().control.as_mut().unwrap());
|
||||
if hidden_logic {
|
||||
let ctx = h.ctx.clone();
|
||||
let app = h.state_mut();
|
||||
let mut control = app.control.take().unwrap();
|
||||
control.tick(&ctx, app);
|
||||
app.control = Some(control);
|
||||
assert!(reply.try_recv().unwrap().unwrap_err().contains("not being drawn"));
|
||||
}
|
||||
marquee_step(h);
|
||||
if !hidden_logic {
|
||||
assert!(reply.try_recv().unwrap().unwrap_err().contains("not being drawn"));
|
||||
}
|
||||
for _ in 0..2 {
|
||||
marquee_step(h);
|
||||
}
|
||||
assert!(!h.ctx.input(|i| i.pointer.any_down() || i.pointer.any_released()));
|
||||
assert!(h.ctx.dragged_id().is_none() && h.ctx.drag_stopped_id().is_none());
|
||||
assert!(h.state().views[0].marquee.is_none() && h.state().views[0].marquee_done.is_none());
|
||||
assert_eq!(h.state().views[0].zoom, zoom, "canceling must not turn a drag into a zoom click");
|
||||
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().bytes.as_slice(), bytes, "no abandoned edit");
|
||||
assert!(h.state().control.as_ref().unwrap().shared.lock().unwrap().inject.is_empty());
|
||||
assert!(h.state().control.as_ref().unwrap().pending.is_empty());
|
||||
assert!(reply.try_recv().is_err(), "the cancellation replies once");
|
||||
}
|
||||
|
||||
/// Complete one public gesture and check its real event trace and one-step zoom.
|
||||
fn complete_positive_gesture(
|
||||
h: &mut Harness<'static, crate::PdfCraftApp>,
|
||||
client: &ControlClient,
|
||||
gesture: Gesture,
|
||||
positive: Gesture,
|
||||
bytes: &[u8],
|
||||
) {
|
||||
let (from, to) = gesture_target(h);
|
||||
let zoom = h.state().views[0].zoom;
|
||||
let (method, params) = gesture_request(positive, from, to);
|
||||
let reply = client.send(method, params);
|
||||
let mut value = None;
|
||||
let mut buttons = Vec::new();
|
||||
let mut positions = Vec::new();
|
||||
for _ in 0..20 {
|
||||
marquee_step(h);
|
||||
h.ctx.input(|i| {
|
||||
for event in &i.events {
|
||||
match event {
|
||||
egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed, pos, .. } => {
|
||||
buttons.push((*pressed, i.time));
|
||||
positions.push(*pos);
|
||||
}
|
||||
egui::Event::PointerMoved(pos) => positions.push(*pos),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Ok(result) = reply.try_recv() {
|
||||
value = Some(result.unwrap());
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(value.is_some(), "a fresh completed request succeeds: {positive:?}, after {gesture:?}");
|
||||
assert_eq!(buttons.iter().map(|(pressed, _)| *pressed).collect::<Vec<_>>(), [true, false]);
|
||||
assert!(!h.ctx.input(|i| i.pointer.any_down()));
|
||||
assert!(h.ctx.dragged_id().is_none());
|
||||
if positive == Gesture::Marquee {
|
||||
assert!(h.state().views[0].zoom > zoom, "the normal marquee rectangle zooms");
|
||||
} else {
|
||||
let options = h.ctx.options(|o| o.input_options);
|
||||
assert!(buttons[1].1 - buttons[0].1 < options.max_click_duration, "real press/release remain a short click");
|
||||
assert!(positions.iter().all(|pos| pos.distance(from) < options.max_click_dist), "real input remains stationary");
|
||||
assert!((h.state().views[0].zoom - zoom * 1.5).abs() < 0.001, "a normal click zooms one step");
|
||||
}
|
||||
assert!(h.state().views[0].marquee.is_none() && h.state().views[0].marquee_done.is_none());
|
||||
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().bytes.as_slice(), bytes, "completed zooms do not edit the document");
|
||||
assert!(reply.try_recv().is_err(), "successful request replies once");
|
||||
}
|
||||
|
||||
/// Real page hit-testing, real control batches and the production marquee completion path.
|
||||
/// Exercise both hidden-logic expiry and expiry first observed by the next input hook.
|
||||
#[test]
|
||||
fn a_timed_out_marquee_never_zooms_and_completed_clicks_and_drags_still_work() {
|
||||
for hidden_logic in [false, true] {
|
||||
for gesture in [Gesture::AtomicClick, Gesture::HeldClick, Gesture::Marquee] {
|
||||
let (mut h, client) = marquee_harness();
|
||||
for _ in 0..5 {
|
||||
marquee_step(&mut h);
|
||||
}
|
||||
let (from, to) = gesture_target(&h);
|
||||
let zoom = h.state().views[0].zoom;
|
||||
let bytes = h.state().session.get(h.state().views[0].id).unwrap().bytes.clone();
|
||||
let (method, params) = gesture_request(gesture, from, to);
|
||||
let reply = client.send(method, params);
|
||||
assert!(await_gesture_admission(&mut h, gesture), "a real interruptible admission: {gesture:?}, hidden={hidden_logic}");
|
||||
assert_interruptible_admission(&h, gesture, from);
|
||||
assert!(reply.try_recv().is_err(), "the terminal batch has not run");
|
||||
assert_eq!(h.state().views[0].zoom, zoom);
|
||||
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().bytes, bytes);
|
||||
assert_timed_out_gesture(&mut h, &reply, hidden_logic, zoom, &bytes);
|
||||
|
||||
// Complete the same public gesture, then an atomic click after held input.
|
||||
// Real event traces prove the stationary drag is a valid short point click.
|
||||
let positives = [gesture, Gesture::AtomicClick];
|
||||
let count = if gesture == Gesture::AtomicClick { 1 } else { 2 };
|
||||
for positive in positives.into_iter().take(count) {
|
||||
complete_positive_gesture(&mut h, &client, gesture, positive, &bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A screenshot that times out cancels its slot and gives its reservation back, with a
|
||||
/// defined error instead of a silent success.
|
||||
#[test]
|
||||
fn a_timed_out_screenshot_releases_its_slot_and_budget() {
|
||||
struct FakeHost;
|
||||
impl Host for FakeHost {
|
||||
fn state(&self) -> Value {
|
||||
json!({})
|
||||
}
|
||||
fn command(&mut self, _id: &str) -> Reply {
|
||||
Err("no commands in this test".into())
|
||||
}
|
||||
fn panel(&mut self, _reset: bool, _params: &Value) -> Reply {
|
||||
Err("no side panels in this test".into())
|
||||
}
|
||||
fn commands(&self) -> Value {
|
||||
json!([])
|
||||
}
|
||||
fn set(&mut self, _key: &str, _value: &str) -> Reply {
|
||||
Err("no settings in this test".into())
|
||||
}
|
||||
fn open(&mut self, _path: &str) -> Reply {
|
||||
Err("no files in this test".into())
|
||||
}
|
||||
fn quit(&mut self, _ctx: &egui::Context) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
let (mut control, _client) = attach(&ctx);
|
||||
let (slot, callback) = craft_ui::capture::CaptureSlot::new(&ctx, egui::ViewportId::ROOT, MAX_IMAGE_BYTES);
|
||||
let (reply, rx) = channel();
|
||||
control.pending.push(Pending::Screenshot {
|
||||
slot,
|
||||
callback: Some(callback),
|
||||
region: None,
|
||||
reply,
|
||||
since: web_time::Instant::now().checked_sub(std::time::Duration::from_secs(10)).unwrap_or_else(web_time::Instant::now),
|
||||
geometry: Geometry { size: egui::vec2(800.0, 600.0), pixels_per_point: 1.0, epoch: 0 },
|
||||
reserved: 800 * 600 * 4,
|
||||
attempts: 0,
|
||||
});
|
||||
control.finish_pending(&ctx, &mut FakeHost);
|
||||
let error = rx.try_recv().expect("the request is answered").expect_err("a timeout is an error");
|
||||
assert!(error.contains("not being drawn"), "{error}");
|
||||
assert_eq!(control.reserved_bytes(), 0, "the reservation is released");
|
||||
assert!(control.pending.is_empty(), "the cancelled capture is not retried");
|
||||
}
|
||||
|
||||
/// A real successful callback must not bypass expiry, including when resize would otherwise
|
||||
/// retry it. A non-expired completion still produces a real encoded PNG reply.
|
||||
#[test]
|
||||
fn completed_screenshots_obey_the_admission_deadline_before_success_or_retry() {
|
||||
for (expired, resized) in [(false, false), (true, false), (true, true)] {
|
||||
let ctx = egui::Context::default();
|
||||
ctx.run_ui(
|
||||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(64.0, 32.0))), ..Default::default() },
|
||||
|_| {},
|
||||
)
|
||||
.drop_without_applying_deltas();
|
||||
let (mut control, _client) = attach(&ctx);
|
||||
let geometry = Geometry::current(&ctx, 0);
|
||||
let (slot, callback) = craft_ui::capture::CaptureSlot::new(&ctx, egui::ViewportId::ROOT, MAX_IMAGE_BYTES);
|
||||
let image = Arc::new(egui::ColorImage::filled([64, 32], egui::Color32::RED));
|
||||
let weak_image = Arc::downgrade(&image);
|
||||
callback.complete(image);
|
||||
assert!(weak_image.upgrade().is_some(), "the completed slot really retains pixels");
|
||||
let (reply, rx) = channel();
|
||||
control.pending.push(Pending::Screenshot {
|
||||
slot,
|
||||
callback: None,
|
||||
region: None,
|
||||
reply,
|
||||
since: if expired {
|
||||
web_time::Instant::now().checked_sub(std::time::Duration::from_secs(10)).unwrap()
|
||||
} else {
|
||||
web_time::Instant::now()
|
||||
},
|
||||
geometry,
|
||||
reserved: geometry.image_bytes().unwrap(),
|
||||
attempts: 0,
|
||||
});
|
||||
if resized {
|
||||
ctx.run_ui(
|
||||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(80.0, 40.0))), ..Default::default() },
|
||||
|_| {},
|
||||
)
|
||||
.drop_without_applying_deltas();
|
||||
assert_ne!(Geometry::current(&ctx, 0).size, geometry.size);
|
||||
}
|
||||
control.finish_pending(&ctx, &mut NoopHost);
|
||||
let result = rx.try_recv().expect("one settled reply");
|
||||
if expired {
|
||||
assert!(result.unwrap_err().contains("not being drawn"), "expiry takes priority over success or resize");
|
||||
} else {
|
||||
let image = result.expect("the live completed capture still succeeds");
|
||||
assert_eq!(image["width"], 64);
|
||||
assert_eq!(image["height"], 32);
|
||||
use base64::Engine as _;
|
||||
let png = base64::engine::general_purpose::STANDARD.decode(image["png_base64"].as_str().expect("actual PNG reply")).unwrap();
|
||||
assert!(png.starts_with(b"\x89PNG\r\n\x1a\n"));
|
||||
}
|
||||
assert!(control.pending.is_empty(), "an expired capture is never retried");
|
||||
assert_eq!(control.reserved_bytes(), 0);
|
||||
assert!(weak_image.upgrade().is_none(), "the slot releases its pixels");
|
||||
assert!(rx.try_recv().is_err(), "exactly one reply");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_unissued_screenshots_are_not_issued_and_late_callbacks_are_harmless() {
|
||||
let ctx = egui::Context::default();
|
||||
let (mut control, _client) = attach(&ctx);
|
||||
let (slot, callback) = craft_ui::capture::CaptureSlot::new(&ctx, egui::ViewportId::ROOT, MAX_IMAGE_BYTES);
|
||||
let late_callback = callback.clone();
|
||||
let geometry = Geometry { size: egui::vec2(64.0, 32.0), pixels_per_point: 1.0, epoch: 0 };
|
||||
let (reply, rx) = channel();
|
||||
control.pending.push(Pending::Screenshot {
|
||||
slot,
|
||||
callback: Some(callback),
|
||||
region: None,
|
||||
reply,
|
||||
since: web_time::Instant::now().checked_sub(std::time::Duration::from_secs(10)).unwrap(),
|
||||
geometry,
|
||||
reserved: 64 * 32 * 4,
|
||||
attempts: 0,
|
||||
});
|
||||
let output = ctx.run_ui(egui::RawInput::default(), |ui| control.finish_frame(ui.ctx(), &mut NoopHost));
|
||||
assert!(
|
||||
output
|
||||
.viewport_output
|
||||
.values()
|
||||
.all(|viewport| !viewport.commands.iter().any(|command| matches!(command, egui::ViewportCommand::Screenshot(_))))
|
||||
);
|
||||
output.drop_without_applying_deltas();
|
||||
assert!(rx.try_recv().unwrap().unwrap_err().contains("not being drawn"));
|
||||
assert!(control.pending.is_empty());
|
||||
assert_eq!(control.reserved_bytes(), 0);
|
||||
let image = Arc::new(egui::ColorImage::filled([64, 32], egui::Color32::RED));
|
||||
let weak_image = Arc::downgrade(&image);
|
||||
late_callback.complete(image);
|
||||
assert!(weak_image.upgrade().is_none(), "a late completion cannot resurrect a canceled request");
|
||||
control.finish_pending(&ctx, &mut NoopHost);
|
||||
assert!(rx.try_recv().is_err(), "no second reply");
|
||||
}
|
||||
|
||||
/// One queued batch is consumed by exactly one UI pass.
|
||||
#[test]
|
||||
fn one_batch_is_consumed_by_one_pass() {
|
||||
let shared = Arc::new(Mutex::new(Shared::default()));
|
||||
let mut plugin = ControlPlugin { shared: shared.clone() };
|
||||
{
|
||||
let mut s = shared.lock().unwrap();
|
||||
s.epoch = 3;
|
||||
s.inject.push_back(RequestBatches {
|
||||
request: 1,
|
||||
epoch: 3,
|
||||
started: false,
|
||||
since: web_time::Instant::now(),
|
||||
release: Vec::new(),
|
||||
batches: VecDeque::from([vec![egui::Event::PointerMoved(egui::pos2(2.0, 3.0))]]),
|
||||
});
|
||||
s.inject.push_back(RequestBatches {
|
||||
request: 2,
|
||||
epoch: 3,
|
||||
started: false,
|
||||
since: web_time::Instant::now(),
|
||||
release: Vec::new(),
|
||||
batches: VecDeque::from([vec![egui::Event::PointerMoved(egui::pos2(4.0, 5.0))]]),
|
||||
});
|
||||
}
|
||||
let mut first = egui::RawInput::default();
|
||||
plugin.input_hook(&egui::Context::default(), &mut first);
|
||||
assert_eq!(first.events.len(), 1);
|
||||
let mut second = egui::RawInput::default();
|
||||
plugin.input_hook(&egui::Context::default(), &mut second);
|
||||
assert_eq!(second.events.len(), 1);
|
||||
assert_eq!(second.events[0], egui::Event::PointerMoved(egui::pos2(4.0, 5.0)));
|
||||
let mut third = egui::RawInput::default();
|
||||
plugin.input_hook(&egui::Context::default(), &mut third);
|
||||
assert!(third.events.is_empty(), "a consumed batch is never repeated");
|
||||
}
|
||||
}
|
||||
mod lifecycle_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod screenshot_geometry_tests {
|
||||
|
||||
@@ -0,0 +1,922 @@
|
||||
use super::*;
|
||||
use egui::Plugin as _;
|
||||
use egui_kittest::Harness;
|
||||
|
||||
/// A request is answered after accepted frames only: a discarded layout pass is not a frame
|
||||
/// the agent can see, so it must not advance the count.
|
||||
#[test]
|
||||
fn discarded_passes_are_not_counted_as_accepted_frames() {
|
||||
let shared = Arc::new(Mutex::new(Shared::default()));
|
||||
let mut plugin = ControlPlugin { shared: shared.clone() };
|
||||
let ctx = egui::Context::default();
|
||||
let mut accepted = egui::FullOutput::default();
|
||||
plugin.output_hook(&ctx, &mut accepted);
|
||||
let mut discarded = egui::FullOutput::default();
|
||||
discarded.platform_output.request_discard_reasons.push(egui::RepaintCause::new_reason("control test discard"));
|
||||
plugin.output_hook(&ctx, &mut discarded);
|
||||
assert_eq!(shared.lock().unwrap().passes, 1, "only the presented pass counts");
|
||||
}
|
||||
|
||||
/// A frame that requests discard on every pass and hits `max_passes` still presents its
|
||||
/// last pass: that presented pass must count, or `Pending::Frames` waits for a frame that
|
||||
/// never arrives. This runs the real multi-pass loop rather than a hand-built FullOutput.
|
||||
#[test]
|
||||
fn a_final_capped_pass_is_counted_as_presented() {
|
||||
let shared = Arc::new(Mutex::new(Shared::default()));
|
||||
let ctx = egui::Context::default();
|
||||
ctx.add_plugin(ControlPlugin { shared: shared.clone() });
|
||||
ctx.options_mut(|o| o.max_passes = 2.try_into().expect("two is non-zero"));
|
||||
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| ui.ctx().request_discard("control test must discard"));
|
||||
output.textures_delta.clear();
|
||||
assert_eq!(output.platform_output.num_completed_passes, 2, "the cap was reached");
|
||||
assert!(!output.platform_output.request_discard_reasons.is_empty(), "the last pass still requested a discard");
|
||||
assert_eq!(shared.lock().unwrap().passes, 1, "the presented final pass counts once");
|
||||
}
|
||||
|
||||
/// `max_passes` above two still counts a frame that settles after earlier discarded passes:
|
||||
/// egui clears the previous pass's discard reasons at the start of each pass, so the merged
|
||||
/// output of a presented final pass is not mistaken for a discarded one.
|
||||
#[test]
|
||||
fn a_frame_that_settles_before_a_higher_pass_cap_is_counted_once() {
|
||||
let shared = Arc::new(Mutex::new(Shared::default()));
|
||||
let ctx = egui::Context::default();
|
||||
ctx.add_plugin(ControlPlugin { shared: shared.clone() });
|
||||
ctx.options_mut(|o| o.max_passes = 3.try_into().expect("three is non-zero"));
|
||||
let mut pass = 0;
|
||||
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
pass += 1;
|
||||
if pass < 3 {
|
||||
ui.ctx().request_discard("control test must discard the first two passes");
|
||||
}
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
assert_eq!(output.platform_output.num_completed_passes, 3, "the third pass settled at the higher cap");
|
||||
assert!(output.platform_output.request_discard_reasons.is_empty(), "earlier discard reasons do not survive the settling pass");
|
||||
assert_eq!(shared.lock().unwrap().passes, 1, "the one presented frame counts once");
|
||||
}
|
||||
|
||||
/// `ui.key` delivers held modifiers through `ModifiersChanged`, because egui does not derive
|
||||
/// `InputState.modifiers` from a key event's own field. Shift+Tab and ⌘Enter handlers read it.
|
||||
#[test]
|
||||
fn key_commands_deliver_modifier_state() {
|
||||
let held = egui::Modifiers { command: true, ctrl: true, ..egui::Modifiers::NONE };
|
||||
let batches = key_batches(egui::Key::Enter, held);
|
||||
let key = |pressed| egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed, repeat: false, modifiers: held };
|
||||
assert_eq!(
|
||||
batches,
|
||||
vec![vec![egui::Event::ModifiersChanged(held), key(true)], vec![key(false), egui::Event::ModifiersChanged(egui::Modifiers::NONE)]]
|
||||
);
|
||||
let ctx = egui::Context::default();
|
||||
let mut press_seen = None;
|
||||
ctx.run_ui(egui::RawInput { events: batches[0].clone(), ..Default::default() }, |ui| press_seen = Some(ui.input(|i| i.modifiers)))
|
||||
.textures_delta
|
||||
.clear();
|
||||
let mut release_seen = None;
|
||||
ctx.run_ui(egui::RawInput { events: batches[1].clone(), ..Default::default() }, |ui| release_seen = Some(ui.input(|i| i.modifiers)))
|
||||
.textures_delta
|
||||
.clear();
|
||||
assert_eq!(press_seen, Some(held), "the press sees the modifiers it was sent with");
|
||||
assert_eq!(release_seen, Some(egui::Modifiers::NONE), "the modifier release follows the key-up");
|
||||
}
|
||||
|
||||
/// A batch admitted for one document/focus generation must not apply after that generation
|
||||
/// changes; the request is reported as stale instead of replaying the input.
|
||||
#[test]
|
||||
fn a_stale_synthetic_batch_is_dropped_and_reported() {
|
||||
let shared = Arc::new(Mutex::new(Shared::default()));
|
||||
let mut plugin = ControlPlugin { shared: shared.clone() };
|
||||
{
|
||||
let mut s = shared.lock().unwrap();
|
||||
s.epoch = 2;
|
||||
s.inject.push_back(RequestBatches {
|
||||
request: 41,
|
||||
epoch: 1,
|
||||
started: false,
|
||||
since: web_time::Instant::now(),
|
||||
release: Vec::new(),
|
||||
batches: VecDeque::from([vec![egui::Event::PointerMoved(egui::pos2(1.0, 1.0))]]),
|
||||
});
|
||||
}
|
||||
let mut raw = egui::RawInput::default();
|
||||
plugin.input_hook(&egui::Context::default(), &mut raw);
|
||||
assert!(raw.events.is_empty(), "stale input must not reach the new document");
|
||||
let s = shared.lock().unwrap();
|
||||
assert_eq!(s.dropped, vec![41]);
|
||||
assert!(s.inject.is_empty());
|
||||
}
|
||||
|
||||
/// A stale request is dropped alone. A request admitted later for the current generation
|
||||
/// behind it still runs: one agent's outdated input must not fail another's.
|
||||
#[test]
|
||||
fn a_stale_request_does_not_drop_a_later_current_one() {
|
||||
let shared = Arc::new(Mutex::new(Shared::default()));
|
||||
let mut plugin = ControlPlugin { shared: shared.clone() };
|
||||
{
|
||||
let mut s = shared.lock().unwrap();
|
||||
s.epoch = 2;
|
||||
s.inject.push_back(RequestBatches {
|
||||
request: 41,
|
||||
epoch: 1,
|
||||
started: false,
|
||||
since: web_time::Instant::now(),
|
||||
release: Vec::new(),
|
||||
batches: VecDeque::from([vec![egui::Event::PointerMoved(egui::pos2(1.0, 1.0))]]),
|
||||
});
|
||||
s.inject.push_back(RequestBatches {
|
||||
request: 42,
|
||||
epoch: 2,
|
||||
started: false,
|
||||
since: web_time::Instant::now(),
|
||||
release: Vec::new(),
|
||||
batches: VecDeque::from([vec![egui::Event::PointerMoved(egui::pos2(2.0, 2.0))]]),
|
||||
});
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
let mut first = egui::RawInput::default();
|
||||
plugin.input_hook(&ctx, &mut first);
|
||||
assert!(first.events.is_empty(), "the stale request is not replayed");
|
||||
let mut second = egui::RawInput::default();
|
||||
plugin.input_hook(&ctx, &mut second);
|
||||
assert_eq!(second.events, [egui::Event::PointerMoved(egui::pos2(2.0, 2.0))], "the current request still applies");
|
||||
let s = shared.lock().unwrap();
|
||||
assert_eq!(s.dropped, vec![41]);
|
||||
assert!(s.inject.is_empty());
|
||||
}
|
||||
|
||||
/// Once a request has started, the focus/dialog change caused by its own first batch must not
|
||||
/// drop its release: a key press without its release leaves `keys_down` stuck.
|
||||
#[test]
|
||||
fn a_started_request_finishes_its_batches_after_focus_moves() {
|
||||
let shared = Arc::new(Mutex::new(Shared::default()));
|
||||
let mut plugin = ControlPlugin { shared: shared.clone() };
|
||||
{
|
||||
let mut s = shared.lock().unwrap();
|
||||
s.epoch = 7;
|
||||
s.inject.push_back(RequestBatches {
|
||||
request: 9,
|
||||
epoch: 7,
|
||||
started: false,
|
||||
since: web_time::Instant::now(),
|
||||
release: Vec::new(),
|
||||
batches: VecDeque::from([
|
||||
vec![egui::Event::Key {
|
||||
key: egui::Key::Tab,
|
||||
physical_key: None,
|
||||
pressed: true,
|
||||
repeat: false,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
}],
|
||||
vec![egui::Event::Key {
|
||||
key: egui::Key::Tab,
|
||||
physical_key: None,
|
||||
pressed: false,
|
||||
repeat: false,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
}],
|
||||
]),
|
||||
});
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
let mut press = egui::RawInput::default();
|
||||
plugin.input_hook(&ctx, &mut press);
|
||||
assert!(matches!(press.events.as_slice(), [egui::Event::Key { pressed: true, .. }]));
|
||||
// The press moved focus: the next pass sees another input-target generation.
|
||||
shared.lock().unwrap().epoch = 8;
|
||||
let mut release = egui::RawInput::default();
|
||||
plugin.input_hook(&ctx, &mut release);
|
||||
assert!(matches!(release.events.as_slice(), [egui::Event::Key { pressed: false, .. }]), "the release still arrives");
|
||||
let s = shared.lock().unwrap();
|
||||
assert!(s.dropped.is_empty(), "a started request is not reported as stale");
|
||||
assert!(s.inject.is_empty());
|
||||
}
|
||||
|
||||
/// A host for timeout tests: the control layer must not need a real app.
|
||||
struct NoopHost;
|
||||
|
||||
impl Host for NoopHost {
|
||||
fn state(&self) -> Value {
|
||||
json!({})
|
||||
}
|
||||
fn command(&mut self, _id: &str) -> Reply {
|
||||
Err("no commands in this test".into())
|
||||
}
|
||||
fn panel(&mut self, _reset: bool, _params: &Value) -> Reply {
|
||||
Err("no side panels in this test".into())
|
||||
}
|
||||
fn commands(&self) -> Value {
|
||||
json!([])
|
||||
}
|
||||
fn set(&mut self, _key: &str, _value: &str) -> Reply {
|
||||
Err("no settings in this test".into())
|
||||
}
|
||||
fn open(&mut self, _path: &str) -> Reply {
|
||||
Err("no files in this test".into())
|
||||
}
|
||||
fn quit(&mut self, _ctx: &egui::Context) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// A host that starts unready (fonts not installed) and counts dispatched state reads.
|
||||
/// `state` takes `&self`, so the count sits in a `Cell`.
|
||||
struct ReadinessHost {
|
||||
ready: bool,
|
||||
states: std::cell::Cell<usize>,
|
||||
/// Methods the host was asked to run, in dispatch order.
|
||||
calls: std::cell::RefCell<Vec<&'static str>>,
|
||||
}
|
||||
|
||||
impl ReadinessHost {
|
||||
fn new(ready: bool) -> Self {
|
||||
Self { ready, states: std::cell::Cell::new(0), calls: std::cell::RefCell::new(Vec::new()) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Host for ReadinessHost {
|
||||
fn state(&self) -> Value {
|
||||
self.states.set(self.states.get() + 1);
|
||||
self.calls.borrow_mut().push("ui.state");
|
||||
json!({ "ready": self.ready })
|
||||
}
|
||||
fn command(&mut self, _id: &str) -> Reply {
|
||||
Err("no commands in this test".into())
|
||||
}
|
||||
fn panel(&mut self, _reset: bool, _params: &Value) -> Reply {
|
||||
Err("no side panels in this test".into())
|
||||
}
|
||||
fn commands(&self) -> Value {
|
||||
self.calls.borrow_mut().push("ui.commands");
|
||||
json!([])
|
||||
}
|
||||
fn set(&mut self, _key: &str, _value: &str) -> Reply {
|
||||
Err("no settings in this test".into())
|
||||
}
|
||||
fn open(&mut self, _path: &str) -> Reply {
|
||||
Err("no files in this test".into())
|
||||
}
|
||||
fn quit(&mut self, _ctx: &egui::Context) -> bool {
|
||||
false
|
||||
}
|
||||
fn accepts_input(&self) -> bool {
|
||||
self.ready
|
||||
}
|
||||
}
|
||||
|
||||
/// The admission timestamp of the one pending request, whichever kind it is.
|
||||
fn pending_since(control: &Control) -> Option<web_time::Instant> {
|
||||
let first = control.pending.first()?;
|
||||
match first {
|
||||
Pending::Frames { since, .. } | Pending::Screenshot { since, .. } => Some(*since),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_expired_in_the_channel_is_never_dispatched_or_deferred() {
|
||||
for ready in [false, true] {
|
||||
let ctx = egui::Context::default();
|
||||
let (mut control, client) = attach(&ctx);
|
||||
let mut host = ReadinessHost::new(ready);
|
||||
let (reply, waiting) = channel();
|
||||
let since = web_time::Instant::now().checked_sub(std::time::Duration::from_secs(10)).unwrap();
|
||||
client.tx.send(ControlRequest { since, method: "ui.state".into(), params: json!({}), reply }).unwrap();
|
||||
control.tick(&ctx, &mut host);
|
||||
let error = waiting.try_recv().expect("expired channel request replies on the first tick").expect_err("expired request cannot dispatch");
|
||||
assert!(error.contains(if ready { "finished starting" } else { "still starting" }), "{error}");
|
||||
assert_eq!(host.states.get(), 0);
|
||||
assert!(control.deferred.is_empty() && control.pending.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
/// A request held during startup keeps its arrival time. Once the app accepts input, one
|
||||
/// older than the control deadline fails instead of being dispatched into a later UI.
|
||||
#[test]
|
||||
fn a_deferred_request_expires_instead_of_dispatching_after_the_deadline() {
|
||||
let ctx = egui::Context::default();
|
||||
let (mut control, client) = attach(&ctx);
|
||||
let mut host = ReadinessHost::new(false);
|
||||
let waiting = client.send("ui.state", json!({}));
|
||||
control.tick(&ctx, &mut host);
|
||||
assert_eq!(control.deferred.len(), 1, "unready host defers the request");
|
||||
assert!(waiting.try_recv().is_err(), "it is not answered while starting");
|
||||
let past = web_time::Instant::now().checked_sub(std::time::Duration::from_secs(10)).unwrap();
|
||||
for deferred in &mut control.deferred {
|
||||
deferred.since = past;
|
||||
}
|
||||
// It expires even while the app is still not ready, so the client is not left waiting.
|
||||
control.tick(&ctx, &mut host);
|
||||
let error = waiting.try_recv().expect("expired deferred request receives a reply").expect_err("it must not dispatch");
|
||||
assert!(error.contains("still starting"), "{error}");
|
||||
assert!(control.deferred.is_empty());
|
||||
// And readiness arriving later cannot revive it.
|
||||
host.ready = true;
|
||||
control.tick(&ctx, &mut host);
|
||||
assert_eq!(host.states.get(), 0, "an expired request is never dispatched");
|
||||
}
|
||||
|
||||
/// A request deferred only briefly still runs once fonts are ready.
|
||||
#[test]
|
||||
fn a_fresh_deferred_request_still_dispatches_when_the_app_becomes_ready() {
|
||||
let ctx = egui::Context::default();
|
||||
let (mut control, client) = attach(&ctx);
|
||||
let mut host = ReadinessHost::new(false);
|
||||
let waiting = client.send("ui.state", json!({}));
|
||||
control.tick(&ctx, &mut host);
|
||||
assert!(waiting.try_recv().is_err());
|
||||
host.ready = true;
|
||||
control.tick(&ctx, &mut host);
|
||||
let reply = waiting.try_recv().expect("fresh deferred request replies").expect("it dispatches");
|
||||
assert_eq!(reply["ready"], true);
|
||||
assert_eq!(host.states.get(), 1, "the fresh deferred request is dispatched once");
|
||||
}
|
||||
|
||||
/// A request deferred during startup keeps its one arrival timestamp. Dispatching it into
|
||||
/// synthetic input, a deferred frame reply or a screenshot reservation must not start a fresh
|
||||
/// 5 s in that phase: every later phase measures the same deadline from arrival.
|
||||
#[test]
|
||||
fn a_startup_wait_counts_against_one_admission_deadline_in_every_phase() {
|
||||
let params = [("ui.type", json!({ "text": "x" })), ("ui.resize", json!({ "width": 800, "height": 600 })), ("ui.screenshot", json!({}))];
|
||||
for (method, params) in params {
|
||||
// `ui.resize` needs a native window; the other phases are checked on every target.
|
||||
if method == "ui.resize" && cfg!(target_arch = "wasm32") {
|
||||
continue;
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
// A real window size, so an admitted screenshot has capturable geometry.
|
||||
ctx.run_ui(
|
||||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(640.0, 480.0))), ..Default::default() },
|
||||
|_| {},
|
||||
)
|
||||
.drop_without_applying_deltas();
|
||||
let (mut control, client) = attach(&ctx);
|
||||
let mut host = ReadinessHost::new(false);
|
||||
let waiting = client.send(method, params);
|
||||
control.tick(&ctx, &mut host);
|
||||
assert_eq!(control.deferred.len(), 1, "{method}: deferred while starting");
|
||||
// Three of the five seconds were spent starting: the request is still inside its
|
||||
// deadline, but only just.
|
||||
let arrived = web_time::Instant::now().checked_sub(std::time::Duration::from_secs(3)).unwrap();
|
||||
for deferred in &mut control.deferred {
|
||||
deferred.since = arrived;
|
||||
}
|
||||
host.ready = true;
|
||||
control.tick(&ctx, &mut host);
|
||||
assert!(control.deferred.is_empty(), "{method}: dispatched once the app accepts input");
|
||||
assert!(waiting.try_recv().is_err(), "{method}: a preserved deadline does not answer early");
|
||||
assert_eq!(pending_since(&control), Some(arrived), "{method}: the pending reply keeps the arrival deadline");
|
||||
if method == "ui.type" {
|
||||
let shared = control.shared.lock().unwrap();
|
||||
assert_eq!(shared.inject.front().map(|request| request.since), Some(arrived), "{method}: queued input keeps the arrival deadline");
|
||||
}
|
||||
// Move that same shared timestamp past its own deadline (five seconds after arrival).
|
||||
// Expiry measures the one admission instant, so nothing waits for a fresh five.
|
||||
let past = arrived.checked_sub(timeout()).expect("a representable instant");
|
||||
for pending in &mut control.pending {
|
||||
match pending {
|
||||
Pending::Frames { since, .. } | Pending::Screenshot { since, .. } => *since = past,
|
||||
}
|
||||
}
|
||||
if let Ok(mut shared) = control.shared.lock() {
|
||||
for request in &mut shared.inject {
|
||||
request.since = past;
|
||||
}
|
||||
}
|
||||
control.finish_pending(&ctx, &mut host);
|
||||
let error = waiting.try_recv().expect("the expired request replies").expect_err("it must not succeed");
|
||||
assert!(error.contains("not being drawn"), "{method}: {error}");
|
||||
assert!(control.pending.is_empty(), "{method}: the expired request is not retried");
|
||||
assert_eq!(control.reserved_bytes(), 0, "{method}: any reservation is released");
|
||||
}
|
||||
}
|
||||
|
||||
/// One client's requests keep their arrival order: a request deferred during startup runs
|
||||
/// before a newer request that was still waiting in the channel when the app became ready.
|
||||
#[test]
|
||||
fn a_deferred_request_runs_before_a_newer_queued_one() {
|
||||
let ctx = egui::Context::default();
|
||||
let (mut control, client) = attach(&ctx);
|
||||
let mut host = ReadinessHost::new(false);
|
||||
let older = client.send("ui.state", json!({}));
|
||||
control.tick(&ctx, &mut host);
|
||||
assert_eq!(control.deferred.len(), 1, "the older request is deferred while starting");
|
||||
let newer = client.send("ui.commands", json!({}));
|
||||
host.ready = true;
|
||||
control.tick(&ctx, &mut host);
|
||||
assert_eq!(*host.calls.borrow(), ["ui.state", "ui.commands"], "the older deferred request is dispatched first");
|
||||
assert_eq!(older.try_recv().expect("the older request replies").expect("it dispatches")["ready"], true);
|
||||
assert_eq!(newer.try_recv().expect("the newer request replies").expect("it dispatches"), json!([]));
|
||||
}
|
||||
|
||||
/// A request whose startup wait used up the deadline is refused once the app is ready, and the
|
||||
/// message says the app finished starting instead of claiming it is still starting.
|
||||
#[test]
|
||||
fn a_request_that_outlasted_startup_reports_that_the_app_finished_starting() {
|
||||
let ctx = egui::Context::default();
|
||||
let (mut control, client) = attach(&ctx);
|
||||
let mut host = ReadinessHost::new(false);
|
||||
let waiting = client.send("ui.state", json!({}));
|
||||
control.tick(&ctx, &mut host);
|
||||
assert_eq!(control.deferred.len(), 1);
|
||||
let past = web_time::Instant::now().checked_sub(std::time::Duration::from_secs(10)).unwrap();
|
||||
for deferred in &mut control.deferred {
|
||||
deferred.since = past;
|
||||
}
|
||||
host.ready = true;
|
||||
control.tick(&ctx, &mut host);
|
||||
let error = waiting.try_recv().expect("the expired request replies").expect_err("it is never dispatched");
|
||||
assert!(error.contains("finished starting"), "{error}");
|
||||
assert!(!error.contains("was still starting"), "the app is ready now: {error}");
|
||||
assert_eq!(host.states.get(), 0, "an expired request is never dispatched");
|
||||
assert!(control.deferred.is_empty() && control.pending.is_empty());
|
||||
}
|
||||
|
||||
/// Keep key/modifier cleanup, but a canceled pointer must never produce a release event.
|
||||
#[test]
|
||||
fn a_timed_out_started_request_cancels_pointer_and_releases_keys() {
|
||||
let ctx = egui::Context::default();
|
||||
let (mut control, _client) = attach(&ctx);
|
||||
let held = egui::Modifiers { command: true, ctrl: true, ..egui::Modifiers::NONE };
|
||||
let pos = egui::pos2(24.0, 32.0);
|
||||
let key = |pressed| egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed, repeat: false, modifiers: held };
|
||||
let ids = control
|
||||
.inject(
|
||||
vec![
|
||||
vec![
|
||||
egui::Event::ModifiersChanged(held),
|
||||
key(true),
|
||||
egui::Event::PointerMoved(pos),
|
||||
egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: held },
|
||||
],
|
||||
vec![egui::Event::PointerMoved(egui::pos2(40.0, 48.0))],
|
||||
vec![key(false), egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: held }],
|
||||
],
|
||||
web_time::Instant::now(),
|
||||
)
|
||||
.unwrap();
|
||||
let (reply, rx) = channel();
|
||||
control.pending.push(Pending::Frames { until_pass: u64::MAX, batches: ids, since: web_time::Instant::now(), reply, value: json!({}) });
|
||||
ctx.run_ui(egui::RawInput::default(), |_| {}).drop_without_applying_deltas();
|
||||
assert!(ctx.input(|i| i.keys_down.contains(&egui::Key::Enter) && i.pointer.primary_down()));
|
||||
assert_eq!(ctx.input(|i| i.modifiers), held);
|
||||
age_input(&mut control);
|
||||
let next = egui::pos2(80.0, 90.0);
|
||||
control.inject(vec![vec![egui::Event::PointerMoved(next)]], web_time::Instant::now()).unwrap();
|
||||
control.finish_pending(&ctx, &mut NoopHost);
|
||||
assert!(rx.try_recv().unwrap().unwrap_err().contains("not being drawn"));
|
||||
assert!(control.pending.is_empty());
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
assert!(!ui.input(|i| i.keys_down.contains(&egui::Key::Enter)));
|
||||
assert_eq!(ui.input(|i| i.modifiers), egui::Modifiers::NONE);
|
||||
assert_eq!(ui.input(|i| i.pointer.hover_pos()), Some(next), "another live request survives");
|
||||
assert!(!ui.input(|i| i.pointer.any_down() || i.pointer.any_released()));
|
||||
assert!(ui.ctx().dragged_id().is_none() && ui.ctx().drag_stopped_id().is_none());
|
||||
})
|
||||
.drop_without_applying_deltas();
|
||||
assert!(control.shared.lock().unwrap().inject.is_empty());
|
||||
assert!(rx.try_recv().is_err(), "only one timeout reply");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_deadline_crossing_inside_an_admitted_pass_cancels_before_the_next_interaction() {
|
||||
let ctx = egui::Context::default();
|
||||
let (mut control, _client) = attach(&ctx);
|
||||
let pos = egui::pos2(24.0, 32.0);
|
||||
let ids = control
|
||||
.inject(
|
||||
vec![
|
||||
vec![
|
||||
egui::Event::PointerMoved(pos),
|
||||
egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE },
|
||||
],
|
||||
vec![egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }],
|
||||
],
|
||||
web_time::Instant::now(),
|
||||
)
|
||||
.unwrap();
|
||||
let (reply, rx) = channel();
|
||||
control.pending.push(Pending::Frames { until_pass: u64::MAX, batches: ids, since: web_time::Instant::now(), reply, value: json!({}) });
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
assert!(ui.input(|i| i.pointer.primary_down()), "the press was admitted before expiry");
|
||||
age_input(&mut control);
|
||||
control.finish_pending(ui.ctx(), &mut NoopHost);
|
||||
assert!(rx.try_recv().is_err(), "do not cancel an already-calculated interaction mid-pass");
|
||||
})
|
||||
.drop_without_applying_deltas();
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
control.finish_pending(ui.ctx(), &mut NoopHost);
|
||||
assert!(!ui.input(|i| i.pointer.any_down() || i.pointer.any_released()));
|
||||
assert!(ui.ctx().drag_stopped_id().is_none());
|
||||
})
|
||||
.drop_without_applying_deltas();
|
||||
assert!(rx.try_recv().unwrap().unwrap_err().contains("not being drawn"));
|
||||
assert!(control.shared.lock().unwrap().inject.is_empty());
|
||||
}
|
||||
|
||||
/// Deterministic elapsed-time input: age the same admission in both halves, without sleeping
|
||||
/// or fabricating any egui pointer/widget state.
|
||||
fn age_input(control: &mut Control) {
|
||||
let past = web_time::Instant::now().checked_sub(std::time::Duration::from_secs(10)).unwrap();
|
||||
for request in &mut control.shared.lock().unwrap().inject {
|
||||
request.since = past;
|
||||
}
|
||||
for pending in &mut control.pending {
|
||||
if let Pending::Frames { since, .. } = pending {
|
||||
*since = past;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The public gestures this test drives through real UI passes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Gesture {
|
||||
AtomicClick,
|
||||
HeldClick,
|
||||
Marquee,
|
||||
}
|
||||
|
||||
fn gesture_request(gesture: Gesture, from: egui::Pos2, to: egui::Pos2) -> (&'static str, Value) {
|
||||
match gesture {
|
||||
Gesture::AtomicClick => ("ui.click", json!({"x": from.x, "y": from.y})),
|
||||
Gesture::HeldClick => ("ui.drag", json!({"from": [from.x, from.y], "to": [from.x, from.y], "steps": 6})),
|
||||
Gesture::Marquee => ("ui.drag", json!({"from": [from.x, from.y], "to": [to.x, to.y], "steps": 6})),
|
||||
}
|
||||
}
|
||||
|
||||
fn marquee_harness() -> (Harness<'static, crate::PdfCraftApp>, ControlClient) {
|
||||
let client_slot: Arc<Mutex<Option<ControlClient>>> = Arc::new(Mutex::new(None));
|
||||
let slot = client_slot.clone();
|
||||
let h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).with_step_dt(0.01).build_eframe(move |cc| {
|
||||
let mut app = crate::PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("synthetic-form.pdf", None, include_bytes!("../../tests/data/form.pdf").to_vec()).unwrap();
|
||||
app.set_option("quick", "marquee-zoom").unwrap();
|
||||
*slot.lock().unwrap() = Some(app.attach_control(&cc.egui_ctx));
|
||||
app
|
||||
});
|
||||
let client = client_slot.lock().unwrap().take().unwrap();
|
||||
(h, client)
|
||||
}
|
||||
|
||||
fn marquee_step(h: &mut Harness<'static, crate::PdfCraftApp>) {
|
||||
let ctx = h.ctx.clone();
|
||||
let mut raw = std::mem::take(h.input_mut());
|
||||
h.state_mut().prepare_raw_input(&ctx, &mut raw);
|
||||
*h.input_mut() = raw;
|
||||
h.step();
|
||||
}
|
||||
|
||||
/// The actual laid-out page rect and a short point gesture inside it.
|
||||
fn gesture_target(h: &Harness<'static, crate::PdfCraftApp>) -> (egui::Pos2, egui::Pos2) {
|
||||
let page = h.state().views[0].page_screen_rect(0).expect("the actual PDF page was laid out").intersect(h.state().views[0].viewport_rect());
|
||||
assert!(page.width() > 100.0 && page.height() > 100.0, "a visible hit target: {page:?}");
|
||||
let from = page.center();
|
||||
let to = from + egui::vec2(40.0, 50.0);
|
||||
assert!(page.contains(to));
|
||||
(from, to)
|
||||
}
|
||||
|
||||
/// Step until the public request has really reached egui and can still be interrupted.
|
||||
fn await_gesture_admission(h: &mut Harness<'static, crate::PdfCraftApp>, gesture: Gesture) -> bool {
|
||||
let mut admitted = false;
|
||||
for _ in 0..8 {
|
||||
marquee_step(h);
|
||||
admitted = if gesture == Gesture::AtomicClick {
|
||||
let shared = h.state().control.as_ref().unwrap().shared.lock().unwrap();
|
||||
shared.inject.front().is_some_and(|request| request.started && request.batches.len() == 1)
|
||||
} else {
|
||||
h.ctx.input(|i| i.pointer.primary_down()) && (gesture != Gesture::Marquee || h.state().views[0].marquee.is_some())
|
||||
};
|
||||
if admitted {
|
||||
break;
|
||||
}
|
||||
}
|
||||
admitted
|
||||
}
|
||||
|
||||
/// The request is queued exactly once, with the expected terminal batch not yet applied.
|
||||
fn assert_interruptible_admission(h: &Harness<'static, crate::PdfCraftApp>, gesture: Gesture, from: egui::Pos2) {
|
||||
{
|
||||
let shared = h.state().control.as_ref().unwrap().shared.lock().unwrap();
|
||||
assert_eq!(shared.inject.len(), 1, "only the actual public request is queued");
|
||||
let pending = shared.inject.front().unwrap();
|
||||
assert!(pending.started);
|
||||
let terminal = pending.batches.back().expect("the terminal batch is still pending");
|
||||
if gesture == Gesture::AtomicClick {
|
||||
assert_eq!(pending.batches.len(), 1);
|
||||
assert_eq!(terminal.len(), 2);
|
||||
assert!(
|
||||
matches!(terminal[0], egui::Event::PointerButton { pressed: true, button: egui::PointerButton::Primary, pos, .. } if pos == from)
|
||||
);
|
||||
assert!(
|
||||
matches!(terminal[1], egui::Event::PointerButton { pressed: false, button: egui::PointerButton::Primary, pos, .. } if pos == from)
|
||||
);
|
||||
assert!(!h.ctx.input(|i| i.pointer.primary_down()), "atomic click has only hovered so far");
|
||||
} else {
|
||||
assert!(h.ctx.input(|i| i.pointer.primary_down()), "a real pass consumed the press");
|
||||
assert_eq!(terminal.len(), 1);
|
||||
assert!(matches!(terminal[0], egui::Event::PointerButton { pressed: false, button: egui::PointerButton::Primary, .. }));
|
||||
}
|
||||
}
|
||||
assert!(!h.ctx.input(|i| i.pointer.any_released()), "the terminal release has not run");
|
||||
assert!(h.ctx.drag_stopped_id().is_none());
|
||||
if gesture == Gesture::Marquee {
|
||||
assert!(h.ctx.dragged_id().is_some() && h.state().views[0].marquee.is_some(), "the app has a real drag preview");
|
||||
} else {
|
||||
assert!(h.state().views[0].marquee.is_none() && h.state().views[0].marquee_done.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
/// Expire the admission both through hidden logic and through the next input hook, then
|
||||
/// assert that cancellation left no gesture, zoom or edit behind.
|
||||
fn assert_timed_out_gesture(h: &mut Harness<'static, crate::PdfCraftApp>, reply: &Receiver<Reply>, hidden_logic: bool, zoom: f32, bytes: &[u8]) {
|
||||
age_input(h.state_mut().control.as_mut().unwrap());
|
||||
if hidden_logic {
|
||||
let ctx = h.ctx.clone();
|
||||
let app = h.state_mut();
|
||||
let mut control = app.control.take().unwrap();
|
||||
control.tick(&ctx, app);
|
||||
app.control = Some(control);
|
||||
assert!(reply.try_recv().unwrap().unwrap_err().contains("not being drawn"));
|
||||
}
|
||||
marquee_step(h);
|
||||
if !hidden_logic {
|
||||
assert!(reply.try_recv().unwrap().unwrap_err().contains("not being drawn"));
|
||||
}
|
||||
for _ in 0..2 {
|
||||
marquee_step(h);
|
||||
}
|
||||
assert!(!h.ctx.input(|i| i.pointer.any_down() || i.pointer.any_released()));
|
||||
assert!(h.ctx.dragged_id().is_none() && h.ctx.drag_stopped_id().is_none());
|
||||
assert!(h.state().views[0].marquee.is_none() && h.state().views[0].marquee_done.is_none());
|
||||
assert_eq!(h.state().views[0].zoom, zoom, "canceling must not turn a drag into a zoom click");
|
||||
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().bytes.as_slice(), bytes, "no abandoned edit");
|
||||
assert!(h.state().control.as_ref().unwrap().shared.lock().unwrap().inject.is_empty());
|
||||
assert!(h.state().control.as_ref().unwrap().pending.is_empty());
|
||||
assert!(reply.try_recv().is_err(), "the cancellation replies once");
|
||||
}
|
||||
|
||||
/// Complete one public gesture and check its real event trace and one-step zoom.
|
||||
fn complete_positive_gesture(
|
||||
h: &mut Harness<'static, crate::PdfCraftApp>,
|
||||
client: &ControlClient,
|
||||
gesture: Gesture,
|
||||
positive: Gesture,
|
||||
bytes: &[u8],
|
||||
) {
|
||||
let (from, to) = gesture_target(h);
|
||||
let zoom = h.state().views[0].zoom;
|
||||
let (method, params) = gesture_request(positive, from, to);
|
||||
let reply = client.send(method, params);
|
||||
let mut value = None;
|
||||
let mut buttons = Vec::new();
|
||||
let mut positions = Vec::new();
|
||||
for _ in 0..20 {
|
||||
marquee_step(h);
|
||||
h.ctx.input(|i| {
|
||||
for event in &i.events {
|
||||
match event {
|
||||
egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed, pos, .. } => {
|
||||
buttons.push((*pressed, i.time));
|
||||
positions.push(*pos);
|
||||
}
|
||||
egui::Event::PointerMoved(pos) => positions.push(*pos),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Ok(result) = reply.try_recv() {
|
||||
value = Some(result.unwrap());
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(value.is_some(), "a fresh completed request succeeds: {positive:?}, after {gesture:?}");
|
||||
assert_eq!(buttons.iter().map(|(pressed, _)| *pressed).collect::<Vec<_>>(), [true, false]);
|
||||
assert!(!h.ctx.input(|i| i.pointer.any_down()));
|
||||
assert!(h.ctx.dragged_id().is_none());
|
||||
if positive == Gesture::Marquee {
|
||||
assert!(h.state().views[0].zoom > zoom, "the normal marquee rectangle zooms");
|
||||
} else {
|
||||
let options = h.ctx.options(|o| o.input_options);
|
||||
assert!(buttons[1].1 - buttons[0].1 < options.max_click_duration, "real press/release remain a short click");
|
||||
assert!(positions.iter().all(|pos| pos.distance(from) < options.max_click_dist), "real input remains stationary");
|
||||
assert!((h.state().views[0].zoom - zoom * 1.5).abs() < 0.001, "a normal click zooms one step");
|
||||
}
|
||||
assert!(h.state().views[0].marquee.is_none() && h.state().views[0].marquee_done.is_none());
|
||||
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().bytes.as_slice(), bytes, "completed zooms do not edit the document");
|
||||
assert!(reply.try_recv().is_err(), "successful request replies once");
|
||||
}
|
||||
|
||||
/// Real page hit-testing, real control batches and the production marquee completion path.
|
||||
/// Exercise both hidden-logic expiry and expiry first observed by the next input hook.
|
||||
#[test]
|
||||
fn a_timed_out_marquee_never_zooms_and_completed_clicks_and_drags_still_work() {
|
||||
for hidden_logic in [false, true] {
|
||||
for gesture in [Gesture::AtomicClick, Gesture::HeldClick, Gesture::Marquee] {
|
||||
let (mut h, client) = marquee_harness();
|
||||
for _ in 0..5 {
|
||||
marquee_step(&mut h);
|
||||
}
|
||||
let (from, to) = gesture_target(&h);
|
||||
let zoom = h.state().views[0].zoom;
|
||||
let bytes = h.state().session.get(h.state().views[0].id).unwrap().bytes.clone();
|
||||
let (method, params) = gesture_request(gesture, from, to);
|
||||
let reply = client.send(method, params);
|
||||
assert!(await_gesture_admission(&mut h, gesture), "a real interruptible admission: {gesture:?}, hidden={hidden_logic}");
|
||||
assert_interruptible_admission(&h, gesture, from);
|
||||
assert!(reply.try_recv().is_err(), "the terminal batch has not run");
|
||||
assert_eq!(h.state().views[0].zoom, zoom);
|
||||
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().bytes, bytes);
|
||||
assert_timed_out_gesture(&mut h, &reply, hidden_logic, zoom, &bytes);
|
||||
|
||||
// Complete the same public gesture, then an atomic click after held input.
|
||||
// Real event traces prove the stationary drag is a valid short point click.
|
||||
let positives = [gesture, Gesture::AtomicClick];
|
||||
let count = if gesture == Gesture::AtomicClick { 1 } else { 2 };
|
||||
for positive in positives.into_iter().take(count) {
|
||||
complete_positive_gesture(&mut h, &client, gesture, positive, &bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A screenshot that times out cancels its slot and gives its reservation back, with a
|
||||
/// defined error instead of a silent success.
|
||||
#[test]
|
||||
fn a_timed_out_screenshot_releases_its_slot_and_budget() {
|
||||
struct FakeHost;
|
||||
impl Host for FakeHost {
|
||||
fn state(&self) -> Value {
|
||||
json!({})
|
||||
}
|
||||
fn command(&mut self, _id: &str) -> Reply {
|
||||
Err("no commands in this test".into())
|
||||
}
|
||||
fn panel(&mut self, _reset: bool, _params: &Value) -> Reply {
|
||||
Err("no side panels in this test".into())
|
||||
}
|
||||
fn commands(&self) -> Value {
|
||||
json!([])
|
||||
}
|
||||
fn set(&mut self, _key: &str, _value: &str) -> Reply {
|
||||
Err("no settings in this test".into())
|
||||
}
|
||||
fn open(&mut self, _path: &str) -> Reply {
|
||||
Err("no files in this test".into())
|
||||
}
|
||||
fn quit(&mut self, _ctx: &egui::Context) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
let (mut control, _client) = attach(&ctx);
|
||||
let (slot, callback) = craft_ui::capture::CaptureSlot::new(&ctx, egui::ViewportId::ROOT, MAX_IMAGE_BYTES);
|
||||
let (reply, rx) = channel();
|
||||
control.pending.push(Pending::Screenshot {
|
||||
slot,
|
||||
callback: Some(callback),
|
||||
region: None,
|
||||
reply,
|
||||
since: web_time::Instant::now().checked_sub(std::time::Duration::from_secs(10)).unwrap_or_else(web_time::Instant::now),
|
||||
geometry: Geometry { size: egui::vec2(800.0, 600.0), pixels_per_point: 1.0, epoch: 0 },
|
||||
reserved: 800 * 600 * 4,
|
||||
attempts: 0,
|
||||
});
|
||||
control.finish_pending(&ctx, &mut FakeHost);
|
||||
let error = rx.try_recv().expect("the request is answered").expect_err("a timeout is an error");
|
||||
assert!(error.contains("not being drawn"), "{error}");
|
||||
assert_eq!(control.reserved_bytes(), 0, "the reservation is released");
|
||||
assert!(control.pending.is_empty(), "the cancelled capture is not retried");
|
||||
}
|
||||
|
||||
/// A real successful callback must not bypass expiry, including when resize would otherwise
|
||||
/// retry it. A non-expired completion still produces a real encoded PNG reply.
|
||||
#[test]
|
||||
fn completed_screenshots_obey_the_admission_deadline_before_success_or_retry() {
|
||||
for (expired, resized) in [(false, false), (true, false), (true, true)] {
|
||||
let ctx = egui::Context::default();
|
||||
ctx.run_ui(
|
||||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(64.0, 32.0))), ..Default::default() },
|
||||
|_| {},
|
||||
)
|
||||
.drop_without_applying_deltas();
|
||||
let (mut control, _client) = attach(&ctx);
|
||||
let geometry = Geometry::current(&ctx, 0);
|
||||
let (slot, callback) = craft_ui::capture::CaptureSlot::new(&ctx, egui::ViewportId::ROOT, MAX_IMAGE_BYTES);
|
||||
let image = Arc::new(egui::ColorImage::filled([64, 32], egui::Color32::RED));
|
||||
let weak_image = Arc::downgrade(&image);
|
||||
callback.complete(image);
|
||||
assert!(weak_image.upgrade().is_some(), "the completed slot really retains pixels");
|
||||
let (reply, rx) = channel();
|
||||
control.pending.push(Pending::Screenshot {
|
||||
slot,
|
||||
callback: None,
|
||||
region: None,
|
||||
reply,
|
||||
since: if expired {
|
||||
web_time::Instant::now().checked_sub(std::time::Duration::from_secs(10)).unwrap()
|
||||
} else {
|
||||
web_time::Instant::now()
|
||||
},
|
||||
geometry,
|
||||
reserved: geometry.image_bytes().unwrap(),
|
||||
attempts: 0,
|
||||
});
|
||||
if resized {
|
||||
ctx.run_ui(
|
||||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(80.0, 40.0))), ..Default::default() },
|
||||
|_| {},
|
||||
)
|
||||
.drop_without_applying_deltas();
|
||||
assert_ne!(Geometry::current(&ctx, 0).size, geometry.size);
|
||||
}
|
||||
control.finish_pending(&ctx, &mut NoopHost);
|
||||
let result = rx.try_recv().expect("one settled reply");
|
||||
if expired {
|
||||
assert!(result.unwrap_err().contains("not being drawn"), "expiry takes priority over success or resize");
|
||||
} else {
|
||||
let image = result.expect("the live completed capture still succeeds");
|
||||
assert_eq!(image["width"], 64);
|
||||
assert_eq!(image["height"], 32);
|
||||
use base64::Engine as _;
|
||||
let png = base64::engine::general_purpose::STANDARD.decode(image["png_base64"].as_str().expect("actual PNG reply")).unwrap();
|
||||
assert!(png.starts_with(b"\x89PNG\r\n\x1a\n"));
|
||||
}
|
||||
assert!(control.pending.is_empty(), "an expired capture is never retried");
|
||||
assert_eq!(control.reserved_bytes(), 0);
|
||||
assert!(weak_image.upgrade().is_none(), "the slot releases its pixels");
|
||||
assert!(rx.try_recv().is_err(), "exactly one reply");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_unissued_screenshots_are_not_issued_and_late_callbacks_are_harmless() {
|
||||
let ctx = egui::Context::default();
|
||||
let (mut control, _client) = attach(&ctx);
|
||||
let (slot, callback) = craft_ui::capture::CaptureSlot::new(&ctx, egui::ViewportId::ROOT, MAX_IMAGE_BYTES);
|
||||
let late_callback = callback.clone();
|
||||
let geometry = Geometry { size: egui::vec2(64.0, 32.0), pixels_per_point: 1.0, epoch: 0 };
|
||||
let (reply, rx) = channel();
|
||||
control.pending.push(Pending::Screenshot {
|
||||
slot,
|
||||
callback: Some(callback),
|
||||
region: None,
|
||||
reply,
|
||||
since: web_time::Instant::now().checked_sub(std::time::Duration::from_secs(10)).unwrap(),
|
||||
geometry,
|
||||
reserved: 64 * 32 * 4,
|
||||
attempts: 0,
|
||||
});
|
||||
let output = ctx.run_ui(egui::RawInput::default(), |ui| control.finish_frame(ui.ctx(), &mut NoopHost));
|
||||
assert!(
|
||||
output
|
||||
.viewport_output
|
||||
.values()
|
||||
.all(|viewport| !viewport.commands.iter().any(|command| matches!(command, egui::ViewportCommand::Screenshot(_))))
|
||||
);
|
||||
output.drop_without_applying_deltas();
|
||||
assert!(rx.try_recv().unwrap().unwrap_err().contains("not being drawn"));
|
||||
assert!(control.pending.is_empty());
|
||||
assert_eq!(control.reserved_bytes(), 0);
|
||||
let image = Arc::new(egui::ColorImage::filled([64, 32], egui::Color32::RED));
|
||||
let weak_image = Arc::downgrade(&image);
|
||||
late_callback.complete(image);
|
||||
assert!(weak_image.upgrade().is_none(), "a late completion cannot resurrect a canceled request");
|
||||
control.finish_pending(&ctx, &mut NoopHost);
|
||||
assert!(rx.try_recv().is_err(), "no second reply");
|
||||
}
|
||||
|
||||
/// One queued batch is consumed by exactly one UI pass.
|
||||
#[test]
|
||||
fn one_batch_is_consumed_by_one_pass() {
|
||||
let shared = Arc::new(Mutex::new(Shared::default()));
|
||||
let mut plugin = ControlPlugin { shared: shared.clone() };
|
||||
{
|
||||
let mut s = shared.lock().unwrap();
|
||||
s.epoch = 3;
|
||||
s.inject.push_back(RequestBatches {
|
||||
request: 1,
|
||||
epoch: 3,
|
||||
started: false,
|
||||
since: web_time::Instant::now(),
|
||||
release: Vec::new(),
|
||||
batches: VecDeque::from([vec![egui::Event::PointerMoved(egui::pos2(2.0, 3.0))]]),
|
||||
});
|
||||
s.inject.push_back(RequestBatches {
|
||||
request: 2,
|
||||
epoch: 3,
|
||||
started: false,
|
||||
since: web_time::Instant::now(),
|
||||
release: Vec::new(),
|
||||
batches: VecDeque::from([vec![egui::Event::PointerMoved(egui::pos2(4.0, 5.0))]]),
|
||||
});
|
||||
}
|
||||
let mut first = egui::RawInput::default();
|
||||
plugin.input_hook(&egui::Context::default(), &mut first);
|
||||
assert_eq!(first.events.len(), 1);
|
||||
let mut second = egui::RawInput::default();
|
||||
plugin.input_hook(&egui::Context::default(), &mut second);
|
||||
assert_eq!(second.events.len(), 1);
|
||||
assert_eq!(second.events[0], egui::Event::PointerMoved(egui::pos2(4.0, 5.0)));
|
||||
let mut third = egui::RawInput::default();
|
||||
plugin.input_hook(&egui::Context::default(), &mut third);
|
||||
assert!(third.events.is_empty(), "a consumed batch is never repeated");
|
||||
}
|
||||
Reference in New Issue
Block a user