Merge pull request #10223 from FredApps/ci/test-runtime-2026-10-29

test: partition long parity matrices without dropping cases
This commit is contained in:
Dylan M. Taylor
2026-10-30 07:12:16 -04:00
committed by GitHub
15 changed files with 406 additions and 125 deletions
+19
View File
@@ -26,3 +26,22 @@ 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.
# 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|layer_pages_that_just_miss_borrow_the_effect_map_share/)'
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/)'
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
+6
View File
@@ -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
color_range:
description: "Compare cropped Color Range on 24 MP (Linux, exact outputs)"
type: boolean
@@ -50,6 +54,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"
@@ -153,6 +158,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
+29 -2
View File
@@ -26,8 +26,9 @@ fn bits(v: Vec<f32>) -> Vec<u32> {
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);
@@ -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<f32> = (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();
+20 -6
View File
@@ -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 {
+60 -17
View File
@@ -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<T>(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,8 +135,8 @@ fn gpu() -> Option<Gpu> {
None
}
};
let comp = compositor()?;
let mut paged = 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,11 @@ 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<photocraft_gpu::Stats, String> {
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);
@@ -380,7 +390,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 +1269,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,28 +1342,42 @@ 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
// 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> = 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 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
@@ -2921,8 +2945,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:?}");
@@ -3024,7 +3049,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);
@@ -3043,7 +3068,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);
}
}
@@ -3053,7 +3078,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
@@ -3214,3 +3239,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());
}
+29 -5
View File
@@ -44,11 +44,13 @@ fn open(name: &str, path: &std::path::Path) -> Result<ImportResult, photocraft_i
import_file(name, path, &photocraft_io::ImportOptions::default(), &Interrupt::NONE)
}
#[test]
fn import_file_opens_what_importing_the_bytes_opens() {
let dir = TempDir::new("same");
// Every generated case still runs once, with the same document and warning comparisons.
// Separate nextest processes let the runner schedule the matrix alongside other tests rather
// than leaving one multi-minute test on the critical path.
fn import_file_opens_what_importing_the_bytes_opens_part(part: usize) {
let dir = TempDir::new(&format!("same-{part}"));
let mut checked = 0;
for case in testgen::all_cases() {
for case in testgen::all_cases().into_iter().skip(part).step_by(4) {
let ext = if case.file.header.version.is_psb() { "psb" } else { "psd" };
let name = format!("case.{ext}");
let bytes = case.file.to_bytes().unwrap();
@@ -66,7 +68,29 @@ fn import_file_opens_what_importing_the_bytes_opens() {
(a, b) => 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]
+28 -3
View File
@@ -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::<Vec<_>>());
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);
}
@@ -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]
@@ -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.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);
@@ -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] {
+49 -7
View File
@@ -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,12 +683,15 @@ 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();
for theme in ThemeKind::ALL {
let mut h = fixture_render(theme, true);
let mut h = fixture_render(ThemeKind::Pro, true);
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);
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");
@@ -727,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);
+46 -31
View File
@@ -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);
}
+14 -5
View File
@@ -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<Harness> {
@@ -47,9 +48,17 @@ 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");
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<f32> = (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
}
@@ -88,7 +97,7 @@ fn row(h: &mut Harness) -> Vec<i32> {
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);
+22 -3
View File
@@ -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);
}
+16
View File
@@ -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=<n>` 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