diff --git a/crates/ui-egui/tests/it/bookmark_position.rs b/crates/ui-egui/tests/it/bookmark_position.rs index 03fd8b50a..09fd1d4be 100644 --- a/crates/ui-egui/tests/it/bookmark_position.rs +++ b/crates/ui-egui/tests/it/bookmark_position.rs @@ -55,21 +55,10 @@ fn harness(rotate: u16) -> Harness<'static, PdfCraftApp> { app }); h.run_steps(4); - settle(&mut h); + crate::support::settle(&mut h); h } -/// Run frames until rendering has finished and the layout holds still. -fn settle(h: &mut Harness<'static, PdfCraftApp>) { - for _ in 0..200 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } -} - /// Make a bookmark with ⌘B, as the Bookmarks button does, and say what it goes to. fn new_bookmark(h: &mut Harness<'static, PdfCraftApp>) -> (Option, DestView) { h.key_press_modifiers(Modifiers::COMMAND, Key::B); @@ -103,7 +92,7 @@ fn a_new_bookmark_goes_to_the_top_of_the_window_not_the_top_of_the_page() { h.state_mut().apply_edit(Edit::AddBookmark { parent: vec![], index: 0, title: "Mid".into(), page: 1, left: None, top: Some(250.0) }); h.run_steps(3); h.get_by_label("Mid").click(); - settle(&mut h); + crate::support::settle(&mut h); let (page, view) = new_bookmark(&mut h); assert_eq!(page, Some(1), "the page at the top of the window"); let (left, top) = xyz(view); @@ -151,7 +140,7 @@ fn a_bookmark_made_on_a_turned_page_pins_the_axis_the_window_scrolls_along() { fn rotate_view(h: &mut Harness<'static, PdfCraftApp>, degrees: u16) { h.state_mut().views[0].rotation = degrees; h.run_steps(3); - settle(h); + crate::support::settle(h); } #[test] @@ -216,12 +205,12 @@ fn a_bookmark_in_a_spread_of_unequal_pages_goes_to_the_page_crossing_the_top_of_ }); h.state_mut().views[0].layout = PageLayout::TwoUp; h.run_steps(4); - settle(&mut h); + crate::support::settle(&mut h); // 20 pt below page 2's top is at the window's top, where page 1 has not started. h.state_mut().apply_edit(Edit::AddBookmark { parent: vec![], index: 0, title: "Right".into(), page: 1, left: None, top: Some(380.0) }); h.run_steps(3); h.get_by_label("Right").click(); - settle(&mut h); + crate::support::settle(&mut h); let (page, view) = new_bookmark(&mut h); assert_eq!(page, Some(1), "page 2 crosses the top of the window; page 1 starts below it"); close(xyz(view).1, 380.0, "top"); diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 6194c7516..68b41ac98 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -40,20 +40,10 @@ fn harness_with_bytes(bytes: Vec, setup: impl FnOnce(&mut PdfCraftApp) + 'st app }); h.run_steps(4); - settle(&mut h); + crate::support::settle(&mut h); h } -fn settle(h: &mut Harness<'static, PdfCraftApp>) { - for _ in 0..200 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } -} - /// A point on page 1 in PDF user space → screen (no rotation; MediaBox 300×200). fn at(h: &Harness<'static, PdfCraftApp>, x: f32, y: f32) -> Pos2 { let r = h.state().views[0].page_screen_rect(0).expect("page 1 on screen"); @@ -146,7 +136,7 @@ fn sidebar_comment_centres_the_annotation_at_high_zoom() { && let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") { let _gpu = crate::gpu(); - settle(&mut h); + crate::support::settle(&mut h); h.render().unwrap().save(format!("{dir}/comment-centred.png")).unwrap(); } } @@ -754,7 +744,7 @@ fn a_dragged_text_box_follows_a_rotated_view() { } h.state_mut().set_option("zoom", "150").unwrap(); h.run_steps(3); - settle(&mut h); + crate::support::settle(&mut h); let screen = |h: &Harness<'static, PdfCraftApp>, x: f32, y: f32| { let r = h.state().views[0].page_screen_rect(0).expect("page 1 on screen"); let (u, v) = (x / 300.0, (200.0 - y) / 200.0); @@ -1726,7 +1716,7 @@ fn attachment_harness(setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> Harness app }); h.run_steps(4); - settle(&mut h); + crate::support::settle(&mut h); h } @@ -1915,7 +1905,7 @@ fn hovering_a_comment_shows_its_author_and_text() { app }); h.run_steps(4); - settle(&mut h); + crate::support::settle(&mut h); assert!(h.query_by_label_contains("Check this figure").is_none(), "no popup before hovering"); let p = at(&h, 100.0, 85.0); h.hover_at(p); @@ -1945,7 +1935,7 @@ fn preference_hides_author_only_highlight_hover_previews() { app }); h.run_steps(4); - settle(&mut h); + crate::support::settle(&mut h); let p = at(&h, 100.0, 85.0); // By default, author-only hover is shown. @@ -1992,7 +1982,7 @@ fn markup_hover_harness(pdf: Vec, view_rotation: u16) -> Harness<'static, Pd app }); h.run_steps(4); - settle(&mut h); + crate::support::settle(&mut h); h } diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs index 515e3e04f..dc13bdb61 100644 --- a/crates/ui-egui/tests/it/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -28,13 +28,7 @@ fn harness_bytes_with_step_dt(bytes: Vec, step_dt: f32) -> Harness<'static, app.set_option("zoom", "150").unwrap(); app }); - for _ in 0..60 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); h } @@ -161,13 +155,7 @@ fn text_tool_deleting_a_draft_hides_the_old_added_glyphs_and_cancel_restores_the click(&mut h, point); h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); h.key_press(egui::Key::Backspace); - for _ in 0..100 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); assert!(h.state().views[0].content.draft.as_ref().unwrap().text.is_empty()); let shot = h.render().unwrap(); for x in 105..140 { @@ -795,18 +783,6 @@ fn red(pixels: &image::RgbaImage, r: egui::Rect) -> usize { .count() } -/// Steps until no render is pending. -fn settle(h: &mut Harness<'static, PdfCraftApp>) { - for _ in 0..200 { - h.run_steps(1); - if !h.state().render_pending() { - return; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } - panic!("the page did not finish rendering"); -} - /// An added picture resized by a corner is drawn at its new size while dragged and after the /// release until the page is rendered again. It used to stay at its old size under the outline for /// the whole drag, and to show there again after the release. @@ -826,7 +802,7 @@ fn a_resized_added_picture_shows_only_at_its_new_size() { h.state_mut().add_image("red.png".into(), png); h.run_steps(3); assert_eq!(h.state().views[0].content.selected, Some((0, 0)), "the picture is selected"); - settle(&mut h); + crate::support::settle(&mut h); let rect = added(&h)[0].content.rect(); let r = item_on_screen(&h, rect); // The lower right corner to the middle: half the size, the upper left corner kept. @@ -841,7 +817,7 @@ fn a_resized_added_picture_shows_only_at_its_new_size() { h.hover_at(grip + (to - grip) * (k as f32 / 4.0)); h.run_steps(1); } - settle(&mut h); + crate::support::settle(&mut h); let pixels = h.render().unwrap(); assert!(red(&pixels, kept) > 100, "while dragged, the picture is drawn at its new size"); assert_eq!(red(&pixels, gone), 0, "while dragged, the picture shows at its old size"); @@ -856,7 +832,7 @@ fn a_resized_added_picture_shows_only_at_its_new_size() { } assert_ne!(added(&h)[0].content.rect(), rect, "the resize is applied"); h.state_mut().views[0].hold_rasters = false; - settle(&mut h); + crate::support::settle(&mut h); h.run_steps(2); let pixels = h.render().unwrap(); assert!(red(&pixels, kept) > 100 && red(&pixels, gone) == 0, "the page raster has the picture at its new size"); diff --git a/crates/ui-egui/tests/it/control.rs b/crates/ui-egui/tests/it/control.rs index 54bfe2fa3..ec29ecc97 100644 --- a/crates/ui-egui/tests/it/control.rs +++ b/crates/ui-egui/tests/it/control.rs @@ -73,13 +73,15 @@ fn harness_pages_at(pages: usize, width: f32, scale: f32) -> (Harness<'static, P /// Send a request and run frames until it is answered. fn call(h: &mut Harness<'static, PdfCraftApp>, c: &ControlClient, method: &str, params: Value) -> Reply { let rx = c.send(method, params); - for _ in 0..30 { - h.step(); - if let Ok(r) = rx.try_recv() { - return r; - } + let mut reply = None; + let answered = crate::support::step_until(h, |_| { + reply = rx.try_recv().ok(); + reply.is_some() + }); + match reply { + Some(r) if answered => r, + _ => panic!("{method}: no reply in 30 s"), } - panic!("{method}: no reply after 30 frames"); } fn ok(h: &mut Harness<'static, PdfCraftApp>, c: &ControlClient, method: &str, params: Value) -> Value { @@ -1674,15 +1676,12 @@ fn control_open_replies_pending_while_the_worker_opens_and_focuses_once_it_has() // The worker completes the open on later frames; the tab shows, and only then does a // repeated open focus it instead of reporting another pending open. let mut documents = 0; - for _ in 0..30 { - h.run_steps(1); - let state = ok(&mut h, &c, "ui.state", json!({})); + let opened = crate::support::step_until(&mut h, |h| { + let state = ok(h, &c, "ui.state", json!({})); documents = state["documents"].as_array().map(|d| d.len()).unwrap_or_default(); - if documents == 1 { - break; - } - } - assert_eq!(documents, 1, "the pending open completed into a tab"); + documents == 1 + }); + assert!(opened, "the pending open completed into a tab: {documents} tabs"); let again = ok(&mut h, &c, "ui.open", json!({ "path": a })); assert_eq!(again["focused"], true, "{again}"); diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 73464d33e..54b99d619 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -2826,13 +2826,7 @@ fn deleting_a_draft_does_not_show_the_original_text_underneath() { h.drop_at(at); h.run_steps(3); h.state_mut().views[0].line_editor.as_mut().unwrap().text.clear(); - for _ in 0..100 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); let shot = h.render().unwrap(); if let Ok(path) = std::env::var("PDFCRAFT_TEXT_PREVIEW_SHOT") { shot.save(path).unwrap(); @@ -2931,13 +2925,7 @@ fn opening_a_paragraph_leaves_the_page_as_it_is() { }); h.run_steps(4); assert!(h.state_mut().execute("edit.edit_text")); - for _ in 0..100 { - h.run_steps(1); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); let page = h.state().views[0].page_screen_rect(0).expect("on screen"); let k = page.width() / 300.0; let block = h.state().session.get(h.state().views[0].id).unwrap().text_blocks(0)[0].rect; @@ -3008,13 +2996,7 @@ fn a_quick_click_into_another_paragraph_keeps_both_in_place() { }; let (top, bottom) = (screen(&blocks[0]), screen(&blocks[1])); click_at(&mut h, top.center()); - for _ in 0..100 { - h.run_steps(1); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); assert_eq!(h.state().views[0].line_editor.as_ref().map(|e| e.text.as_str()), Some("Top")); let ink = |pixels: &image::RgbaImage, r: egui::Rect| { (r.top() as u32..r.bottom() as u32) @@ -4013,14 +3995,13 @@ fn save_paragraph_drag(h: &mut Harness<'static, PdfCraftApp>, name: &str, origin fn paragraph_drag_shot(h: &mut Harness<'static, PdfCraftApp>, name: &str) { if let Ok(directory) = std::env::var("PDFCRAFT_PARAGRAPH_DRAG_SHOTS") { - for _ in 0..60 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } - assert!(!h.state().render_pending(), "the screenshot must show the current document generation"); + assert!( + crate::support::step_until(h, |h| { + h.run_steps(1); + !h.state().render_pending() + }), + "the screenshot must show the current document generation" + ); let directory = std::path::Path::new(&directory); std::fs::create_dir_all(directory).unwrap(); h.render().unwrap().save(directory.join(name)).unwrap(); @@ -5170,14 +5151,13 @@ fn image_pixels_follow_the_frame_while_a_corner_is_dragged() { h.run_steps(4); // The dragged pixels are the page raster's: wait for it, as a slow runner may not have it yet // (the drag then drew no picture at all). - for _ in 0..400 { - h.run_steps(1); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } - assert!(!h.state().render_pending(), "the page raster arrives"); + assert!( + crate::support::step_until(&mut h, |h| { + h.run_steps(1); + !h.state().render_pending() + }), + "the page raster arrives" + ); let page = h.state().views[0].page_screen_rect(0).unwrap(); let at = |x: f32, y: f32| egui::pos2(page.left() + x / 200.0 * page.width(), page.top() + (300.0 - y) / 300.0 * page.height()); let p = at(95.0, 195.0); @@ -5876,13 +5856,7 @@ fn a_paragraph_running_off_its_page_edits_without_covering_the_next_page() { h.drag_at(p); h.run_steps(1); h.drop_at(p); - for _ in 0..60 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); assert!( h.state().views[0].line_editor.as_ref().is_some_and(|e| e.page == 0 && e.text.contains("line 50")), "the paragraph's off-page lines are in it" @@ -5971,15 +5945,13 @@ fn mode_tabs_keep_the_hand_over_page_text_scrolled_under_them() { h.run_steps(4); let top = h.state().views[0].page_screen_rect(0).expect("on screen").top(); let mut cursor = egui::CursorIcon::Default; - for _ in 0..50 { + let text_cursor = crate::support::step_until(&mut h, |h| { h.hover_at(text.center() + egui::vec2(0.0, top)); h.run_steps(2); cursor = h.output().platform_output.cursor_icon; - if cursor == egui::CursorIcon::Text { - break; - } - } - assert_eq!(cursor, egui::CursorIcon::Text); + cursor == egui::CursorIcon::Text + }); + assert!(text_cursor, "the I-beam over page text, not {cursor:?}"); assert_eq!(mode_tab_cursors(&mut h, text), HAND_ON_EVERY_TAB); } diff --git a/crates/ui-egui/tests/it/forms.rs b/crates/ui-egui/tests/it/forms.rs index 230addd65..676a16db4 100644 --- a/crates/ui-egui/tests/it/forms.rs +++ b/crates/ui-egui/tests/it/forms.rs @@ -14,13 +14,7 @@ fn harness() -> Harness<'static, PdfCraftApp> { app.set_option("zoom", "150").unwrap(); app }); - for _ in 0..60 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); h } @@ -500,8 +494,13 @@ fn wait_for_file(h: &mut Harness<'static, PdfCraftApp>, path: &std::path::Path) h.run_steps(1); } +/// A fresh directory of its own for each call. The tests share one process and run in parallel, +/// so a name alone is not enough: two tests that both used "same-frame" deleted each other's +/// directory mid-save, and the save then never wrote its file (#4186). fn scratch(name: &str) -> std::path::PathBuf { - let dir = std::env::temp_dir().join(format!("pdfcraft-forms-{name}-{}", std::process::id())); + static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("pdfcraft-forms-{name}-{}-{n}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); dir @@ -1029,13 +1028,7 @@ fn calculated_field_and_script_side_effect_on_another_page_re_render() { event: "calculate".into(), script: Some("event.value = 'from_' + getField('name').value;".into()), })); - for _ in 0..60 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); click_field(&mut h, "name", 0); h.event(egui::Event::Text("hello".into())); h.run_steps(2); @@ -1043,13 +1036,7 @@ fn calculated_field_and_script_side_effect_on_another_page_re_render() { h.run_steps(2); assert_eq!(value(&h, "page2_field"), ["from_hello"]); // Wait for all re-renders to complete. - for _ in 0..60 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); assert_eq!(value(&h, "name"), ["hello"]); assert_eq!(value(&h, "page2_field"), ["from_hello"]); } @@ -1065,13 +1052,7 @@ fn overlay_clears_after_fresh_tiles_zoom_then_edit_and_rejected_edit() { h.key_press(egui::Key::Tab); h.run_steps(2); assert_eq!(value(&h, "name"), ["GoodVal"]); - for _ in 0..60 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); assert!(!h.state().views[0].forms.unbaked.contains(&("name".to_string(), 0))); // 2. Rejected edit: typing rejected by validate script leaves unbaked clean. @@ -1091,13 +1072,7 @@ fn overlay_clears_after_fresh_tiles_zoom_then_edit_and_rejected_edit() { // 3. Zoom then edit: zoom to another scale, let tiles settle, then edit. h.state_mut().views[0].zoom_step(true); - for _ in 0..60 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); click_field(&mut h, "name", 0); h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); h.event(egui::Event::Text("ZoomedVal".into())); @@ -1105,13 +1080,7 @@ fn overlay_clears_after_fresh_tiles_zoom_then_edit_and_rejected_edit() { h.key_press(egui::Key::Tab); h.run_steps(2); assert_eq!(value(&h, "name"), ["ZoomedVal"]); - for _ in 0..60 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); assert!(!h.state().views[0].forms.unbaked.contains(&("name".to_string(), 0))); } diff --git a/crates/ui-egui/tests/it/object_selection.rs b/crates/ui-egui/tests/it/object_selection.rs index 6e428ad74..fc2a8e4fe 100644 --- a/crates/ui-egui/tests/it/object_selection.rs +++ b/crates/ui-egui/tests/it/object_selection.rs @@ -73,13 +73,7 @@ fn harness_document(clipping: bool, background: bool) -> Harness<'static, PdfCra assert!(app.execute("edit.edit_text")); app }); - for _ in 0..60 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); h } diff --git a/crates/ui-egui/tests/it/ocr.rs b/crates/ui-egui/tests/it/ocr.rs index 20fb73344..7f2e3bdc5 100644 --- a/crates/ui-egui/tests/it/ocr.rs +++ b/crates/ui-egui/tests/it/ocr.rs @@ -133,13 +133,7 @@ fn an_ocr_word_opens_for_editing_and_stays_open() { assert!(app.execute("edit.edit_text")); app }); - for _ in 0..60 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); let (r, k) = { let app = h.state(); let d = app.session.get(app.views[0].id).unwrap(); diff --git a/crates/ui-egui/tests/it/pickers.rs b/crates/ui-egui/tests/it/pickers.rs index e4ce56d7c..bbab087d9 100644 --- a/crates/ui-egui/tests/it/pickers.rs +++ b/crates/ui-egui/tests/it/pickers.rs @@ -115,9 +115,12 @@ fn a_pick_for_a_document_that_is_no_longer_active_is_dropped() { let _ = std::fs::remove_dir_all(&dir); } -/// A scratch folder for one test. +/// A scratch folder for one test, of its own for each call: the tests share one process and run +/// in parallel, so two that picked the same name would delete each other's folder (#4186). fn scratch(name: &str) -> std::path::PathBuf { - let dir = std::env::temp_dir().join(format!("pdfcraft-pickers-{name}-{}", std::process::id())); + static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("pdfcraft-pickers-{name}-{}-{n}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); dir diff --git a/crates/ui-egui/tests/it/prepare.rs b/crates/ui-egui/tests/it/prepare.rs index 106d2d539..bf0f6c68d 100644 --- a/crates/ui-egui/tests/it/prepare.rs +++ b/crates/ui-egui/tests/it/prepare.rs @@ -17,13 +17,7 @@ fn harness() -> Harness<'static, PdfCraftApp> { app.set_option("zoom", "150").unwrap(); app }); - for _ in 0..60 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); h } diff --git a/crates/ui-egui/tests/it/redact.rs b/crates/ui-egui/tests/it/redact.rs index c2d150e11..3e11861cb 100644 --- a/crates/ui-egui/tests/it/redact.rs +++ b/crates/ui-egui/tests/it/redact.rs @@ -14,13 +14,7 @@ fn harness() -> Harness<'static, PdfCraftApp> { app.set_option("zoom", "150").unwrap(); app }); - for _ in 0..60 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); h } diff --git a/crates/ui-egui/tests/it/support/mod.rs b/crates/ui-egui/tests/it/support/mod.rs index c4c0ee060..2e1e11ffb 100644 --- a/crates/ui-egui/tests/it/support/mod.rs +++ b/crates/ui-egui/tests/it/support/mod.rs @@ -2,21 +2,43 @@ use egui_kittest::Harness; use pdfcraft_ui_egui::PdfCraftApp; -pub fn wait_until(h: &mut Harness<'static, PdfCraftApp>, what: &str, ready: impl Fn(&PdfCraftApp) -> bool) { +/// Step frames until `ready` holds or 30 seconds pass, and say whether it held. For background work +/// (a worker-thread open, page text arriving) that a test then asserts on itself: a frame count is +/// not a time, and a busy runner may not have finished the work within one. Sleeps briefly between +/// frames so the worker threads get the CPU. +#[must_use] +pub fn step_until(h: &mut Harness<'static, PdfCraftApp>, mut ready: impl FnMut(&mut Harness<'static, PdfCraftApp>) -> bool) -> bool { let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); loop { h.step(); - if ready(h.state()) { - return; + if ready(h) { + return true; + } + if std::time::Instant::now() >= deadline { + return false; } - assert!( - std::time::Instant::now() < deadline, - "timed out waiting for {what}: views={}, active={:?}, password={:?}, toast={:?}", - h.state().views.len(), - h.state().active, - h.state().password_prompt.as_ref().map(|p| (&p.name, &p.error)), - h.state().toast - ); std::thread::sleep(std::time::Duration::from_millis(5)); } } + +/// Step frames until nothing is waiting to be rendered, and fail the test if that takes over 30 +/// seconds: what a test checks next is on the rendered page. +pub fn settle(h: &mut Harness<'static, PdfCraftApp>) { + let settled = step_until(h, |h| { + h.run_steps(1); + !h.state().render_pending() + }); + assert!(settled, "pages rendered"); +} + +pub fn wait_until(h: &mut Harness<'static, PdfCraftApp>, what: &str, ready: impl Fn(&PdfCraftApp) -> bool) { + let held = step_until(h, |h| ready(h.state())); + assert!( + held, + "timed out waiting for {what}: views={}, active={:?}, password={:?}, toast={:?}", + h.state().views.len(), + h.state().active, + h.state().password_prompt.as_ref().map(|p| (&p.name, &p.error)), + h.state().toast + ); +} diff --git a/crates/ui-egui/tests/it/ui.rs b/crates/ui-egui/tests/it/ui.rs index 6472b7c66..275cdab76 100644 --- a/crates/ui-egui/tests/it/ui.rs +++ b/crates/ui-egui/tests/it/ui.rs @@ -451,7 +451,7 @@ fn overflowing_tabs_can_be_reached_by_scrolling() { assert!(h.state().active.is_some_and(|i| i < 19)); if let Ok(dir) = std::env::var("PDFCRAFT_TAB_SHOTS") { let _gpu = crate::gpu(); - settle(&mut h); + crate::support::settle(&mut h); h.render().unwrap().save(format!("{dir}/overflow-tabs.png")).unwrap(); } } @@ -565,7 +565,7 @@ fn narrow_tab_strips_accept_vertical_wheel_scrolling() { assert!(h.get_by_label_contains("Display theme:").rect().right() <= width); if let Ok(dir) = std::env::var("PDFCRAFT_TAB_SHOTS") { let _gpu = crate::gpu(); - settle(&mut h); + crate::support::settle(&mut h); h.render().unwrap().save(format!("{dir}/tabs-{width}.png")).unwrap(); } } @@ -612,23 +612,13 @@ endstream endobj trailer << /Root 1 0 R >> %%EOF"; -fn settle(h: &mut Harness<'static, PdfCraftApp>) { - for _ in 0..200 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } -} - #[test] fn find_counts_matches_across_pages() { let mut h = harness(|app| { app.open_bytes("text.pdf", None, TEXT_FIXTURE.to_vec()).expect("opens"); app.set_option("find", "brown").unwrap(); }); - settle(&mut h); + crate::support::settle(&mut h); h.get_by_label_contains("of 2"); let v = &h.state().views[0]; let f = v.find.as_ref().expect("find open"); @@ -641,7 +631,7 @@ fn find_reports_no_matches() { app.open_bytes("text.pdf", None, TEXT_FIXTURE.to_vec()).expect("opens"); app.set_option("find", "zebra").unwrap(); }); - settle(&mut h); + crate::support::settle(&mut h); h.get_by_label("No matches"); } @@ -651,7 +641,7 @@ fn drag_selects_text_and_copy_returns_it() { app.open_bytes("text.pdf", None, TEXT_FIXTURE.to_vec()).expect("opens"); app.set_option("left", "closed").unwrap(); }); - settle(&mut h); + crate::support::settle(&mut h); // "The quick brown fox": glyph 4 = 'q', glyph 14 = 'n' of "brown". let (a, b) = { let v = &h.state().views[0]; @@ -738,7 +728,7 @@ fn zoom_keeps_the_point_under_the_cursor_still() { app.set_option("left", "closed").unwrap(); app.set_option("panel", "none").unwrap(); }); - settle(&mut h); + crate::support::settle(&mut h); let before = h.state().views[0].glyph_screen_pos(0, 4).expect("glyph on screen"); let z = h.state().views[0].zoom; h.state_mut().views[0].zoom_at(z * 2.0, before); @@ -754,7 +744,7 @@ fn trackpad_pinch_zooms_the_pdf_under_the_pointer() { app.set_option("left", "closed").unwrap(); app.set_option("panel", "none").unwrap(); }); - settle(&mut h); + crate::support::settle(&mut h); let before = h.state().views[0].glyph_screen_pos(0, 4).expect("glyph on screen"); let zoom = h.state().views[0].zoom; h.hover_at(before); @@ -781,7 +771,7 @@ fn selection_works_in_every_view_rotation() { // Keep the whole rotated page on screen. app.set_option("zoom", "60").unwrap(); }); - settle(&mut h); + crate::support::settle(&mut h); let (a, b) = { let v = &h.state().views[0]; (v.glyph_screen_pos(0, 4).expect("glyph"), v.glyph_screen_pos(0, 14).expect("glyph")) @@ -1321,7 +1311,7 @@ fn document_tabs_expose_selection_and_activate_from_keyboard() { } if let Ok(dir) = std::env::var("PDFCRAFT_TAB_SHOTS") { let _gpu = crate::gpu(); - settle(&mut h); + crate::support::settle(&mut h); h.render().unwrap().save(format!("{dir}/focused-tab-{key:?}.png")).unwrap(); } } @@ -1547,7 +1537,7 @@ fn short_window_rail_buttons_scroll_clear_of_page_navigation() { app.open_bytes("short.pdf", None, FIXTURE.to_vec()).unwrap(); app }); - settle(&mut h); + crate::support::settle(&mut h); let rail = |h: &Harness<'static, PdfCraftApp>, label: &str| { h.query_all_by_label(label).map(|n| n.rect()).find(|r| r.left() >= width - 48.0).unwrap_or_else(|| panic!("{label} in the rail")) }; diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index dc1d5585d..3520f0a98 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -1266,13 +1266,7 @@ fn highlight_fields_tints_the_field_area() { h.state_mut().set_option("panel", "none").unwrap(); // Highlighting is on by default; start from plain fields. h.state_mut().set_option("fields", "off").unwrap(); - for _ in 0..100 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); let r = rect(&h, 0).expect("page 1"); // The field spans x 50..250, y 300..330 (PDF space, y up) on a 300×400 page; sample inside it. let at = egui::pos2(r.min.x + r.width() * (60.0 / 300.0), r.min.y + r.height() * (1.0 - 305.0 / 400.0)); @@ -1409,13 +1403,7 @@ fn required_radio_buttons_get_a_round_red_border() { app.set_option("fields", "on").unwrap(); app }); - for _ in 0..100 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); let r = rect(&h, 0).expect("page 1"); let ppp = h.ctx.pixels_per_point(); let img = h.render().expect("renders"); @@ -1560,13 +1548,7 @@ fn required_fields_get_a_red_border_when_highlighting() { h.state_mut().set_option("fields", "on").unwrap(); let props = pdfcraft_engine::FieldProps { required: Some(true), ..Default::default() }; h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldProps { name: "fullname".into(), props: Box::new(props) }); - for _ in 0..100 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); let r = rect(&h, 0).expect("page 1"); // Just inside the left edge of the field (x 50, y 300..330 on a 300×400 page). let ppp = h.ctx.pixels_per_point(); @@ -1622,13 +1604,7 @@ trailer << /Root 1 0 R >> /// Mean and largest difference between page 1 on screen and the page rendered directly at the /// view's exact device scale, over the lined area of `page_raster_maps_one_to_one_onto_screen_pixels`. fn compare_page_with_raster(h: &mut Harness<'static, PdfCraftApp>, pdf: &[u8], zoom: f32) -> (f64, u8) { - for _ in 0..100 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(h); let ppp = h.ctx.pixels_per_point(); let r = rect(h, 0).expect("page 1"); let img = h.render().expect("renders"); @@ -1693,16 +1669,6 @@ fn grid_harness(pdf: Vec, zoom: &str) -> Harness<'static, PdfCraftApp> { }) } -fn settle(h: &mut Harness<'static, PdfCraftApp>) { - for _ in 0..400 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } -} - /// Detail in the middle of the window: the summed difference between neighbouring pixels. fn centre_detail(h: &mut Harness<'static, PdfCraftApp>) -> u64 { let img = h.render().expect("renders"); @@ -1720,7 +1686,7 @@ fn centre_detail(h: &mut Harness<'static, PdfCraftApp>) -> u64 { fn zooming_a_tiled_page_shows_its_earlier_tiles_until_new_ones_arrive() { let _gpu = gpu(); let mut h = grid_harness(fine_grid_pdf(), "300"); - settle(&mut h); + crate::support::settle(&mut h); let sharp = centre_detail(&mut h); // The first frame at a new zoom has no tiles for it yet (they are requested at its end). h.state_mut().set_option("zoom", "310").unwrap(); @@ -1736,13 +1702,13 @@ fn zooming_a_tiled_page_shows_its_earlier_tiles_until_new_ones_arrive() { fn returning_to_a_visited_zoom_reuses_its_cached_tiles() { let _gpu = gpu(); let mut h = grid_harness(fine_grid_pdf(), "300"); - settle(&mut h); + crate::support::settle(&mut h); let before = h.state().views[0].tiles_debug(0); assert!(!before.is_empty(), "the 300 % page should be tiled, but it cached no tiles"); // Zoom right out, where the 1200 pt page fits in one raster and is not tiled at all, so // none of its tiles is demanded... h.state_mut().set_option("zoom", "50").unwrap(); - settle(&mut h); + crate::support::settle(&mut h); // ...then back to the first zoom, which was already rendered sharp. h.state_mut().set_option("zoom", "300").unwrap(); h.step(); diff --git a/crates/ui-egui/tests/it/viewer.rs b/crates/ui-egui/tests/it/viewer.rs index a3c86fc52..8052e6a31 100644 --- a/crates/ui-egui/tests/it/viewer.rs +++ b/crates/ui-egui/tests/it/viewer.rs @@ -40,13 +40,7 @@ fn harness() -> Harness<'static, PdfCraftApp> { app }); // Background rendering and text extraction can take seconds on a loaded CI runner (FreeBSD VM). - for _ in 0..3000 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); h } @@ -1732,13 +1726,7 @@ fn a_picture_inside_form_artwork_under_text_is_selected_and_copied() { app.open_bytes("scan.pdf", None, pdf).unwrap(); app }); - for _ in 0..40 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); h.state_mut().system_clipboard = false; let i = h.state().active.unwrap(); let r = h.state().views[i].page_screen_rect(0).expect("on screen"); diff --git a/crates/ui-egui/tests/it/xfa.rs b/crates/ui-egui/tests/it/xfa.rs index cf0fc193b..6db98a52b 100644 --- a/crates/ui-egui/tests/it/xfa.rs +++ b/crates/ui-egui/tests/it/xfa.rs @@ -103,17 +103,6 @@ fn ordinary_forms_and_documents_have_no_xfa_notice() { assert_eq!(xfa(&h), Some(pdfcraft_render::Xfa::Dynamic)); } -/// Run frames until the pages are drawn (fields can be clicked then). -fn wait_for_pages(h: &mut Harness<'static, PdfCraftApp>) { - for _ in 0..40 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } -} - /// Click the centre of a field's widget. fn click_field(h: &mut Harness<'static, PdfCraftApp>, name: &str) { let p = { @@ -133,7 +122,7 @@ fn click_field(h: &mut Harness<'static, PdfCraftApp>, name: &str) { #[test] fn xfa_buttons_run_their_scripts_in_the_app() { let mut h = open(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template())); - wait_for_pages(&mut h); + crate::support::settle(&mut h); let names = |h: &Harness<'static, PdfCraftApp>| { let s = h.state(); s.session.get(s.views[0].id).unwrap().form.iter().map(|f| f.name.clone()).collect::>() @@ -189,7 +178,7 @@ fn a_form_whose_scripts_were_turned_off_at_open_says_so() { fn scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back() { let tpl = pdfcraft_xfa::fixtures::scripted_template().replace(r#"xfa.host.messageBox("Hello " + qty.rawValue);"#, RUNAWAY); let mut h = open(pdfcraft_xfa::fixtures::shell(&tpl)); - wait_for_pages(&mut h); + crate::support::settle(&mut h); // The form opens with its scripts on, and the user closes the notice bar. let id = h.state().views[0].id; assert!(!h.state().session.get(id).unwrap().xfa_scripts_off());