From 4f6ebb1b6e009415d7469f7d909cd23064a1f5e5 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:23:32 +0100 Subject: [PATCH 1/8] test: partition long parity matrices without dropping cases --- .github/workflows/ci.yml | 6 +++++ crates/algo/src/stylize2/tests.rs | 31 ++++++++++++++++++++++-- crates/io/tests/it/psd_import_file.rs | 34 +++++++++++++++++++++++---- docs/development.md | 16 +++++++++++++ 4 files changed, 80 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2e76c76ba1..cd8e1ec3d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,10 @@ on: types: [opened, synchronize, reopened, labeled] workflow_dispatch: inputs: + full_sweep: + description: "Run every testkit sweep case (runtime comparison and integration validation)" + type: boolean + default: false selection_morphology: description: "Compare selection morphology on 36 MP (Linux, exact outputs)" type: boolean @@ -30,6 +34,7 @@ permissions: env: CARGO_TERM_COLOR: always RUST_BACKTRACE: 1 + PHOTOCRAFT_FULL_SWEEP: ${{ inputs.full_sweep && '1' || '0' }} # No test or fuzz run may print a page: the engine refuses to spool, and if something ever # bypasses that, lp fails loudly on a printer that does not exist. PHOTOCRAFT_NO_PRINT: "1" @@ -133,6 +138,7 @@ jobs: # Cargo.toml's dev profile already keeps line tables only. dev-debug: "" env: | + PHOTOCRAFT_FULL_SWEEP=${{ inputs.full_sweep && '1' || '0' }} PHOTOCRAFT_NO_PRINT=1 LPDEST=photocraft-no-such-printer timeout-minutes: 100 diff --git a/crates/algo/src/stylize2/tests.rs b/crates/algo/src/stylize2/tests.rs index c7139153af..4751511e62 100644 --- a/crates/algo/src/stylize2/tests.rs +++ b/crates/algo/src/stylize2/tests.rs @@ -26,8 +26,9 @@ fn bits(v: Vec) -> Vec { const MODELS: [ColorMode; 8] = [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab, ColorMode::Bitmap, ColorMode::Duotone, ColorMode::Indexed, ColorMode::Multichannel]; -#[test] -fn swept_blocks_match_the_pixel_search() { +// Keep the same daily/full-sweep cases, but expose four independent tests to nextest so one +// large reference comparison cannot hold a runner after its other tests have finished. +fn swept_blocks_match_the_pixel_search_part(part: u64) { let bounds = Rect::new(-13, -9, 31, 26); // Unless the heights are level-based, the colour mode reaches extrude only through the channel // count and whether the samples are CMYK ink, so modes that agree on both repeat those cases. @@ -35,6 +36,7 @@ fn swept_blocks_match_the_pixel_search() { let mut seen = std::collections::HashSet::new(); let sweep = photocraft_testkit::Sweep::new(4); let mut case = 0; + let mut selected = 0; for mode in MODELS { for alpha in [false, true] { let n = PixelFormat::new(mode, SampleType::F32, alpha).channels(); @@ -48,6 +50,11 @@ fn swept_blocks_match_the_pixel_search() { if !sweep.includes(case) { continue; } + let assigned = selected % 4; + selected += 1; + if assigned != part { + continue; + } let spec = ExtrudeSpec { kind: ExtrudeType::Blocks, size, @@ -70,6 +77,26 @@ fn swept_blocks_match_the_pixel_search() { } } +#[test] +fn swept_blocks_match_the_pixel_search_part_0() { + swept_blocks_match_the_pixel_search_part(0); +} + +#[test] +fn swept_blocks_match_the_pixel_search_part_1() { + swept_blocks_match_the_pixel_search_part(1); +} + +#[test] +fn swept_blocks_match_the_pixel_search_part_2() { + swept_blocks_match_the_pixel_search_part(2); +} + +#[test] +fn swept_blocks_match_the_pixel_search_part_3() { + swept_blocks_match_the_pixel_search_part(3); +} + #[test] fn streak_scatter_matches_the_pixel_search() { let bounds = Rect::new(-17, -9, 39, 14); diff --git a/crates/io/tests/it/psd_import_file.rs b/crates/io/tests/it/psd_import_file.rs index f8e9243cc1..cd1b08761a 100644 --- a/crates/io/tests/it/psd_import_file.rs +++ b/crates/io/tests/it/psd_import_file.rs @@ -44,11 +44,13 @@ fn open(name: &str, path: &std::path::Path) -> Result panic!("{}: bytes {:?}, file {:?}", case.name, a.map(|r| r.warnings), b.map(|r| r.warnings)), } } - assert!(checked > 50, "only {checked} cases opened"); + // Four parts each opening at least 13 cases retain (and slightly strengthen) the former + // aggregate floor of more than 50 successful opens. + assert!(checked >= 13, "part {part}: only {checked} cases opened"); +} + +#[test] +fn import_file_opens_what_importing_the_bytes_opens_part_0() { + import_file_opens_what_importing_the_bytes_opens_part(0); +} + +#[test] +fn import_file_opens_what_importing_the_bytes_opens_part_1() { + import_file_opens_what_importing_the_bytes_opens_part(1); +} + +#[test] +fn import_file_opens_what_importing_the_bytes_opens_part_2() { + import_file_opens_what_importing_the_bytes_opens_part(2); +} + +#[test] +fn import_file_opens_what_importing_the_bytes_opens_part_3() { + import_file_opens_what_importing_the_bytes_opens_part(3); } #[test] diff --git a/docs/development.md b/docs/development.md index a9e9fc486e..71e34e7ebb 100644 --- a/docs/development.md +++ b/docs/development.md @@ -64,6 +64,22 @@ cargo test --doc --workspace # nextest does not run doctests [cargo-nextest](https://nexte.st) runs each test in its own process. `.config/nextest.toml` puts the GPU tests (`photocraft-gpu`, and ui-egui's integration and canvas/GPU tests) in a group of at most 4 at a time, because each process opens its own wgpu device and the in-process `gpu_lock()` no longer serializes them. On a 32-thread Windows machine the full workspace went from 297 s (`cargo test`) to about 120 s (#2204). Ignored tests run with `cargo nextest run --run-ignored only`. The longest sweeps (`photocraft-testkit`'s `Sweep`) run a quarter of their cases, a different quarter each UTC day; `PHOTOCRAFT_FULL_SWEEP=1` runs all of them, and `PHOTOCRAFT_SWEEP_PART=` replays the part a failing run printed. CI runs it with `--profile ci` (fail-fast, a 10-minute hang timeout), plus `cargo test --doc` for the doctests (#6117); `PHOTOCRAFT_FULL_SWEEP=1 cargo test --workspace` stays the final check before a PR. +### Long matrix tests and CI timings + +Nextest's `SLOW` line at 60 seconds is a progress warning, not a sleep or a failed test. +The existing ten-minute hang limit and all GPU concurrency limits remain in force. +The PSD file/bytes import equivalence matrix and swept-block reference comparison each expose +four test functions so nextest can schedule their cases independently. Every formerly selected +case still runs once, with the same assertions. The extrude matrix retains the existing daily +sweep selection; splitting it does not sample fewer cases. PSD temporary directories include the +part number so ordinary `cargo test` can also run the parts concurrently in one process. + +For a like-for-like timing comparison, use the CI workflow's `full_sweep` manual input, or set +`PHOTOCRAFT_FULL_SWEEP=1` locally. Compare the complete nextest summary and runner wall time, +not only the longest individual test: partitioning adds scheduling opportunities, not less work. +Keep the same runner, feature set, fonts, and warm/cold build state. No speedup should be claimed +from changing the 60-second warning threshold or omitting tests. + ### Shortcut capture without a GPU, and native snapshots The ordinary `shortcut_capture_tests` check modifier capture, conflicts, restored bindings and From c8c8fc78215a7f268d3db8065eb0d0d8648c6e9f Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Fri, 30 Oct 2026 00:39:19 +0100 Subject: [PATCH 2/8] Reduce repeated GPU setup and UI work; partition remaining regression matrices --- .../cmd/retouch_cmds/content_aware_move.rs | 7 +- crates/gpu/src/setup.rs | 26 +++++-- crates/gpu/tests/it/parity.rs | 46 +++++++++-- crates/io/tests/it/tiff_layers.rs | 31 +++++++- .../tests/it/camera_raw_localization.rs | 18 +++-- crates/ui-egui/tests/it/camera_raw_scope.rs | 6 +- crates/ui-egui/tests/it/checker_canvas.rs | 77 +++++++++++-------- crates/ui-egui/tests/it/full_res_preview.rs | 11 ++- crates/ui-egui/tests/it/live_stroke_canvas.rs | 25 +++++- 9 files changed, 183 insertions(+), 64 deletions(-) diff --git a/crates/engine/src/cmd/retouch_cmds/content_aware_move.rs b/crates/engine/src/cmd/retouch_cmds/content_aware_move.rs index dbc73d9388..7b2f9e8026 100644 --- a/crates/engine/src/cmd/retouch_cmds/content_aware_move.rs +++ b/crates/engine/src/cmd/retouch_cmds/content_aware_move.rs @@ -768,10 +768,12 @@ mod tests { // An 8-bit selection of scattered 1/255 pixels: scaled down in 8 bits it averaged to 0 // everywhere, so the coarse preview failed with "the selection is empty" while the // command itself moves it. - let mut s = textured(1200, 800); + // Just over four times the preview budget still averages each isolated 1/255 + // sample below half an 8-bit level, reproducing the loss without a 0.77 MP solve. + let mut s = textured(600, 600); s.edit("faint selection", |doc, _| { let mut sel = Surface::new(photocraft_color::PixelFormat::GRAY8); - let r = Rect::new(20, 20, 1120, 720); + let r = Rect::new(20, 20, 536, 536); let data: Vec = (r.y0..r.y1).flat_map(|y| (r.x0..r.x1).map(move |x| if x % 4 == 0 && y % 4 == 0 { 1.0 / 255.0 } else { 0.0 })).collect(); sel.write_region(r, &data); doc.selection = Some(sel); @@ -781,6 +783,7 @@ mod tests { let p = json!({"offset": [40, 40]}); let job = preview(&s, &p).unwrap(); assert!(job.is_coarse()); + assert!(job.scale < 0.51, "the sparse mask must actually be averaged down"); let (got, damage) = job.run(&JobCtx::new()).unwrap(); assert!(!damage.is_empty()); let b = got.bounds(); diff --git a/crates/gpu/src/setup.rs b/crates/gpu/src/setup.rs index c5b5c90384..3cb4fad401 100644 --- a/crates/gpu/src/setup.rs +++ b/crates/gpu/src/setup.rs @@ -117,8 +117,23 @@ impl Compositor { pipelines.extend(maps); } let dummy = Tex::new(device, "pc_compose_dummy", 1, 1, wgpu::TextureFormat::Rgba8Unorm, wgpu::TextureUsages::TEXTURE_BINDING).view; - let comp = Self { - kit: Kit { pipelines, bgl0, bgl1, dummy }, + let kit = Kit { pipelines, bgl0, bgl1, dummy }; + let comp = Self::from_kit(kit, device.limits().max_texture_dimension_2d, acc_format, map_error.is_none()); + (comp, error) + } + + /// Create an independent compositor on the same device, reusing compiled pipelines. + /// Texture caches, budgets, focus and timing start at their defaults; no document state is + /// shared. Render with the same device and queue as this compositor. + pub fn sibling(&self) -> Self { + let mut sibling = Self::from_kit(self.kit.clone(), self.device_max, self.acc_format, self.effect_maps); + sibling.health = self.health.clone(); + sibling + } + + fn from_kit(kit: Kit, device_max: u32, acc_format: wgpu::TextureFormat, effect_maps: bool) -> Self { + Self { + kit, pool: Vec::new(), residents: HashMap::new(), fx: HashMap::new(), @@ -129,7 +144,7 @@ impl Compositor { pattern_doc: DocId(0), pattern_budget: PATTERN_BUDGET, max_dim: 0, - device_max: device.limits().max_texture_dimension_2d, + device_max, page: PAGE, chunk: CHUNK, resident_budget: RESIDENT_BUDGET, @@ -146,13 +161,12 @@ impl Compositor { admit_freed: 0, fail_allocs: 0, acc_format, - effect_maps: map_error.is_none(), + effect_maps, cmyk: 0, health: None, timer: None, } - .with_texture_limit(device.limits().max_texture_dimension_2d); - (comp, error) + .with_texture_limit(device_max) } fn with_texture_limit(mut self, max: u32) -> Self { diff --git a/crates/gpu/tests/it/parity.rs b/crates/gpu/tests/it/parity.rs index 901a21365b..e99c078467 100644 --- a/crates/gpu/tests/it/parity.rs +++ b/crates/gpu/tests/it/parity.rs @@ -127,7 +127,7 @@ fn gpu() -> Option { } }; let comp = compositor()?; - let mut paged = compositor()?; + let mut paged = comp.sibling(); paged.set_texture_limit(PAGED_LIMIT); Some(Gpu { device, queue, comp, paged, _lock: lock }) } @@ -380,7 +380,7 @@ fn prefix_follows_an_edit_up_the_stack_and_stays_exact() { #[test] fn chunks_keep_their_own_checkpoint_after_a_partial_refresh() { let Some(mut g) = gpu() else { return }; - let mut uncached = Compositor::try_new_with_format(&g.device, wgpu::TextureFormat::Rgba32Float).unwrap(); + let mut uncached = g.comp.sibling(); uncached.set_texture_limit(PAGED_LIMIT); uncached.set_composite_cache_budget(0); let mut doc = base_doc(600, 300); @@ -1259,7 +1259,7 @@ fn growing_layer_keeps_its_resident_tiles() { let what = format!("{fmt:?} dab {i}"); let s = diff_rect(&mut g, &d, d.bounds(), &what).unwrap_or_else(|e| panic!("{e}")); assert!(s.tiles_uploaded <= 2, "{what}: only the dab's tiles upload: {s:?}"); - let mut fresh = Compositor::try_new_with_format(&g.device, wgpu::TextureFormat::Rgba32Float).unwrap(); + let mut fresh = g.comp.sibling(); let want = render_to_vec(&mut fresh, &g.device, &g.queue, &d, d.bounds()).unwrap(); assert!(render_to_vec(&mut g.comp, &g.device, &g.queue, &d, d.bounds()).unwrap() == want, "{what}: same pixels as a fresh upload"); } @@ -1332,8 +1332,7 @@ fn small_layers_composite_over_their_bounds() { check(&mut g, &big, "small layers across chunks"); } -#[test] -fn many_layers_submit_inside_one_chunk() { +fn many_layers_submit_inside_one_chunk_mode(paged: bool) { // P12: a refresh recorded every pass of a page cell into one command buffer, and with a few // hundred layers Metal ran out of command buffers (device lost, "pc_compose_uniforms is // invalid"). 1200 layers need well over 1000 passes in the document's only chunk: each is @@ -1348,12 +1347,25 @@ fn many_layers_submit_inside_one_chunk() { l.mask = Some(mask(Rect::from_xywh(0, 0, 64, 48), 3000 + i as u32, 1.0)); d.layers.push(l); } - let stats = diff_rect(&mut g, &d, d.bounds(), "1200 layers").unwrap_or_else(|e| panic!("{e}")); + let cpu = photocraft_compose::render(&d, d.bounds()); + let comp = if paged { &mut g.paged } else { &mut g.comp }; + let (out, stats) = photocraft_gpu::render_to_vec_stats(comp, &g.device, &g.queue, &d, d.bounds()).unwrap(); + assert!(worst_diff(&cpu.px, &out).0 <= TOL, "1200 layers, paged={paged}"); assert_eq!(stats.layers_batched, 0, "{stats:?}"); assert_eq!(stats.chunks, 1); assert!(stats.flushes > 0, "{stats:?}"); } +#[test] +fn many_layers_submit_inside_one_chunk_whole() { + many_layers_submit_inside_one_chunk_mode(false); +} + +#[test] +fn many_layers_submit_inside_one_chunk_paged() { + many_layers_submit_inside_one_chunk_mode(true); +} + #[test] fn runs_of_plain_layers_blend_in_one_pass() { // Consecutive plain layers are drawn several to a pass (`fs_layerbatch`). Every layer mode at @@ -3003,7 +3015,7 @@ fn the_focused_view_stays_resident_after_a_full_refresh() { let _gamma = TEXT_GAMMA.lock().unwrap_or_else(|e| e.into_inner()); let Some(g) = gpu() else { return }; let d = big_doc(); - let mut comp = Compositor::try_new_with_format(&g.device, wgpu::TextureFormat::Rgba32Float).unwrap(); + let mut comp = g.comp.sibling(); // Exercise input-page residency itself: prefix hits can legitimately skip those pages. comp.set_composite_cache_budget(0); comp.set_texture_limit(PAGED_LIMIT); @@ -3032,7 +3044,7 @@ fn layer_pages_that_just_miss_borrow_the_effect_map_share() { let _gamma = TEXT_GAMMA.lock().unwrap_or_else(|e| e.into_inner()); let Some(g) = gpu() else { return }; let d = big_doc(); - let mut comp = Compositor::try_new_with_format(&g.device, wgpu::TextureFormat::Rgba32Float).unwrap(); + let mut comp = g.comp.sibling(); comp.set_texture_limit(PAGED_LIMIT); let need = comp.working_set(&d, d.bounds()); // Pages get three quarters of the budget and effect maps an eighth: at 5/4 of the need the @@ -3193,3 +3205,21 @@ fn dissolve_clipped_adjustment_matches_cpu() { diff_rect(&mut g, &d, Rect::new(250, 1, 270, 20), "clipped adjustment offset region").unwrap_or_else(|e| panic!("{e}")); } } + +/// Siblings share only immutable GPU objects, not resident pages or document caches. +#[test] +fn sibling_compositors_have_independent_residency_and_configuration() { + let Some(mut g) = gpu() else { return }; + let d = base_doc(300, 300); + let mut sibling = g.comp.sibling(); + assert_eq!(sibling.resident_bytes(), 0); + sibling.set_texture_limit(PAGED_LIMIT); + sibling.set_resident_budget(0); + let (out, _) = photocraft_gpu::render_to_vec_stats(&mut sibling, &g.device, &g.queue, &d, d.bounds()).unwrap(); + assert!(worst_diff(&photocraft_compose::render(&d, d.bounds()).px, &out).0 <= TOL); + assert_eq!(g.comp.resident_bytes(), 0, "the original has not rendered"); + let (out, _) = photocraft_gpu::render_to_vec_stats(&mut g.comp, &g.device, &g.queue, &d, d.bounds()).unwrap(); + assert!(worst_diff(&photocraft_compose::render(&d, d.bounds()).px, &out).0 <= TOL); + assert!(g.comp.resident_bytes() > 0); + assert_ne!(g.comp.resident_budget(), sibling.resident_budget()); +} diff --git a/crates/io/tests/it/tiff_layers.rs b/crates/io/tests/it/tiff_layers.rs index 70ec2fef2e..f2ccddc92e 100644 --- a/crates/io/tests/it/tiff_layers.rs +++ b/crates/io/tests/it/tiff_layers.rs @@ -357,8 +357,7 @@ fn advanced_blending_survives_layered_tiff_in_both_byte_orders() { } } -#[test] -fn damaged_layer_data_opens_flattened() { +fn damaged_layer_data_opens_flattened_part(part: u64) { let d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS); let r = tiff(&d, true); let (_, layers) = tags(&r.bytes); @@ -376,9 +375,15 @@ fn damaged_layer_data_opens_flattened() { // them, a different quarter each day. let sweep = photocraft_testkit::Sweep::new(4); let mut case = 0; + let mut selected = 0; let mut runs = || { case += 1; - sweep.includes(case) + if !sweep.includes(case) { + return false; + } + let belongs = selected % 4 == part; + selected += 1; + belongs }; for cut in (0..r.bytes.len()).step_by(11).filter(|_| runs()) { let _ = import("x.tif", &r.bytes[..cut]); @@ -1139,3 +1144,23 @@ fn channels_past_sixteen_leave_no_resources_behind() { assert_eq!(names, want.iter().map(String::as_str).collect::>()); assert!(back.quick_mask.is_none()); } + +#[test] +fn damaged_layer_data_opens_flattened_part_0() { + damaged_layer_data_opens_flattened_part(0); +} + +#[test] +fn damaged_layer_data_opens_flattened_part_1() { + damaged_layer_data_opens_flattened_part(1); +} + +#[test] +fn damaged_layer_data_opens_flattened_part_2() { + damaged_layer_data_opens_flattened_part(2); +} + +#[test] +fn damaged_layer_data_opens_flattened_part_3() { + damaged_layer_data_opens_flattened_part(3); +} diff --git a/crates/ui-egui/tests/it/camera_raw_localization.rs b/crates/ui-egui/tests/it/camera_raw_localization.rs index 7d91567591..0a00598e6b 100644 --- a/crates/ui-egui/tests/it/camera_raw_localization.rs +++ b/crates/ui-egui/tests/it/camera_raw_localization.rs @@ -28,6 +28,8 @@ fn all_registered_languages_render_camera_raw_and_switch_without_resetting_view_ h.run_steps(3); let ctx = h.ctx.clone(); h.state_mut().set_theme(&ctx, ThemeKind::Pro); + // Labels and retained state are under test, not the collapse animation. + h.ctx.style_mut(|style| style.animation_time = 0.0); h.state_mut().session.prefs.edit(|p| p.interface.language = lang.code().into()); h.run_steps(4); let tr = |s| i18n::tr_ctx(lang, "cameraRaw", s); @@ -38,17 +40,17 @@ fn all_registered_languages_render_camera_raw_and_switch_without_resetting_view_ } assert!(h.query_by_role_and_label(Role::Slider, tr("Blacks")).is_some()); h.get_by_role_and_label(Role::DisclosureTriangle, tr("Light")).click(); - h.run_steps(12); + h.run_steps(2); h.get_by_role_and_label(Role::DisclosureTriangle, tr("Color")).click(); - h.run_steps(12); + h.run_steps(2); assert!(h.query_by_role_and_label(Role::Label, tr("White Balance: As Shot")).is_some(), "{}: White Balance", lang.code()); for label in ["Temperature", "Tint", "Vibrance", "Saturation"] { assert!(h.query_by_role_and_label(Role::Slider, tr(label)).is_some(), "{}: {label}", lang.code()); } h.get_by_role_and_label(Role::DisclosureTriangle, tr("Color")).click(); - h.run_steps(12); + h.run_steps(2); h.get_by_role_and_label(Role::DisclosureTriangle, tr("Color Mixer")).click(); - h.run_steps(12); + h.run_steps(2); for tab in ["Hue", "Saturation", "Luminance"] { assert!(h.get_by_role_and_label(Role::Button, tr(tab)).rect().right() <= 1426.0, "{}: mixer tabs overflow the panel", lang.code()); } @@ -56,7 +58,7 @@ fn all_registered_languages_render_camera_raw_and_switch_without_resetting_view_ assert!(h.query_by_role_and_label(Role::Slider, tr(band)).is_some(), "{}: {band}", lang.code()); } h.get_by_role_and_label(Role::DisclosureTriangle, tr("Color Mixer")).click(); - h.run_steps(12); + h.run_steps(2); for (section, labels) in [ ("Effects", vec!["Texture", "Clarity", "Dehaze", "Vignetting", "Grain", "Roughness"]), ("Curve", vec!["Highlights", "Lights", "Darks", "Shadows"]), @@ -64,16 +66,16 @@ fn all_registered_languages_render_camera_raw_and_switch_without_resetting_view_ ("Detail", vec!["Sharpening", "Masking", "Noise Reduction", "Luminance Detail", "Color Detail"]), ] { h.get_by_role_and_label(Role::DisclosureTriangle, tr(section)).click(); - h.run_steps(12); + h.run_steps(2); for label in labels { assert!(h.query_all_by_label(tr(label)).next().is_some(), "{}: {section}/{label}", lang.code()); } // Detail is also the sharpening slider label; target the section disclosure. h.get_by_role_and_label(Role::DisclosureTriangle, tr(section)).click(); - h.run_steps(12); + h.run_steps(2); } h.get_by_role_and_label(Role::DisclosureTriangle, tr("Color Mixer")).click(); - h.run_steps(12); + h.run_steps(2); let graph = h.get_by_label(tr("Tone Histogram")).rect(); h.event(egui::Event::PointerMoved(graph.center())); for pressed in [true, false] { diff --git a/crates/ui-egui/tests/it/camera_raw_scope.rs b/crates/ui-egui/tests/it/camera_raw_scope.rs index ba6ee7d705..48d2c85adb 100644 --- a/crates/ui-egui/tests/it/camera_raw_scope.rs +++ b/crates/ui-egui/tests/it/camera_raw_scope.rs @@ -667,9 +667,13 @@ fn pixel_hover_tracks_displayed_rgb_before_after_and_keeps_analysis_cached() { #[test] fn pixel_hover_gpu_badge_single_band_and_crosshair_appear_and_clear_in_all_themes() { let _gpu = gpu_lock(); + let mut h = fixture_render(ThemeKind::Pro, true); for theme in ThemeKind::ALL { - let mut h = fixture_render(theme, true); let ctx = h.ctx.clone(); + h.state_mut().set_theme(&ctx, theme); + h.run_steps(3); + settle(&mut h); + assert_eq!(h.state().ui.theme, theme); menu(h.state_mut(), &ctx, json!({"ui":{"scope":{"vectorscope":true}}})).unwrap(); h.run_steps(4); let histogram = rect(&h, "scopeRect"); diff --git a/crates/ui-egui/tests/it/checker_canvas.rs b/crates/ui-egui/tests/it/checker_canvas.rs index 513d277abf..8e453ecaac 100644 --- a/crates/ui-egui/tests/it/checker_canvas.rs +++ b/crates/ui-egui/tests/it/checker_canvas.rs @@ -304,46 +304,61 @@ fn pixel_grid_does_not_flip_colour_across_mid_tones() { } } -#[test] -fn pixel_grid_is_solid_and_visible_on_white_and_black_at_both_display_scales() { +fn pixel_grid_is_solid_and_visible_at_scale(gpu: bool, ppp: f32) { let _gpu = photocraft_testkit::gpu_lock(); use photocraft_color::{ColorMode, PixelFormat, SampleType}; use photocraft_doc::{Document, Layer, LayerContent, Size}; use photocraft_geom::Rect; use photocraft_raster::Surface; - for gpu in [true, false] { - for ppp in [1.0, 2.0] { - let Some(mut h) = harness_with_scale(gpu, ppp) else { return }; - for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { - for v in [0.0, 1.0] { - let mut doc = Document::new("grid contrast", Size::new(32, 16), ColorMode::Rgb, depth); - let mut surface = Surface::new(PixelFormat::new(ColorMode::Rgb, depth, true)); - surface.fill_rect(Rect::new(0, 0, 32, 16), &[v, v, v, 1.0]); - doc.layers.push(Layer::new("opaque", LayerContent::Raster(surface))); - let app = h.state_mut(); - app.session.add_document(doc, None); - app.sync_views(); - app.ui.view.show.pixel_grid = true; - for zoom in [7.119, 8.0, 22.825] { - control(&mut h, "ui.set", json!({"zoom": zoom, "center": [16.0, 8.0]})); - assert_eq!(h.state().perf.gpu, gpu); - let img = h.render().expect("render opaque grid"); - let center = h.state().last_canvas_rect.center() * ppp; - let x0 = center.x - 16.0 * zoom; - let y = (center.y + 3.25 * zoom).floor() as u32; - for x in 2..30 { - let edge = (x0 + x as f32 * zoom).floor() as u32; - for sy in y..y + 3 { - let inside = img.get_pixel((x0 + (x as f32 + 0.5) * zoom).floor() as u32, sy)[0]; - assert_eq!(inside, (v * 255.0) as u8); - let contrast = (edge - 1..=edge + 1).map(|sx| img.get_pixel(sx, sy)[0].abs_diff(inside)).max().expect("line"); - assert!(contrast >= 28, "{gpu:?}/{depth:?}, scale {ppp}, zoom {zoom}, fill {v}, ({x}, {sy}): invisible grid ({contrast})"); - } - } + let Some(mut h) = harness_with_scale(gpu, ppp) else { return }; + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + for v in [0.0, 1.0] { + let mut doc = Document::new("grid contrast", Size::new(32, 16), ColorMode::Rgb, depth); + let mut surface = Surface::new(PixelFormat::new(ColorMode::Rgb, depth, true)); + surface.fill_rect(Rect::new(0, 0, 32, 16), &[v, v, v, 1.0]); + doc.layers.push(Layer::new("opaque", LayerContent::Raster(surface))); + let app = h.state_mut(); + app.session.add_document(doc, None); + app.sync_views(); + app.ui.view.show.pixel_grid = true; + for zoom in [7.119, 8.0, 22.825] { + control(&mut h, "ui.set", json!({"zoom": zoom, "center": [16.0, 8.0]})); + assert_eq!(h.state().perf.gpu, gpu); + let img = h.render().expect("render opaque grid"); + let center = h.state().last_canvas_rect.center() * ppp; + let x0 = center.x - 16.0 * zoom; + let y = (center.y + 3.25 * zoom).floor() as u32; + for x in 2..30 { + let edge = (x0 + x as f32 * zoom).floor() as u32; + for sy in y..y + 3 { + let inside = img.get_pixel((x0 + (x as f32 + 0.5) * zoom).floor() as u32, sy)[0]; + assert_eq!(inside, (v * 255.0) as u8); + let contrast = (edge - 1..=edge + 1).map(|sx| img.get_pixel(sx, sy)[0].abs_diff(inside)).max().expect("line"); + assert!(contrast >= 28, "{gpu:?}/{depth:?}, scale {ppp}, zoom {zoom}, fill {v}, ({x}, {sy}): invisible grid ({contrast})"); } } } } } } + +#[test] +fn pixel_grid_is_solid_and_visible_gpu_1x() { + pixel_grid_is_solid_and_visible_at_scale(true, 1.0); +} + +#[test] +fn pixel_grid_is_solid_and_visible_gpu_2x() { + pixel_grid_is_solid_and_visible_at_scale(true, 2.0); +} + +#[test] +fn pixel_grid_is_solid_and_visible_cpu_1x() { + pixel_grid_is_solid_and_visible_at_scale(false, 1.0); +} + +#[test] +fn pixel_grid_is_solid_and_visible_cpu_2x() { + pixel_grid_is_solid_and_visible_at_scale(false, 2.0); +} diff --git a/crates/ui-egui/tests/it/full_res_preview.rs b/crates/ui-egui/tests/it/full_res_preview.rs index f472dd2ad3..38a418752a 100644 --- a/crates/ui-egui/tests/it/full_res_preview.rs +++ b/crates/ui-egui/tests/it/full_res_preview.rs @@ -48,8 +48,15 @@ fn control(h: &mut Harness, method: &str, params: Value) { fn columns_document() -> Document { let mut doc = Document::with_background("columns", Size::new(W as u32, H as u32), ColorMode::Rgb, SampleType::U8, Color::WHITE); let s = doc.layers[0].surface_mut().expect("background pixels"); - for x in (0..W).step_by(2) { - s.fill_rect(Rect::new(x, 0, x + 1, H), &[0.0, 0.0, 0.0, 1.0]); + let row: Vec = (0..W) + .flat_map(|x| { + let v = if x % 2 == 0 { 0.0 } else { 1.0 }; + [v, v, v, 1.0] + }) + .collect(); + for y in (0..H).step_by(64) { + let end = (y + 64).min(H); + s.write_region(Rect::new(0, y, W, end), &row.repeat((end - y) as usize)); } doc } diff --git a/crates/ui-egui/tests/it/live_stroke_canvas.rs b/crates/ui-egui/tests/it/live_stroke_canvas.rs index 58d6632ea3..a152f6678c 100644 --- a/crates/ui-egui/tests/it/live_stroke_canvas.rs +++ b/crates/ui-egui/tests/it/live_stroke_canvas.rs @@ -118,8 +118,7 @@ fn max_diff(a: &[[u8; 4]], b: &[[u8; 4]]) -> u8 { a.iter().zip(b).flat_map(|(p, q)| (0..3).map(move |i| p[i].abs_diff(q[i]))).max().unwrap_or(0) } -#[test] -fn the_canvas_shows_the_committed_stroke_while_it_is_drawn() { +fn the_canvas_shows_the_committed_stroke_part(part: usize) { let _gpu = photocraft_testkit::gpu_lock(); let Some(mut h) = harness() else { return }; h.run_steps(4); @@ -135,7 +134,7 @@ fn the_canvas_shows_the_committed_stroke_while_it_is_drawn() { cases.push(Case { zoom: 4.0, smoothing: 0.1, hardness: 0.0, depth: 8, pressure: false, flip: true }); cases.push(Case { zoom: 12.0, smoothing: 0.5, hardness: 0.0, depth: 16, pressure: true, flip: true }); let mut gpu_seen = false; - for c in cases { + for c in cases.into_iter().skip(part).step_by(4) { let (before, drawing, done, gpu) = frames(&mut h, c); gpu_seen |= gpu; // The stroke is on screen while drawing (not a blank canvas compared with a blank one)… @@ -148,3 +147,23 @@ fn the_canvas_shows_the_committed_stroke_while_it_is_drawn() { eprintln!("note: the GPU compositor fell back to the CPU for every case"); } } + +#[test] +fn the_canvas_shows_the_committed_stroke_part_0() { + the_canvas_shows_the_committed_stroke_part(0); +} + +#[test] +fn the_canvas_shows_the_committed_stroke_part_1() { + the_canvas_shows_the_committed_stroke_part(1); +} + +#[test] +fn the_canvas_shows_the_committed_stroke_part_2() { + the_canvas_shows_the_committed_stroke_part(2); +} + +#[test] +fn the_canvas_shows_the_committed_stroke_part_3() { + the_canvas_shows_the_committed_stroke_part(3); +} From 7be59770a42e875751ac5e20478fff732e8edd16 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Fri, 30 Oct 2026 00:50:31 +0100 Subject: [PATCH 3/8] Use persisted animation preference for localization test setup --- crates/ui-egui/tests/it/camera_raw_localization.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/tests/it/camera_raw_localization.rs b/crates/ui-egui/tests/it/camera_raw_localization.rs index 0a00598e6b..eedaa6be93 100644 --- a/crates/ui-egui/tests/it/camera_raw_localization.rs +++ b/crates/ui-egui/tests/it/camera_raw_localization.rs @@ -29,7 +29,7 @@ fn all_registered_languages_render_camera_raw_and_switch_without_resetting_view_ let ctx = h.ctx.clone(); h.state_mut().set_theme(&ctx, ThemeKind::Pro); // Labels and retained state are under test, not the collapse animation. - h.ctx.style_mut(|style| style.animation_time = 0.0); + h.state_mut().session.prefs.edit(|p| p.interface.enable_animations = false); h.state_mut().session.prefs.edit(|p| p.interface.language = lang.code().into()); h.run_steps(4); let tr = |s| i18n::tr_ctx(lang, "cameraRaw", s); From db6e27ac02a01a92b7fb283a0f631f343b319422 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Fri, 30 Oct 2026 01:42:18 +0100 Subject: [PATCH 4/8] Partition GPU theme cases and profile remaining parity bottlenecks --- .config/nextest.toml | 7 +++ crates/gpu/tests/it/parity.rs | 33 ++++++++++---- crates/ui-egui/tests/it/camera_raw_scope.rs | 50 ++++++++++++++++++--- crates/ui-egui/tests/it/full_res_preview.rs | 8 ++-- 4 files changed, 80 insertions(+), 18 deletions(-) diff --git a/.config/nextest.toml b/.config/nextest.toml index b72ae458c9..3e8bca16b4 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -26,3 +26,10 @@ test-group = 'gpu' fail-fast = true slow-timeout = { period = "60s", terminate-after = 10 } failure-output = "immediate-final" + +# Keep phase timings for the remaining expensive Windows GPU regressions visible. This does +# not relax their timeout, isolate failures differently or remove any test cases. +[[profile.ci.overrides]] +platform = 'cfg(target_os = "windows")' +filter = 'package(photocraft-gpu) & test(/documents_larger_than_the_texture_limit|layer_effects_update_incrementally|pages_are_evicted_under_the_budget|the_focused_view_stays_resident_after_a_full_refresh/)' +success-output = "immediate" diff --git a/crates/gpu/tests/it/parity.rs b/crates/gpu/tests/it/parity.rs index e99c078467..a9a6b0dd3b 100644 --- a/crates/gpu/tests/it/parity.rs +++ b/crates/gpu/tests/it/parity.rs @@ -85,6 +85,15 @@ fn hdr_adjustments_preserve_range_and_cpu_parity() { } } +/// Phase timings for diagnosing expensive parity checks; nextest shows them only for the +/// selected slow Windows tests, without changing their timeout or pass criteria. +fn timed(what: &str, run: impl FnOnce() -> T) -> T { + let start = std::time::Instant::now(); + let result = run(); + eprintln!("parity phase {what}: {:?}", start.elapsed()); + result +} + /// The default adapter, or the one wgpu's `WGPU_BACKEND` and `WGPU_DX12_COMPILER` choose: e.g. /// `WGPU_BACKEND=dx12 WGPU_DX12_COMPILER=fxc` tests D3D12 with FXC, the app's default on Windows, /// where the default adapter may be Vulkan. A backend asked for that can't run the tests fails @@ -126,7 +135,7 @@ fn gpu() -> Option { None } }; - let comp = compositor()?; + let comp = timed("pipeline creation", compositor)?; let mut paged = comp.sibling(); paged.set_texture_limit(PAGED_LIMIT); Some(Gpu { device, queue, comp, paged, _lock: lock }) @@ -208,10 +217,13 @@ fn worst_diff(cpu: &[[f32; 4]], gpu: &[[f32; 4]]) -> (f32, usize) { /// GPU (whole textures, then pages) vs CPU over `rect`: Err with the worst pixel if over the /// tolerance, else the stats of the unpaged render. fn diff_rect(g: &mut Gpu, doc: &Document, rect: Rect, what: &str) -> Result { - let cpu = photocraft_compose::render(doc, rect); + let cpu = timed("CPU reference", || photocraft_compose::render(doc, rect)); let mut first = None; for (label, comp) in [("", &mut g.comp), (" (paged)", &mut g.paged)] { - let (out, stats) = photocraft_gpu::render_to_vec_stats(comp, &g.device, &g.queue, doc, rect).map_err(|e| format!("{what}{label}: {e}"))?; + let (out, stats) = timed(&format!("{what}{label} GPU render/readback"), || { + photocraft_gpu::render_to_vec_stats(comp, &g.device, &g.queue, doc, rect) + }) + .map_err(|e| format!("{what}{label}: {e}"))?; let worst = worst_diff(&cpu.px, &out); let w = rect.width() as usize; let (x, y) = (rect.x0 + (worst.1 % w) as i32, rect.y0 + (worst.1 / w) as i32); @@ -1338,13 +1350,15 @@ fn many_layers_submit_inside_one_chunk_mode(paged: bool) { // invalid"). 1200 layers need well over 1000 passes in the document's only chunk: each is // masked, so it is drawn in a pass of its own rather than batched with its neighbours. let Some(mut g) = gpu() else { return }; - let mut d = base_doc(64, 48); + // Submission count, not image area, triggers the command-buffer exhaustion. + // Keep all 1200 masked passes and blend modes, with a smaller pixel oracle. + let mut d = base_doc(16, 12); let modes: Vec = BlendMode::layer_modes().collect(); for (i, mode) in modes.iter().copied().cycle().take(1200).enumerate() { - let mut l = noise_layer("l", PixelFormat::RGBA8, Rect::from_xywh(0, 0, 64, 48), 500 + i as u32, 0.0); + let mut l = noise_layer("l", PixelFormat::RGBA8, Rect::from_xywh(0, 0, 16, 12), 500 + i as u32, 0.0); l.blend = mode; l.opacity = 0.9; - l.mask = Some(mask(Rect::from_xywh(0, 0, 64, 48), 3000 + i as u32, 1.0)); + l.mask = Some(mask(Rect::from_xywh(0, 0, 16, 12), 3000 + i as u32, 1.0)); d.layers.push(l); } let cpu = photocraft_compose::render(&d, d.bounds()); @@ -2912,8 +2926,9 @@ fn pages_are_evicted_under_the_budget() { let page = u64::from(g.paged.page_size()).pow(2) * 4; g.paged.set_resident_budget(page); for round in 0..3 { - let cpu = photocraft_compose::flatten(&d); - let (out, s) = photocraft_gpu::render_to_vec_stats(&mut g.paged, &g.device, &g.queue, &d, d.bounds()).unwrap(); + let cpu = timed("eviction CPU reference", || photocraft_compose::flatten(&d)); + let (out, s) = + timed("eviction GPU render/readback", || photocraft_gpu::render_to_vec_stats(&mut g.paged, &g.device, &g.queue, &d, d.bounds())).unwrap(); let worst = worst_diff(&cpu.px, &out); assert!(worst.0 <= TOL, "round {round}: max diff {:.2}/255 at {}", worst.0 * 255.0, worst.1); assert!(s.evicted > 0, "round {round}: {s:?}"); @@ -3034,7 +3049,7 @@ fn the_focused_view_stays_resident_after_a_full_refresh() { assert!(s.evicted > 0, "{s:?}"); let (out, s) = photocraft_gpu::render_to_vec_stats(&mut comp, &g.device, &g.queue, &d, view).unwrap(); assert_eq!(s.tiles_uploaded, 0, "{s:?}"); - let cpu = photocraft_compose::render(&d, view); + let cpu = timed("focused-view CPU reference", || photocraft_compose::render(&d, view)); assert!(worst_diff(&cpu.px, &out).0 <= TOL); } } diff --git a/crates/ui-egui/tests/it/camera_raw_scope.rs b/crates/ui-egui/tests/it/camera_raw_scope.rs index 48d2c85adb..7214b8bb54 100644 --- a/crates/ui-egui/tests/it/camera_raw_scope.rs +++ b/crates/ui-egui/tests/it/camera_raw_scope.rs @@ -592,10 +592,9 @@ fn curve_deletion_reentry_keyboard_limits_and_controller_replacement_share_captu assert!(std::sync::Arc::ptr_eq(&original, &h.state().session.active().unwrap().doc)); } -#[test] -fn curve_gpu_paints_the_moved_handle_in_every_theme() { +fn curve_gpu_paints_the_moved_handle_in_every_theme_part(part: usize) { let _gpu = gpu_lock(); - for theme in ThemeKind::ALL { + for theme in ThemeKind::ALL.into_iter().skip(part).step_by(4) { let mut h = fixture_render(theme, true); let graph = open_curve(&mut h); set_curve(&mut h, json!([[0, 0], [128, 128], [255, 255]])); @@ -618,6 +617,26 @@ fn curve_gpu_paints_the_moved_handle_in_every_theme() { } } +#[test] +fn curve_gpu_paints_the_moved_handle_in_every_theme_part_0() { + curve_gpu_paints_the_moved_handle_in_every_theme_part(0); +} + +#[test] +fn curve_gpu_paints_the_moved_handle_in_every_theme_part_1() { + curve_gpu_paints_the_moved_handle_in_every_theme_part(1); +} + +#[test] +fn curve_gpu_paints_the_moved_handle_in_every_theme_part_2() { + curve_gpu_paints_the_moved_handle_in_every_theme_part(2); +} + +#[test] +fn curve_gpu_paints_the_moved_handle_in_every_theme_part_3() { + curve_gpu_paints_the_moved_handle_in_every_theme_part(3); +} + #[test] fn pixel_hover_tracks_displayed_rgb_before_after_and_keeps_analysis_cached() { let mut h = fixture(ThemeKind::Pro); @@ -664,11 +683,10 @@ fn pixel_hover_tracks_displayed_rgb_before_after_and_keeps_analysis_cached() { assert!(inspect(&h)["vectorscopeRect"].is_null()); } -#[test] -fn pixel_hover_gpu_badge_single_band_and_crosshair_appear_and_clear_in_all_themes() { +fn pixel_hover_gpu_badge_single_band_and_crosshair_appear_and_clear_in_all_themes_part(part: usize) { let _gpu = gpu_lock(); let mut h = fixture_render(ThemeKind::Pro, true); - for theme in ThemeKind::ALL { + for theme in ThemeKind::ALL.into_iter().skip(part).step_by(4) { let ctx = h.ctx.clone(); h.state_mut().set_theme(&ctx, theme); h.run_steps(3); @@ -731,6 +749,26 @@ fn pixel_hover_gpu_badge_single_band_and_crosshair_appear_and_clear_in_all_theme } } +#[test] +fn pixel_hover_gpu_badge_single_band_and_crosshair_appear_and_clear_in_all_themes_part_0() { + pixel_hover_gpu_badge_single_band_and_crosshair_appear_and_clear_in_all_themes_part(0); +} + +#[test] +fn pixel_hover_gpu_badge_single_band_and_crosshair_appear_and_clear_in_all_themes_part_1() { + pixel_hover_gpu_badge_single_band_and_crosshair_appear_and_clear_in_all_themes_part(1); +} + +#[test] +fn pixel_hover_gpu_badge_single_band_and_crosshair_appear_and_clear_in_all_themes_part_2() { + pixel_hover_gpu_badge_single_band_and_crosshair_appear_and_clear_in_all_themes_part(2); +} + +#[test] +fn pixel_hover_gpu_badge_single_band_and_crosshair_appear_and_clear_in_all_themes_part_3() { + pixel_hover_gpu_badge_single_band_and_crosshair_appear_and_clear_in_all_themes_part(3); +} + #[test] fn sampler_list_replacement_and_operations_cannot_silently_cancel_each_other() { let mut h = fixture(ThemeKind::Pro); diff --git a/crates/ui-egui/tests/it/full_res_preview.rs b/crates/ui-egui/tests/it/full_res_preview.rs index 38a418752a..f2bcbf19be 100644 --- a/crates/ui-egui/tests/it/full_res_preview.rs +++ b/crates/ui-egui/tests/it/full_res_preview.rs @@ -1,7 +1,7 @@ //! Preferences › Performance › Low Resolution Previews: on (the default) live previews of a large //! document render on a reduced copy, off they render at full resolution. //! -//! A 3000 × 2000 document of 1 px black and white columns: the reduced copy (every 2nd pixel at +//! A 2502 × 2000 document of 1 px black and white columns: the reduced copy (every 2nd pixel at //! this size) is all black, so its preview shows a flat colour where the full-size one shows the //! columns at 100 % (and their mipmapped grey zoomed out). Drives the real app offscreen on wgpu; //! skips when no GPU adapter exists (like `drag_preview_canvas.rs`). @@ -15,7 +15,8 @@ use serde_json::{Value, json}; type Harness = egui_kittest::Harness<'static, PhotocraftApp>; -const W: i32 = 3000; +// Just above the 5 MP proxy threshold; keep the same factor-two reduction. +const W: i32 = 2502; const H: i32 = 2000; fn harness() -> Option { @@ -47,6 +48,7 @@ fn control(h: &mut Harness, method: &str, params: Value) { fn columns_document() -> Document { let mut doc = Document::with_background("columns", Size::new(W as u32, H as u32), ColorMode::Rgb, SampleType::U8, Color::WHITE); + assert_eq!(photocraft_ui_egui::proxy::factor(&doc), 2, "the fixture must exercise a reduced preview"); let s = doc.layers[0].surface_mut().expect("background pixels"); let row: Vec = (0..W) .flat_map(|x| { @@ -95,7 +97,7 @@ fn row(h: &mut Harness) -> Vec { photocraft_ui_egui::rulers::content_rect(app, app.last_canvas_rect) }; // egui_kittest waits 10 s for the GPU. Windows CI renders on WARP, a software rasterizer, - // where the first full-resolution composite of this 6 MP document can take longer; the + // where the first full-resolution composite of this 5 MP document can take longer; the // submitted work still finishes, so wait for it again instead of failing. let img = (0..3).find_map(|_| h.render().ok()).expect("render"); let (x0, y) = (canvas.min.x as u32 + 20, canvas.min.y as u32 + 40); From 4c000a26836e414dd8dc812d31bfced4ee1d37a9 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Fri, 30 Oct 2026 01:54:14 +0100 Subject: [PATCH 5/8] style: match rustfmt for GPU timing closure --- crates/gpu/tests/it/parity.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/gpu/tests/it/parity.rs b/crates/gpu/tests/it/parity.rs index a9a6b0dd3b..28249eee39 100644 --- a/crates/gpu/tests/it/parity.rs +++ b/crates/gpu/tests/it/parity.rs @@ -220,10 +220,8 @@ fn diff_rect(g: &mut Gpu, doc: &Document, rect: Rect, what: &str) -> Result Date: Fri, 30 Oct 2026 04:06:44 +0100 Subject: [PATCH 6/8] test: reduce software GPU contention for heavy Windows regressions --- .config/nextest.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.config/nextest.toml b/.config/nextest.toml index 3e8bca16b4..10a62e365d 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -33,3 +33,7 @@ failure-output = "immediate-final" platform = 'cfg(target_os = "windows")' filter = 'package(photocraft-gpu) & test(/documents_larger_than_the_texture_limit|layer_effects_update_incrementally|pages_are_evicted_under_the_budget|the_focused_view_stays_resident_after_a_full_refresh/)' success-output = "immediate" +# These multi-page effect regressions saturate the Windows software GPU. Reserve two +# scheduling slots so four independent devices do not compete for the same CPU renderer. +# All cases and the 60-second warning remain unchanged. +threads-required = 2 From 3b352fa217bbef8701bcc4469c4203663312370f Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Fri, 30 Oct 2026 05:07:28 +0100 Subject: [PATCH 7/8] test: reserve GPU slots for Windows page-eviction stress --- .config/nextest.toml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.config/nextest.toml b/.config/nextest.toml index 10a62e365d..d28cdc4bcb 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -29,6 +29,14 @@ failure-output = "immediate-final" # Keep phase timings for the remaining expensive Windows GPU regressions visible. This does # not relax their timeout, isolate failures differently or remove any test cases. +# Page eviction redraws a complete multi-effect document several times. On the Windows +# CPU-backed adapter it uses all GPU slots, avoiding oversubscription without skipping work. +[[profile.ci.overrides]] +platform = 'cfg(target_os = "windows")' +filter = 'package(photocraft-gpu) & test(pages_are_evicted_under_the_budget)' +threads-required = 4 +success-output = "immediate" + [[profile.ci.overrides]] platform = 'cfg(target_os = "windows")' filter = 'package(photocraft-gpu) & test(/documents_larger_than_the_texture_limit|layer_effects_update_incrementally|pages_are_evicted_under_the_budget|the_focused_view_stays_resident_after_a_full_refresh/)' From 287aad4cbfb5c2142e92125ee1c006cfeee3a9d1 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Fri, 30 Oct 2026 06:13:27 +0100 Subject: [PATCH 8/8] test: partition font acceptance matrix and schedule GPU budget stress --- .config/nextest.toml | 4 +- .../src/tools/type_tool/font_commit_tests.rs | 89 +++++++++++-------- 2 files changed, 55 insertions(+), 38 deletions(-) diff --git a/.config/nextest.toml b/.config/nextest.toml index d28cdc4bcb..703f9208ee 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -29,11 +29,11 @@ failure-output = "immediate-final" # Keep phase timings for the remaining expensive Windows GPU regressions visible. This does # not relax their timeout, isolate failures differently or remove any test cases. -# Page eviction redraws a complete multi-effect document several times. On the Windows +# Page eviction and budget borrowing redraw multi-effect documents. On the Windows # CPU-backed adapter it uses all GPU slots, avoiding oversubscription without skipping work. [[profile.ci.overrides]] platform = 'cfg(target_os = "windows")' -filter = 'package(photocraft-gpu) & test(pages_are_evicted_under_the_budget)' +filter = 'package(photocraft-gpu) & test(/pages_are_evicted_under_the_budget|layer_pages_that_just_miss_borrow_the_effect_map_share/)' threads-required = 4 success-output = "immediate" diff --git a/crates/ui-egui/src/tools/type_tool/font_commit_tests.rs b/crates/ui-egui/src/tools/type_tool/font_commit_tests.rs index e6389e4cd2..bb80791b79 100644 --- a/crates/ui-egui/src/tools/type_tool/font_commit_tests.rs +++ b/crates/ui-egui/src/tools/type_tool/font_commit_tests.rs @@ -168,51 +168,68 @@ fn commit(h: &mut Harness<'_, Probe>, method: &str) { } #[test] -fn selected_fonts_apply_and_survive_commit_across_controls_and_acceptance_methods() { +fn selected_fonts_commit_pattern_0() { + selected_fonts_commit_cases(0); +} + +#[test] +fn selected_fonts_commit_pattern_1() { + selected_fonts_commit_cases(1); +} + +#[test] +fn selected_fonts_commit_pattern_2() { + selected_fonts_commit_cases(2); +} + +#[test] +fn selected_fonts_commit_pattern_3() { + selected_fonts_commit_cases(3); +} + +fn selected_fonts_commit_cases(pattern: usize) { let mut cases = 0; for new in [false, true] { - for pattern in 0..4 { - for character in [false, true] { - for range in [[0, 2], [2, 4], [4, 6], [0, 6], [4, 2], [1, 5]] { - for target in FONTS { - for accept in ["enter", "click", "arrow-enter"] { - for method in ["checkmark", "outside"] { - let label = (new, pattern, character, range, target, accept, method); - let (mut h, id) = fixture(new, pattern, character, "abcdef"); - select(&mut h, range); - h.run(); - let mut expected = families(&saved(&h, id)); - for font in &mut expected[range[0].min(range[1])..range[0].max(range[1])] { - *font = target.into(); - } - choose(&mut h, target, accept); - let applied = saved(&h, id); - assert_eq!(families(&applied), expected, "{label:?}"); - assert_eq!(fields(&h)[0].as_deref(), Some(target), "selected font caption: {label:?}"); - let pixels = layer(&h.state().app.session.active().unwrap().doc, id).cache.clone(); - commit(&mut h, method); - assert!(h.state().app.ui.text_edit.is_none(), "{label:?}"); - assert_eq!(saved(&h, id), applied, "{label:?}"); - assert_eq!(layer(&h.state().app.session.active().unwrap().doc, id).cache, pixels, "{label:?}"); - let uniform = expected.iter().all(|f| f == &expected[0]); - assert_eq!( - fields(&h)[0].clone().unwrap_or_default(), - if uniform { expected[0].clone() } else { String::new() }, - "mixed caption after commit: {label:?}" - ); - super::edit_active(&mut h.state_mut().app).unwrap(); - select(&mut h, range); - h.run(); - assert_eq!(fields(&h)[0].as_deref(), Some(target), "reselected caption: {label:?}"); - cases += 1; + for character in [false, true] { + for range in [[0, 2], [2, 4], [4, 6], [0, 6], [4, 2], [1, 5]] { + for target in FONTS { + for accept in ["enter", "click", "arrow-enter"] { + for method in ["checkmark", "outside"] { + let label = (new, pattern, character, range, target, accept, method); + let (mut h, id) = fixture(new, pattern, character, "abcdef"); + select(&mut h, range); + h.run(); + let mut expected = families(&saved(&h, id)); + for font in &mut expected[range[0].min(range[1])..range[0].max(range[1])] { + *font = target.into(); } + choose(&mut h, target, accept); + let applied = saved(&h, id); + assert_eq!(families(&applied), expected, "{label:?}"); + assert_eq!(fields(&h)[0].as_deref(), Some(target), "selected font caption: {label:?}"); + let pixels = layer(&h.state().app.session.active().unwrap().doc, id).cache.clone(); + commit(&mut h, method); + assert!(h.state().app.ui.text_edit.is_none(), "{label:?}"); + assert_eq!(saved(&h, id), applied, "{label:?}"); + assert_eq!(layer(&h.state().app.session.active().unwrap().doc, id).cache, pixels, "{label:?}"); + let uniform = expected.iter().all(|f| f == &expected[0]); + assert_eq!( + fields(&h)[0].clone().unwrap_or_default(), + if uniform { expected[0].clone() } else { String::new() }, + "mixed caption after commit: {label:?}" + ); + super::edit_active(&mut h.state_mut().app).unwrap(); + select(&mut h, range); + h.run(); + assert_eq!(fields(&h)[0].as_deref(), Some(target), "reselected caption: {label:?}"); + cases += 1; } } } } } } - assert_eq!(cases, 1152); + assert_eq!(cases, 288); } #[test]