mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-02 18:13:39 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user