diff --git a/crates/ui-egui/src/region.rs b/crates/ui-egui/src/region.rs index 3994a950d..26552313e 100644 --- a/crates/ui-egui/src/region.rs +++ b/crates/ui-egui/src/region.rs @@ -445,641 +445,4 @@ pub fn window_for(full_w: usize, full_h: usize, visible: (f32, f32, f32, f32), m } #[cfg(test)] -mod tests { - use super::*; - use crate::state::AppSettings; - - const BIG: usize = 16384; - - fn sizes(drawn: f32, canvas: f32, native: usize) -> ViewSizes { - ViewSizes { drawn_long: drawn, canvas_long: canvas, native_long: native, texture_side: BIG, draft: None, windows: true } - } - - fn auto() -> AppSettings { - AppSettings::default() - } - - // Given a fit view on a Retina Mac (about 2800 px), the preview is enough: no original decoded - #[test] - fn a_retina_fit_view_needs_no_window() { - let p = plan(&auto(), sizes(2800.0, 2800.0, 6000)); - assert_eq!(p.main_edge, 2560, "the preview source level"); - assert_eq!(p.window_edge, None); - } - - // Given a 4K display, the fit view is 1.5x the preview: the window makes it sharp - #[test] - fn a_large_fit_view_gets_a_window() { - let p = plan(&auto(), sizes(3840.0, 3840.0, 6000)); - assert_eq!((p.main_edge, p.window_edge), (2560, Some(3840))); - } - - // Given 1:1 on a 24 MP photo in a 2800 px canvas, the whole frame stays canvas-sized and the - // window is cut from the photo's own pixels - #[test] - fn one_to_one_renders_the_canvas_and_a_native_window() { - let p = plan(&auto(), sizes(6000.0, 2800.0, 6000)); - assert_eq!((p.main_edge, p.window_edge), (2560, Some(6000))); - } - - // Given 800 %, the window is still at 100 % (the GPU magnifies it): the cost does not grow - #[test] - fn beyond_one_to_one_the_window_stays_at_native_resolution() { - let p = plan(&auto(), sizes(48000.0, 2800.0, 6000)); - assert_eq!((p.main_edge, p.window_edge), (2560, Some(6000))); - let q = plan(&auto(), sizes(480.0 * 100.0, 2800.0, 6000)); - assert_eq!(q.window_edge, Some(6000)); - } - - // Given a small photo zoomed in, the whole-frame render already holds all its pixels - #[test] - fn a_small_photo_needs_no_window() { - let p = plan(&auto(), sizes(8000.0, 1400.0, 1000)); - assert_eq!((p.main_edge, p.window_edge), (1000, None)); - } - - // Given a slider drag at 400 % (drafts at 0.6 scale), the window plan does not change: it is - // the whole-frame draft that gets cheaper - #[test] - fn a_drag_shrinks_the_whole_frame_draft_not_the_window() { - let still = plan(&auto(), sizes(24000.0, 2800.0, 6000)); - let drag = plan(&auto(), ViewSizes { draft: Some(0.6), ..sizes(24000.0, 2800.0, 6000) }); - assert!(drag.main_edge < still.main_edge, "{drag:?} vs {still:?}"); - assert_eq!(drag.window_edge, still.window_edge); - } - - // Given no window (a whole-frame diagnostic), the view at rest renders the - // whole frame at the size it is drawn, but a slider drag drafts at the canvas size like the - // windowed path - #[test] - fn without_a_window_a_drag_drafts_at_the_canvas_size() { - let no_window = |draft: Option| ViewSizes { windows: false, draft, ..sizes(24000.0, 2800.0, 6000) }; - let still = plan(&auto(), no_window(None)); - let drag = plan(&auto(), no_window(Some(0.6))); - assert_eq!(still.window_edge, None); - assert!(still.main_edge > 2800, "at rest the zoomed frame: {still:?}"); - assert!(drag.main_edge <= 2800, "a drag drafts at most the canvas: {drag:?}"); - } - - // Given a slider drag, the GPU renders it at full size and the CPU a smaller draft (#330) - #[test] - fn a_drag_shrinks_the_draft_only_on_the_cpu() { - use lightcraft_catalog::PhotoId; - let mut gpu = DragScale::default(); - assert_eq!(gpu.scale(None, true, None, 0.0), None, "no drag, no draft"); - assert_eq!(gpu.scale(Some((PhotoId(1), "Exposure")), true, None, 0.0), Some(1.0)); - let mut cpu = DragScale::default(); - assert_eq!(cpu.scale(Some((PhotoId(1), "Exposure")), false, None, 0.0), Some(CPU_DRAFT_SCALE)); - } - - // Given a drag that started before the GPU was ready, it keeps its size until it ends: the - // resolution never jumps in the middle of a drag - #[test] - fn a_drag_keeps_its_draft_size_until_it_ends() { - use lightcraft_catalog::PhotoId; - let mut d = DragScale::default(); - let drag = Some((PhotoId(1), "Exposure")); - assert_eq!(d.scale(drag, false, None, 0.0), Some(CPU_DRAFT_SCALE)); - assert_eq!(d.scale(drag, true, None, 0.0), Some(CPU_DRAFT_SCALE), "the GPU came up mid-drag"); - assert_eq!(d.scale(None, true, None, 0.0), None); - assert_eq!(d.scale(drag, true, None, 0.0), Some(1.0), "the next drag gets full size"); - // and the other way: the GPU lost mid-drag keeps the drag at full size until it ends - assert_eq!(d.scale(drag, false, None, 0.0), Some(1.0)); - } - - /// One whole drag of `drag` at full size whose drafts all take `ms` to show, ending at `now`. - fn timed_drag(d: &mut DragScale, drag: Option<(lightcraft_catalog::PhotoId, &str)>, ms: f64, now: f64) -> Option { - let scale = d.scale(drag, true, None, now); - for key in 1..=6 { - d.observe(key, ms, None); - } - d.scale(None, true, None, now); - scale - } - - // Given full-size drafts over the slider budget twice in a row (a LUT profile or refined mask - // the CPU renders, a photo past a GPU limit, a slow GPU), the next drag of that control on - // that photo drafts small; once could be a busy moment. Other controls and photos keep full size - #[test] - fn a_drag_over_budget_twice_drafts_small_the_next_time() { - use lightcraft_catalog::PhotoId; - let mut d = DragScale::default(); - let exposure = Some((PhotoId(1), "Exposure")); - assert_eq!(d.scale(exposure, true, Some(7), 0.0), Some(1.0)); - d.observe(7, 400.0, None); // the render from before the drag: not a draft of it - for (key, ms) in [(8, 300.0), (9, 30.0), (10, 25.0), (11, 9.0)] { - d.observe(key, ms, None); - d.observe(key, ms, None); // a render is timed once, however many frames show it - } - assert_eq!(d.scale(exposure, true, None, 0.0), Some(1.0), "over budget, but not far enough to give up mid-drag"); - assert_eq!(d.scale(None, true, None, 0.0), None); - assert_eq!(timed_drag(&mut d, exposure, 25.0, 1.0), Some(1.0), "one slow drag: maybe the machine was busy"); - assert_eq!(d.scale(exposure, true, None, 2.0), Some(CPU_DRAFT_SCALE)); - d.scale(None, true, None, 2.0); - assert_eq!(d.scale(Some((PhotoId(1), "Contrast")), true, None, 2.0), Some(1.0)); - assert_eq!(d.scale(Some((PhotoId(2), "Exposure")), true, None, 2.0), Some(1.0)); - } - - // Given a pair found slow, it drafts small only for a while: then a drag tries full size - // again; a slow retry doubles the wait (up to its cap), a fast one forgets the verdict - #[test] - fn a_slow_verdict_expires_and_is_retried() { - use lightcraft_catalog::PhotoId; - let mut d = DragScale::default(); - let drag = Some((PhotoId(1), "Exposure")); - timed_drag(&mut d, drag, 25.0, 0.0); - timed_drag(&mut d, drag, 25.0, 0.0); - assert_eq!(timed_drag(&mut d, drag, 25.0, RETRY_AFTER_S - 1.0), Some(CPU_DRAFT_SCALE), "slow until the wait is over"); - assert_eq!(timed_drag(&mut d, drag, 25.0, RETRY_AFTER_S), Some(1.0), "then a drag tries full size: still slow"); - let mut at = RETRY_AFTER_S; - let mut wait = RETRY_AFTER_S * 2.0; - for _ in 0..6 { - assert_eq!(timed_drag(&mut d, drag, 25.0, at + wait - 1.0), Some(CPU_DRAFT_SCALE), "the wait doubled to {wait} s"); - assert_eq!(timed_drag(&mut d, drag, 25.0, at + wait), Some(1.0), "a retry after {wait} s, slow again"); - at += wait; - wait = (wait * 2.0).min(RETRY_MAX_S); - } - assert_eq!(wait, RETRY_MAX_S, "the wait stops growing"); - assert_eq!(timed_drag(&mut d, drag, 8.0, at + wait), Some(1.0), "a retry, fast this time"); - assert_eq!(timed_drag(&mut d, drag, 25.0, at + wait + 1.0), Some(1.0), "forgotten: one slow drag is a suspicion again"); - assert_eq!(timed_drag(&mut d, drag, 25.0, at + wait + 2.0), Some(1.0), "suspect: still full size"); - assert_eq!(d.scale(drag, true, None, at + wait + 3.0), Some(CPU_DRAFT_SCALE), "slow from the start again: wait 30 s"); - } - - // Given a drag only just over budget, it doesn't count against the pair (it would flip between - // the two sizes); one clearly over does - #[test] - fn only_a_drag_clearly_over_budget_counts() { - use lightcraft_catalog::PhotoId; - let mut d = DragScale::default(); - let drag = Some((PhotoId(1), "Exposure")); - for t in 0..4 { - timed_drag(&mut d, drag, 18.0, f64::from(t)); - } - assert_eq!(d.scale(drag, true, None, 4.0), Some(1.0), "18 ms drafts: just over the 16 ms budget"); - d.scale(None, true, None, 4.0); - timed_drag(&mut d, drag, VERDICT_MS + 5.0, 5.0); - timed_drag(&mut d, drag, VERDICT_MS + 5.0, 6.0); - assert_eq!(d.scale(drag, true, None, 7.0), Some(CPU_DRAFT_SCALE)); - } - - // Given one slow drag while the machine was busy, then fast ones, the slider keeps full size - #[test] - fn a_busy_moment_doesnt_make_a_slider_draft_small() { - use lightcraft_catalog::PhotoId; - let mut d = DragScale::default(); - let drag = Some((PhotoId(1), "Exposure")); - timed_drag(&mut d, drag, 40.0, 0.0); - for t in 1..=3 { - assert_eq!(timed_drag(&mut d, drag, 8.0, f64::from(t)), Some(1.0)); - } - timed_drag(&mut d, drag, 40.0, 4.0); - assert_eq!(d.scale(drag, true, None, 5.0), Some(1.0), "the fast drags forgot the first slow one"); - } - - // Given a hostile host clock (NaN, infinite, going backwards), a drag never panics; a clock it - // can't read tries full size - #[test] - fn a_hostile_clock_never_panics() { - use lightcraft_catalog::PhotoId; - let mut d = DragScale::default(); - let drag = Some((PhotoId(1), "Exposure")); - timed_drag(&mut d, drag, 25.0, 100.0); - timed_drag(&mut d, drag, 25.0, 100.0); - assert_eq!(d.scale(drag, true, None, 50.0), Some(CPU_DRAFT_SCALE), "the clock went backwards: still slow"); - d.scale(None, true, None, 50.0); - assert_eq!(d.scale(drag, true, None, f64::NAN), Some(1.0)); - d.scale(None, true, None, f64::NAN); - for now in [f64::INFINITY, f64::NEG_INFINITY, f64::NAN, -1.0] { - timed_drag(&mut d, drag, 25.0, now); - } - } - - // Given full-size drafts within budget (the first one, rebuilding the stage caches, may be - // slow), the next drag stays at full size - #[test] - fn a_drag_within_budget_stays_full_size() { - use lightcraft_catalog::PhotoId; - let mut d = DragScale::default(); - let drag = Some((PhotoId(1), "Exposure")); - d.scale(drag, true, None, 0.0); - for (key, ms) in [(1, 120.0), (2, 4.0), (3, 6.0), (4, 30.0), (5, 5.0)] { - d.observe(key, ms, None); - } - d.scale(None, true, None, 0.0); - assert_eq!(d.scale(drag, true, None, 0.0), Some(1.0)); - // hostile times are ignored, never a panic - for (key, ms) in [(6, f64::NAN), (7, f64::INFINITY), (8, -1.0)] { - d.observe(key, ms, None); - } - d.scale(None, true, None, 0.0); - assert_eq!(d.scale(drag, true, None, 0.0), Some(1.0)); - } - - // Given renders within budget whose frames (the texture upload) take it over, the drag counts - // as over budget: a render time alone said 231 of 234 drafts fit while none showed in 16 ms - #[test] - fn a_drafts_time_includes_the_frame_that_shows_it() { - use lightcraft_catalog::PhotoId; - let mut d = DragScale::default(); - let drag = Some((PhotoId(1), "Exposure")); - for _ in 0..2 { - d.scale(drag, true, None, 0.0); - for key in 1..=5 { - d.observe(key, 8.0, Some(14.0)); // the previous frame, which uploaded the last draft - } - d.observe(5, 8.0, Some(14.0)); - d.scale(None, true, None, 0.0); - } - assert_eq!(d.scale(drag, true, None, 0.0), Some(CPU_DRAFT_SCALE), "8 ms renders + 14 ms frames: over budget"); - // the same renders without the host's frame times (headless) stay within budget - let mut headless = DragScale::default(); - headless.scale(drag, true, None, 0.0); - for _ in 0..2 { - timed_drag(&mut headless, drag, 8.0, 0.0); - } - assert_eq!(headless.scale(drag, true, None, 0.0), Some(1.0)); - // a hostile frame time counts as nothing, never a panic - let mut hostile = DragScale::default(); - hostile.scale(drag, true, None, 0.0); - for (key, frame) in [(1, f64::NAN), (2, f64::INFINITY), (3, -1.0), (4, f64::NAN)] { - hostile.observe(key, 8.0, Some(frame)); - } - hostile.scale(None, true, None, 0.0); - assert_eq!(hostile.scale(drag, true, None, 0.0), Some(1.0)); - } - - // Given more slow drags than are remembered, only the one remembered longest ago is forgotten: - // clearing them all would bring back a slow full-size drag for every pair - #[test] - fn a_full_slow_set_forgets_only_the_oldest_drag() { - use lightcraft_catalog::PhotoId; - let mut d = DragScale::default(); - for photo in 0..=VERDICTS_KEPT as u64 { - d.remember_slow(PhotoId(photo), "Exposure"); - } - assert_eq!(d.scale(Some((PhotoId(0), "Exposure")), true, None, 0.0), Some(1.0), "the oldest is forgotten"); - d.scale(None, true, None, 0.0); - for photo in [1, VERDICTS_KEPT as u64] { - assert_eq!(d.scale(Some((PhotoId(photo), "Exposure")), true, None, 0.0), Some(CPU_DRAFT_SCALE), "photo {photo}"); - d.scale(None, true, None, 0.0); - } - d.remember_slow(PhotoId(1), "Exposure"); // already remembered: nothing is forgotten - assert_eq!(d.scale(Some((PhotoId(2), "Exposure")), true, None, 0.0), Some(CPU_DRAFT_SCALE)); - } - - // Given draft frames that took seconds (the window hidden or occluded mid-drag), they count as - // unknown: fast renders don't mark the drag slow because the window wasn't drawing. Two such - // drags, since one slow drag only makes the pair suspect - #[test] - fn a_hidden_windows_frame_times_dont_mark_a_drag_slow() { - use lightcraft_catalog::PhotoId; - let mut d = DragScale::default(); - let drag = Some((PhotoId(1), "Exposure")); - for t in 0..2u32 { - assert_eq!(d.scale(drag, true, None, f64::from(t)), Some(1.0), "drag {}", t + 1); - for key in 1..=6 { - d.observe(u64::from(t) * 10 + key, 6.0, Some(4000.0)); - } - d.scale(None, true, None, f64::from(t)); - } - assert_eq!(d.scale(drag, true, None, 2.0), Some(1.0), "after two hidden-window drags: still full size"); - } - - // Given full-size drafts far over budget (a refined mask the CPU renders at 190 ms a step), - // the drag drops to the small draft after a few of them instead of at its end; after a second - // such drag, the next starts small - #[test] - fn a_drag_far_over_budget_drafts_small_before_it_ends() { - use lightcraft_catalog::PhotoId; - let mut d = DragScale::default(); - let drag = Some((PhotoId(1), "Exposure")); - assert_eq!(d.scale(drag, true, None, 0.0), Some(1.0)); - let mut scales = Vec::new(); - for key in 1..=8 { - d.observe(key, 190.0, None); - scales.push(d.scale(drag, true, None, 0.0)); - } - // drafts 1–4 are timed one frame late; the median of 2–4 decides on the fifth frame - assert_eq!(scales[..4], [Some(1.0); 4], "{scales:?}"); - assert_eq!(scales[4..], [Some(CPU_DRAFT_SCALE); 4], "{scales:?}"); - d.scale(None, true, None, 0.0); - assert_eq!(timed_drag(&mut d, drag, 190.0, 1.0), Some(1.0), "one such drag: maybe a busy moment"); - assert_eq!(d.scale(drag, true, None, 2.0), Some(CPU_DRAFT_SCALE)); - } - - // Given a full-size drag zoomed in (a window covers the view), the hidden whole-frame draft is - // the small one: at 1:1 on a Retina display it cost 7–9 ms a step for pixels nobody sees - #[test] - fn a_full_size_drag_under_a_window_drafts_the_whole_frame_small() { - let zoomed = sizes(24000.0, 2800.0, 6000); - let small = plan(&auto(), ViewSizes { draft: Some(CPU_DRAFT_SCALE), ..zoomed }); - let full = plan(&auto(), ViewSizes { draft: Some(1.0), ..zoomed }); - assert!(full.window_edge.is_some(), "{full:?}"); - assert_eq!(full, small); - // at fit without a window, full size stays full size - let fit = sizes(2800.0, 2800.0, 6000); - assert_eq!(plan(&auto(), ViewSizes { draft: Some(1.0), ..fit }), plan(&auto(), fit)); - } - - // Given a drag at fit at full size, the draft is as sharp as the render at rest - #[test] - fn a_full_size_drag_at_fit_keeps_the_resolution() { - for drawn in [1400.0, 2800.0] { - let still = plan(&auto(), sizes(drawn, drawn, 6000)); - let drag = plan(&auto(), ViewSizes { draft: Some(1.0), ..sizes(drawn, drawn, 6000) }); - assert_eq!(drag, still, "{drawn}"); - } - } - - // Given no window and a full-size drag, the draft is still at most the canvas: a drag at 1:1 - // must not render the photo's every pixel each frame - #[test] - fn a_full_size_drag_without_a_window_drafts_at_the_canvas_size() { - let drag = plan(&auto(), ViewSizes { windows: false, draft: Some(1.0), ..sizes(24000.0, 2800.0, 6000) }); - assert!(drag.main_edge <= 2816, "{drag:?}"); - } - - // Given the user capped the preview size, the whole-frame render obeys it, and a fit view - // stays on it (no original decoded); a zoomed view still gets its sharp window - #[test] - fn a_preview_limit_caps_the_whole_frame_render_and_spares_the_fit_view() { - let s = AppSettings { preview_limit: 1600, ..Default::default() }; - let fit = plan(&s, sizes(2800.0, 2800.0, 6000)); - assert_eq!((fit.main_edge, fit.window_edge), (1600, None)); - let zoomed = plan(&s, sizes(6000.0, 2800.0, 6000)); - assert_eq!((zoomed.main_edge, zoomed.window_edge), (1600, Some(6000))); - } - - // Given a GPU with 2048 px textures, the whole-frame render obeys it, the window frame need not - // (a window is cut to the texture side separately) - #[test] - fn the_texture_side_caps_the_whole_frame_render_only() { - let p = plan(&auto(), ViewSizes { texture_side: 2048, ..sizes(6000.0, 2800.0, 6000) }); - assert_eq!((p.main_edge, p.window_edge), (2048, Some(6000))); - } - - // Given a picture that is not the frame (an embedded JPEG cropped differently), no window - #[test] - fn a_window_needs_a_picture_with_the_frames_aspect() { - assert!(same_aspect(1.5, 1.5) && same_aspect(1.51, 1.5)); - assert!(!same_aspect(1.5, 1.0) && !same_aspect(f32::NAN, 1.0) && !same_aspect(1.0, 0.0)); - } - - // Given a drag at fit on a 4K or 5K display (where the fit view has a window), the window stays: - // it is only the whole-frame draft that gets smaller - #[test] - fn a_drag_at_fit_on_a_large_display_keeps_the_window() { - for drawn in [3300.0, 3840.0, 4096.0, 5120.0] { - let still = plan(&auto(), sizes(drawn, drawn, 6000)); - let drag = plan(&auto(), ViewSizes { draft: Some(0.6), ..sizes(drawn, drawn, 6000) }); - assert!(still.window_edge.is_some(), "{drawn}: {still:?}"); - assert_eq!(drag.window_edge, still.window_edge, "{drawn}"); - } - } - - // Given a mode whose picture can't be a window (a whole-frame diagnostic, or a - // spot read too far for one render), the whole-frame render is as big as the view - #[test] - fn without_windows_the_whole_frame_is_rendered_at_the_drawn_size() { - let p = plan(&auto(), ViewSizes { windows: false, ..sizes(6000.0, 2800.0, 6000) }); - assert_eq!((p.main_edge, p.window_edge), (6000, None)); - let fit = plan(&auto(), ViewSizes { windows: false, ..sizes(2800.0, 2800.0, 6000) }); - assert_eq!((fit.main_edge, fit.window_edge), (2816, None)); - let deep = plan(&auto(), ViewSizes { windows: false, ..sizes(48000.0, 2800.0, 6000) }); - assert_eq!(deep.main_edge, 6000, "never above the photo's own pixels"); - let small = plan(&auto(), ViewSizes { windows: false, texture_side: 2048, ..sizes(6000.0, 2800.0, 6000) }); - assert_eq!(small.main_edge, 2048); - } - - // Given a user who chose a preview size above the preview source level (3840 or 5120 on a big - // display), the whole-frame render is that big - #[test] - fn a_large_preview_limit_is_honoured() { - for limit in [3840u32, 5120] { - let s = AppSettings { preview_limit: limit, ..Default::default() }; - let p = plan(&s, sizes(5120.0, 5120.0, 6000)); - assert_eq!(p.main_edge, limit as usize, "{limit}"); - assert_eq!(p.window_edge, None, "a fit view at the limit needs no window"); - } - let s = AppSettings { preview_limit: 2560, ..Default::default() }; - assert_eq!(plan(&s, sizes(5120.0, 5120.0, 6000)).main_edge, 2560); - } - - // Given a medium format photo wider than 8192 px, 1:1 is its own pixels - #[test] - fn a_window_frame_can_be_as_big_as_the_photo() { - let p = plan(&auto(), sizes(40000.0, 2800.0, 11648)); - assert_eq!(p.window_edge, Some(11648)); - } - - // Given a drag at fit on a Retina Mac, the window does not switch on (it would flicker with the drag) - #[test] - fn a_drag_at_fit_does_not_switch_the_window_on() { - let p = plan(&auto(), ViewSizes { draft: Some(0.6), ..sizes(2800.0, 2800.0, 6000) }); - assert_eq!(p.window_edge, None); - } - - // Given a pinch from fit to 400 %, the sizes of the first frame hold until the gesture is quiet - #[test] - fn a_pinch_holds_the_render_sizes() { - use lightcraft_catalog::PhotoId; - let (fit, zoomed) = (plan(&auto(), sizes(1400.0, 1400.0, 6000)), plan(&auto(), sizes(24000.0, 1400.0, 6000))); - assert_ne!(fit, zoomed); - let mut hold = SizeHold::default(); - let p = PhotoId(1); - assert_eq!(hold.apply(p, 0.0, false, fit), fit); - // the pinch runs for a second: every frame would plan something else - for k in 1..60 { - assert_eq!(hold.apply(p, k as f64 / 60.0, true, zoomed), fit, "frame {k}"); - } - // it stopped at t = 59/60: still held for a moment (no flicker between two events)… - assert!(hold.holding(1.1)); - assert_eq!(hold.apply(p, 1.1, false, zoomed), fit); - // …then the view is planned for where it ended - assert!(!hold.holding(59.0 / 60.0 + HOLD_SECS + 0.01)); - assert_eq!(hold.apply(p, 1.3, false, zoomed), zoomed); - assert_eq!(hold.apply(p, 1.4, false, zoomed), zoomed); - } - - // Given a pinch, the display-fit size holds like the render sizes and is retaken after it - #[test] - fn a_pinch_holds_the_display_fit_size() { - use lightcraft_catalog::PhotoId; - let (fit, zoomed) = (plan(&auto(), sizes(1400.0, 1400.0, 6000)), plan(&auto(), sizes(24000.0, 1400.0, 6000))); - let mut hold = SizeHold::default(); - let p = PhotoId(1); - hold.apply(p, 0.0, false, fit); - assert_eq!(hold.display_fit(0.0, false, [1000, 800]), [1000, 800]); - for k in 1..60 { - let now = k as f64 / 60.0; - hold.apply(p, now, true, zoomed); - assert_eq!(hold.display_fit(now, true, [1000 + k, 800 + k]), [1000, 800], "frame {k}"); - } - // stopped at t = 59/60: still held within the hold time… - hold.apply(p, 1.1, false, zoomed); - assert_eq!(hold.display_fit(1.1, false, [2560, 2048]), [1000, 800]); - // …then the size it ended at is taken, and kept while nothing moves - hold.apply(p, 1.3, false, zoomed); - assert_eq!(hold.display_fit(1.3, false, [2560, 2048]), [2560, 2048]); - assert_eq!(hold.display_fit(1.4, false, [2561, 2049]), [2561, 2049]); - // another photo forgets the held size - hold.apply(p, 1.5, true, zoomed); - hold.display_fit(1.5, true, [2561, 2049]); - hold.apply(PhotoId(2), 1.6, true, zoomed); - assert_eq!(hold.display_fit(1.6, true, [640, 480]), [640, 480]); - } - - // Given another photo, the held sizes are forgotten - #[test] - fn another_photo_is_planned_afresh() { - use lightcraft_catalog::PhotoId; - let (fit, zoomed) = (plan(&auto(), sizes(1400.0, 1400.0, 6000)), plan(&auto(), sizes(24000.0, 1400.0, 6000))); - let mut hold = SizeHold::default(); - hold.apply(PhotoId(1), 0.0, false, fit); - assert_eq!(hold.apply(PhotoId(2), 0.1, true, zoomed), zoomed); - } - - // Given side by side, a window is drawn in its pane only; given a wipe, on its side of the line - #[test] - fn a_window_is_clipped_to_its_pane_or_its_side_of_the_wipe() { - use crate::state::BeforeAfter::*; - let canvas = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1000.0, 600.0)); - let (left, right) = ( - egui::Rect::from_min_max(egui::pos2(24.0, 24.0), egui::pos2(494.0, 576.0)), - egui::Rect::from_min_max(egui::pos2(506.0, 24.0), egui::pos2(976.0, 576.0)), - ); - assert_eq!(window_clip(SideBySide, true, left, canvas, left), left); - assert_eq!(window_clip(SideBySide, false, right, canvas, right), right); - let img = egui::Rect::from_min_max(egui::pos2(100.0, 50.0), egui::pos2(900.0, 550.0)); - let before = window_clip(Split, true, canvas, canvas, img); - let after = window_clip(Split, false, canvas, canvas, img); - assert_eq!((before.max.x, after.min.x), (500.0, 500.0), "the line is the image's middle"); - assert_eq!(window_clip(SplitTopBottom, true, canvas, canvas, img).max.y, 300.0); - assert_eq!(window_clip(SplitTopBottom, false, canvas, canvas, img).min.y, 300.0); - // zoomed in, the photo's middle (x = 1600) is off screen: the line is the view's middle - let zoomed = egui::Rect::from_min_max(egui::pos2(-200.0, -900.0), egui::pos2(3400.0, 1500.0)); - assert_eq!(window_clip(Split, false, canvas, canvas, zoomed).min.x, 500.0); - assert_eq!(window_clip(SplitTopBottom, true, canvas, canvas, zoomed).max.y, 300.0); - assert_eq!(window_clip(Off, false, canvas, canvas, img), canvas); - assert_eq!(window_clip(Original, true, canvas, canvas, img), canvas); - } - - // Given a window asked for while another is rendering and the original isn't decoded yet, it - // waits for the decoded original instead of decoding another copy - #[test] - fn a_window_waits_for_the_original_another_is_decoding() { - assert!(defer_window(true, false)); - assert!(!defer_window(true, true), "the original is in"); - assert!(!defer_window(false, false), "nothing is decoding it"); - } - - // Hostile numbers give a plan, never a panic - #[test] - fn hostile_sizes_give_a_usable_plan() { - for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -3.0, 0.0] { - let p = plan(&auto(), ViewSizes { drawn_long: v, canvas_long: v, draft: Some(v), ..sizes(1.0, 1.0, 6000) }); - assert!((8..=2560).contains(&p.main_edge), "{v}: {p:?}"); - } - let p = plan(&auto(), sizes(1000.0, 1000.0, 0)); - assert!(p.main_edge >= 8); - } - - // Given a visible area in the middle of a big frame, the window covers it with a margin, on the grid - #[test] - fn the_window_covers_the_visible_area_with_a_margin_on_the_grid() { - let w = window_for(48_000, 32_000, (20_000.0, 10_000.0, 21_400.0, 10_900.0), MAX_SPAN).unwrap(); - let m = margin_for(48_000); - assert!(w.x + m <= 20_000 && w.x + w.w >= 21_400 + m, "{w:?}"); - assert!(w.y + m <= 10_000 && w.y + w.h >= 10_900 + m, "{w:?}"); - assert_eq!((w.x % SNAP, w.y % SNAP, w.w % SNAP, w.h % SNAP), (0, 0, 0, 0)); - } - - // Given a small pan, the same window is kept (no re-render); a big pan changes it - #[test] - fn small_pans_keep_the_window() { - let a = window_for(48_000, 32_000, (20_000.0, 10_000.0, 21_400.0, 10_900.0), MAX_SPAN).unwrap(); - let b = window_for(48_000, 32_000, (20_040.0, 10_030.0, 21_440.0, 10_930.0), MAX_SPAN).unwrap(); - assert_eq!(a, b); - let c = window_for(48_000, 32_000, (26_000.0, 10_000.0, 27_400.0, 10_900.0), MAX_SPAN).unwrap(); - assert_ne!(a, c); - } - - // Given a frame smaller than the margin, or a view at its edge, the window is clamped into it - #[test] - fn the_window_stays_inside_the_frame() { - let w = window_for(3000, 2000, (-500.0, -500.0, 900.0, 700.0), MAX_SPAN).unwrap(); - assert_eq!((w.x, w.y), (0, 0)); - let w = window_for(3000, 2000, (2500.0, 1500.0, 9000.0, 9000.0), MAX_SPAN).unwrap(); - assert!(w.x + w.w <= 3000 && w.y + w.h <= 2000, "{w:?}"); - assert_eq!((w.x + w.w, w.y + w.h), (3000, 2000)); - } - - // Given a deeper zoom, the margin grows with the wide stages' kernels (3σ of the base, clarity - // and dehaze planes are 0.045 of the frame), up to a cap that bounds the window - #[test] - fn the_margin_grows_with_the_zoom_up_to_a_cap() { - assert_eq!(margin_for(1000), SNAP, "never less than one grid step"); - assert!(margin_for(8000) > margin_for(3000)); - assert_eq!(margin_for(48_000), 2304, "4.5 % of an 8:1 frame, under the cap"); - assert_eq!(margin_for(80_000), MAX_MARGIN); - assert_eq!(margin_for(0), SNAP); - assert!(margin_for(usize::MAX) <= MAX_MARGIN); - for l in [100, 3000, 8000, 48_000, 400_000] { - assert_eq!(margin_for(l) % SNAP, 0); - } - } - - // Given a tile rendered for other settings, another photo or another zoom, it is not drawn - #[test] - fn a_tile_is_drawn_only_while_it_shows_what_the_loupe_shows() { - use lightcraft_catalog::PhotoId; - let v = RegionView { - photo: PhotoId(1), - before: false, - key: 7, - full: (6000, 4000), - window: PixelWindow { x: 0, y: 0, w: 256, h: 256 }, - settings: 11, - }; - assert!(v.is_current(PhotoId(1), (6000, 4000), 11, false)); - assert!(!v.is_current(PhotoId(2), (6000, 4000), 11, false), "another photo"); - assert!(!v.is_current(PhotoId(1), (6000, 4001), 11, false), "another zoom"); - assert!(!v.is_current(PhotoId(1), (6000, 4000), 12, false), "another look (an edit, an undo)"); - assert!(v.is_current(PhotoId(1), (6000, 4000), 12, true), "a drag's drafts follow the value"); - assert!(!v.is_current(PhotoId(2), (6000, 4000), 12, true), "…of this photo only"); - } - - // Given a GPU that allows 2048 px textures, no window is wider than that - #[test] - fn a_window_fits_the_texture_side() { - for tex in [512, 2048, 4096] { - let span = max_span(tex); - assert!(span <= tex.max(SNAP) && span.is_multiple_of(SNAP), "{tex}: {span}"); - let w = window_for(48_000, 32_000, (20_000.0, 10_000.0, 21_400.0, 10_900.0), span).unwrap(); - assert!(w.w <= span && w.h <= span, "{tex}: {w:?}"); - } - assert_eq!(max_span(100_000), MAX_SPAN); - assert_eq!(max_span(0), SNAP); - } - - // Given nothing visible, or hostile numbers, there is no window (never a panic) - #[test] - fn nothing_visible_or_hostile_input_is_no_window() { - assert_eq!(window_for(1000, 1000, (2000.0, 0.0, 3000.0, 100.0), MAX_SPAN), None); - assert_eq!(window_for(1000, 1000, (10.0, 10.0, 10.0, 90.0), MAX_SPAN), None); - assert_eq!(window_for(0, 1000, (0.0, 0.0, 10.0, 10.0), MAX_SPAN), None); - for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { - assert_eq!(window_for(1000, 1000, (v, 0.0, 10.0, 10.0), MAX_SPAN), None); - } - let w = window_for(usize::MAX / 2, usize::MAX / 2, (0.0, 0.0, 1e30, 1e30), MAX_SPAN).unwrap(); - assert!(w.w <= MAX_SPAN && w.h <= MAX_SPAN, "{w:?}"); - } - - // Given a view of an enormous area, the window is bounded - #[test] - fn a_window_is_bounded() { - let w = window_for(100_000, 100_000, (0.0, 0.0, 90_000.0, 90_000.0), MAX_SPAN).unwrap(); - assert!(w.w <= MAX_SPAN && w.h <= MAX_SPAN); - } -} +mod tests; diff --git a/crates/ui-egui/src/region/tests.rs b/crates/ui-egui/src/region/tests.rs new file mode 100644 index 000000000..c4558c1d8 --- /dev/null +++ b/crates/ui-egui/src/region/tests.rs @@ -0,0 +1,636 @@ +use super::*; +use crate::state::AppSettings; + +const BIG: usize = 16384; + +fn sizes(drawn: f32, canvas: f32, native: usize) -> ViewSizes { + ViewSizes { drawn_long: drawn, canvas_long: canvas, native_long: native, texture_side: BIG, draft: None, windows: true } +} + +fn auto() -> AppSettings { + AppSettings::default() +} + +// Given a fit view on a Retina Mac (about 2800 px), the preview is enough: no original decoded +#[test] +fn a_retina_fit_view_needs_no_window() { + let p = plan(&auto(), sizes(2800.0, 2800.0, 6000)); + assert_eq!(p.main_edge, 2560, "the preview source level"); + assert_eq!(p.window_edge, None); +} + +// Given a 4K display, the fit view is 1.5x the preview: the window makes it sharp +#[test] +fn a_large_fit_view_gets_a_window() { + let p = plan(&auto(), sizes(3840.0, 3840.0, 6000)); + assert_eq!((p.main_edge, p.window_edge), (2560, Some(3840))); +} + +// Given 1:1 on a 24 MP photo in a 2800 px canvas, the whole frame stays canvas-sized and the +// window is cut from the photo's own pixels +#[test] +fn one_to_one_renders_the_canvas_and_a_native_window() { + let p = plan(&auto(), sizes(6000.0, 2800.0, 6000)); + assert_eq!((p.main_edge, p.window_edge), (2560, Some(6000))); +} + +// Given 800 %, the window is still at 100 % (the GPU magnifies it): the cost does not grow +#[test] +fn beyond_one_to_one_the_window_stays_at_native_resolution() { + let p = plan(&auto(), sizes(48000.0, 2800.0, 6000)); + assert_eq!((p.main_edge, p.window_edge), (2560, Some(6000))); + let q = plan(&auto(), sizes(480.0 * 100.0, 2800.0, 6000)); + assert_eq!(q.window_edge, Some(6000)); +} + +// Given a small photo zoomed in, the whole-frame render already holds all its pixels +#[test] +fn a_small_photo_needs_no_window() { + let p = plan(&auto(), sizes(8000.0, 1400.0, 1000)); + assert_eq!((p.main_edge, p.window_edge), (1000, None)); +} + +// Given a slider drag at 400 % (drafts at 0.6 scale), the window plan does not change: it is +// the whole-frame draft that gets cheaper +#[test] +fn a_drag_shrinks_the_whole_frame_draft_not_the_window() { + let still = plan(&auto(), sizes(24000.0, 2800.0, 6000)); + let drag = plan(&auto(), ViewSizes { draft: Some(0.6), ..sizes(24000.0, 2800.0, 6000) }); + assert!(drag.main_edge < still.main_edge, "{drag:?} vs {still:?}"); + assert_eq!(drag.window_edge, still.window_edge); +} + +// Given no window (a whole-frame diagnostic), the view at rest renders the +// whole frame at the size it is drawn, but a slider drag drafts at the canvas size like the +// windowed path +#[test] +fn without_a_window_a_drag_drafts_at_the_canvas_size() { + let no_window = |draft: Option| ViewSizes { windows: false, draft, ..sizes(24000.0, 2800.0, 6000) }; + let still = plan(&auto(), no_window(None)); + let drag = plan(&auto(), no_window(Some(0.6))); + assert_eq!(still.window_edge, None); + assert!(still.main_edge > 2800, "at rest the zoomed frame: {still:?}"); + assert!(drag.main_edge <= 2800, "a drag drafts at most the canvas: {drag:?}"); +} + +// Given a slider drag, the GPU renders it at full size and the CPU a smaller draft (#330) +#[test] +fn a_drag_shrinks_the_draft_only_on_the_cpu() { + use lightcraft_catalog::PhotoId; + let mut gpu = DragScale::default(); + assert_eq!(gpu.scale(None, true, None, 0.0), None, "no drag, no draft"); + assert_eq!(gpu.scale(Some((PhotoId(1), "Exposure")), true, None, 0.0), Some(1.0)); + let mut cpu = DragScale::default(); + assert_eq!(cpu.scale(Some((PhotoId(1), "Exposure")), false, None, 0.0), Some(CPU_DRAFT_SCALE)); +} + +// Given a drag that started before the GPU was ready, it keeps its size until it ends: the +// resolution never jumps in the middle of a drag +#[test] +fn a_drag_keeps_its_draft_size_until_it_ends() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + assert_eq!(d.scale(drag, false, None, 0.0), Some(CPU_DRAFT_SCALE)); + assert_eq!(d.scale(drag, true, None, 0.0), Some(CPU_DRAFT_SCALE), "the GPU came up mid-drag"); + assert_eq!(d.scale(None, true, None, 0.0), None); + assert_eq!(d.scale(drag, true, None, 0.0), Some(1.0), "the next drag gets full size"); + // and the other way: the GPU lost mid-drag keeps the drag at full size until it ends + assert_eq!(d.scale(drag, false, None, 0.0), Some(1.0)); +} + +/// One whole drag of `drag` at full size whose drafts all take `ms` to show, ending at `now`. +fn timed_drag(d: &mut DragScale, drag: Option<(lightcraft_catalog::PhotoId, &str)>, ms: f64, now: f64) -> Option { + let scale = d.scale(drag, true, None, now); + for key in 1..=6 { + d.observe(key, ms, None); + } + d.scale(None, true, None, now); + scale +} + +// Given full-size drafts over the slider budget twice in a row (a LUT profile or refined mask +// the CPU renders, a photo past a GPU limit, a slow GPU), the next drag of that control on +// that photo drafts small; once could be a busy moment. Other controls and photos keep full size +#[test] +fn a_drag_over_budget_twice_drafts_small_the_next_time() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let exposure = Some((PhotoId(1), "Exposure")); + assert_eq!(d.scale(exposure, true, Some(7), 0.0), Some(1.0)); + d.observe(7, 400.0, None); // the render from before the drag: not a draft of it + for (key, ms) in [(8, 300.0), (9, 30.0), (10, 25.0), (11, 9.0)] { + d.observe(key, ms, None); + d.observe(key, ms, None); // a render is timed once, however many frames show it + } + assert_eq!(d.scale(exposure, true, None, 0.0), Some(1.0), "over budget, but not far enough to give up mid-drag"); + assert_eq!(d.scale(None, true, None, 0.0), None); + assert_eq!(timed_drag(&mut d, exposure, 25.0, 1.0), Some(1.0), "one slow drag: maybe the machine was busy"); + assert_eq!(d.scale(exposure, true, None, 2.0), Some(CPU_DRAFT_SCALE)); + d.scale(None, true, None, 2.0); + assert_eq!(d.scale(Some((PhotoId(1), "Contrast")), true, None, 2.0), Some(1.0)); + assert_eq!(d.scale(Some((PhotoId(2), "Exposure")), true, None, 2.0), Some(1.0)); +} + +// Given a pair found slow, it drafts small only for a while: then a drag tries full size +// again; a slow retry doubles the wait (up to its cap), a fast one forgets the verdict +#[test] +fn a_slow_verdict_expires_and_is_retried() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + timed_drag(&mut d, drag, 25.0, 0.0); + timed_drag(&mut d, drag, 25.0, 0.0); + assert_eq!(timed_drag(&mut d, drag, 25.0, RETRY_AFTER_S - 1.0), Some(CPU_DRAFT_SCALE), "slow until the wait is over"); + assert_eq!(timed_drag(&mut d, drag, 25.0, RETRY_AFTER_S), Some(1.0), "then a drag tries full size: still slow"); + let mut at = RETRY_AFTER_S; + let mut wait = RETRY_AFTER_S * 2.0; + for _ in 0..6 { + assert_eq!(timed_drag(&mut d, drag, 25.0, at + wait - 1.0), Some(CPU_DRAFT_SCALE), "the wait doubled to {wait} s"); + assert_eq!(timed_drag(&mut d, drag, 25.0, at + wait), Some(1.0), "a retry after {wait} s, slow again"); + at += wait; + wait = (wait * 2.0).min(RETRY_MAX_S); + } + assert_eq!(wait, RETRY_MAX_S, "the wait stops growing"); + assert_eq!(timed_drag(&mut d, drag, 8.0, at + wait), Some(1.0), "a retry, fast this time"); + assert_eq!(timed_drag(&mut d, drag, 25.0, at + wait + 1.0), Some(1.0), "forgotten: one slow drag is a suspicion again"); + assert_eq!(timed_drag(&mut d, drag, 25.0, at + wait + 2.0), Some(1.0), "suspect: still full size"); + assert_eq!(d.scale(drag, true, None, at + wait + 3.0), Some(CPU_DRAFT_SCALE), "slow from the start again: wait 30 s"); +} + +// Given a drag only just over budget, it doesn't count against the pair (it would flip between +// the two sizes); one clearly over does +#[test] +fn only_a_drag_clearly_over_budget_counts() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + for t in 0..4 { + timed_drag(&mut d, drag, 18.0, f64::from(t)); + } + assert_eq!(d.scale(drag, true, None, 4.0), Some(1.0), "18 ms drafts: just over the 16 ms budget"); + d.scale(None, true, None, 4.0); + timed_drag(&mut d, drag, VERDICT_MS + 5.0, 5.0); + timed_drag(&mut d, drag, VERDICT_MS + 5.0, 6.0); + assert_eq!(d.scale(drag, true, None, 7.0), Some(CPU_DRAFT_SCALE)); +} + +// Given one slow drag while the machine was busy, then fast ones, the slider keeps full size +#[test] +fn a_busy_moment_doesnt_make_a_slider_draft_small() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + timed_drag(&mut d, drag, 40.0, 0.0); + for t in 1..=3 { + assert_eq!(timed_drag(&mut d, drag, 8.0, f64::from(t)), Some(1.0)); + } + timed_drag(&mut d, drag, 40.0, 4.0); + assert_eq!(d.scale(drag, true, None, 5.0), Some(1.0), "the fast drags forgot the first slow one"); +} + +// Given a hostile host clock (NaN, infinite, going backwards), a drag never panics; a clock it +// can't read tries full size +#[test] +fn a_hostile_clock_never_panics() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + timed_drag(&mut d, drag, 25.0, 100.0); + timed_drag(&mut d, drag, 25.0, 100.0); + assert_eq!(d.scale(drag, true, None, 50.0), Some(CPU_DRAFT_SCALE), "the clock went backwards: still slow"); + d.scale(None, true, None, 50.0); + assert_eq!(d.scale(drag, true, None, f64::NAN), Some(1.0)); + d.scale(None, true, None, f64::NAN); + for now in [f64::INFINITY, f64::NEG_INFINITY, f64::NAN, -1.0] { + timed_drag(&mut d, drag, 25.0, now); + } +} + +// Given full-size drafts within budget (the first one, rebuilding the stage caches, may be +// slow), the next drag stays at full size +#[test] +fn a_drag_within_budget_stays_full_size() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + d.scale(drag, true, None, 0.0); + for (key, ms) in [(1, 120.0), (2, 4.0), (3, 6.0), (4, 30.0), (5, 5.0)] { + d.observe(key, ms, None); + } + d.scale(None, true, None, 0.0); + assert_eq!(d.scale(drag, true, None, 0.0), Some(1.0)); + // hostile times are ignored, never a panic + for (key, ms) in [(6, f64::NAN), (7, f64::INFINITY), (8, -1.0)] { + d.observe(key, ms, None); + } + d.scale(None, true, None, 0.0); + assert_eq!(d.scale(drag, true, None, 0.0), Some(1.0)); +} + +// Given renders within budget whose frames (the texture upload) take it over, the drag counts +// as over budget: a render time alone said 231 of 234 drafts fit while none showed in 16 ms +#[test] +fn a_drafts_time_includes_the_frame_that_shows_it() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + for _ in 0..2 { + d.scale(drag, true, None, 0.0); + for key in 1..=5 { + d.observe(key, 8.0, Some(14.0)); // the previous frame, which uploaded the last draft + } + d.observe(5, 8.0, Some(14.0)); + d.scale(None, true, None, 0.0); + } + assert_eq!(d.scale(drag, true, None, 0.0), Some(CPU_DRAFT_SCALE), "8 ms renders + 14 ms frames: over budget"); + // the same renders without the host's frame times (headless) stay within budget + let mut headless = DragScale::default(); + headless.scale(drag, true, None, 0.0); + for _ in 0..2 { + timed_drag(&mut headless, drag, 8.0, 0.0); + } + assert_eq!(headless.scale(drag, true, None, 0.0), Some(1.0)); + // a hostile frame time counts as nothing, never a panic + let mut hostile = DragScale::default(); + hostile.scale(drag, true, None, 0.0); + for (key, frame) in [(1, f64::NAN), (2, f64::INFINITY), (3, -1.0), (4, f64::NAN)] { + hostile.observe(key, 8.0, Some(frame)); + } + hostile.scale(None, true, None, 0.0); + assert_eq!(hostile.scale(drag, true, None, 0.0), Some(1.0)); +} + +// Given more slow drags than are remembered, only the one remembered longest ago is forgotten: +// clearing them all would bring back a slow full-size drag for every pair +#[test] +fn a_full_slow_set_forgets_only_the_oldest_drag() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + for photo in 0..=VERDICTS_KEPT as u64 { + d.remember_slow(PhotoId(photo), "Exposure"); + } + assert_eq!(d.scale(Some((PhotoId(0), "Exposure")), true, None, 0.0), Some(1.0), "the oldest is forgotten"); + d.scale(None, true, None, 0.0); + for photo in [1, VERDICTS_KEPT as u64] { + assert_eq!(d.scale(Some((PhotoId(photo), "Exposure")), true, None, 0.0), Some(CPU_DRAFT_SCALE), "photo {photo}"); + d.scale(None, true, None, 0.0); + } + d.remember_slow(PhotoId(1), "Exposure"); // already remembered: nothing is forgotten + assert_eq!(d.scale(Some((PhotoId(2), "Exposure")), true, None, 0.0), Some(CPU_DRAFT_SCALE)); +} + +// Given draft frames that took seconds (the window hidden or occluded mid-drag), they count as +// unknown: fast renders don't mark the drag slow because the window wasn't drawing. Two such +// drags, since one slow drag only makes the pair suspect +#[test] +fn a_hidden_windows_frame_times_dont_mark_a_drag_slow() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + for t in 0..2u32 { + assert_eq!(d.scale(drag, true, None, f64::from(t)), Some(1.0), "drag {}", t + 1); + for key in 1..=6 { + d.observe(u64::from(t) * 10 + key, 6.0, Some(4000.0)); + } + d.scale(None, true, None, f64::from(t)); + } + assert_eq!(d.scale(drag, true, None, 2.0), Some(1.0), "after two hidden-window drags: still full size"); +} + +// Given full-size drafts far over budget (a refined mask the CPU renders at 190 ms a step), +// the drag drops to the small draft after a few of them instead of at its end; after a second +// such drag, the next starts small +#[test] +fn a_drag_far_over_budget_drafts_small_before_it_ends() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + assert_eq!(d.scale(drag, true, None, 0.0), Some(1.0)); + let mut scales = Vec::new(); + for key in 1..=8 { + d.observe(key, 190.0, None); + scales.push(d.scale(drag, true, None, 0.0)); + } + // drafts 1–4 are timed one frame late; the median of 2–4 decides on the fifth frame + assert_eq!(scales[..4], [Some(1.0); 4], "{scales:?}"); + assert_eq!(scales[4..], [Some(CPU_DRAFT_SCALE); 4], "{scales:?}"); + d.scale(None, true, None, 0.0); + assert_eq!(timed_drag(&mut d, drag, 190.0, 1.0), Some(1.0), "one such drag: maybe a busy moment"); + assert_eq!(d.scale(drag, true, None, 2.0), Some(CPU_DRAFT_SCALE)); +} + +// Given a full-size drag zoomed in (a window covers the view), the hidden whole-frame draft is +// the small one: at 1:1 on a Retina display it cost 7–9 ms a step for pixels nobody sees +#[test] +fn a_full_size_drag_under_a_window_drafts_the_whole_frame_small() { + let zoomed = sizes(24000.0, 2800.0, 6000); + let small = plan(&auto(), ViewSizes { draft: Some(CPU_DRAFT_SCALE), ..zoomed }); + let full = plan(&auto(), ViewSizes { draft: Some(1.0), ..zoomed }); + assert!(full.window_edge.is_some(), "{full:?}"); + assert_eq!(full, small); + // at fit without a window, full size stays full size + let fit = sizes(2800.0, 2800.0, 6000); + assert_eq!(plan(&auto(), ViewSizes { draft: Some(1.0), ..fit }), plan(&auto(), fit)); +} + +// Given a drag at fit at full size, the draft is as sharp as the render at rest +#[test] +fn a_full_size_drag_at_fit_keeps_the_resolution() { + for drawn in [1400.0, 2800.0] { + let still = plan(&auto(), sizes(drawn, drawn, 6000)); + let drag = plan(&auto(), ViewSizes { draft: Some(1.0), ..sizes(drawn, drawn, 6000) }); + assert_eq!(drag, still, "{drawn}"); + } +} + +// Given no window and a full-size drag, the draft is still at most the canvas: a drag at 1:1 +// must not render the photo's every pixel each frame +#[test] +fn a_full_size_drag_without_a_window_drafts_at_the_canvas_size() { + let drag = plan(&auto(), ViewSizes { windows: false, draft: Some(1.0), ..sizes(24000.0, 2800.0, 6000) }); + assert!(drag.main_edge <= 2816, "{drag:?}"); +} + +// Given the user capped the preview size, the whole-frame render obeys it, and a fit view +// stays on it (no original decoded); a zoomed view still gets its sharp window +#[test] +fn a_preview_limit_caps_the_whole_frame_render_and_spares_the_fit_view() { + let s = AppSettings { preview_limit: 1600, ..Default::default() }; + let fit = plan(&s, sizes(2800.0, 2800.0, 6000)); + assert_eq!((fit.main_edge, fit.window_edge), (1600, None)); + let zoomed = plan(&s, sizes(6000.0, 2800.0, 6000)); + assert_eq!((zoomed.main_edge, zoomed.window_edge), (1600, Some(6000))); +} + +// Given a GPU with 2048 px textures, the whole-frame render obeys it, the window frame need not +// (a window is cut to the texture side separately) +#[test] +fn the_texture_side_caps_the_whole_frame_render_only() { + let p = plan(&auto(), ViewSizes { texture_side: 2048, ..sizes(6000.0, 2800.0, 6000) }); + assert_eq!((p.main_edge, p.window_edge), (2048, Some(6000))); +} + +// Given a picture that is not the frame (an embedded JPEG cropped differently), no window +#[test] +fn a_window_needs_a_picture_with_the_frames_aspect() { + assert!(same_aspect(1.5, 1.5) && same_aspect(1.51, 1.5)); + assert!(!same_aspect(1.5, 1.0) && !same_aspect(f32::NAN, 1.0) && !same_aspect(1.0, 0.0)); +} + +// Given a drag at fit on a 4K or 5K display (where the fit view has a window), the window stays: +// it is only the whole-frame draft that gets smaller +#[test] +fn a_drag_at_fit_on_a_large_display_keeps_the_window() { + for drawn in [3300.0, 3840.0, 4096.0, 5120.0] { + let still = plan(&auto(), sizes(drawn, drawn, 6000)); + let drag = plan(&auto(), ViewSizes { draft: Some(0.6), ..sizes(drawn, drawn, 6000) }); + assert!(still.window_edge.is_some(), "{drawn}: {still:?}"); + assert_eq!(drag.window_edge, still.window_edge, "{drawn}"); + } +} + +// Given a mode whose picture can't be a window (a whole-frame diagnostic, or a +// spot read too far for one render), the whole-frame render is as big as the view +#[test] +fn without_windows_the_whole_frame_is_rendered_at_the_drawn_size() { + let p = plan(&auto(), ViewSizes { windows: false, ..sizes(6000.0, 2800.0, 6000) }); + assert_eq!((p.main_edge, p.window_edge), (6000, None)); + let fit = plan(&auto(), ViewSizes { windows: false, ..sizes(2800.0, 2800.0, 6000) }); + assert_eq!((fit.main_edge, fit.window_edge), (2816, None)); + let deep = plan(&auto(), ViewSizes { windows: false, ..sizes(48000.0, 2800.0, 6000) }); + assert_eq!(deep.main_edge, 6000, "never above the photo's own pixels"); + let small = plan(&auto(), ViewSizes { windows: false, texture_side: 2048, ..sizes(6000.0, 2800.0, 6000) }); + assert_eq!(small.main_edge, 2048); +} + +// Given a user who chose a preview size above the preview source level (3840 or 5120 on a big +// display), the whole-frame render is that big +#[test] +fn a_large_preview_limit_is_honoured() { + for limit in [3840u32, 5120] { + let s = AppSettings { preview_limit: limit, ..Default::default() }; + let p = plan(&s, sizes(5120.0, 5120.0, 6000)); + assert_eq!(p.main_edge, limit as usize, "{limit}"); + assert_eq!(p.window_edge, None, "a fit view at the limit needs no window"); + } + let s = AppSettings { preview_limit: 2560, ..Default::default() }; + assert_eq!(plan(&s, sizes(5120.0, 5120.0, 6000)).main_edge, 2560); +} + +// Given a medium format photo wider than 8192 px, 1:1 is its own pixels +#[test] +fn a_window_frame_can_be_as_big_as_the_photo() { + let p = plan(&auto(), sizes(40000.0, 2800.0, 11648)); + assert_eq!(p.window_edge, Some(11648)); +} + +// Given a drag at fit on a Retina Mac, the window does not switch on (it would flicker with the drag) +#[test] +fn a_drag_at_fit_does_not_switch_the_window_on() { + let p = plan(&auto(), ViewSizes { draft: Some(0.6), ..sizes(2800.0, 2800.0, 6000) }); + assert_eq!(p.window_edge, None); +} + +// Given a pinch from fit to 400 %, the sizes of the first frame hold until the gesture is quiet +#[test] +fn a_pinch_holds_the_render_sizes() { + use lightcraft_catalog::PhotoId; + let (fit, zoomed) = (plan(&auto(), sizes(1400.0, 1400.0, 6000)), plan(&auto(), sizes(24000.0, 1400.0, 6000))); + assert_ne!(fit, zoomed); + let mut hold = SizeHold::default(); + let p = PhotoId(1); + assert_eq!(hold.apply(p, 0.0, false, fit), fit); + // the pinch runs for a second: every frame would plan something else + for k in 1..60 { + assert_eq!(hold.apply(p, k as f64 / 60.0, true, zoomed), fit, "frame {k}"); + } + // it stopped at t = 59/60: still held for a moment (no flicker between two events)… + assert!(hold.holding(1.1)); + assert_eq!(hold.apply(p, 1.1, false, zoomed), fit); + // …then the view is planned for where it ended + assert!(!hold.holding(59.0 / 60.0 + HOLD_SECS + 0.01)); + assert_eq!(hold.apply(p, 1.3, false, zoomed), zoomed); + assert_eq!(hold.apply(p, 1.4, false, zoomed), zoomed); +} + +// Given a pinch, the display-fit size holds like the render sizes and is retaken after it +#[test] +fn a_pinch_holds_the_display_fit_size() { + use lightcraft_catalog::PhotoId; + let (fit, zoomed) = (plan(&auto(), sizes(1400.0, 1400.0, 6000)), plan(&auto(), sizes(24000.0, 1400.0, 6000))); + let mut hold = SizeHold::default(); + let p = PhotoId(1); + hold.apply(p, 0.0, false, fit); + assert_eq!(hold.display_fit(0.0, false, [1000, 800]), [1000, 800]); + for k in 1..60 { + let now = k as f64 / 60.0; + hold.apply(p, now, true, zoomed); + assert_eq!(hold.display_fit(now, true, [1000 + k, 800 + k]), [1000, 800], "frame {k}"); + } + // stopped at t = 59/60: still held within the hold time… + hold.apply(p, 1.1, false, zoomed); + assert_eq!(hold.display_fit(1.1, false, [2560, 2048]), [1000, 800]); + // …then the size it ended at is taken, and kept while nothing moves + hold.apply(p, 1.3, false, zoomed); + assert_eq!(hold.display_fit(1.3, false, [2560, 2048]), [2560, 2048]); + assert_eq!(hold.display_fit(1.4, false, [2561, 2049]), [2561, 2049]); + // another photo forgets the held size + hold.apply(p, 1.5, true, zoomed); + hold.display_fit(1.5, true, [2561, 2049]); + hold.apply(PhotoId(2), 1.6, true, zoomed); + assert_eq!(hold.display_fit(1.6, true, [640, 480]), [640, 480]); +} + +// Given another photo, the held sizes are forgotten +#[test] +fn another_photo_is_planned_afresh() { + use lightcraft_catalog::PhotoId; + let (fit, zoomed) = (plan(&auto(), sizes(1400.0, 1400.0, 6000)), plan(&auto(), sizes(24000.0, 1400.0, 6000))); + let mut hold = SizeHold::default(); + hold.apply(PhotoId(1), 0.0, false, fit); + assert_eq!(hold.apply(PhotoId(2), 0.1, true, zoomed), zoomed); +} + +// Given side by side, a window is drawn in its pane only; given a wipe, on its side of the line +#[test] +fn a_window_is_clipped_to_its_pane_or_its_side_of_the_wipe() { + use crate::state::BeforeAfter::*; + let canvas = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1000.0, 600.0)); + let (left, right) = ( + egui::Rect::from_min_max(egui::pos2(24.0, 24.0), egui::pos2(494.0, 576.0)), + egui::Rect::from_min_max(egui::pos2(506.0, 24.0), egui::pos2(976.0, 576.0)), + ); + assert_eq!(window_clip(SideBySide, true, left, canvas, left), left); + assert_eq!(window_clip(SideBySide, false, right, canvas, right), right); + let img = egui::Rect::from_min_max(egui::pos2(100.0, 50.0), egui::pos2(900.0, 550.0)); + let before = window_clip(Split, true, canvas, canvas, img); + let after = window_clip(Split, false, canvas, canvas, img); + assert_eq!((before.max.x, after.min.x), (500.0, 500.0), "the line is the image's middle"); + assert_eq!(window_clip(SplitTopBottom, true, canvas, canvas, img).max.y, 300.0); + assert_eq!(window_clip(SplitTopBottom, false, canvas, canvas, img).min.y, 300.0); + // zoomed in, the photo's middle (x = 1600) is off screen: the line is the view's middle + let zoomed = egui::Rect::from_min_max(egui::pos2(-200.0, -900.0), egui::pos2(3400.0, 1500.0)); + assert_eq!(window_clip(Split, false, canvas, canvas, zoomed).min.x, 500.0); + assert_eq!(window_clip(SplitTopBottom, true, canvas, canvas, zoomed).max.y, 300.0); + assert_eq!(window_clip(Off, false, canvas, canvas, img), canvas); + assert_eq!(window_clip(Original, true, canvas, canvas, img), canvas); +} + +// Given a window asked for while another is rendering and the original isn't decoded yet, it +// waits for the decoded original instead of decoding another copy +#[test] +fn a_window_waits_for_the_original_another_is_decoding() { + assert!(defer_window(true, false)); + assert!(!defer_window(true, true), "the original is in"); + assert!(!defer_window(false, false), "nothing is decoding it"); +} + +// Hostile numbers give a plan, never a panic +#[test] +fn hostile_sizes_give_a_usable_plan() { + for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -3.0, 0.0] { + let p = plan(&auto(), ViewSizes { drawn_long: v, canvas_long: v, draft: Some(v), ..sizes(1.0, 1.0, 6000) }); + assert!((8..=2560).contains(&p.main_edge), "{v}: {p:?}"); + } + let p = plan(&auto(), sizes(1000.0, 1000.0, 0)); + assert!(p.main_edge >= 8); +} + +// Given a visible area in the middle of a big frame, the window covers it with a margin, on the grid +#[test] +fn the_window_covers_the_visible_area_with_a_margin_on_the_grid() { + let w = window_for(48_000, 32_000, (20_000.0, 10_000.0, 21_400.0, 10_900.0), MAX_SPAN).unwrap(); + let m = margin_for(48_000); + assert!(w.x + m <= 20_000 && w.x + w.w >= 21_400 + m, "{w:?}"); + assert!(w.y + m <= 10_000 && w.y + w.h >= 10_900 + m, "{w:?}"); + assert_eq!((w.x % SNAP, w.y % SNAP, w.w % SNAP, w.h % SNAP), (0, 0, 0, 0)); +} + +// Given a small pan, the same window is kept (no re-render); a big pan changes it +#[test] +fn small_pans_keep_the_window() { + let a = window_for(48_000, 32_000, (20_000.0, 10_000.0, 21_400.0, 10_900.0), MAX_SPAN).unwrap(); + let b = window_for(48_000, 32_000, (20_040.0, 10_030.0, 21_440.0, 10_930.0), MAX_SPAN).unwrap(); + assert_eq!(a, b); + let c = window_for(48_000, 32_000, (26_000.0, 10_000.0, 27_400.0, 10_900.0), MAX_SPAN).unwrap(); + assert_ne!(a, c); +} + +// Given a frame smaller than the margin, or a view at its edge, the window is clamped into it +#[test] +fn the_window_stays_inside_the_frame() { + let w = window_for(3000, 2000, (-500.0, -500.0, 900.0, 700.0), MAX_SPAN).unwrap(); + assert_eq!((w.x, w.y), (0, 0)); + let w = window_for(3000, 2000, (2500.0, 1500.0, 9000.0, 9000.0), MAX_SPAN).unwrap(); + assert!(w.x + w.w <= 3000 && w.y + w.h <= 2000, "{w:?}"); + assert_eq!((w.x + w.w, w.y + w.h), (3000, 2000)); +} + +// Given a deeper zoom, the margin grows with the wide stages' kernels (3σ of the base, clarity +// and dehaze planes are 0.045 of the frame), up to a cap that bounds the window +#[test] +fn the_margin_grows_with_the_zoom_up_to_a_cap() { + assert_eq!(margin_for(1000), SNAP, "never less than one grid step"); + assert!(margin_for(8000) > margin_for(3000)); + assert_eq!(margin_for(48_000), 2304, "4.5 % of an 8:1 frame, under the cap"); + assert_eq!(margin_for(80_000), MAX_MARGIN); + assert_eq!(margin_for(0), SNAP); + assert!(margin_for(usize::MAX) <= MAX_MARGIN); + for l in [100, 3000, 8000, 48_000, 400_000] { + assert_eq!(margin_for(l) % SNAP, 0); + } +} + +// Given a tile rendered for other settings, another photo or another zoom, it is not drawn +#[test] +fn a_tile_is_drawn_only_while_it_shows_what_the_loupe_shows() { + use lightcraft_catalog::PhotoId; + let v = RegionView { + photo: PhotoId(1), + before: false, + key: 7, + full: (6000, 4000), + window: PixelWindow { x: 0, y: 0, w: 256, h: 256 }, + settings: 11, + }; + assert!(v.is_current(PhotoId(1), (6000, 4000), 11, false)); + assert!(!v.is_current(PhotoId(2), (6000, 4000), 11, false), "another photo"); + assert!(!v.is_current(PhotoId(1), (6000, 4001), 11, false), "another zoom"); + assert!(!v.is_current(PhotoId(1), (6000, 4000), 12, false), "another look (an edit, an undo)"); + assert!(v.is_current(PhotoId(1), (6000, 4000), 12, true), "a drag's drafts follow the value"); + assert!(!v.is_current(PhotoId(2), (6000, 4000), 12, true), "…of this photo only"); +} + +// Given a GPU that allows 2048 px textures, no window is wider than that +#[test] +fn a_window_fits_the_texture_side() { + for tex in [512, 2048, 4096] { + let span = max_span(tex); + assert!(span <= tex.max(SNAP) && span.is_multiple_of(SNAP), "{tex}: {span}"); + let w = window_for(48_000, 32_000, (20_000.0, 10_000.0, 21_400.0, 10_900.0), span).unwrap(); + assert!(w.w <= span && w.h <= span, "{tex}: {w:?}"); + } + assert_eq!(max_span(100_000), MAX_SPAN); + assert_eq!(max_span(0), SNAP); +} + +// Given nothing visible, or hostile numbers, there is no window (never a panic) +#[test] +fn nothing_visible_or_hostile_input_is_no_window() { + assert_eq!(window_for(1000, 1000, (2000.0, 0.0, 3000.0, 100.0), MAX_SPAN), None); + assert_eq!(window_for(1000, 1000, (10.0, 10.0, 10.0, 90.0), MAX_SPAN), None); + assert_eq!(window_for(0, 1000, (0.0, 0.0, 10.0, 10.0), MAX_SPAN), None); + for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + assert_eq!(window_for(1000, 1000, (v, 0.0, 10.0, 10.0), MAX_SPAN), None); + } + let w = window_for(usize::MAX / 2, usize::MAX / 2, (0.0, 0.0, 1e30, 1e30), MAX_SPAN).unwrap(); + assert!(w.w <= MAX_SPAN && w.h <= MAX_SPAN, "{w:?}"); +} + +// Given a view of an enormous area, the window is bounded +#[test] +fn a_window_is_bounded() { + let w = window_for(100_000, 100_000, (0.0, 0.0, 90_000.0, 90_000.0), MAX_SPAN).unwrap(); + assert!(w.w <= MAX_SPAN && w.h <= MAX_SPAN); +}