mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-02 22:49:48 -06:00
Merge storytold/main into fix/2678-failed-thumb-retries
main added a thumbnail test next to the retry test (#2975): both are kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GfjNkJiMUagz3DN3zoV2rV
This commit is contained in:
@@ -16,6 +16,12 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
sonarcloud:
|
||||
# Tracked at @main so org-wide CI improvements propagate without PR churn.
|
||||
# Track shared CI improvements on main.
|
||||
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
|
||||
with:
|
||||
shards: 8
|
||||
python-coverage-source: packaging/linux/flatpak
|
||||
python-coverage-script: packaging/linux/flatpak/test_generate_cargo_sources.py
|
||||
timeout-minutes: 120
|
||||
fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
secrets: inherit
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::process::{Command, Stdio};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_lightcraft-cli");
|
||||
use super::binary;
|
||||
|
||||
#[test]
|
||||
fn contact_sheet_exports_pdf_and_protects_originals() {
|
||||
@@ -106,7 +106,7 @@ fn calibrate_reads_raf_inputs_and_reports_bad_files() {
|
||||
let input = tmp("invalid.RAF");
|
||||
std::fs::write(&input, b"FUJIFILMCCD-RAW").unwrap();
|
||||
let out = tmp("raf-profiles");
|
||||
let result = Command::new(BIN).args(["calibrate", "--out"]).arg(&out).arg(&input).output().unwrap();
|
||||
let result = Command::new(binary()).args(["calibrate", "--out"]).arg(&out).arg(&input).output().unwrap();
|
||||
assert!(!result.status.success());
|
||||
let stderr = String::from_utf8_lossy(&result.stderr);
|
||||
assert!(stderr.contains("1 of 1 raw files"), "RAF was silently ignored: {stderr}");
|
||||
@@ -124,8 +124,13 @@ fn mean(path: &std::path::Path) -> f64 {
|
||||
fn mcp_over_stdio_sets_exposure_and_renders() {
|
||||
let input = tmp("in.png");
|
||||
gradient_png(&input);
|
||||
let mut child =
|
||||
Command::new(BIN).args(["mcp", input.to_str().unwrap()]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap();
|
||||
let mut child = Command::new(binary())
|
||||
.args(["mcp", input.to_str().unwrap()])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
let mut stdout = BufReader::new(child.stdout.take().unwrap());
|
||||
let mut rpc = |id: u64, method: &str, params: Value| -> Value {
|
||||
@@ -163,9 +168,9 @@ fn render_subcommand() {
|
||||
gradient_png(&input);
|
||||
let plain = tmp("r-plain.png");
|
||||
let out = tmp("r-out.jpg");
|
||||
let st = Command::new(BIN).args(["render", input.to_str().unwrap(), "-o", plain.to_str().unwrap()]).status().unwrap();
|
||||
let st = Command::new(binary()).args(["render", input.to_str().unwrap(), "-o", plain.to_str().unwrap()]).status().unwrap();
|
||||
assert!(st.success());
|
||||
let st = Command::new(BIN)
|
||||
let st = Command::new(binary())
|
||||
.args(["render", input.to_str().unwrap(), "-o", out.to_str().unwrap(), "--set", "light.exposure=1", "--size", "60"])
|
||||
.status()
|
||||
.unwrap();
|
||||
@@ -176,7 +181,7 @@ fn render_subcommand() {
|
||||
assert_eq!((d.width, d.height), (120, 80));
|
||||
assert!(mean(&out) > mean(&plain) + 10.0);
|
||||
// Bad control id fails cleanly.
|
||||
let o = Command::new(BIN).args(["render", input.to_str().unwrap(), "-o", out.to_str().unwrap(), "--set", "nope=1"]).output().unwrap();
|
||||
let o = Command::new(binary()).args(["render", input.to_str().unwrap(), "-o", out.to_str().unwrap(), "--set", "nope=1"]).output().unwrap();
|
||||
assert!(!o.status.success());
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains("unknown control"));
|
||||
}
|
||||
@@ -204,10 +209,10 @@ fn render_settings_applies_an_lcpreset_and_refuses_unknown_files() {
|
||||
);
|
||||
assert!(ok, "{err}: {lines:?}");
|
||||
let plain = tmp("lcpreset-plain.png");
|
||||
let st = Command::new(BIN).args(["render", input.to_str().unwrap(), "-o", plain.to_str().unwrap()]).status().unwrap();
|
||||
let st = Command::new(binary()).args(["render", input.to_str().unwrap(), "-o", plain.to_str().unwrap()]).status().unwrap();
|
||||
assert!(st.success());
|
||||
let bright = tmp("lcpreset-bright.png");
|
||||
let o = Command::new(BIN)
|
||||
let o = Command::new(binary())
|
||||
.args(["render", input.to_str().unwrap(), "-o", bright.to_str().unwrap(), "--settings", preset.to_str().unwrap()])
|
||||
.output()
|
||||
.unwrap();
|
||||
@@ -217,7 +222,7 @@ fn render_settings_applies_an_lcpreset_and_refuses_unknown_files() {
|
||||
let other = tmp("other.lcpreset");
|
||||
std::fs::write(&other, r#"{"format": "other", "version": 1, "presets": []}"#).unwrap();
|
||||
let out = tmp("lcpreset-other.png");
|
||||
let o = Command::new(BIN)
|
||||
let o = Command::new(binary())
|
||||
.args(["render", input.to_str().unwrap(), "-o", out.to_str().unwrap(), "--settings", other.to_str().unwrap()])
|
||||
.output()
|
||||
.unwrap();
|
||||
@@ -236,8 +241,10 @@ fn render_refuses_to_overwrite_its_input() {
|
||||
std::fs::create_dir_all(dir.join("sub")).unwrap();
|
||||
let other_spelling = dir.join("sub/../self-in.png");
|
||||
for out in [&input, &other_spelling] {
|
||||
let o =
|
||||
Command::new(BIN).args(["render", input.to_str().unwrap(), "-o", out.to_str().unwrap(), "--set", "light.exposure=1"]).output().unwrap();
|
||||
let o = Command::new(binary())
|
||||
.args(["render", input.to_str().unwrap(), "-o", out.to_str().unwrap(), "--set", "light.exposure=1"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!o.status.success());
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains("never writes over an original"), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
}
|
||||
@@ -249,7 +256,7 @@ fn render_export_options() {
|
||||
let input = tmp("o-in.png");
|
||||
gradient_png(&input);
|
||||
let out = tmp("o-out.tif");
|
||||
let st = Command::new(BIN)
|
||||
let st = Command::new(binary())
|
||||
.args(["render", input.to_str().unwrap(), "-o", out.to_str().unwrap()])
|
||||
.args(["--opt", "shortEdge=40", "--opt", "colorSpace=displayP3", "--opt", "bitDepth=16"])
|
||||
.status()
|
||||
@@ -260,7 +267,7 @@ fn render_export_options() {
|
||||
assert_eq!(d.space.named, Some(lightcraft_codecs::NamedSpace::DisplayP3));
|
||||
// original + sidecar named after the output
|
||||
let out = tmp("o-copy.png");
|
||||
let st = Command::new(BIN)
|
||||
let st = Command::new(binary())
|
||||
.args(["render", input.to_str().unwrap(), "-o", out.to_str().unwrap(), "--set", "light.exposure=1", "--opt", "format=original"])
|
||||
.status()
|
||||
.unwrap();
|
||||
@@ -269,26 +276,26 @@ fn render_export_options() {
|
||||
let xmp = std::fs::read_to_string(out.with_extension("xmp")).unwrap();
|
||||
assert!(xmp.contains("exposure"), "{xmp}");
|
||||
// unknown extension fails with a hint
|
||||
let o = Command::new(BIN).args(["render", input.to_str().unwrap(), "-o", tmp("x.bmp").to_str().unwrap()]).output().unwrap();
|
||||
let o = Command::new(binary()).args(["render", input.to_str().unwrap(), "-o", tmp("x.bmp").to_str().unwrap()]).output().unwrap();
|
||||
assert!(!o.status.success() && String::from_utf8_lossy(&o.stderr).contains("unknown extension"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_subcommand_lists_registry() {
|
||||
let o = Command::new(BIN).args(["commands", "--json"]).output().unwrap();
|
||||
let o = Command::new(binary()).args(["commands", "--json"]).output().unwrap();
|
||||
assert!(o.status.success());
|
||||
let v: Value = serde_json::from_slice(&o.stdout).unwrap();
|
||||
let ids: Vec<&str> = v.as_array().unwrap().iter().filter_map(|c| c["id"].as_str()).collect();
|
||||
for id in ["develop.set", "photo.rate", "library.import", "edit.undo", "app.export"] {
|
||||
assert!(ids.contains(&id), "{id}");
|
||||
}
|
||||
let o = Command::new(BIN).arg("controls").output().unwrap();
|
||||
let o = Command::new(binary()).arg("controls").output().unwrap();
|
||||
assert!(String::from_utf8_lossy(&o.stdout).contains("light.exposure"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_subcommand_lists_commands_sorted_by_id() {
|
||||
let o = Command::new(BIN).args(["commands", "--json"]).output().unwrap();
|
||||
let o = Command::new(binary()).args(["commands", "--json"]).output().unwrap();
|
||||
assert!(o.status.success());
|
||||
let v: Value = serde_json::from_slice(&o.stdout).unwrap();
|
||||
let cmds = v.as_array().unwrap();
|
||||
@@ -302,7 +309,7 @@ fn commands_subcommand_lists_commands_sorted_by_id() {
|
||||
let unsorted = ids.windows(2).find(|w| w[0] >= w[1]);
|
||||
assert!(unsorted.is_none(), "--json should list commands sorted by id, but {unsorted:?} are out of order");
|
||||
|
||||
let o = Command::new(BIN).arg("commands").output().unwrap();
|
||||
let o = Command::new(binary()).arg("commands").output().unwrap();
|
||||
assert!(o.status.success());
|
||||
let text = String::from_utf8_lossy(&o.stdout);
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
@@ -338,7 +345,7 @@ fn snapshot_subcommand_renders_the_ui_headlessly() {
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let o = Command::new(BIN)
|
||||
let o = Command::new(binary())
|
||||
.args(["snapshot", "--demo", "--script", script.to_str().unwrap(), "-o", a.to_str().unwrap(), "--size", "640x400"])
|
||||
.output()
|
||||
.unwrap();
|
||||
@@ -353,7 +360,7 @@ fn snapshot_subcommand_renders_the_ui_headlessly() {
|
||||
assert_eq!(mean(p) < mean(&a) - 2.0, dimmed, "{}", p.display());
|
||||
}
|
||||
// no script: one settled screenshot, at 2× scale
|
||||
let o = Command::new(BIN).args(["snapshot", "-o", a.to_str().unwrap(), "--size", "320x240", "--scale", "2"]).output().unwrap();
|
||||
let o = Command::new(binary()).args(["snapshot", "-o", a.to_str().unwrap(), "--size", "320x240", "--scale", "2"]).output().unwrap();
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
let d = lightcraft_codecs::decode(&std::fs::read(&a).unwrap(), Default::default()).unwrap();
|
||||
assert_eq!((d.width, d.height), (640, 480));
|
||||
@@ -375,7 +382,7 @@ fn snapshot_script_failure_exits_non_zero() {
|
||||
)
|
||||
.unwrap();
|
||||
let _ = std::fs::remove_file(&out);
|
||||
let o = Command::new(BIN)
|
||||
let o = Command::new(binary())
|
||||
.args(["snapshot", "--demo", "--script", script.to_str().unwrap(), "-o", out.to_str().unwrap(), "--size", "320x200"])
|
||||
.output()
|
||||
.unwrap();
|
||||
@@ -400,7 +407,7 @@ fn snapshot_script_with_non_object_params_is_an_error() {
|
||||
let script = tmp("snap-bad-params.jsonl");
|
||||
let out = tmp("snap-bad-params.png");
|
||||
std::fs::write(&script, format!("{}\n", json!({"method": "ui.screenshot", "params": [1]}))).unwrap();
|
||||
let o = Command::new(BIN)
|
||||
let o = Command::new(binary())
|
||||
.args(["snapshot", "--demo", "--script", script.to_str().unwrap(), "-o", out.to_str().unwrap(), "--size", "320x200"])
|
||||
.output()
|
||||
.unwrap();
|
||||
@@ -417,7 +424,7 @@ fn snapshot_ui_zoom_keeps_requested_pixel_dimensions() {
|
||||
.unwrap();
|
||||
for (size, scale, expected) in [("400x240", "1", (400, 240)), ("345x200", "0.9", (311, 180))] {
|
||||
let out = tmp(&format!("snap-ui-zoom-{scale}.png"));
|
||||
let o = Command::new(BIN)
|
||||
let o = Command::new(binary())
|
||||
.args(["snapshot", "--script", script.to_str().unwrap(), "-o", out.to_str().unwrap(), "--size", size, "--scale", scale])
|
||||
.output()
|
||||
.unwrap();
|
||||
@@ -440,7 +447,7 @@ fn snapshot_defaults_to_cpu_without_gpu_environment_overrides() {
|
||||
)
|
||||
.unwrap();
|
||||
let image = tmp("default-cpu.png");
|
||||
let output = Command::new(BIN)
|
||||
let output = Command::new(binary())
|
||||
.env_remove("LIGHTCRAFT_GPU")
|
||||
.env_remove("LIGHTCRAFT_GPU_BACKEND")
|
||||
.env_remove("WGPU_BACKEND")
|
||||
@@ -476,7 +483,7 @@ fn snapshot_starts_without_a_gpu() {
|
||||
.unwrap();
|
||||
for (var, value, reason) in [("LIGHTCRAFT_GPU", "0", "LIGHTCRAFT_GPU=0"), ("LIGHTCRAFT_GPU_BACKEND", "off", "LIGHTCRAFT_GPU_BACKEND=off")] {
|
||||
let out = tmp(&format!("nogpu-{var}.png"));
|
||||
let o = Command::new(BIN)
|
||||
let o = Command::new(binary())
|
||||
.env_remove("LIGHTCRAFT_GPU")
|
||||
.env_remove("LIGHTCRAFT_GPU_BACKEND")
|
||||
.env(var, value)
|
||||
@@ -496,7 +503,7 @@ fn snapshot_starts_without_a_gpu() {
|
||||
}
|
||||
|
||||
fn run_cli(args: &[&str], stdin: Option<&str>) -> (bool, Vec<Value>, String) {
|
||||
let mut child = Command::new(BIN).arg("run").args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
let mut child = Command::new(binary()).arg("run").args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
if let Some(text) = stdin {
|
||||
child.stdin.take().unwrap().write_all(text.as_bytes()).unwrap();
|
||||
}
|
||||
@@ -561,7 +568,7 @@ fn devices_are_listed_and_imported_from() {
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
std::fs::create_dir_all(base.join("CARD/DCIM/100TEST")).unwrap();
|
||||
gradient_png(&base.join("CARD/DCIM/100TEST/IMG_0001.png"));
|
||||
let o = Command::new(BIN).env("LIGHTCRAFT_DEVICE_ROOTS", &base).args(["run", "--demo", "library.devices"]).output().unwrap();
|
||||
let o = Command::new(binary()).env("LIGHTCRAFT_DEVICE_ROOTS", &base).args(["run", "--demo", "library.devices"]).output().unwrap();
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
let line: Value = serde_json::from_slice(o.stdout.split(|b| *b == b'\n').next().unwrap()).unwrap();
|
||||
let dev = &line["result"][0];
|
||||
@@ -570,7 +577,7 @@ fn devices_are_listed_and_imported_from() {
|
||||
let dcim = dev["path"].as_str().unwrap().to_string();
|
||||
// JSON-escaped: a Windows path has backslashes
|
||||
let paths = serde_json::to_string(&[&dcim]).unwrap();
|
||||
let o = Command::new(BIN).args(["run", "library.importPreview", &format!("paths={paths}")]).output().unwrap();
|
||||
let o = Command::new(binary()).args(["run", "library.importPreview", &format!("paths={paths}")]).output().unwrap();
|
||||
let line: Value = serde_json::from_slice(o.stdout.split(|b| *b == b'\n').next().unwrap()).unwrap();
|
||||
assert_eq!(line["result"]["candidates"].as_array().map(Vec::len), Some(1), "{line}");
|
||||
}
|
||||
@@ -583,7 +590,7 @@ fn a_library_open_in_another_process_is_refused() {
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
let lib_s = lib.to_str().unwrap();
|
||||
let mut holder =
|
||||
Command::new(BIN).args(["mcp", "--library", lib_s]).stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
Command::new(binary()).args(["mcp", "--library", lib_s]).stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
let mut err = BufReader::new(holder.stderr.take().unwrap());
|
||||
let mut line = String::new();
|
||||
while !line.contains("opened library") {
|
||||
@@ -643,7 +650,7 @@ fn warnings_are_logged_on_stderr() {
|
||||
gradient_png(&input);
|
||||
let out = tmp("log-out.jpg");
|
||||
let run = |level: &str| {
|
||||
let o = Command::new(BIN)
|
||||
let o = Command::new(binary())
|
||||
.args(["render", input.to_str().unwrap(), "-o", out.to_str().unwrap()])
|
||||
.env("LIGHTCRAFT_GPU_BACKEND", "bogus")
|
||||
.env("RUST_LOG", level)
|
||||
@@ -667,7 +674,7 @@ fn snapshot_rejects_invalid_dimensions_before_creating_an_image() {
|
||||
[("NaNx100", "1"), ("100x100", "inf"), ("9000x9000", "1"), ("9000x100", "2"), ("100x100", "0.001")].into_iter().enumerate()
|
||||
{
|
||||
let image = tmp(&format!("invalid-viewport-{index}.png"));
|
||||
let output = Command::new(BIN).args(["snapshot", "--size", size, "--scale", scale, "-o"]).arg(&image).output().unwrap();
|
||||
let output = Command::new(binary()).args(["snapshot", "--size", size, "--scale", scale, "-o"]).arg(&image).output().unwrap();
|
||||
assert!(!output.status.success());
|
||||
assert!(String::from_utf8_lossy(&output.stderr).contains("snapshot: bad --size/--scale"), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
assert!(!image.exists());
|
||||
@@ -677,8 +684,12 @@ fn snapshot_rejects_invalid_dimensions_before_creating_an_image() {
|
||||
#[test]
|
||||
fn snapshot_preserves_fractional_scale_rounding() {
|
||||
let image = tmp("fractional-scale-rounding.png");
|
||||
let output =
|
||||
Command::new(BIN).env("LIGHTCRAFT_GPU", "0").args(["snapshot", "--size", "345x200", "--scale", "0.9", "-o"]).arg(&image).output().unwrap();
|
||||
let output = Command::new(binary())
|
||||
.env("LIGHTCRAFT_GPU", "0")
|
||||
.args(["snapshot", "--size", "345x200", "--scale", "0.9", "-o"])
|
||||
.arg(&image)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
let decoded = lightcraft_codecs::decode(&std::fs::read(image).unwrap(), Default::default()).unwrap();
|
||||
assert_eq!((decoded.width, decoded.height), (311, 180));
|
||||
@@ -709,7 +720,7 @@ fn snapshot_leaves_desktop_gpu_preferences_untouched() {
|
||||
.unwrap();
|
||||
for gpu_environment in [None, Some("1")] {
|
||||
let image = dir.join(format!("{}.png", gpu_environment.unwrap_or("default")));
|
||||
let mut cmd = Command::new(BIN);
|
||||
let mut cmd = Command::new(binary());
|
||||
cmd.env("XDG_CONFIG_HOME", &config).env_remove("LIGHTCRAFT_GPU").env_remove("LIGHTCRAFT_GPU_BACKEND").env_remove("WGPU_BACKEND");
|
||||
if let Some(value) = gpu_environment {
|
||||
cmd.env("LIGHTCRAFT_GPU", value);
|
||||
@@ -770,13 +781,14 @@ fn denoise_serve_runs_tiles_for_another_computer() {
|
||||
let sha = lightcraft_denoise::hash::sha256_file(&onnx).unwrap();
|
||||
|
||||
// without its token the server doesn't start
|
||||
let o = Command::new(BIN).args(["denoise-serve", "--models", models.to_str().unwrap(), "--token-env", "LC_TEST_NO_SUCH_VAR"]).output().unwrap();
|
||||
let o =
|
||||
Command::new(binary()).args(["denoise-serve", "--models", models.to_str().unwrap(), "--token-env", "LC_TEST_NO_SUCH_VAR"]).output().unwrap();
|
||||
assert!(!o.status.success() && String::from_utf8_lossy(&o.stderr).contains("LC_TEST_NO_SUCH_VAR"));
|
||||
|
||||
let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
|
||||
let addr = format!("127.0.0.1:{port}");
|
||||
let token = "cli-test-token-0123456789";
|
||||
let mut child = Command::new(BIN)
|
||||
let mut child = Command::new(binary())
|
||||
.args(["denoise-serve", "--models", models.to_str().unwrap(), "--listen", &addr, "--run-on", "cpu"])
|
||||
.env("LIGHTCRAFT_DENOISE_TOKEN", token)
|
||||
.stderr(Stdio::piped())
|
||||
|
||||
@@ -2,3 +2,8 @@
|
||||
|
||||
mod cli;
|
||||
mod mcp_shutdown;
|
||||
|
||||
/// Nextest archives relocate the executable; plain Cargo uses its compile-time path.
|
||||
fn binary() -> std::ffi::OsString {
|
||||
std::env::var_os("NEXTEST_BIN_EXE_lightcraft_cli").unwrap_or_else(|| env!("CARGO_BIN_EXE_lightcraft-cli").into())
|
||||
}
|
||||
|
||||
@@ -6,13 +6,7 @@ use std::time::{Duration, Instant};
|
||||
|
||||
#[test]
|
||||
fn broken_stdout_exits_with_stdin_still_open() {
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_lightcraft-cli"))
|
||||
.arg("mcp")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut child = Command::new(super::binary()).arg("mcp").stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
let mut input = child.stdin.take().unwrap();
|
||||
drop(child.stdout.take());
|
||||
input.write_all(b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"}\n{\"jsonrpc\":").unwrap();
|
||||
|
||||
@@ -77,3 +77,6 @@ web-sys = { workspace = true, features = [
|
||||
# it (HEVC patents); build with `--features heif` to include it. On wasm32 panics abort, so
|
||||
# heic-rs's `catch_unwind` guard does not apply there.
|
||||
heif = ["lightcraft-codecs/heif"]
|
||||
|
||||
[dev-dependencies]
|
||||
egui = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
//! When the browser host may consume queued drops. Kept out of the wasm-only `web` module so the
|
||||
//! rule that keeps stored drops out of a library behind a foreground window is testable natively.
|
||||
|
||||
use lightcraft_ui_egui::LightcraftApp;
|
||||
|
||||
/// The browser host's two drop queues: file handles not yet read, and originals whose asynchronous
|
||||
/// storage has finished and that wait to be imported. Taking either only through this trait keeps
|
||||
/// the gate and the drain together.
|
||||
pub trait QueuedDrops {
|
||||
type Dropped;
|
||||
type Pending;
|
||||
/// Whether a prompt or the blocking library window owns input right now.
|
||||
fn drops_wait(&self) -> bool;
|
||||
/// Record that all currently unread handles are held by this foreground barrier.
|
||||
fn defer_dropped(&mut self);
|
||||
fn take_dropped(&mut self) -> Vec<Self::Dropped>;
|
||||
/// Take fresh arrivals only, retaining handles protected by an earlier foreground barrier.
|
||||
fn take_unprotected_dropped(&mut self) -> Vec<Self::Dropped>;
|
||||
fn take_pending(&mut self) -> Vec<Self::Pending>;
|
||||
}
|
||||
|
||||
/// A prompt or the blocking library window (over an already open library, or its own New Library
|
||||
/// name prompt or asynchronous folder picker) owns input: leaves the dropped-file queue and the
|
||||
/// originals still being stored for a later UI pass, instead of importing them behind the window.
|
||||
/// With no library open and no dialog in front, the existing drop path is unchanged.
|
||||
pub fn drops_wait(app: &LightcraftApp) -> bool {
|
||||
lightcraft_ui_egui::panels::notices::blocks_input(app) || lightcraft_ui_egui::panels::library_problem::blocks_file_drops(app)
|
||||
}
|
||||
|
||||
/// One UI pass's queues, drained by [`drain_ready`].
|
||||
pub struct Drained<D, P> {
|
||||
pub dropped: Vec<D>,
|
||||
/// `None` when new file handles were refused behind a dialog: the originals already stored
|
||||
/// stay queued for a later pass (as before), rather than importing behind that dialog.
|
||||
pub pending: Option<Vec<P>>,
|
||||
}
|
||||
|
||||
/// The actual queue drain `WebApp` runs each UI pass: while [`drops_wait`] holds, neither queue is
|
||||
/// touched. With an ordinary dialog open, only fresh handles are taken for refusal; earlier
|
||||
/// barrier-held handles remain queued. If fresh handles are refused, stored originals are left
|
||||
/// queued as well. Otherwise the existing stored-original drain policy is unchanged.
|
||||
pub fn drain_ready<Q: QueuedDrops>(queues: &mut Q, dialog_open: bool) -> Option<Drained<Q::Dropped, Q::Pending>> {
|
||||
if queues.drops_wait() {
|
||||
queues.defer_dropped();
|
||||
return None;
|
||||
}
|
||||
let dropped = if dialog_open { queues.take_unprotected_dropped() } else { queues.take_dropped() };
|
||||
let pending = if !dropped.is_empty() && dialog_open { None } else { Some(queues.take_pending()) };
|
||||
Some(Drained { dropped, pending })
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod tests {
|
||||
use super::{QueuedDrops, drain_ready, drops_wait};
|
||||
use lightcraft_engine::Session;
|
||||
use lightcraft_ui_egui::panels::library_problem::LibraryProblem;
|
||||
use lightcraft_ui_egui::state::Dialog;
|
||||
use lightcraft_ui_egui::{LightcraftApp, Services};
|
||||
|
||||
struct Scratch(std::path::PathBuf);
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
type Asked = std::sync::Arc<std::sync::Mutex<Vec<std::sync::mpsc::Sender<Vec<String>>>>>;
|
||||
|
||||
fn app_with_library(tag: &str) -> Result<(Scratch, LightcraftApp), String> {
|
||||
let dir = std::env::temp_dir().join(format!("lc-web-drop-gate-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).map_err(|error| error.to_string())?;
|
||||
let scratch = Scratch(dir);
|
||||
let mut session = Session::new().with_fs();
|
||||
session.open_library(&scratch.0, false).map_err(|error| error.to_string())?;
|
||||
Ok((scratch, LightcraftApp::new(session, Services::default())))
|
||||
}
|
||||
|
||||
/// Stands in for `WebApp`: the same shared orchestration over two real queues, so a test sees
|
||||
/// that a held window leaves both queues untouched, that the ready path drains both, and that
|
||||
/// a refused new drop keeps the already stored originals queued.
|
||||
struct FakeQueues {
|
||||
app: LightcraftApp,
|
||||
dropped: Vec<String>,
|
||||
protected: usize,
|
||||
pending: Vec<String>,
|
||||
}
|
||||
|
||||
impl FakeQueues {
|
||||
fn new(app: LightcraftApp) -> Self {
|
||||
Self { app, dropped: vec!["dropped-photo".into()], protected: 0, pending: vec!["web/hash/photo.png".into()] }
|
||||
}
|
||||
}
|
||||
|
||||
impl QueuedDrops for FakeQueues {
|
||||
type Dropped = String;
|
||||
type Pending = String;
|
||||
|
||||
fn drops_wait(&self) -> bool {
|
||||
drops_wait(&self.app)
|
||||
}
|
||||
|
||||
fn defer_dropped(&mut self) {
|
||||
self.protected = self.dropped.len();
|
||||
}
|
||||
|
||||
fn take_dropped(&mut self) -> Vec<Self::Dropped> {
|
||||
self.protected = 0;
|
||||
std::mem::take(&mut self.dropped)
|
||||
}
|
||||
|
||||
fn take_unprotected_dropped(&mut self) -> Vec<Self::Dropped> {
|
||||
self.dropped.split_off(self.protected.min(self.dropped.len()))
|
||||
}
|
||||
|
||||
fn take_pending(&mut self) -> Vec<Self::Pending> {
|
||||
std::mem::take(&mut self.pending)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_drops_wait_through_the_choosers_new_library_prompt_and_drain_after_it() {
|
||||
let (_dir, mut app) = app_with_library("chooser").expect("create chooser test library");
|
||||
let mut problem = LibraryProblem::choose_later(&app.ui.settings);
|
||||
if let Some(c) = problem.chooser.as_mut() {
|
||||
c.over = Some(app.session.library_generation());
|
||||
}
|
||||
app.library_problem = Some(problem);
|
||||
let mut queues = FakeQueues::new(app);
|
||||
|
||||
// The list's own New Library name prompt hides the list but still owns the library.
|
||||
queues.app.ui.dialog = Some(Dialog::TextPrompt {
|
||||
title: "New Library".into(),
|
||||
hint: "Library name".into(),
|
||||
value: String::new(),
|
||||
command: "app.newLibrary".into(),
|
||||
params: serde_json::json!({}),
|
||||
key: "name".into(),
|
||||
});
|
||||
assert!(drain_ready(&mut queues, false).is_none(), "the name prompt holds both queues");
|
||||
assert_eq!((queues.dropped.len(), queues.pending.len()), (1, 1), "the queued drop survived the prompt");
|
||||
|
||||
// Cancelling the prompt brings the list back; it still owns the drop until it goes.
|
||||
queues.app.ui.dialog = None;
|
||||
assert!(drain_ready(&mut queues, false).is_none(), "the list is back and still holds the queue");
|
||||
queues.app.library_problem = None;
|
||||
let got = drain_ready(&mut queues, false).expect("the queues should be ready");
|
||||
assert_eq!(got.dropped, vec!["dropped-photo".to_string()]);
|
||||
assert_eq!(got.pending, Some(vec!["web/hash/photo.png".to_string()]));
|
||||
assert!(queues.dropped.is_empty() && queues.pending.is_empty(), "both queues drained once the window was gone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_new_drop_behind_a_dialog_keeps_stored_originals_queued() {
|
||||
let (_dir, app) = app_with_library("refused").expect("create refused-drop test library");
|
||||
// not held by the gate: the dialog alone refuses the newly dropped handles
|
||||
let mut queues = FakeQueues::new(app);
|
||||
let got = drain_ready(&mut queues, true).expect("the queues should be ready");
|
||||
assert_eq!(got.dropped, vec!["dropped-photo".to_string()]);
|
||||
assert_eq!(got.pending, None, "the refused drop must not drain originals behind the dialog");
|
||||
assert_eq!(queues.pending.len(), 1, "the earlier originals stayed queued");
|
||||
|
||||
// Nothing new was dropped: originals read from an earlier drop still finish, as before.
|
||||
let got = drain_ready(&mut queues, true).expect("the queues should be ready");
|
||||
assert!(got.dropped.is_empty());
|
||||
assert_eq!(got.pending, Some(vec!["web/hash/photo.png".to_string()]));
|
||||
assert!(queues.pending.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_no_library_library_picker_holds_both_queues() {
|
||||
let mut app = LightcraftApp::new(Session::new().with_fs(), Services::default());
|
||||
let asked: Asked = Default::default();
|
||||
app.services.picker = Some(Box::new(move |_| {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
asked.lock().unwrap().push(tx);
|
||||
Ok(rx)
|
||||
}));
|
||||
// New Library's name prompt was confirmed; the asynchronous place picker is still open.
|
||||
assert_eq!(app.run("app.newLibrary", serde_json::json!({"name": "Queued"})).unwrap(), serde_json::Value::Null);
|
||||
assert!(app.session.library.is_none());
|
||||
app.library_problem = Some(LibraryProblem::choose_later(&app.ui.settings));
|
||||
let mut queues = FakeQueues::new(app);
|
||||
assert!(drain_ready(&mut queues, false).is_none(), "a library picker that will open one holds both queues");
|
||||
assert_eq!((queues.dropped.len(), queues.pending.len()), (1, 1), "nothing was drained while the picker is up");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_no_library_window_lets_the_existing_drop_path_consume_the_queues() {
|
||||
let mut app = LightcraftApp::new(Session::with_demo(), Services::default());
|
||||
app.library_problem = Some(LibraryProblem::new("/no/such/library", "missing"));
|
||||
let mut queues = FakeQueues::new(app);
|
||||
assert!(!drops_wait(&queues.app), "with no library open the existing drop path is unchanged");
|
||||
let got = drain_ready(&mut queues, false).expect("the queues should be ready");
|
||||
assert_eq!(got.dropped, vec!["dropped-photo".to_string()]);
|
||||
assert_eq!(got.pending, Some(vec!["web/hash/photo.png".to_string()]));
|
||||
assert!(queues.dropped.is_empty() && queues.pending.is_empty(), "the host consumed both queues");
|
||||
|
||||
queues.app.notices.push("The settings file could not be read and was reset.".into());
|
||||
queues.dropped.push("late-drop".into());
|
||||
queues.pending.push("web/hash/late.png".into());
|
||||
assert!(drain_ready(&mut queues, false).is_none(), "a prompt still owns input");
|
||||
assert_eq!((queues.dropped.len(), queues.pending.len()), (1, 1), "the prompt left both queues alone");
|
||||
}
|
||||
|
||||
/// Uses the product's actual raw-handle queue and provenance APIs. Only the stored-original
|
||||
/// vector is host-owned; there is no test copy of protected-prefix bookkeeping or policy.
|
||||
struct RealAppQueues {
|
||||
h: lightcraft_ui_egui::headless::Headless,
|
||||
pending: Vec<String>,
|
||||
pending_takes: usize,
|
||||
}
|
||||
|
||||
impl QueuedDrops for RealAppQueues {
|
||||
type Dropped = egui::DroppedFileHandle;
|
||||
type Pending = String;
|
||||
|
||||
fn drops_wait(&self) -> bool {
|
||||
drops_wait(&self.h.app)
|
||||
}
|
||||
|
||||
fn defer_dropped(&mut self) {
|
||||
self.h.app.defer_dropped_files();
|
||||
}
|
||||
|
||||
fn take_dropped(&mut self) -> Vec<Self::Dropped> {
|
||||
self.h.app.take_dropped_files()
|
||||
}
|
||||
|
||||
fn take_unprotected_dropped(&mut self) -> Vec<Self::Dropped> {
|
||||
self.h.app.take_unprotected_dropped_files()
|
||||
}
|
||||
|
||||
fn take_pending(&mut self) -> Vec<Self::Pending> {
|
||||
self.pending_takes += 1;
|
||||
std::mem::take(&mut self.pending)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct UnreadDrop {
|
||||
path: std::path::PathBuf,
|
||||
reads: std::sync::Arc<std::sync::atomic::AtomicUsize>,
|
||||
}
|
||||
|
||||
impl egui::DroppedFile for UnreadDrop {
|
||||
fn path(&self) -> &std::path::Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
fn bytes(&self) -> Result<Vec<u8>, String> {
|
||||
self.reads.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
Err("the queue gate must not read dropped bytes".into())
|
||||
}
|
||||
}
|
||||
|
||||
fn unread(path: &str) -> (egui::DroppedFileHandle, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
|
||||
let reads = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let handle = std::sync::Arc::new(UnreadDrop { path: path.into(), reads: reads.clone() });
|
||||
(handle, reads)
|
||||
}
|
||||
|
||||
fn queue_raw(queues: &mut RealAppQueues, handles: &[egui::DroppedFileHandle]) {
|
||||
let mut raw = egui::RawInput { dropped_files: handles.to_vec(), ..Default::default() };
|
||||
queues.h.app.raw_input_hook(&queues.h.view.ctx, &mut raw);
|
||||
assert!(raw.dropped_files.is_empty(), "the product takes raw ownership once");
|
||||
}
|
||||
|
||||
fn hold_library(queues: &mut RealAppQueues) -> Result<(), String> {
|
||||
let mut problem = LibraryProblem::choose_later(&queues.h.app.ui.settings);
|
||||
problem.chooser.as_mut().ok_or("the launch list has no chooser")?.over = Some(queues.h.app.session.library_generation());
|
||||
queues.h.app.library_problem = Some(problem);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn real_queues(tag: &str) -> Result<(Scratch, RealAppQueues), String> {
|
||||
let (dir, app) = app_with_library(tag)?;
|
||||
let h = lightcraft_ui_egui::headless::Headless::new(app, [1300.0, 900.0], 1.0);
|
||||
Ok((dir, RealAppQueues { h, pending: vec!["web/hash/stored.png".into()], pending_takes: 0 }))
|
||||
}
|
||||
|
||||
fn protect(queues: &mut RealAppQueues, handle: &egui::DroppedFileHandle) -> Result<(), String> {
|
||||
hold_library(queues)?;
|
||||
queue_raw(queues, std::slice::from_ref(handle));
|
||||
// WebApp's gate runs before App.ui, including the cold first frame. The gate
|
||||
// itself must establish provenance; a warmed native UI pass cannot stand in for it.
|
||||
assert_eq!(queues.h.frames(), 0, "this starts before the first native UI pass");
|
||||
assert!(drops_wait(&queues.h.app));
|
||||
assert!(drain_ready(queues, false).is_none());
|
||||
assert_eq!(queues.h.frames(), 0, "the host gate protects ownership without drawing");
|
||||
assert_eq!(queues.pending_takes, 0);
|
||||
assert!(queues.h.app.scan.is_none() && queues.h.app.import.is_none());
|
||||
assert_eq!(queues.h.app.session.catalog.len(), 0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_unread_barrier_drop_survives_a_dialog_while_stored_originals_finish() {
|
||||
let (_dir, mut queues) = real_queues("real-held-dialog").expect("create real-queue library");
|
||||
let (a, reads) = unread("A.png");
|
||||
protect(&mut queues, &a).expect("hold the library barrier");
|
||||
queues.h.app.library_problem = None;
|
||||
queues.h.app.ui.dialog = Some(Dialog::WhatsNew);
|
||||
// WebApp drains BEFORE App.ui. A native UI pass here would conceal the host-order bug.
|
||||
let got = drain_ready(&mut queues, true).expect("only the ordinary dialog remains");
|
||||
assert!(got.dropped.is_empty(), "the earlier unread A must not be drained for dialog refusal");
|
||||
assert_eq!(
|
||||
got.pending,
|
||||
Some(vec!["web/hash/stored.png".into()]),
|
||||
"with no fresh refusal, earlier stored originals retain their existing completion policy"
|
||||
);
|
||||
assert_eq!(queues.pending_takes, 1);
|
||||
let again = drain_ready(&mut queues, true).unwrap();
|
||||
assert!(again.dropped.is_empty(), "A stays protected across another host pass");
|
||||
queues.h.app.ui.dialog = None;
|
||||
let released = drain_ready(&mut queues, false).unwrap();
|
||||
assert_eq!(released.dropped.len(), 1);
|
||||
assert!(std::sync::Arc::ptr_eq(&released.dropped[0], &a), "the original A handle is released");
|
||||
assert!(drain_ready(&mut queues, false).unwrap().dropped.is_empty(), "A transfers exactly once");
|
||||
assert!(queues.h.app.take_dropped_files().is_empty());
|
||||
assert_eq!(reads.load(std::sync::atomic::Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_fresh_dialog_drop_is_refused_without_consuming_protected_or_stored_files() {
|
||||
let (_dir, mut queues) = real_queues("real-mixed-dialog").expect("create real-queue library");
|
||||
let (a, reads_a) = unread("A.png");
|
||||
let (b, reads_b) = unread("B.png");
|
||||
protect(&mut queues, &a).expect("hold the library barrier");
|
||||
queues.h.app.library_problem = None;
|
||||
queues.h.app.ui.dialog = Some(Dialog::WhatsNew);
|
||||
queue_raw(&mut queues, std::slice::from_ref(&b));
|
||||
let got = drain_ready(&mut queues, true).unwrap();
|
||||
assert_eq!(got.dropped.len(), 1, "only fresh B is offered to the browser's existing refusal branch");
|
||||
assert!(std::sync::Arc::ptr_eq(&got.dropped[0], &b));
|
||||
assert_eq!(got.pending, None, "refusing B must not consume stored originals");
|
||||
assert_eq!(queues.pending_takes, 0);
|
||||
assert_eq!(queues.pending, vec!["web/hash/stored.png".to_string()]);
|
||||
assert!(matches!(queues.h.app.ui.dialog, Some(Dialog::WhatsNew)));
|
||||
queues.h.app.ui.dialog = None;
|
||||
let released = drain_ready(&mut queues, false).unwrap();
|
||||
assert_eq!(released.dropped.len(), 1, "protected A survived B's refusal");
|
||||
assert!(std::sync::Arc::ptr_eq(&released.dropped[0], &a));
|
||||
assert_eq!(released.pending, Some(vec!["web/hash/stored.png".into()]));
|
||||
let empty = drain_ready(&mut queues, false).unwrap();
|
||||
assert!(empty.dropped.is_empty());
|
||||
assert_eq!(empty.pending, Some(Vec::<String>::new()));
|
||||
assert_eq!(reads_a.load(std::sync::atomic::Ordering::SeqCst), 0);
|
||||
assert_eq!(reads_b.load(std::sync::atomic::Ordering::SeqCst), 0);
|
||||
// Consuming A clears its protection: a later ordinary drop is still fresh.
|
||||
let (c, reads_c) = unread("C.png");
|
||||
queues.h.app.ui.dialog = Some(Dialog::WhatsNew);
|
||||
queue_raw(&mut queues, std::slice::from_ref(&c));
|
||||
let fresh = drain_ready(&mut queues, true).unwrap();
|
||||
assert_eq!(fresh.dropped.len(), 1);
|
||||
assert!(std::sync::Arc::ptr_eq(&fresh.dropped[0], &c));
|
||||
assert_eq!(fresh.pending, None);
|
||||
queues.h.app.ui.dialog = None;
|
||||
assert!(drain_ready(&mut queues, false).unwrap().dropped.is_empty());
|
||||
assert_eq!(reads_c.load(std::sync::atomic::Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_protected_drops_keep_order_through_barrier_reentry_and_transfer_once() {
|
||||
let (_dir, mut queues) = real_queues("real-reentered").expect("create real-queue library");
|
||||
let (a, reads_a) = unread("A.png");
|
||||
let (b, reads_b) = unread("B.png");
|
||||
protect(&mut queues, &a).expect("hold the library barrier");
|
||||
queues.h.app.library_problem = None;
|
||||
queues.h.app.ui.dialog = Some(Dialog::WhatsNew);
|
||||
assert!(drain_ready(&mut queues, true).unwrap().dropped.is_empty());
|
||||
queues.h.app.ui.dialog = None;
|
||||
hold_library(&mut queues).expect("reenter the library barrier"); // Before a pass can consume A.
|
||||
queue_raw(&mut queues, std::slice::from_ref(&b));
|
||||
assert!(drain_ready(&mut queues, false).is_none(), "the reentered host gate protects B before native UI");
|
||||
assert_eq!(queues.h.frames(), 0, "neither protected arrival depends on font readiness or a completed UI pass");
|
||||
queues.h.app.library_problem = None;
|
||||
queues.h.app.ui.dialog = Some(Dialog::WhatsNew);
|
||||
assert!(drain_ready(&mut queues, true).unwrap().dropped.is_empty(), "both real barrier arrivals remain protected");
|
||||
queues.h.app.ui.dialog = None;
|
||||
let released = drain_ready(&mut queues, false).unwrap();
|
||||
assert_eq!(released.dropped.len(), 2);
|
||||
assert!(std::sync::Arc::ptr_eq(&released.dropped[0], &a));
|
||||
assert!(std::sync::Arc::ptr_eq(&released.dropped[1], &b));
|
||||
assert!(drain_ready(&mut queues, false).unwrap().dropped.is_empty());
|
||||
assert!(queues.h.app.take_dropped_files().is_empty());
|
||||
assert_eq!(reads_a.load(std::sync::atomic::Ordering::SeqCst), 0);
|
||||
assert_eq!(reads_b.load(std::sync::atomic::Ordering::SeqCst), 0);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,10 @@
|
||||
pub mod automation;
|
||||
pub mod backup;
|
||||
pub mod bench;
|
||||
// The web host's drop gate, also compiled natively so its queue handling has tests (the module is
|
||||
// unused in a native, non-test library build and would otherwise be dead code there).
|
||||
#[cfg(any(test, target_arch = "wasm32"))]
|
||||
mod drop_gate;
|
||||
pub mod files;
|
||||
pub mod store;
|
||||
pub mod wire;
|
||||
|
||||
@@ -422,6 +422,33 @@ struct WebApp {
|
||||
ui_written: Vec<u8>,
|
||||
}
|
||||
|
||||
/// The browser host's drop queues, taken only through the shared gate: a prompt or blocking
|
||||
/// library window leaves both untouched for a later UI pass.
|
||||
impl crate::drop_gate::QueuedDrops for WebApp {
|
||||
type Dropped = egui::DroppedFileHandle;
|
||||
type Pending = String;
|
||||
|
||||
fn drops_wait(&self) -> bool {
|
||||
crate::drop_gate::drops_wait(&self.app)
|
||||
}
|
||||
|
||||
fn defer_dropped(&mut self) {
|
||||
self.app.defer_dropped_files();
|
||||
}
|
||||
|
||||
fn take_dropped(&mut self) -> Vec<Self::Dropped> {
|
||||
self.app.take_dropped_files()
|
||||
}
|
||||
|
||||
fn take_unprotected_dropped(&mut self) -> Vec<Self::Dropped> {
|
||||
self.app.take_unprotected_dropped_files()
|
||||
}
|
||||
|
||||
fn take_pending(&mut self) -> Vec<Self::Pending> {
|
||||
self.originals.take_pending()
|
||||
}
|
||||
}
|
||||
|
||||
impl WebApp {
|
||||
/// Construct the web app from the boot options: install originals and open the library from
|
||||
/// browser storage (a read failure starts an empty session and a blocking `LibraryProblem`),
|
||||
@@ -534,14 +561,18 @@ impl WebApp {
|
||||
/// Consume the drops the raw-input hook queued once (never re-read `raw.dropped_files` in a
|
||||
/// hidden logic tick), and add originals whose bytes have arrived.
|
||||
fn import_dropped(&mut self, ctx: &egui::Context) {
|
||||
let dropped = self.app.take_dropped_files();
|
||||
// A prompt or the blocking library window (including the chooser's own New Library name
|
||||
// prompt or asynchronous folder picker) holds both queues for a later UI pass: nothing is
|
||||
// read or imported behind it. `drain_ready` is the same orchestration the native tests run.
|
||||
let dialog_open = self.app.ui.dialog.is_some();
|
||||
let Some(drained) = crate::drop_gate::drain_ready(self, dialog_open) else { return };
|
||||
// a dialog is on screen (the card said so while the files hovered): nothing is read or imported
|
||||
// (files read from an earlier drop still finish below)
|
||||
if !dropped.is_empty() && self.app.ui.dialog.is_some() {
|
||||
if !drained.dropped.is_empty() && dialog_open {
|
||||
self.app.toast(ctx, lightcraft_ui_egui::i18n::tr("Close the open dialog, then drop the files again"));
|
||||
return;
|
||||
}
|
||||
for f in dropped {
|
||||
for f in drained.dropped {
|
||||
let (originals, backend, ctx) = (self.originals.clone(), self.backend.clone(), ctx.clone());
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let name = egui::DroppedFile::path(f.as_ref()).to_string_lossy().to_string();
|
||||
@@ -551,7 +582,7 @@ impl WebApp {
|
||||
}
|
||||
});
|
||||
}
|
||||
let paths = self.originals.take_pending();
|
||||
let Some(paths) = drained.pending else { return };
|
||||
if !paths.is_empty() {
|
||||
match self.app.run("library.import", json!({"paths": paths})) {
|
||||
// what the import did, not what was submitted: duplicates and unsupported files add nothing,
|
||||
@@ -723,12 +754,10 @@ impl eframe::App for WebApp {
|
||||
/// page title, and advance the benchmark once per root pass.
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
// Drops are the UI phase's: a hidden `logic`/`run_logic` tick must not import them twice.
|
||||
// A prompt owns the library too: keep taking the queue only after it is gone, so a drop
|
||||
// during a notice cannot add photos behind it.
|
||||
// `import_dropped` itself takes both queues only through the gate, so a prompt or the
|
||||
// blocking library window (over an already open library) keeps a stored drop queued.
|
||||
let ctx = ui.ctx().clone();
|
||||
if !lightcraft_ui_egui::panels::notices::blocks_input(&self.app) {
|
||||
self.import_dropped(&ctx);
|
||||
}
|
||||
self.import_dropped(&ctx);
|
||||
self.app.ui(ui);
|
||||
// eframe's web runner ignores `ViewportCommand::Title`: name the open photo in the page title
|
||||
let title = self.app.page_title(self.files.is_dirty() || self.flushing.get());
|
||||
|
||||
@@ -20,7 +20,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Cancel Background Task",
|
||||
[],
|
||||
None,
|
||||
"{id} | {all: true} → {cancelled: n} — ask a task (or every cancellable task) to stop; it stops at its next file or photo. An unknown id (it may have just finished) or a task that can't be cancelled is an error",
|
||||
"{id} | {all: true} → {cancelled: n} — ask a task (or every cancellable task) to stop; it stops at its next file or photo (a preview build also stops between a photo's own previews). An unknown id (it may have just finished) or a task that can't be cancelled is an error",
|
||||
always,
|
||||
|s, p| {
|
||||
const C: &str = "activity.cancel";
|
||||
|
||||
@@ -31,6 +31,10 @@ pub struct PreviewBuild {
|
||||
pub what: &'static str,
|
||||
/// Why the whole build failed (e.g. the smart previews folder isn't there).
|
||||
pub error: std::sync::Mutex<Option<String>>,
|
||||
/// Test-only: the gate this build's lanes wait at before a photo's first render (see
|
||||
/// [`test_gate`]). Captured by `build` on the calling thread; production builds never have one.
|
||||
#[cfg(test)]
|
||||
pub(crate) gate: Option<Arc<test_gate::PreviewGate>>,
|
||||
}
|
||||
|
||||
impl PreviewBuild {
|
||||
@@ -79,10 +83,15 @@ fn task_for(s: &Session, state: &PreviewBuild) -> TaskGuard {
|
||||
|
||||
/// Render one photo's jobs in order. The source the first job decodes serves the ones after it
|
||||
/// (the grid thumbnail is made from the view's source instead of decoding the file again).
|
||||
pub(crate) fn run_photo(jobs: Vec<RenderJob>) -> bool {
|
||||
/// `None` when `cancel` was asked for before a job started: that photo is not counted as done.
|
||||
fn run_photo_cancellable(jobs: Vec<RenderJob>, cancel: Option<&AtomicBool>) -> Option<bool> {
|
||||
let mut decoded: Option<crate::media::DecodedSource> = None;
|
||||
let mut ok = true;
|
||||
for mut j in jobs {
|
||||
// between a photo's own previews too (a cancelled 1:1 view skips its grid thumbnail)
|
||||
if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
|
||||
return None;
|
||||
}
|
||||
if let Some(src) = &decoded
|
||||
&& !matches!(j.source, crate::media::SourceRef::Loaded(_))
|
||||
{
|
||||
@@ -94,7 +103,13 @@ pub(crate) fn run_photo(jobs: Vec<RenderJob>) -> bool {
|
||||
decoded = r.loaded;
|
||||
}
|
||||
}
|
||||
ok
|
||||
Some(ok)
|
||||
}
|
||||
|
||||
/// [`run_photo_cancellable`] for callers that render a photo without a cancel flag.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn run_photo(jobs: Vec<RenderJob>) -> bool {
|
||||
run_photo_cancellable(jobs, None) == Some(true)
|
||||
}
|
||||
|
||||
/// How many photos render side by side: as many as an export batch for standard previews (each
|
||||
@@ -105,6 +120,112 @@ fn lanes(n: usize, full: bool) -> usize {
|
||||
if full { l.min(2) } else { l }
|
||||
}
|
||||
|
||||
/// A test-only rendezvous at the start of a photo's jobs, so a cancel can be delivered at a known
|
||||
/// point instead of racing a render. One gate belongs to the build `build` starts on the test
|
||||
/// thread that installed it ([`install`]); it is never global and production builds have none.
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_gate {
|
||||
use std::sync::{Arc, Condvar, Mutex, PoisonError};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// The longest a lane waits for a test to open the gate: if a test ends without its
|
||||
/// [`ReleaseOnDrop`] running, the build still finishes instead of stranding its worker.
|
||||
const MAX_HOLD: Duration = Duration::from_secs(30);
|
||||
/// The longest a test waits for a lane to reach the gate.
|
||||
const MAX_ENTER_WAIT: Duration = Duration::from_secs(10);
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
entered: usize,
|
||||
released: bool,
|
||||
}
|
||||
|
||||
/// The gate a test's preview build waits at before rendering a photo (see `install`).
|
||||
#[derive(Default)]
|
||||
pub(crate) struct PreviewGate {
|
||||
state: Mutex<State>,
|
||||
opened: Condvar,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for PreviewGate {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("PreviewGate").finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl PreviewGate {
|
||||
pub(crate) fn new() -> Arc<Self> {
|
||||
Arc::new(Self::default())
|
||||
}
|
||||
|
||||
/// Called by a lane about to render a photo's first job; returns once the test opens the
|
||||
/// gate, or after `MAX_HOLD` so a lost test cannot strand the worker.
|
||||
pub(crate) fn enter(&self) {
|
||||
let mut st = self.state.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
st.entered = st.entered.saturating_add(1);
|
||||
self.opened.notify_all();
|
||||
let deadline = Instant::now() + MAX_HOLD;
|
||||
while !st.released {
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
if remaining.is_zero() {
|
||||
break;
|
||||
}
|
||||
let (next, _) = self.opened.wait_timeout(st, remaining).unwrap_or_else(|e| e.into_inner());
|
||||
st = next;
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait (at most `MAX_ENTER_WAIT`) until at least `n` lanes have reached the gate.
|
||||
pub(crate) fn wait_entered(&self, n: usize) -> bool {
|
||||
let mut st = self.state.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let deadline = Instant::now() + MAX_ENTER_WAIT;
|
||||
loop {
|
||||
if st.entered >= n {
|
||||
return true;
|
||||
}
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
if remaining.is_zero() {
|
||||
return false;
|
||||
}
|
||||
let (next, _) = self.opened.wait_timeout(st, remaining).unwrap_or_else(|e| e.into_inner());
|
||||
st = next;
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the gate: every lane waiting, or arriving later, passes without waiting.
|
||||
pub(crate) fn release(&self) {
|
||||
let mut st = self.state.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
st.released = true;
|
||||
self.opened.notify_all();
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the gate when dropped, wherever a test ends (assertion failure, panic, early return).
|
||||
pub(crate) struct ReleaseOnDrop(pub(crate) Arc<PreviewGate>);
|
||||
|
||||
impl Drop for ReleaseOnDrop {
|
||||
fn drop(&mut self) {
|
||||
self.0.release();
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// The gate for the next preview build started on this thread, taken by `build` so it is
|
||||
/// per invocation (never shared by two builds or two tests).
|
||||
static NEXT_GATE: std::cell::RefCell<Option<Arc<PreviewGate>>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Have the next preview build started on this thread wait at `gate` before its first photo.
|
||||
pub(crate) fn install(gate: Arc<PreviewGate>) {
|
||||
NEXT_GATE.with(|g| *g.borrow_mut() = Some(gate));
|
||||
}
|
||||
|
||||
/// Take the gate installed for this build, leaving none for later builds.
|
||||
pub(crate) fn take() -> Option<Arc<PreviewGate>> {
|
||||
NEXT_GATE.with(|g| g.borrow_mut().take())
|
||||
}
|
||||
}
|
||||
|
||||
fn run_all(jobs: Vec<Vec<RenderJob>>, state: &PreviewBuild, task: &TaskGuard, lanes: usize) {
|
||||
let total = jobs.len();
|
||||
let queue = std::sync::Mutex::new(jobs.into_iter());
|
||||
@@ -115,7 +236,17 @@ fn run_all(jobs: Vec<Vec<RenderJob>>, state: &PreviewBuild, task: &TaskGuard, la
|
||||
}
|
||||
let next = queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner).next();
|
||||
let Some(photo_jobs) = next else { break };
|
||||
let ok = crate::guard::catch("building previews", || run_photo(photo_jobs)).unwrap_or(false);
|
||||
// A cancel accepted while this lane waited for the queue (or for another lane's
|
||||
// photo) stops before this photo's first render; a cancelled photo is not counted.
|
||||
if state.cancel.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
#[cfg(test)]
|
||||
if let Some(gate) = &state.gate {
|
||||
gate.enter();
|
||||
}
|
||||
let outcome = crate::guard::catch("building previews", || run_photo_cancellable(photo_jobs, Some(&state.cancel))).unwrap_or(Some(false));
|
||||
let Some(ok) = outcome else { break };
|
||||
if !ok {
|
||||
state.failed.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
@@ -143,6 +274,9 @@ fn run_all(jobs: Vec<Vec<RenderJob>>, state: &PreviewBuild, task: &TaskGuard, la
|
||||
|
||||
fn build(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.buildPreviews";
|
||||
// tests: this invocation's gate, consumed here so an early error cannot attach it to a later build
|
||||
#[cfg(test)]
|
||||
let gate = test_gate::take();
|
||||
let edge = match str_param(p, "size").unwrap_or("standard") {
|
||||
"standard" => Some(p.get("edge").and_then(Value::as_u64).map_or(STANDARD_EDGE, |e| e.clamp(256, 8192) as usize)),
|
||||
"full" | "1:1" => None,
|
||||
@@ -154,6 +288,9 @@ fn build(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// explicit ids, else the selection, else everything in view
|
||||
let ids = if p.get("ids").is_some() || p.get("id").is_some() || !s.selection.ids.is_empty() { s.targets(p)? } else { s.visible_cloned() };
|
||||
let jobs: Vec<Vec<RenderJob>> = ids.iter().map(|id| jobs_for(s, *id, edge)).filter(|j| !j.is_empty()).collect();
|
||||
#[cfg(test)]
|
||||
let state = Arc::new(PreviewBuild { total: jobs.len(), gate, ..Default::default() });
|
||||
#[cfg(not(test))]
|
||||
let state = Arc::new(PreviewBuild { total: jobs.len(), ..Default::default() });
|
||||
s.preview_build = Some(state.clone());
|
||||
let task = task_for(s, &state);
|
||||
|
||||
@@ -601,6 +601,12 @@ fn degenerate_crops_render_with_spots_and_masks() {
|
||||
s.execute("develop.set", &json!({"control": "effects.clarity", "value": 80})).unwrap();
|
||||
s.execute("develop.set", &json!({"control": "effects.dehaze", "value": 50})).unwrap();
|
||||
set_crop_unchecked(&mut s, rect, 30.0);
|
||||
// Measure the degenerate render, not procedural demo generation. Load exactly the
|
||||
// source level this crop requests; keep the original photo, effects and output size.
|
||||
let level = s.render_job(id, 700, 450, false, true).unwrap().level;
|
||||
let source = s.source_now(id, level).unwrap();
|
||||
assert!(source.width > 0 && source.height > 0);
|
||||
assert!(s.media.has_source(id, level), "the timed render must reuse the prepared source");
|
||||
let t = std::time::Instant::now();
|
||||
let r = s.render_now(id, 700, 450).unwrap();
|
||||
assert!(r.image.width >= 1 && r.image.height >= 1, "{rect:?}");
|
||||
@@ -1194,18 +1200,51 @@ fn preview_build_shows_in_activity_until_it_finishes() {
|
||||
assert_eq!(s.execute("library.previewProgress", &json!({})).unwrap()["running"], false);
|
||||
}
|
||||
|
||||
/// Issue #345: `activity.cancel` stops a preview build like `library.cancelPreviews`.
|
||||
/// Issue #345: `activity.cancel` stops a preview build like `library.cancelPreviews`. A test-only
|
||||
/// gate holds the lane at its first photo, so the cancel lands before any render instead of racing
|
||||
/// a 24 MP one; the accepted cancel must not count that photo.
|
||||
#[test]
|
||||
fn activity_cancel_stops_a_preview_build() {
|
||||
use crate::cmd::previews::test_gate::{self, PreviewGate, ReleaseOnDrop};
|
||||
let mut s = demo();
|
||||
let ids: Vec<u64> = s.catalog.photos().map(|p| p.id.0).collect();
|
||||
s.execute("library.buildPreviews", &json!({"ids": ids, "size": "full"})).unwrap();
|
||||
let ids: Vec<u64> = s.catalog.photos().take(2).map(|p| p.id.0).collect();
|
||||
let gate = PreviewGate::new();
|
||||
// also opens the gate if an assertion below panics, so the worker is never stranded
|
||||
let release = ReleaseOnDrop(gate.clone());
|
||||
test_gate::install(gate.clone());
|
||||
s.execute("library.buildPreviews", &json!({"ids": ids, "size": "standard", "edge": 256})).unwrap();
|
||||
let id = s.activity.list().first().map(|t| t.id).expect("a previews row");
|
||||
assert!(gate.wait_entered(1), "a lane reached its first photo");
|
||||
s.execute("activity.cancel", &json!({"id": id})).unwrap();
|
||||
let row = s.activity.list().into_iter().find(|t| t.id == id).expect("the row stays while the lane is held");
|
||||
assert!(row.cancelling, "the row shows the accepted cancel: {row:?}");
|
||||
drop(release);
|
||||
wait_activity_empty(&s);
|
||||
let p = s.execute("library.previewProgress", &json!({})).unwrap();
|
||||
assert_eq!(p["cancelled"], true, "{p}");
|
||||
assert!(p["done"].as_u64() < p["total"].as_u64(), "stopped part-way: {p}");
|
||||
assert_eq!(p["done"], json!(0), "no photo rendered after the accepted cancel: {p}");
|
||||
assert_eq!(p["running"], false, "{p}");
|
||||
}
|
||||
|
||||
/// The gated fixture is real work: without a cancel, both photos render and are counted.
|
||||
#[test]
|
||||
fn preview_build_at_the_test_gate_completes_without_cancel() {
|
||||
use crate::cmd::previews::test_gate::{self, PreviewGate, ReleaseOnDrop};
|
||||
let mut s = demo();
|
||||
let ids: Vec<u64> = s.catalog.photos().take(2).map(|p| p.id.0).collect();
|
||||
let gate = PreviewGate::new();
|
||||
let release = ReleaseOnDrop(gate.clone());
|
||||
test_gate::install(gate.clone());
|
||||
let r = s.execute("library.buildPreviews", &json!({"ids": ids, "size": "standard", "edge": 256})).unwrap();
|
||||
assert_eq!(r["total"], json!(2), "{r}");
|
||||
assert!(gate.wait_entered(1), "a lane reached its first photo");
|
||||
drop(release);
|
||||
wait_activity_empty(&s);
|
||||
let p = s.execute("library.previewProgress", &json!({})).unwrap();
|
||||
assert_eq!(p["running"], false, "{p}");
|
||||
assert_eq!(p["cancelled"], false, "{p}");
|
||||
assert_eq!(p["done"], json!(2), "both photos rendered: {p}");
|
||||
assert_eq!(p["failed"], json!(0), "{p}");
|
||||
}
|
||||
|
||||
/// Colour range: a click samples the colour under it, so the mask selects that colour (white
|
||||
|
||||
@@ -18,10 +18,24 @@ pub(crate) fn temp(name: &str) -> PathBuf {
|
||||
d
|
||||
}
|
||||
|
||||
/// Seed CPU-only settings before installing models in a fresh test folder. These tests cover catalog and
|
||||
/// model logic; GPU kernels have separate tests. Calling the settings mutation command here would also
|
||||
/// detach the process-wide detector accelerator, interfering with other tests in the same process.
|
||||
fn keep_on_cpu(s: &mut Session) {
|
||||
let dir = s.face_models_dir.as_ref().unwrap();
|
||||
let settings = dir.join("settings.json");
|
||||
assert!(!settings.exists(), "only seed a fresh fixture's settings");
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
std::fs::write(settings, br#"{"cpuOnly":true}"#).unwrap();
|
||||
let r = s.execute("faces.gpu", &json!({})).unwrap();
|
||||
assert_eq!(r["cpuOnly"], true, "the test session must run face models on the CPU: {r}");
|
||||
}
|
||||
|
||||
/// A demo session with a tiny model installed and chosen.
|
||||
pub(crate) fn setup(d: &std::path::Path, dim: u64) -> (Session, String) {
|
||||
let mut s = Session::with_demo();
|
||||
s.face_models_dir = Some(d.join("models"));
|
||||
keep_on_cpu(&mut s);
|
||||
let model = d.join(format!("tiny{dim}.onnx"));
|
||||
std::fs::write(&model, lightcraft_faces::synthetic::tiny_embedder_model(dim)).unwrap();
|
||||
let r = s.execute("faces.models.install", &json!({"path": model.to_string_lossy(), "acknowledged": true})).unwrap();
|
||||
@@ -30,6 +44,47 @@ pub(crate) fn setup(d: &std::path::Path, dim: u64) -> (Session, String) {
|
||||
(s, id)
|
||||
}
|
||||
|
||||
/// A fresh empty library with the tiny model installed and chosen. The tests below only need a decodable
|
||||
/// picture (and enough pixels for an optional real detector), not the demo's 12 procedural 24 MP scenes:
|
||||
/// each demo photo costs a full develop render before the 112 px model input.
|
||||
fn fresh_library(d: &std::path::Path, dim: u64) -> (Session, PathBuf) {
|
||||
let lib = d.join("lib");
|
||||
let mut s = Session::new().with_fs();
|
||||
s.face_models_dir = Some(d.join("models"));
|
||||
keep_on_cpu(&mut s);
|
||||
s.open_library(&lib, false).unwrap();
|
||||
let model = d.join(format!("tiny{dim}.onnx"));
|
||||
std::fs::write(&model, lightcraft_faces::synthetic::tiny_embedder_model(dim)).unwrap();
|
||||
s.execute("faces.models.install", &json!({"path": model.to_string_lossy(), "acknowledged": true})).unwrap();
|
||||
(s, lib)
|
||||
}
|
||||
|
||||
/// A real, distinct 1024×768 PNG (seed picks its colours).
|
||||
fn write_photo(path: &std::path::Path, seed: u8) {
|
||||
let (w, h) = (1024usize, 768usize);
|
||||
let data: Vec<[u8; 4]> = (0..w * h)
|
||||
.map(|i| [((i % w * 3 + seed as usize * 17) % 256) as u8, ((i / w * 5 + seed as usize * 29) % 256) as u8, seed.wrapping_mul(41), 255])
|
||||
.collect();
|
||||
let img = lightcraft_raster::Rgba8 { width: w, height: h, data };
|
||||
let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, bytes).unwrap();
|
||||
}
|
||||
|
||||
/// Import three distinct real file-backed photos: two for faces and one with none for the optional detector.
|
||||
fn import_three_photos(s: &mut Session, d: &std::path::Path) -> (PhotoId, PhotoId, PhotoId) {
|
||||
let mut paths = Vec::new();
|
||||
for (i, name) in ["ann.png", "bea.png", "empty.png"].iter().enumerate() {
|
||||
let path = d.join("photos").join(name);
|
||||
write_photo(&path, i as u8 + 1);
|
||||
paths.push(path.to_string_lossy().into_owned());
|
||||
}
|
||||
let r = s.execute("library.import", &json!({"paths": paths})).unwrap();
|
||||
let ids: Vec<PhotoId> = r["imported"].as_array().unwrap().iter().map(|v| PhotoId(v.as_u64().unwrap())).collect();
|
||||
assert_eq!(ids.len(), 3, "three real photos imported: {r}");
|
||||
(ids[0], ids[1], ids[2])
|
||||
}
|
||||
|
||||
/// Installs the real YuNet from `LC_YUNET_MODEL=<the .onnx file>` (what Settings > Faces downloads; nothing is
|
||||
/// committed) and says whether it did: the parts of a test that need to find faces run only then.
|
||||
pub(crate) fn install_yunet(s: &mut Session) -> bool {
|
||||
@@ -61,35 +116,52 @@ pub(crate) fn two_photos(s: &Session) -> (PhotoId, PhotoId) {
|
||||
#[test]
|
||||
fn indexing_embeds_each_face_once_and_reports_progress() {
|
||||
let d = temp("index");
|
||||
let (mut s, _) = setup(&d, 64);
|
||||
let (a, b) = two_photos(&s);
|
||||
let (mut s, _) = fresh_library(&d, 64);
|
||||
let (a, b, c) = import_three_photos(&mut s, &d);
|
||||
set_regions(&mut s, a, vec![region(0.1, Some("Ann")), region(0.5, None)]);
|
||||
set_regions(&mut s, b, vec![region(0.3, None)]);
|
||||
// only a report
|
||||
let r = s.execute("faces.index", &json!({"budgetMs": 0})).unwrap();
|
||||
assert_eq!((r["embedded"].clone(), r["photosDone"].clone(), r["pendingPhotos"].clone()), (json!(0), json!(0), json!(2)), "{r}");
|
||||
// a generous budget does it all, the named photo first
|
||||
let r = s.execute("faces.index", &json!({"budgetMs": 60_000})).unwrap();
|
||||
assert_eq!((r["embedded"].clone(), r["pendingPhotos"].clone(), r["indexedFaces"].clone()), (json!(3), json!(0), json!(3)), "{r}");
|
||||
// `faces.index` stops between whole photos once the budget is spent, so follow its documented contract:
|
||||
// call again until `pendingPhotos` is 0. Two photos need at most two productive calls; the named photo is
|
||||
// first in the queue, and every face is embedded exactly once.
|
||||
let mut embedded = 0u64;
|
||||
for call in 0..2 {
|
||||
let r = s.execute("faces.index", &json!({"budgetMs": 60_000})).unwrap();
|
||||
embedded += r["embedded"].as_u64().unwrap();
|
||||
if call == 0 {
|
||||
assert!(s.faces.index.contains(a.0, ®ion(0.1, Some("Ann")).rect), "the named photo is done first: {r}");
|
||||
assert!(s.faces.index.contains(a.0, ®ion(0.5, None).rect), "both of its faces are embedded: {r}");
|
||||
}
|
||||
if r["pendingPhotos"] == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let r = s.execute("faces.index", &json!({"budgetMs": 0})).unwrap();
|
||||
assert_eq!((r["pendingPhotos"].clone(), r["indexedFaces"].clone()), (json!(0), json!(3)), "{r}");
|
||||
assert_eq!(embedded, 3, "each of the three faces was embedded exactly once across calls");
|
||||
// nothing is embedded twice
|
||||
let r = s.execute("faces.index", &json!({"budgetMs": 60_000})).unwrap();
|
||||
assert_eq!((r["embedded"].clone(), r["photosDone"].clone()), (json!(0), json!(0)));
|
||||
// a small budget still makes progress (at least one photo per call), and `ids` go first
|
||||
let c = s.catalog.photos().map(|p| p.id).find(|id| *id != a && *id != b).unwrap();
|
||||
set_regions(&mut s, c, vec![region(0.2, None)]);
|
||||
set_regions(&mut s, b, vec![region(0.3, None), region(0.6, None)]);
|
||||
let r = s.execute("faces.index", &json!({"budgetMs": 1, "ids": [c.0]})).unwrap();
|
||||
assert_eq!(r["photosDone"], 1, "{r}");
|
||||
assert_eq!(r["pendingPhotos"], 1, "{r}");
|
||||
assert!(s.faces.index.contains(c.0, ®ion(0.2, None).rect), "the photo asked for first was done first");
|
||||
// keep the imported files until the session that references them is gone
|
||||
drop(s);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suggestions_come_from_the_named_faces_and_are_never_applied() {
|
||||
let d = temp("suggest");
|
||||
let (mut s, _) = setup(&d, 64);
|
||||
let (a, b) = two_photos(&s);
|
||||
// Gallery and candidate semantics need real embeddings, not full 24 MP demo renders.
|
||||
let (mut s, _) = fresh_library(&d, 64);
|
||||
let (a, b, _) = import_three_photos(&mut s, &d);
|
||||
set_regions(&mut s, a, vec![region(0.1, Some("Ann"))]);
|
||||
set_regions(&mut s, b, vec![region(0.1, None), region(0.5, Some("Pet?")), region(0.7, None)]);
|
||||
// thresholds that accept anything: Ann is the only person with a named face except "Pet?"
|
||||
@@ -173,6 +245,7 @@ fn embeddings_are_cached_beside_the_library_and_reset_for_another_model() {
|
||||
};
|
||||
let mut s = Session::new().with_fs();
|
||||
s.face_models_dir = Some(models.clone());
|
||||
keep_on_cpu(&mut s);
|
||||
s.open_library(&lib, true).unwrap();
|
||||
let (a, _) = two_photos(&s);
|
||||
set_regions(&mut s, a, vec![region(0.1, Some("Ann")), region(0.5, None)]);
|
||||
@@ -214,8 +287,9 @@ fn without_a_chosen_model_recognition_says_so() {
|
||||
#[test]
|
||||
fn the_background_pump_indexes_by_itself_once_recognition_is_on() {
|
||||
let d = temp("pump");
|
||||
let (mut s, _) = setup(&d, 64);
|
||||
let (a, b) = two_photos(&s);
|
||||
// Exercise real background embedding without unrelated full 24 MP demo renders.
|
||||
let (mut s, _) = fresh_library(&d, 64);
|
||||
let (a, b, _) = import_three_photos(&mut s, &d);
|
||||
set_regions(&mut s, a, vec![region(0.1, Some("Ann")), region(0.5, None)]);
|
||||
set_regions(&mut s, b, vec![region(0.3, None)]);
|
||||
// installing the model switched recognition on; switched off, the pump does nothing, and says so
|
||||
@@ -251,31 +325,33 @@ fn the_background_pump_indexes_by_itself_once_recognition_is_on() {
|
||||
#[test]
|
||||
fn the_background_pump_saves_what_it_embeds_for_the_next_launch() {
|
||||
let d = temp("pumpsave");
|
||||
let (lib, models) = (d.join("lib"), d.join("models"));
|
||||
let mut s = Session::new().with_fs();
|
||||
s.face_models_dir = Some(models);
|
||||
s.open_library(&lib, true).unwrap();
|
||||
let model = d.join("tiny64.onnx");
|
||||
std::fs::write(&model, lightcraft_faces::synthetic::tiny_embedder_model(64)).unwrap();
|
||||
// installing switches recognition on: nothing else is asked for
|
||||
s.execute("faces.models.install", &json!({"path": model.to_string_lossy(), "acknowledged": true})).unwrap();
|
||||
// Installing the tiny model switches recognition on: nothing else is asked for.
|
||||
let (mut s, lib) = fresh_library(&d, 64);
|
||||
// without the detector (not bundled) photos without faces are left unsearched
|
||||
let searching = install_yunet(&mut s);
|
||||
let (a, b) = two_photos(&s);
|
||||
let (a, b, c) = import_three_photos(&mut s, &d);
|
||||
set_regions(&mut s, a, vec![region(0.1, Some("Ann")), region(0.5, None)]);
|
||||
set_regions(&mut s, b, vec![region(0.3, None)]);
|
||||
assert!(s.catalog.photo(c).unwrap().meta.regions.is_empty(), "the third photo has no face to find");
|
||||
let photos = s.catalog.photos().count();
|
||||
let searched_photos = if searching { photos - 2 } else { 0 };
|
||||
assert_eq!(photos, 3, "a fresh library holds only the three imported photos");
|
||||
// only the one photo without a face region is searched when the optional detector is installed
|
||||
let searched_photos = usize::from(searching);
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120);
|
||||
let mut last = json!(null);
|
||||
let (mut last, mut embedded) = (json!(null), 0u64);
|
||||
while std::time::Instant::now() < deadline {
|
||||
last = s.execute("faces.pump", &json!({})).unwrap();
|
||||
embedded += last["embedded"].as_u64().unwrap_or(0);
|
||||
if last["pendingPhotos"] == 0 && s.faces.index.len() == 3 && s.faces.scanned.len() == searched_photos {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
}
|
||||
assert_eq!((s.faces.index.len(), s.faces.scanned.len()), (3, searched_photos), "{last}");
|
||||
assert_eq!(embedded, 3, "the background worker embedded each face exactly once: {last}");
|
||||
// once everything is done another pump starts no work and reports no new embedding
|
||||
let idle = s.execute("faces.pump", &json!({})).unwrap();
|
||||
assert_eq!((idle["embedded"].clone(), idle["inFlight"].clone(), idle["pendingPhotos"].clone()), (json!(0), json!(0), json!(0)), "{idle}");
|
||||
let (tag, path) = (s.faces.index.tag.clone(), s.faces.index.path.clone().unwrap());
|
||||
assert!(path.file_name().unwrap().to_string_lossy().starts_with("face-embeddings-custom-"), "one cache file per model: {}", path.display());
|
||||
// the first batch is written at once; whatever is left is written when the session ends, so a launch never redoes
|
||||
@@ -491,12 +567,9 @@ fn every_scanned_face_is_shown_by_the_same_kind_of_box() {
|
||||
#[test]
|
||||
fn face_scan_row_follows_pending_photos() {
|
||||
let d = temp("activity");
|
||||
let (mut s, _) = setup(&d, 64);
|
||||
// the last two demo photos, not the first two the other scan tests use: two sessions scanning the same demo scene
|
||||
// at once can deadlock (issue #630), and this test is about the row, not that
|
||||
let mut ids = s.catalog.photos().map(|p| p.id).collect::<Vec<_>>();
|
||||
ids.sort();
|
||||
let (a, b) = (ids[ids.len() - 2], ids[ids.len() - 1]);
|
||||
// The activity row needs real background embedding, not full 24 MP demo renders.
|
||||
let (mut s, _) = fresh_library(&d, 64);
|
||||
let (a, b, _) = import_three_photos(&mut s, &d);
|
||||
set_regions(&mut s, a, vec![region(0.1, None), region(0.5, None)]);
|
||||
set_regions(&mut s, b, vec![region(0.3, None)]);
|
||||
let faces_rows = |s: &Session| s.activity.list().into_iter().filter(|t| t.kind == "faces").collect::<Vec<_>>();
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use lightcraft_mcp::{Backend, Headless, ProgressHook, Remote, Server, base64_decode};
|
||||
use lightcraft_mcp::{Backend, Headless, ProgressHook, Remote, Server, base64_decode, call_tool};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Feed a whole session through `Server::serve` and return the replies by id.
|
||||
@@ -184,6 +184,28 @@ fn remote_unreachable_is_a_tool_error() {
|
||||
assert!(r[0]["result"]["content"][0]["text"].as_str().unwrap().contains("not reachable"));
|
||||
}
|
||||
|
||||
/// Eight distinct 1200×800 PNGs on disk, imported into a filesystem session with their real
|
||||
/// metadata: a library of actual files for the batch export to read and develop. Generating the
|
||||
/// procedural 6000×4000 demo library (and rendering from it) costs far more than this test's
|
||||
/// contract — progress, cancellation and atomic 1200-edge PNG writes — needs.
|
||||
fn file_backed_photos(dir: &std::path::Path) -> Headless {
|
||||
let originals = dir.join("originals");
|
||||
std::fs::create_dir_all(&originals).unwrap();
|
||||
let mut paths = Vec::new();
|
||||
for i in 0..8usize {
|
||||
let path = originals.join(format!("frame-{i:02}.png"));
|
||||
let img = lightcraft_raster::Rgba8::from_fn(1200, 800, |x, y| {
|
||||
[((x + i * 37) % 256) as u8, ((y * 3 + i * 11) % 256) as u8, ((x / 4 + y / 2 + i * 23) % 256) as u8, 255]
|
||||
});
|
||||
lightcraft_mcp::write_image(&path, &img, 90).unwrap();
|
||||
paths.push(path.to_string_lossy().to_string());
|
||||
}
|
||||
let mut headless = Headless::new(lightcraft_engine::Session::new().with_fs());
|
||||
let imported = call_tool(&mut headless, "import", &json!({"paths": paths}));
|
||||
assert!(!imported.is_error, "{imported:?}");
|
||||
headless
|
||||
}
|
||||
|
||||
/// [`Headless`] whose batch export, while `hold` is set, waits once its first photo is written:
|
||||
/// it keeps asking the server's progress hook at that photo boundary (which answers pings and
|
||||
/// reads cancels meanwhile) until the request is cancelled or the test clears `hold`. A ping or
|
||||
@@ -236,7 +258,7 @@ fn export_progress_and_cancel() {
|
||||
let (input, mut send) = std::io::pipe().unwrap();
|
||||
let (output, write) = std::io::pipe().unwrap();
|
||||
let hold = Arc::new(AtomicBool::new(true));
|
||||
let backend = HoldAfterFirst { inner: Headless::demo(), hold: hold.clone() };
|
||||
let backend = HoldAfterFirst { inner: file_backed_photos(&dir), hold: hold.clone() };
|
||||
let server = std::thread::spawn(move || Server::new(Box::new(backend)).serve(BufReader::new(input), write).unwrap());
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let reader = std::thread::spawn(move || {
|
||||
@@ -250,8 +272,16 @@ fn export_progress_and_cancel() {
|
||||
let next = || rx.recv_timeout(Duration::from_secs(30)).expect("MCP reply/progress");
|
||||
writeln!(send, "{}", call(1, "query_photos", json!({"limit":8}))).unwrap();
|
||||
let query = next();
|
||||
let ids: Vec<_> = query["result"]["structuredContent"]["photos"].as_array().unwrap().iter().map(|p| p["id"].clone()).collect();
|
||||
assert_eq!(ids.len(), 8);
|
||||
let photos = query["result"]["structuredContent"]["photos"].as_array().unwrap();
|
||||
assert_eq!(photos.len(), 8);
|
||||
// Real files, real metadata: eight distinct 1200×800 PNGs probed from disk, not synthesized.
|
||||
let names: std::collections::HashSet<&str> = photos.iter().map(|p| p["fileName"].as_str().unwrap()).collect();
|
||||
assert_eq!(names.len(), 8, "{names:?}");
|
||||
for p in photos {
|
||||
assert_eq!((p["width"].as_u64(), p["height"].as_u64()), (Some(1200), Some(800)), "{p}");
|
||||
assert_eq!(p["format"], "PNG", "{p}");
|
||||
}
|
||||
let ids: Vec<_> = photos.iter().map(|p| p["id"].clone()).collect();
|
||||
let mut request = call(2, "export", json!({"ids":ids,"dir":dir.join("complete"),"longEdge":1200,"format":"png"}));
|
||||
request["params"]["_meta"] = json!({"progressToken":"photos","io.modelcontextprotocol/protocolVersion":"2026-07-28"});
|
||||
writeln!(send, "{request}").unwrap();
|
||||
@@ -314,10 +344,24 @@ fn export_progress_and_cancel() {
|
||||
let photos: Vec<_> = std::fs::read_dir(&out).unwrap().map(|e| e.unwrap().path()).filter(|p| *p != decoy).collect();
|
||||
assert_eq!(photos.len(), 1, "only the photo completed before the cancel remains: {photos:?}");
|
||||
for path in &photos {
|
||||
lightcraft_codecs::decode(&std::fs::read(path).unwrap(), Default::default()).expect("complete image, no partial/temp file");
|
||||
let d = lightcraft_codecs::decode(&std::fs::read(path).unwrap(), Default::default()).expect("complete image, no partial/temp file");
|
||||
assert_eq!((d.width, d.height), (1200, 800), "{}", path.display());
|
||||
}
|
||||
assert_eq!(std::fs::read_dir(dir.join("complete")).unwrap().count(), 8, "earlier export retained");
|
||||
// The finished export's eight outputs are real 1200×800 PNGs, one per distinct original.
|
||||
let complete: Vec<_> = std::fs::read_dir(dir.join("complete")).unwrap().map(|e| e.unwrap().path()).collect();
|
||||
assert_eq!(complete.len(), 8, "earlier export retained: {complete:?}");
|
||||
let mut exported = std::collections::HashSet::new();
|
||||
for path in &complete {
|
||||
let bytes = std::fs::read(path).unwrap();
|
||||
assert_eq!(&bytes[1..4], b"PNG", "{}", path.display());
|
||||
let d = lightcraft_codecs::decode(&bytes, Default::default()).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
|
||||
assert_eq!((d.width, d.height), (1200, 800), "{}", path.display());
|
||||
exported.insert(bytes);
|
||||
}
|
||||
assert_eq!(exported.len(), 8, "eight distinct outputs, not copies of one export");
|
||||
drop(send);
|
||||
// The fixture's files stay in place until the server (which owns the session) and the reader
|
||||
// have finished: the session reads the originals; the output is what the checks above read.
|
||||
server.join().unwrap();
|
||||
reader.join().unwrap();
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
|
||||
+40
-10
@@ -324,7 +324,13 @@ fn walk_jpeg(b: &[u8], i: &mut usize) -> Option<(usize, u8)> {
|
||||
if m == 0xda {
|
||||
// entropy-coded data up to the next marker that is not a stuffed 0xff or a restart
|
||||
loop {
|
||||
*i += b.get(*i..)?.iter().position(|&x| x == 0xff)?;
|
||||
let Some(p) = b.get(*i..)?.iter().position(|&x| x == 0xff) else {
|
||||
// the terminal search examined the rest of the slice: charge it before failing,
|
||||
// or a crafted file can repeat full-file searches while the budget stays positive
|
||||
*i = b.len();
|
||||
return None;
|
||||
};
|
||||
*i += p;
|
||||
match *b.get(*i + 1)? {
|
||||
0x00 | 0xd0..=0xd7 => *i += 2,
|
||||
0xff => *i += 1,
|
||||
@@ -348,9 +354,29 @@ fn walk_jpeg(b: &[u8], i: &mut usize) -> Option<(usize, u8)> {
|
||||
/// share a budget of eight times the file's size, so the scan stays linear (a real file's candidates
|
||||
/// fail within a few bytes, or are the preview itself).
|
||||
fn scan_for_jpeg(data: &[u8]) -> Option<Vec<u8>> {
|
||||
scan_with_budget(data, scan_budget(data.len())).0
|
||||
}
|
||||
|
||||
/// The walks' shared budget: eight times the file's size, at least 1 MiB (a tiny file still gets a
|
||||
/// useful allowance), saturating so a hostile length cannot wrap it.
|
||||
fn scan_budget(len: usize) -> usize {
|
||||
len.saturating_mul(8).max(1 << 20)
|
||||
}
|
||||
|
||||
/// [`scan_for_jpeg`] with the walks' shared budget passed in, returning the walks' charged extent:
|
||||
/// `(preview, bytes charged)`. The counter records the work each walk did whether or not budget
|
||||
/// remains, so it stays a measure of work rather than of `budget - left`; a last walk may overshoot
|
||||
/// the remaining budget by its own reach. It is a conservative charged-work measure, not literally
|
||||
/// the bytes read: marker payloads skipped by `*i += len` are charged without being read, and a
|
||||
/// failed terminal search is charged to the end of the slice. That over-count is safe for the
|
||||
/// budget. It is a `u64` with saturating addition because the unbudgeted worst case is quadratic in
|
||||
/// the file size (32-bit `usize` could otherwise overflow), and it costs one addition per
|
||||
/// candidate, no allocation.
|
||||
fn scan_with_budget(data: &[u8], budget: usize) -> (Option<Vec<u8>>, u64) {
|
||||
let mut best: Option<(usize, usize)> = None;
|
||||
let mut at = 1;
|
||||
let mut budget = data.len().saturating_mul(8).max(1 << 20);
|
||||
let mut left = budget;
|
||||
let mut walked = 0u64;
|
||||
while let Some(off) = data.get(at..).and_then(|s| s.iter().position(|&b| b == 0xd8)) {
|
||||
let d8 = at + off;
|
||||
at = d8 + 1;
|
||||
@@ -360,8 +386,10 @@ fn scan_for_jpeg(data: &[u8]) -> Option<Vec<u8>> {
|
||||
let start = d8 - 1; // `at` starts at 1, so d8 >= 1
|
||||
let mut reach = start;
|
||||
let found = jpeg_extent(data, start, &mut reach);
|
||||
budget = budget.saturating_sub(reach.saturating_sub(start));
|
||||
if budget == 0 {
|
||||
let spent = reach.saturating_sub(start);
|
||||
left = left.saturating_sub(spent);
|
||||
walked = walked.saturating_add(spent as u64);
|
||||
if left == 0 {
|
||||
break;
|
||||
}
|
||||
if let Some((end, 3)) = found {
|
||||
@@ -371,12 +399,14 @@ fn scan_for_jpeg(data: &[u8]) -> Option<Vec<u8>> {
|
||||
at = end;
|
||||
}
|
||||
}
|
||||
let (start, end) = best?;
|
||||
let mut jpeg = data.get(start..end)?.to_vec();
|
||||
if let Some(first) = jpeg.first_mut() {
|
||||
*first = 0xff;
|
||||
}
|
||||
Some(jpeg)
|
||||
let jpeg = best.and_then(|(start, end)| {
|
||||
let mut jpeg = data.get(start..end)?.to_vec();
|
||||
if let Some(first) = jpeg.first_mut() {
|
||||
*first = 0xff;
|
||||
}
|
||||
Some(jpeg)
|
||||
});
|
||||
(jpeg, walked)
|
||||
}
|
||||
|
||||
/// Canon CR3: the full-size JPEG track (see [`lightcraft_meta::cr3`]), else the `PRVW` / `THMB` boxes.
|
||||
|
||||
@@ -300,19 +300,97 @@ fn scan_jpeg(nc: u8, n: usize) -> Vec<u8> {
|
||||
j
|
||||
}
|
||||
|
||||
/// A crafted container full of JPEG-like starts (each walk running on through the ones after it)
|
||||
/// is scanned in linear time: the walks share a budget (without it this 40 MB file takes about a minute).
|
||||
#[test]
|
||||
fn a_scan_through_many_fake_jpegs_stays_linear() {
|
||||
/// `units` copies of a 200-byte record whose `d8 ff db` start runs each walk on through the
|
||||
/// starts after it (a crafted Minolta-like container).
|
||||
fn many_fake_jpegs(units: usize) -> Vec<u8> {
|
||||
let mut unit = vec![0u8, 0xd8, 0xff, 0xdb, 0x00, 0x02, 0xff, 0xda, 0x00, 0x02];
|
||||
unit.extend(std::iter::repeat_n(0x55u8, 190));
|
||||
let mut f = b"\0MRM\0\x01\0\0".to_vec();
|
||||
for _ in 0..200_000 {
|
||||
f.reserve(units * unit.len());
|
||||
for _ in 0..units {
|
||||
f.extend_from_slice(&unit);
|
||||
}
|
||||
let t = std::time::Instant::now();
|
||||
f
|
||||
}
|
||||
|
||||
/// The production scan's budget policy, restated here so the tests assert an expected bound
|
||||
/// independently instead of calling the helper under test: eight times the file's size, at
|
||||
/// least 1 MiB.
|
||||
fn policy_budget(len: usize) -> u64 {
|
||||
(len as u64 * 8).max(1 << 20)
|
||||
}
|
||||
|
||||
/// A crafted 40 MB container full of JPEG-like starts (each walk running on through the starts
|
||||
/// after it) is scanned in bounded work: the walks share a budget (without it this file would
|
||||
/// walk about 8·10^10 bytes), so the charged work stays within the policy budget plus one walk's
|
||||
/// reach. No wall clock: this cannot fail on a slow runner, and removing the budget shows up as
|
||||
/// the work exploding.
|
||||
#[test]
|
||||
fn a_scan_through_many_fake_jpegs_stays_linear() {
|
||||
let f = many_fake_jpegs(200_000);
|
||||
assert_eq!(f.len(), 40_000_008, "the adversarial fixture the budget was measured on");
|
||||
let (found, walked) = scan_with_budget(&f, policy_budget(f.len()) as usize);
|
||||
assert_eq!(found, None);
|
||||
let bound = policy_budget(f.len()) + f.len() as u64;
|
||||
assert!(walked <= bound, "walked {walked} bytes of a {} byte file (bound {bound})", f.len());
|
||||
}
|
||||
|
||||
/// A walk whose final entropy search finds no `ff` has examined the rest of the file, so that
|
||||
/// search is charged to the budget before the walk fails. Without the charge the 40 MB tail is
|
||||
/// scanned for free and only the few KB before it are reported, while `left` stays positive and
|
||||
/// the budget no longer bounds the work.
|
||||
#[test]
|
||||
fn a_failed_terminal_search_is_charged_to_the_budget() {
|
||||
let mut f = many_fake_jpegs(20);
|
||||
f.extend(std::iter::repeat_n(0x55u8, 40_000_000));
|
||||
let (found, walked) = scan_with_budget(&f, 1);
|
||||
assert_eq!(found, None);
|
||||
assert!(walked >= 40_000_000, "terminal search was not charged: {walked} of {} bytes", f.len());
|
||||
}
|
||||
|
||||
/// The production wrapper stops at the same shared budget. Eighteen fake candidates each declare
|
||||
/// a segment length that makes their walk jump 0xFFF0 bytes, exhausting the tiny file's 1 MiB
|
||||
/// allowance before the valid JPEG after them: the wrapper finds nothing. A `usize::MAX` budget
|
||||
/// (or no budget at all) would walk on and return that JPEG.
|
||||
#[test]
|
||||
fn the_production_wrapper_stops_at_the_shared_budget() {
|
||||
let mut f = b"\0MRM\0\x01\0\0".to_vec();
|
||||
for _ in 0..18 {
|
||||
f.extend_from_slice(&[0x00, 0xd8, 0xff, 0xe1, 0xff, 0xf0]);
|
||||
}
|
||||
f.extend_from_slice(&scan_jpeg(3, 100));
|
||||
assert_eq!(scan_for_jpeg(&f), None);
|
||||
assert!(t.elapsed() < std::time::Duration::from_secs(10), "{:?}", t.elapsed());
|
||||
}
|
||||
|
||||
/// The shared budget is what bounds the work, and the counter reports the work itself. The same
|
||||
/// generator at 200 starts is a 40 KB file: without the budget (never decremented, or restarted
|
||||
/// for each candidate) the scan walks all 200 walks, 4 MB, instead of about 1 MB — so a negative
|
||||
/// run costs megabytes, not the 8·10^10 bytes of the 40 MB fixture.
|
||||
#[test]
|
||||
fn the_scan_budget_bounds_the_work_a_crafted_file_can_cause() {
|
||||
let f = many_fake_jpegs(200);
|
||||
let budget = policy_budget(f.len());
|
||||
let (found, walked) = scan_with_budget(&f, budget as usize);
|
||||
assert_eq!(found, None);
|
||||
assert!(walked >= (f.len() as u64) / 2, "the walks ran: {walked} bytes of {}", f.len());
|
||||
assert!(walked <= budget + (f.len() as u64), "walked {walked} with a {budget} byte budget");
|
||||
// The counter records the work, not the unspent budget: a budget of one file size is spent
|
||||
// by the first walk, and the second walk is still counted in full (it overshoots).
|
||||
let (_, spent) = scan_with_budget(&f, f.len());
|
||||
let len = f.len() as u64;
|
||||
assert!(spent > len && spent <= 2 * len, "spent {spent} with a {len}-byte budget");
|
||||
// No candidate, no work counted.
|
||||
let none = b"no jpeg here";
|
||||
assert_eq!(scan_with_budget(none, policy_budget(none.len()) as usize).1, 0);
|
||||
// One real candidate: the counter counts exactly the reach of its walk.
|
||||
let mut one = vec![0u8];
|
||||
let jpeg = scan_jpeg(3, 100);
|
||||
one.extend_from_slice(&jpeg);
|
||||
let (found, walked) = scan_with_budget(&one, policy_budget(one.len()) as usize);
|
||||
assert!(found.is_some());
|
||||
assert_eq!(walked, jpeg.len() as u64, "one walk of the JPEG's {} bytes", jpeg.len());
|
||||
// The production wrapper takes the same path and returns the counter's preview.
|
||||
assert_eq!(scan_for_jpeg(&one).as_deref(), found.as_deref());
|
||||
}
|
||||
|
||||
/// Containers whose structure we do not walk give up their largest colour JPEG, found by scanning.
|
||||
|
||||
@@ -113,7 +113,7 @@ fn micro(points: usize, pending: bool) {
|
||||
let key = app.session.thumb_job(id, 384).unwrap().key;
|
||||
app.renderer.textures.insert(
|
||||
Slot::Thumb(id),
|
||||
Tex { key, photo: id, tex: texture.clone(), size: [1, 1], histogram: None, ms: 0.0, quick: None, pixels: None },
|
||||
Tex { look: None, key, photo: id, tex: texture.clone(), size: [1, 1], histogram: None, ms: 0.0, quick: None, pixels: None },
|
||||
);
|
||||
}
|
||||
request_thumb(&mut app, id, 384, 10);
|
||||
|
||||
@@ -53,6 +53,7 @@ impl LightcraftApp {
|
||||
slideshow_hidden_at: None,
|
||||
slideshow_auto_paused: false,
|
||||
dropped_files: vec![],
|
||||
deferred_dropped_file_count: 0,
|
||||
synthetic_in_flight: false,
|
||||
ui_passes: 0,
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -215,9 +215,15 @@ impl LightcraftApp {
|
||||
self.last_time = t;
|
||||
panels::denoise::pump_ui_input(self, ctx, now);
|
||||
panels::faces::pump_ui_input(self, ctx, now);
|
||||
// A prompt owns the library too: files dropped while it is up wait in the queue and are
|
||||
// imported on the first UI pass after it goes away, never behind it.
|
||||
if !panels::notices::blocks_input(self) {
|
||||
// A prompt or the blocking library window owns the library too: files dropped while either
|
||||
// is up wait in the queue and are imported on the first UI pass after it goes away, never
|
||||
// behind it. With no library open the window's `hold_dropped` path still takes the photos
|
||||
// once its own dialog and asynchronous library picker are gone, so `blocks_file_drops`
|
||||
// only holds them back until then. Mark those drops: once the barrier opens they may find
|
||||
// a dialog, Lightroom import or import that the library's own open started still running.
|
||||
if panels::notices::blocks_input(self) || panels::library_problem::blocks_file_drops(self) {
|
||||
self.defer_dropped_files();
|
||||
} else {
|
||||
self.import_dropped_files(ctx);
|
||||
}
|
||||
self.check_external_editor(ctx);
|
||||
|
||||
@@ -1,15 +1,50 @@
|
||||
use super::*;
|
||||
|
||||
impl LightcraftApp {
|
||||
/// Preserve the currently queued handles while a prompt or blocking library window owns
|
||||
/// drops. Hosts call this in their UI phase even if the app is not ready to draw yet.
|
||||
pub fn defer_dropped_files(&mut self) {
|
||||
self.deferred_dropped_file_count = self.dropped_files.len();
|
||||
}
|
||||
|
||||
/// Take the host's dropped files, exactly once. The queue is filled by `raw_input_hook`, so a
|
||||
/// hidden logic tick can never replay the last pass's drop. Web hosts call this in their UI
|
||||
/// phase (they need `bytes_async`, which is not available on a native file path).
|
||||
pub fn take_dropped_files(&mut self) -> Vec<egui::DroppedFileHandle> {
|
||||
self.deferred_dropped_file_count = 0;
|
||||
std::mem::take(&mut self.dropped_files)
|
||||
}
|
||||
|
||||
/// Take only drops that did not wait behind a prompt or blocking library window.
|
||||
/// Hosts call this in the UI phase to refuse fresh arrivals behind an ordinary dialog
|
||||
/// while preserving the earlier protected prefix for a later pass.
|
||||
pub fn take_unprotected_dropped_files(&mut self) -> Vec<egui::DroppedFileHandle> {
|
||||
if self.deferred_dropped_file_count == 0 {
|
||||
return self.take_dropped_files();
|
||||
}
|
||||
self.dropped_files.split_off(self.deferred_dropped_file_count.min(self.dropped_files.len()))
|
||||
}
|
||||
|
||||
/// Import native drops as presets, face models or photos.
|
||||
pub(super) fn import_dropped_files(&mut self, ctx: &egui::Context) {
|
||||
// A drop that waited behind a prompt or the library window is not dropped again just
|
||||
// because a dialog, Lightroom catalog import, a running Synchronize Folder or the import
|
||||
// a library open started is still running when the barrier goes. Keep it queued and try
|
||||
// the next pass. A drop made outside the barrier keeps the deliberate "close the
|
||||
// dialog"/"An import is running" refusal below.
|
||||
if self.deferred_dropped_file_count > 0
|
||||
&& (self.ui.dialog.is_some() || self.lightroom.is_some() || self.sync_run.is_some() || import::running(self))
|
||||
{
|
||||
// Only the earlier barrier-held prefix waits. Fresh drops must still be handled
|
||||
// under the current dialog/import policy instead of inheriting that protection.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let fresh = self.take_unprotected_dropped_files();
|
||||
self.handle_dropped_files(ctx, fresh);
|
||||
}
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
return;
|
||||
}
|
||||
let dropped = self.take_dropped_files();
|
||||
if dropped.is_empty() {
|
||||
return;
|
||||
@@ -35,10 +70,15 @@ impl LightcraftApp {
|
||||
return;
|
||||
}
|
||||
let drop = drop_overlay::classify(dropped);
|
||||
if let Some(model) = drop.models.first()
|
||||
&& let Err(e) = panels::faces::open_dialog(self, model)
|
||||
{
|
||||
self.toast(ctx, e);
|
||||
if let Some(model) = drop.models.first() {
|
||||
// no library open yet (the launch list, a library that failed to open): its licence
|
||||
// dialog would open behind the window that owns input. The drop is refused like a
|
||||
// preset; photos below still wait for the library chosen.
|
||||
if panels::library_problem::holds_drops(self) {
|
||||
self.toast(ctx, crate::i18n::tr("Open a library first, then drop the model again"));
|
||||
} else if let Err(e) = panels::faces::open_dialog(self, model) {
|
||||
self.toast(ctx, e);
|
||||
}
|
||||
}
|
||||
if !drop.presets.is_empty() {
|
||||
// presets live in a library: with none open they would go with the session it replaces
|
||||
|
||||
@@ -131,6 +131,25 @@ impl LightcraftApp {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Finish a rendered headless capture, growing its reservation before writing if needed.
|
||||
/// Every path replies once and releases the request's reserved bytes.
|
||||
pub(crate) fn finish_headless_shot(&mut self, shot: &mut PendingShot, img: &egui::ColorImage) {
|
||||
let bytes = img.size[0].checked_mul(img.size[1]).and_then(|p| p.checked_mul(std::mem::size_of::<egui::Color32>()));
|
||||
let result = match bytes {
|
||||
Some(bytes) if bytes > shot.reserved => self.grow_shot_reservation(shot.reserved, bytes).map(|()| {
|
||||
shot.reserved = bytes;
|
||||
}),
|
||||
Some(_) => Ok(()),
|
||||
None => Err("ui.screenshot: the rendered image has invalid dimensions".to_owned()),
|
||||
};
|
||||
let reply = match result {
|
||||
Ok(()) => control::save_screenshot(self, img, shot.target.as_ref()),
|
||||
Err(error) => serde_json::json!({"ok": false, "error": error}),
|
||||
};
|
||||
let _ = shot.reply.send(reply);
|
||||
self.release_shot_bytes(shot.reserved);
|
||||
}
|
||||
|
||||
/// Advance pending screenshots: wait (≥ 3 completed UI frames, ≥ 120 ms) until no renders are
|
||||
/// in flight — or until the forced-capture instant, so sustained rendering cannot turn a
|
||||
/// request into a timeout — then capture through the compositor's one-shot callback, or
|
||||
@@ -183,31 +202,7 @@ impl LightcraftApp {
|
||||
let img = if shadow_ticked { None } else { self.headless_screenshot(ctx, true) };
|
||||
shadow_ticked = true;
|
||||
if let Some(img) = img {
|
||||
let bytes = img.size[0].checked_mul(img.size[1]).and_then(|p| p.checked_mul(std::mem::size_of::<egui::Color32>()));
|
||||
match bytes {
|
||||
Some(bytes) if bytes > shot.reserved => {
|
||||
// A stale/undersized reservation is not a user error: grow it to the
|
||||
// actual image and encode it when the per-image and aggregate caps allow.
|
||||
if let Err(error) = self.grow_shot_reservation(shot.reserved, bytes) {
|
||||
let _ = shot.reply.send(serde_json::json!({"ok": false, "error": error}));
|
||||
self.release_shot_bytes(shot.reserved);
|
||||
continue;
|
||||
}
|
||||
shot.reserved = bytes;
|
||||
}
|
||||
Some(_) => {}
|
||||
None => {
|
||||
let reply = serde_json::json!({
|
||||
"ok": false,
|
||||
"error": "ui.screenshot: the rendered image has invalid dimensions",
|
||||
});
|
||||
let _ = shot.reply.send(reply);
|
||||
self.release_shot_bytes(shot.reserved);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let _ = shot.reply.send(control::save_screenshot(self, &img, shot.target.as_ref()));
|
||||
self.release_shot_bytes(shot.reserved);
|
||||
self.finish_headless_shot(&mut shot, &img);
|
||||
continue;
|
||||
}
|
||||
keep.push(shot);
|
||||
|
||||
@@ -132,11 +132,14 @@ pub(super) fn slideshow_export_body(app: &mut LightcraftApp, ui: &mut egui::Ui,
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::state::Dialog;
|
||||
use crate::tests_fixtures::SyntheticLibrary;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// The app with a picker that answers `answer` and remembers what it was asked.
|
||||
fn app_answering(answer: String) -> (LightcraftApp, Arc<Mutex<Vec<PickRequest>>>) {
|
||||
/// The app, with two real file-backed photos and a picker that answers `answer` and remembers
|
||||
/// what it was asked. Keep the returned fixture alive until the app is dropped.
|
||||
fn app_answering(tag: &str, answer: String) -> (SyntheticLibrary, LightcraftApp, Arc<Mutex<Vec<PickRequest>>>) {
|
||||
let files = SyntheticLibrary::with_photos(tag, 2);
|
||||
let asked = Arc::new(Mutex::new(Vec::<PickRequest>::new()));
|
||||
let requests = asked.clone();
|
||||
let services = crate::Services {
|
||||
@@ -148,9 +151,10 @@ mod tests {
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
let mut s = lightcraft_engine::Session::with_demo();
|
||||
s.execute("slideshow.playback", &json!({"slideDuration": 1, "fadeDuration": 0.5})).unwrap();
|
||||
(LightcraftApp::new(s, services), asked)
|
||||
let mut app = files.app();
|
||||
app.services = services;
|
||||
app.session.execute("slideshow.playback", &json!({"slideDuration": 1, "fadeDuration": 0.5})).unwrap();
|
||||
(files, app, asked)
|
||||
}
|
||||
|
||||
fn run_dialog(h: &mut crate::headless::Headless) -> Value {
|
||||
@@ -164,7 +168,11 @@ mod tests {
|
||||
if h.app.slideshow_export_reported && h.app.session.slideshow_export.is_some() {
|
||||
break;
|
||||
}
|
||||
assert!(Instant::now() < deadline, "export did not finish");
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"export did not finish: {}",
|
||||
h.app.session.slideshow_export.as_ref().map_or_else(|| "no export run".into(), |r| r.json().to_string())
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
let run = h.app.session.slideshow_export.clone().unwrap();
|
||||
@@ -179,7 +187,7 @@ mod tests {
|
||||
let dir = std::env::temp_dir().join(format!("lc-ui-ssexp-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let path = dir.join("Show.pdf").to_string_lossy().to_string();
|
||||
let (mut app, asked) = app_answering(path.clone());
|
||||
let (_files, mut app, asked) = app_answering("ssexp", path.clone());
|
||||
assert!(crate::menus::ui_enabled(&app, "dialog.slideshowExport"));
|
||||
app.run("dialog.slideshowExport", json!({"format": "pdf"})).unwrap();
|
||||
let Some(Dialog::SlideshowExport { settings }) = app.ui.dialog.as_mut() else { panic!("no slideshow export dialog") };
|
||||
@@ -202,7 +210,7 @@ mod tests {
|
||||
let dir = std::env::temp_dir().join(format!("lc-ui-ssexp-v-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let path = dir.join("Show.mp4").to_string_lossy().to_string();
|
||||
let (mut app, asked) = app_answering(path.clone());
|
||||
let (_files, mut app, asked) = app_answering("ssexp-v", path.clone());
|
||||
app.run("dialog.slideshowExport", json!({"format": "video"})).unwrap();
|
||||
let Some(Dialog::SlideshowExport { settings }) = app.ui.dialog.as_mut() else { panic!("no dialog") };
|
||||
assert_eq!((settings.size, settings.fps, settings.container.as_str()), (720, 30, "mp4"));
|
||||
@@ -215,7 +223,7 @@ mod tests {
|
||||
assert!(r["sawRow"].as_bool().unwrap(), "the export showed in the activity stack");
|
||||
assert!(std::path::Path::new(&path).is_file());
|
||||
// JPEG: a save dialog names the folder (no file type)
|
||||
let (mut app, asked) = app_answering(dir.join("Frames").to_string_lossy().to_string());
|
||||
let (_files_jpeg, mut app, asked) = app_answering("ssexp-jpeg", dir.join("Frames").to_string_lossy().to_string());
|
||||
app.run("app.slideshowExport", json!({"format": "jpeg", "size": 64})).unwrap();
|
||||
assert!(asked.lock().unwrap()[0].filter.is_none());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
@@ -32,13 +32,15 @@ fn lines(app: &LightcraftApp, paths: Vec<String>, unknown: bool) -> Vec<String>
|
||||
}
|
||||
let drop = classify(paths);
|
||||
let mut out = Vec::new();
|
||||
if !drop.models.is_empty() {
|
||||
out.push(crate::i18n::tr("Drop to open the face model").to_string());
|
||||
}
|
||||
let plural = |n: usize| if n == 1 { "" } else { "s" };
|
||||
// no library open yet (desktop: the web build's drops import into its session as before):
|
||||
// photos wait for the library chosen, presets are refused (the drop handler does the same)
|
||||
// photos wait for the library chosen, presets and models are refused (the drop handler does the same)
|
||||
let waiting = crate::panels::library_problem::holds_drops(app) && !cfg!(target_arch = "wasm32");
|
||||
if !drop.models.is_empty() && waiting {
|
||||
out.push(crate::i18n::tr("Open a library first, then drop the model again").to_string());
|
||||
} else if !drop.models.is_empty() {
|
||||
out.push(crate::i18n::tr("Drop to open the face model").to_string());
|
||||
}
|
||||
if !drop.presets.is_empty() && waiting {
|
||||
out.push(crate::i18n::tr("Open a library first, then drop the presets again").to_string());
|
||||
} else if !drop.presets.is_empty() {
|
||||
@@ -69,9 +71,11 @@ fn lines(app: &LightcraftApp, paths: Vec<String>, unknown: bool) -> Vec<String>
|
||||
|
||||
/// Veil and card while files are dragged over the window.
|
||||
pub fn show(app: &LightcraftApp, ctx: &Context) {
|
||||
// This card is an `Order::Tooltip` layer, above the prompt's Foreground window. While a prompt
|
||||
// owns input the drag can only be background noise: do not paint over or register it.
|
||||
if crate::panels::notices::blocks_input(app) {
|
||||
// This card is an `Order::Tooltip` layer, above the prompt's or blocking library window's
|
||||
// Foreground layer (and any dialog that holds a no-library window's drops). While input is
|
||||
// owned the drag can only be background noise: do not paint over or register it, or the card
|
||||
// would promise a drop the barrier is holding back.
|
||||
if crate::panels::notices::blocks_input(app) || crate::panels::library_problem::blocks_file_drops(app) {
|
||||
return;
|
||||
}
|
||||
let count = ctx.input(|i| i.raw.hovered_files.len());
|
||||
|
||||
@@ -690,6 +690,14 @@ fn adding_a_face_model_shows_its_terms_and_installs_only_once_accepted() {
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.step();
|
||||
assert!(matches!(&h.app.ui.dialog, Some(Dialog::FaceModel { accepted: true, .. })));
|
||||
// Consent is accepted, but no model is installed until confirmation. Keep this UI
|
||||
// fixture on the CPU so it does not start an unrelated background GPU probe.
|
||||
let models = dir.join("models");
|
||||
std::fs::create_dir_all(&models).unwrap();
|
||||
std::fs::write(models.join("settings.json"), br#"{"cpuOnly":true}"#).unwrap();
|
||||
assert_eq!(h.app.session.execute("faces.gpu", &json!({})).unwrap()["cpuOnly"], true);
|
||||
let before_confirm = h.app.session.execute("faces.models.list", &json!({})).unwrap();
|
||||
assert!(before_confirm["models"].as_array().unwrap().iter().all(|m| m["installed"] != true), "{before_confirm}");
|
||||
let r = h.request("ui.dialog.confirm", json!({}), t);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
let listed = h.app.run("faces.models.list", json!({})).unwrap();
|
||||
@@ -798,7 +806,13 @@ fn people_and_the_name_box_offer_to_set_face_recognition_up() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-ui-facesetup-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
h.app.session.face_models_dir = Some(dir.join("models"));
|
||||
// The setup offer tests recognition controls, not GPU attachment. Seed only this
|
||||
// fixture's settings before any model can load, without detaching a shared detector.
|
||||
let models = dir.join("models");
|
||||
std::fs::create_dir_all(&models).unwrap();
|
||||
std::fs::write(models.join("settings.json"), br#"{"cpuOnly":true}"#).unwrap();
|
||||
h.app.session.face_models_dir = Some(models);
|
||||
assert_eq!(h.app.session.execute("faces.gpu", &json!({})).unwrap()["cpuOnly"], true);
|
||||
let runtime = h.app.session.execute("faces.models.list", &json!({})).unwrap()["runtime"] == true;
|
||||
let id = h.app.session.active().unwrap();
|
||||
let mut meta = h.app.session.catalog.photo(id).unwrap().meta.clone();
|
||||
@@ -1244,14 +1258,29 @@ fn a_screenful_of_small_faces_is_not_evicted_and_left_blank() {
|
||||
}
|
||||
// the smallest faces, so a lot of them fit on the screen
|
||||
h.request("ui.set", json!({"thumbSize": 90.0, "view": "people"}), t);
|
||||
h.settle(SETTLE);
|
||||
// Lay out the faces before waiting for their workers; a global settle also waits for
|
||||
// unrelated work and hides a timed-out initial wait.
|
||||
for _ in 0..4 {
|
||||
h.step();
|
||||
}
|
||||
h.step();
|
||||
let drawn = h.request("ui.widgets", json!({"filter": "unnamed-face:"}), t)["result"].as_array().map_or(0, Vec::len);
|
||||
assert!(drawn > 96, "the test needs more faces on screen than the usual budget, got {drawn}");
|
||||
// the pictures are made and kept, well beyond the old budget (a few demo photos share a scene, so two faces can share a
|
||||
// picture: the count is a little under the number of tiles)
|
||||
let started = Instant::now();
|
||||
while h.app.renderer.variant_textures() < drawn - 20 && started.elapsed() < Duration::from_secs(60) {
|
||||
let timeout = Duration::from_secs(300);
|
||||
while h.app.renderer.variant_textures() < drawn - 20 {
|
||||
let left = timeout.saturating_sub(started.elapsed());
|
||||
assert!(
|
||||
!left.is_zero(),
|
||||
"variant renders did not finish: {} pictures for {drawn} faces, pending {:?}",
|
||||
h.app.renderer.variant_textures(),
|
||||
h.app.renderer.pending_slots()
|
||||
);
|
||||
// Coverage instrumentation makes the procedural scenes expensive. Wait for actual
|
||||
// worker progress instead of continuously rebuilding a full People-view frame.
|
||||
h.app.renderer.wait_for_worker(left.min(Duration::from_millis(250)));
|
||||
h.step();
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
@@ -2356,8 +2385,7 @@ fn click_field_in_view(h: &mut Headless, key: &str, timeout: Duration) {
|
||||
/// accessibility and place fields reach the photo.
|
||||
#[test]
|
||||
fn info_fields_keep_typing_and_save() {
|
||||
// (tall enough for the place fields to show without scrolling)
|
||||
let mut h = demo([1300.0, 1200.0]);
|
||||
let mut h = demo([1300.0, 1000.0]);
|
||||
let t = Duration::from_secs(10);
|
||||
h.request("ui.set", json!({"view": "detail", "right": "info"}), t);
|
||||
h.settle(SETTLE);
|
||||
|
||||
@@ -43,6 +43,8 @@ pub mod window_place;
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
use tests::panels as tests_panels;
|
||||
#[cfg(test)]
|
||||
use tests::{fixtures as tests_fixtures, render_fixtures as tests_render_fixtures};
|
||||
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
|
||||
@@ -191,6 +193,10 @@ pub struct LightcraftApp {
|
||||
slideshow_auto_paused: bool,
|
||||
/// Files dropped since the last UI pass, consumed exactly once by the UI phase.
|
||||
dropped_files: Vec<egui::DroppedFileHandle>,
|
||||
/// Prefix of `dropped_files` that arrived behind a prompt, library window or its picker.
|
||||
/// Preserve only this prefix through subsequent dialogs/imports; later arrivals still take
|
||||
/// the ordinary refusal path unless they too arrive while the original barrier is up.
|
||||
deferred_dropped_file_count: usize,
|
||||
/// A synthetic batch was injected into a pass and has not been consumed by a completed
|
||||
/// (nondiscarded) UI pass yet: a hidden logic tick must not inject another one.
|
||||
synthetic_in_flight: bool,
|
||||
|
||||
@@ -184,13 +184,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
_ => vec![area],
|
||||
};
|
||||
let main_area = *areas.last().unwrap_or(&area);
|
||||
// A previous crop/rotation can stay visible while its replacement renders. Its pixels
|
||||
// must not override the current frame geometry, even when it is the same photo.
|
||||
let current_texture = |t: &&crate::render::Tex| t.photo == id && t.look == Some((crate::render::geometry_look(&d), !crop_tool));
|
||||
let visible_texture_aspect = app
|
||||
.renderer
|
||||
.textures
|
||||
.get(&Slot::Main)
|
||||
.filter(|t| t.photo == id)
|
||||
.or_else(|| app.renderer.textures.get(&Slot::Preview).filter(|t| t.photo == id))
|
||||
.or_else(|| app.renderer.thumb(id))
|
||||
.filter(current_texture)
|
||||
.or_else(|| app.renderer.textures.get(&Slot::Preview).filter(current_texture))
|
||||
.or_else(|| app.renderer.thumb(id).filter(current_texture))
|
||||
.map(|tex| tex.size[0].max(1) as f32 / tex.size[1].max(1) as f32);
|
||||
// Follow the texture only where it really differs (an unsupported raw's embedded JPEG with
|
||||
// another crop): a texture's whole-pixel size is a little off the exact aspect, and the crop
|
||||
|
||||
@@ -710,6 +710,15 @@ pub fn request_thumb(app: &mut LightcraftApp, id: PhotoId, size: usize, priority
|
||||
{
|
||||
return;
|
||||
}
|
||||
// Several cells can show one photo at different sizes (a print page's cells). Before any
|
||||
// thumbnail arrives, a caller at another bucket would replace the pending request every frame
|
||||
// and restart it, so none of them ever gets a picture: keep the request in flight. A higher
|
||||
// priority caller may take it over once; the caller it replaced then coalesces behind it.
|
||||
if let Some(photo) = app.session.catalog.photo(id)
|
||||
&& app.renderer.thumb_pending_other_bucket(photo, bucket, priority)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let Some(job) = app.session.thumb_job(id, size) else { return };
|
||||
#[cfg(test)]
|
||||
{
|
||||
@@ -722,9 +731,9 @@ pub fn request_thumb(app: &mut LightcraftApp, id: PhotoId, size: usize, priority
|
||||
quick.is_some() && app.session.catalog.photo(id).is_some_and(|p| skips_the_real_render(app.session.embedded_thumb_is_final(p), true));
|
||||
if let Some(photo) = app.session.catalog.photo(id) {
|
||||
if embedded_is_final {
|
||||
app.renderer.remember_embedded_thumb(photo, bucket, job.key);
|
||||
app.renderer.remember_embedded_thumb(photo, bucket, job.key, priority);
|
||||
} else {
|
||||
app.renderer.remember_thumb(photo, bucket, job.key, quick.is_some());
|
||||
app.renderer.remember_thumb(photo, bucket, job.key, quick.is_some(), priority);
|
||||
}
|
||||
}
|
||||
match quick {
|
||||
|
||||
@@ -733,6 +733,30 @@ pub fn holds_drops(app: &LightcraftApp) -> bool {
|
||||
app.session.library.is_none() && app.library_problem.as_ref().is_some_and(|p| !p.dismissed)
|
||||
}
|
||||
|
||||
/// Whether the blocking library window must hold file drops back. While a library is already
|
||||
/// open, that window (the launch list over it, or the error of a failed switch) owns input:
|
||||
/// importing a drop now would add photos, presets or a model dialog behind it. With no library
|
||||
/// open, the desktop drop handler's [`hold_dropped`] path takes the photos for the library the
|
||||
/// window opens instead, so the drop still has to be consumed.
|
||||
///
|
||||
/// The launch list hides itself behind its own New Library name prompt (`chooser_owns_dialog`).
|
||||
/// It still owns the library then: a queued drop must wait for the prompt to close, not be taken
|
||||
/// and refused by the drop handler's generic open-dialog check. After the prompt, New Library and
|
||||
/// Open Another… ask for a folder on the host's asynchronous picker; the library opens only when
|
||||
/// that answer arrives, so the queued drop waits too. With no library open a dialog (or a library
|
||||
/// picker that will open one) in front of the window would otherwise refuse the drop or consume it
|
||||
/// before the photos could reach [`hold_dropped`].
|
||||
pub fn blocks_file_drops(app: &LightcraftApp) -> bool {
|
||||
if app.library_problem.as_ref().is_none_or(|p| p.dismissed) {
|
||||
return false;
|
||||
}
|
||||
if app.session.library.is_none() {
|
||||
let choosing = app.pending_picks.iter().any(|p| p.command == "app.newLibrary" || p.command == "app.openLibrary");
|
||||
return app.ui.dialog.is_some() || choosing;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Photos dropped on the window while no library is open (the launch list, or the window of a
|
||||
/// library that failed to open): kept with the command line's files and imported once a library
|
||||
/// opens, instead of going into the session the next library replaces. Gives the photos back when
|
||||
|
||||
+152
-27
@@ -66,7 +66,23 @@ pub enum Slot {
|
||||
HdrHistogram,
|
||||
}
|
||||
|
||||
/// Settings that can change the rendered frame. Colour-only edits, including temporarily
|
||||
/// hidden grading wheels, must not discard a decoded RAW's actual aspect ratio.
|
||||
pub(crate) fn geometry_look(settings: &lightcraft_develop::DevelopSettings) -> u64 {
|
||||
let geometry = lightcraft_develop::DevelopSettings {
|
||||
crop: settings.crop,
|
||||
orientation: settings.orientation,
|
||||
geometry: settings.geometry.clone(),
|
||||
optics: settings.optics,
|
||||
disabled_sections: settings.disabled_sections.iter().filter(|s| matches!(s.as_str(), "geometry" | "optics")).cloned().collect(),
|
||||
..Default::default()
|
||||
};
|
||||
geometry.hash64()
|
||||
}
|
||||
|
||||
pub struct Tex {
|
||||
/// Frame geometry and crop mode captured when these pixels were requested.
|
||||
pub look: Option<(u64, bool)>,
|
||||
pub key: u64,
|
||||
pub photo: PhotoId,
|
||||
pub tex: egui::TextureHandle,
|
||||
@@ -93,12 +109,14 @@ pub trait RenderOffload {
|
||||
/// worker (`None` for prefetches and failures). A 10 MP zoom window's conversion took a few ms of
|
||||
/// the UI thread on every slider step.
|
||||
struct Finished {
|
||||
look: Option<(u64, bool)>,
|
||||
result: RenderResult,
|
||||
color: Option<Arc<egui::ColorImage>>,
|
||||
}
|
||||
|
||||
/// Immutable identity captured when a request is submitted, not when its result is collected.
|
||||
struct Requested {
|
||||
look: Option<(u64, bool)>,
|
||||
id: u64,
|
||||
source: Option<lightcraft_catalog::Source>,
|
||||
}
|
||||
@@ -190,6 +208,8 @@ pub struct Renderer {
|
||||
#[cfg(test)]
|
||||
pub(crate) thumb_jobs_built: usize,
|
||||
requests: HashMap<Slot, Requested>,
|
||||
/// Only jobs actually accepted by an offload: queued replacements retain identity in the job.
|
||||
offload_looks: HashMap<u64, (u64, bool)>,
|
||||
#[cfg(test)]
|
||||
failure_messages: Vec<String>,
|
||||
/// Request id of the pixels each interactive view (loupe, before, hover) shows: a draft that
|
||||
@@ -200,8 +220,9 @@ pub struct Renderer {
|
||||
epoch: u64,
|
||||
preview_generation: Option<(std::sync::Weak<lightcraft_preview::PreviewCache>, u64)>,
|
||||
/// Last thumbnail inputs; weak identities do not retain photo histories or force deep clones.
|
||||
/// (photo, size bucket, job key, whether the request went through an embedded stand-in)
|
||||
thumb_inputs: HashMap<PhotoId, (std::sync::Weak<lightcraft_catalog::Photo>, usize, u64, bool)>,
|
||||
/// (photo, size bucket, job key, whether the request went through an embedded stand-in, and the
|
||||
/// priority of the caller that queued it — a stand-in's background render runs below that)
|
||||
thumb_inputs: HashMap<PhotoId, (std::sync::Weak<lightcraft_catalog::Photo>, usize, u64, bool, u32)>,
|
||||
/// Photos whose grid thumbnail is the camera's embedded preview itself (no render behind it).
|
||||
embedded_thumbs: HashMap<PhotoId, (std::sync::Weak<lightcraft_catalog::Photo>, usize, u64)>,
|
||||
pool: JobPool<Slot, Finished>,
|
||||
@@ -283,6 +304,7 @@ impl Renderer {
|
||||
#[cfg(test)]
|
||||
thumb_jobs_built: 0,
|
||||
requests: HashMap::new(),
|
||||
offload_looks: HashMap::new(),
|
||||
#[cfg(test)]
|
||||
failure_messages: Vec::new(),
|
||||
shown_ids: HashMap::new(),
|
||||
@@ -323,11 +345,27 @@ impl Renderer {
|
||||
impl Renderer {
|
||||
/// Would asking for this thumbnail again (at `priority`) change nothing?
|
||||
pub fn thumb_current(&self, photo: &Arc<lightcraft_catalog::Photo>, bucket: usize, priority: u32) -> bool {
|
||||
let Some(&(ref old, size, key, quick)) = self.thumb_inputs.get(&photo.id) else { return false };
|
||||
if old.as_ptr() != Arc::as_ptr(photo) || size != bucket {
|
||||
let Some(&(ref old, size, key, quick, _)) = self.thumb_inputs.get(&photo.id) else { return false };
|
||||
if old.as_ptr() != Arc::as_ptr(photo) {
|
||||
return false;
|
||||
}
|
||||
let slot = Slot::Thumb(photo.id);
|
||||
if size > bucket {
|
||||
// A larger thumbnail that has already arrived for this photo and settings serves a
|
||||
// smaller cell too (cells at different sizes share one photo). Asking for the smaller
|
||||
// bucket would replace the larger picture, and the larger cell would then ask for its
|
||||
// bucket back on the next frame: two print cells would alternate renders forever.
|
||||
// The photo identity above is the renderer's `Weak<Photo>`: `Catalog` edits through
|
||||
// `Arc::make_mut`, which (per std) moves the value to a new allocation whenever a
|
||||
// `Weak` exists, even with one strong reference. An edit therefore breaks this match,
|
||||
// and the key keeps an older picture of the same allocation from being reused. A
|
||||
// larger request still renders once (below), and while it is in flight
|
||||
// [`Self::thumb_pending_other_bucket`] holds the smaller callers.
|
||||
return self.textures.get(&slot).is_some_and(|t| t.key == key);
|
||||
}
|
||||
if size != bucket {
|
||||
return false;
|
||||
}
|
||||
if quick && !self.textures.contains_key(&slot) {
|
||||
// Stand-in path (`grid::request_thumb`): the embedded preview is still on its way, or
|
||||
// the real thumbnail is requested at the background priority whatever the caller's.
|
||||
@@ -346,16 +384,30 @@ impl Renderer {
|
||||
/// Whether the camera's embedded preview *is* this photo's thumbnail: its quick render arrived
|
||||
/// (so `Slot::ThumbQuick` holds the picture) and no edit has moved the photo on to a settings
|
||||
/// hash of its own. The grid stops here instead of decoding the raw behind a thumbnail the
|
||||
/// embedded JPEG already covers — see `grid::skips_the_real_render`.
|
||||
/// embedded JPEG already covers — see `grid::skips_the_real_render`. A preview that arrived at
|
||||
/// a larger bucket and is still held is final for a smaller cell too, so print cells at
|
||||
/// different sizes do not replace it every frame.
|
||||
pub fn thumb_quick_is_final(&self, photo: &Arc<lightcraft_catalog::Photo>, bucket: usize, priority: u32) -> bool {
|
||||
let Some((old, size, key)) = self.embedded_thumbs.get(&photo.id) else { return false };
|
||||
old.as_ptr() == Arc::as_ptr(photo) && *size == bucket && !self.request_needed(Slot::ThumbQuick(photo.id), *key, priority)
|
||||
if old.as_ptr() != Arc::as_ptr(photo) || *size < bucket {
|
||||
return false;
|
||||
}
|
||||
let slot = Slot::ThumbQuick(photo.id);
|
||||
if *size > bucket {
|
||||
// A larger preview serves a smaller cell only when its pixels are actually here for
|
||||
// the recorded key. The same-bucket failure memoization below says "no" after a failed
|
||||
// job, but a missing larger preview is not a final picture: the smaller cell must ask
|
||||
// for its own bucket rather than stay blank until the catalog revision changes.
|
||||
return self.textures.get(&slot).is_some_and(|t| t.key == *key);
|
||||
}
|
||||
// The requested bucket itself: keep the failure memoization (`failed`) unchanged.
|
||||
!self.request_needed(slot, *key, priority)
|
||||
}
|
||||
|
||||
/// Whether this photo's embedded-preview thumbnail is pending at the requested priority, or
|
||||
/// already running. A queued priority change must reach the grid's stand-in request.
|
||||
pub fn thumb_quick_pending(&self, photo: &Arc<lightcraft_catalog::Photo>, bucket: usize, priority: u32) -> bool {
|
||||
let Some((old, size, key, quick)) = self.thumb_inputs.get(&photo.id) else { return false };
|
||||
let Some((old, size, key, quick, _)) = self.thumb_inputs.get(&photo.id) else { return false };
|
||||
let slot = Slot::ThumbQuick(photo.id);
|
||||
old.as_ptr() == Arc::as_ptr(photo)
|
||||
&& *size == bucket
|
||||
@@ -364,10 +416,44 @@ impl Renderer {
|
||||
&& !self.request_needed(slot, *key, priority.saturating_add(1))
|
||||
}
|
||||
|
||||
/// Whether a request for this photo's thumbnail is already in flight at another bucket and a
|
||||
/// caller asking at `bucket` should leave it there. Several cells can show one photo at
|
||||
/// different sizes at once (a print page's cells), and replacing `Slot::Thumb(id)` from each of
|
||||
/// them every frame restarts the render, so none of them ever gets a picture.
|
||||
///
|
||||
/// A caller with a higher priority than the one that queued the request takes it over once;
|
||||
/// from then on the original caller coalesces behind that higher priority instead of replacing
|
||||
/// it back. Equal priorities keep the request that is in flight (the print cells' case). This
|
||||
/// only chooses which pending bucket survives; after it completes, a larger bucket is reused by
|
||||
/// smaller callers ([`Self::thumb_current`]) rather than being replaced every frame.
|
||||
///
|
||||
/// A full render queued behind a stand-in is submitted at [`crate::panels::grid::BACKGROUND_THUMB_PRIORITY`],
|
||||
/// deliberately below an on-screen caller. That is not the caller's priority, so the comparison
|
||||
/// uses the priority recorded with the inputs: comparing the background slot's own priority
|
||||
/// would let two buckets keep replacing each other's background render every frame.
|
||||
///
|
||||
/// A photo that changed (another `Arc<Photo>`) or a texture for the recorded job key is not
|
||||
/// coalesced: its callers ask for their own bucket as before. A stale texture from before an
|
||||
/// edit does not count — it is still displayed while the replacement renders, and comparing
|
||||
/// the key (not presence) keeps coalescing across buckets in the meantime.
|
||||
pub fn thumb_pending_other_bucket(&self, photo: &Arc<lightcraft_catalog::Photo>, bucket: usize, priority: u32) -> bool {
|
||||
let Some(&(ref old, size, key, _, requested_priority)) = self.thumb_inputs.get(&photo.id) else { return false };
|
||||
if old.as_ptr() != Arc::as_ptr(photo) || size == bucket {
|
||||
return false;
|
||||
}
|
||||
if self.textures.get(&Slot::Thumb(photo.id)).is_some_and(|t| t.key == key) {
|
||||
return false;
|
||||
}
|
||||
// (the inputs must belong to the request still in flight, not to one that already ended)
|
||||
let in_flight = self.pending.get(&Slot::ThumbQuick(photo.id)).is_some_and(|p| p.0 == key)
|
||||
|| self.pending.get(&Slot::Thumb(photo.id)).is_some_and(|p| p.0 == key);
|
||||
in_flight && requested_priority >= priority
|
||||
}
|
||||
|
||||
/// Remember that the camera's embedded preview is this photo's final thumbnail, so the grid's
|
||||
/// per-frame requests stop at it (the quick render's key, which [`Self::thumb_quick_is_final`]
|
||||
/// compares against).
|
||||
pub fn remember_embedded_thumb(&mut self, photo: &Arc<lightcraft_catalog::Photo>, bucket: usize, key: u64) {
|
||||
pub fn remember_embedded_thumb(&mut self, photo: &Arc<lightcraft_catalog::Photo>, bucket: usize, key: u64, priority: u32) {
|
||||
let slot = Slot::Thumb(photo.id);
|
||||
if self.pending.get(&slot).is_some_and(|(pending_key, _)| *pending_key != key) {
|
||||
// An edit can still be rendering when Undo restores this embedded-final thumbnail.
|
||||
@@ -377,20 +463,20 @@ impl Renderer {
|
||||
self.pool.reprioritize(|s, p| if *s == slot { None } else { Some(p) });
|
||||
self.queue.retain(|q| q.slot != slot);
|
||||
}
|
||||
self.remember_thumb(photo, bucket, key, true);
|
||||
self.remember_thumb(photo, bucket, key, true, priority);
|
||||
self.embedded_thumbs.insert(photo.id, (Arc::downgrade(photo), bucket, key));
|
||||
}
|
||||
|
||||
pub fn remember_thumb(&mut self, photo: &Arc<lightcraft_catalog::Photo>, bucket: usize, key: u64, quick: bool) {
|
||||
pub fn remember_thumb(&mut self, photo: &Arc<lightcraft_catalog::Photo>, bucket: usize, key: u64, quick: bool, priority: u32) {
|
||||
if self.thumb_inputs.len() >= 1024 && !self.thumb_inputs.contains_key(&photo.id) {
|
||||
self.thumb_inputs.retain(|_, (p, _, _, _)| p.strong_count() > 0);
|
||||
self.thumb_inputs.retain(|_, (p, _, _, _, _)| p.strong_count() > 0);
|
||||
if self.thumb_inputs.len() >= 1024
|
||||
&& let Some(id) = self.thumb_inputs.keys().next().copied()
|
||||
{
|
||||
self.thumb_inputs.remove(&id);
|
||||
}
|
||||
}
|
||||
self.thumb_inputs.insert(photo.id, (Arc::downgrade(photo), bucket, key, quick));
|
||||
self.thumb_inputs.insert(photo.id, (Arc::downgrade(photo), bucket, key, quick, priority));
|
||||
}
|
||||
|
||||
/// Views that render straight into the display profile's primaries (wide gamut); everything
|
||||
@@ -442,7 +528,7 @@ impl Renderer {
|
||||
lightcraft_engine::display::present(&mut r, d);
|
||||
}
|
||||
let color = r.rendered.as_ref().ok().map(|x| Arc::new(color_image(&x.image)));
|
||||
Finished { result: r, color }
|
||||
Finished { look: None, result: r, color }
|
||||
}
|
||||
|
||||
fn request_needed(&self, slot: Slot, key: u64, priority: u32) -> bool {
|
||||
@@ -491,7 +577,10 @@ impl Renderer {
|
||||
}
|
||||
self.pending.insert(slot, (job.key, priority));
|
||||
job.request_id = lightcraft_preview::next_tick();
|
||||
self.requests.insert(slot, Requested { id: job.request_id, source: Some(job.origin.clone()) });
|
||||
self.requests.insert(
|
||||
slot,
|
||||
Requested { look: Some((geometry_look(&job.settings), job.request.apply_crop)), id: job.request_id, source: Some(job.origin.clone()) },
|
||||
);
|
||||
// (an offload keeps its own per-view stage caches; the flag tells it to)
|
||||
let job = if slot.is_view() { job.with_stages(self.stages.entry(slot).or_default().clone()) } else { job };
|
||||
if self.offload.is_some() {
|
||||
@@ -509,8 +598,9 @@ impl Renderer {
|
||||
key,
|
||||
priority,
|
||||
Box::new(move || {
|
||||
let look = Some((geometry_look(&job.settings), job.request.apply_crop));
|
||||
let run = || if background { lightcraft_engine::memory::in_background(|| job.run()) } else { job.run() };
|
||||
Self::run_for(run, &display)
|
||||
Finished { look, ..Self::run_for(run, &display) }
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -544,7 +634,10 @@ impl Renderer {
|
||||
.as_ref()
|
||||
.map(|job| job.origin.clone())
|
||||
.or_else(|| job.embedded.as_ref().map(|(path, _, _)| lightcraft_catalog::Source::File { path: path.clone() }));
|
||||
self.requests.insert(slot, Requested { id: job.request_id, source });
|
||||
self.requests.insert(
|
||||
slot,
|
||||
Requested { look: job.small.as_ref().map(|j| (geometry_look(&j.settings), j.request.apply_crop)), id: job.request_id, source },
|
||||
);
|
||||
let key = job.key;
|
||||
let display = self.display.clone();
|
||||
self.pool.submit(slot, key, priority, Box::new(move || Self::run_for(|| job.run(), &display)));
|
||||
@@ -563,7 +656,7 @@ impl Renderer {
|
||||
}
|
||||
self.pending.insert(slot, (key, priority));
|
||||
let request_id = lightcraft_preview::next_tick();
|
||||
self.requests.insert(slot, Requested { id: request_id, source: None });
|
||||
self.requests.insert(slot, Requested { look: None, id: request_id, source: None });
|
||||
let display = self.display.clone();
|
||||
self.pool.submit(slot, key, priority, Box::new(move || Self::run_for(|| compose.result(request_id), &display)));
|
||||
}
|
||||
@@ -577,13 +670,16 @@ impl Renderer {
|
||||
self.prefetched.insert(slot, job.key);
|
||||
self.pending.insert(slot, (job.key, priority));
|
||||
job.request_id = lightcraft_preview::next_tick();
|
||||
self.requests.insert(slot, Requested { id: job.request_id, source: Some(job.origin.clone()) });
|
||||
self.requests.insert(
|
||||
slot,
|
||||
Requested { look: Some((geometry_look(&job.settings), job.request.apply_crop)), id: job.request_id, source: Some(job.origin.clone()) },
|
||||
);
|
||||
let key = job.key;
|
||||
self.pool.submit(
|
||||
slot,
|
||||
key,
|
||||
priority,
|
||||
Box::new(move || Finished { result: lightcraft_engine::memory::in_background(|| job.run()), color: None }),
|
||||
Box::new(move || Finished { look: None, result: lightcraft_engine::memory::in_background(|| job.run()), color: None }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -608,6 +704,7 @@ impl Renderer {
|
||||
/// Drop every texture and cached stage (another library was opened: photo ids changed meaning).
|
||||
pub fn forget_all(&mut self) {
|
||||
self.requests.clear();
|
||||
self.offload_looks.clear();
|
||||
self.shown_ids.clear();
|
||||
self.epoch = lightcraft_preview::next_tick();
|
||||
self.preview_generation = None;
|
||||
@@ -653,10 +750,12 @@ impl Renderer {
|
||||
let Some(off) = self.offload.as_mut() else { return };
|
||||
while let Some(i) = self.queue.iter().enumerate().max_by_key(|(_, q)| (q.priority, q.seq)).map(|(i, _)| i) {
|
||||
let q = self.queue.swap_remove(i);
|
||||
let identity = (q.job.request_id, (geometry_look(&q.job.settings), q.job.request.apply_crop));
|
||||
if let Some(job) = off.try_start(q.slot, q.job) {
|
||||
self.queue.push(Queued { job, ..q });
|
||||
break;
|
||||
}
|
||||
self.offload_looks.insert(identity.0, identity.1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -665,6 +764,22 @@ impl Renderer {
|
||||
self.pending.len()
|
||||
}
|
||||
|
||||
/// Requests queued or running, excluding background preparation of neighbouring photos.
|
||||
pub fn in_flight_without_prefetch(&self) -> usize {
|
||||
self.pending.keys().filter(|slot| !matches!(slot, Slot::Prefetch(_))).count()
|
||||
}
|
||||
|
||||
/// Yield to native render workers until a result is ready or the timeout expires.
|
||||
/// The result remains queued for the next poll. Browser and offloaded renderers do not wait.
|
||||
/// Any native pending job, including prefetch, can satisfy the wait. A zero-worker test pool
|
||||
/// must run its jobs inline first, or this call can only time out.
|
||||
pub fn wait_for_worker(&mut self, timeout: std::time::Duration) -> bool {
|
||||
if cfg!(target_arch = "wasm32") || self.offload.is_some() || self.pending.is_empty() {
|
||||
return false;
|
||||
}
|
||||
self.pool.wait(timeout)
|
||||
}
|
||||
|
||||
/// Test seam: make the renderer look busy without starting a job (capture deadlines).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn force_in_flight_for_test(&mut self, count: usize) {
|
||||
@@ -855,14 +970,17 @@ impl Renderer {
|
||||
fn collect(&mut self, ctx: &egui::Context, session: &mut Session, inline_ms: f64) -> bool {
|
||||
let mut finished = Vec::new();
|
||||
while let Some(done) = self.pool.try_recv() {
|
||||
finished.push((done.slot, done.result.result, done.result.color, if done.ms > 0.0 { done.ms } else { inline_ms }));
|
||||
finished.push((done.slot, done.result.result, done.result.color, done.result.look, if done.ms > 0.0 { done.ms } else { inline_ms }));
|
||||
}
|
||||
if let Some(off) = self.offload.as_mut() {
|
||||
finished.extend(off.finished().into_iter().map(|(slot, r, ms)| (slot, r, None, ms)));
|
||||
finished.extend(off.finished().into_iter().map(|(slot, r, ms)| {
|
||||
let look = self.offload_looks.remove(&r.request_id);
|
||||
(slot, r, None, look, ms)
|
||||
}));
|
||||
}
|
||||
self.dispatch();
|
||||
let mut changed = false;
|
||||
for (mut slot, mut r, mut color, ms) in finished {
|
||||
for (mut slot, mut r, mut color, completed_look, ms) in finished {
|
||||
self.completed += 1;
|
||||
// made for another display profile than the one in use: obsolete
|
||||
if r.display.is_some_and(|id| Some(id) != self.display_id()) {
|
||||
@@ -872,7 +990,7 @@ impl Renderer {
|
||||
// an interactive view still takes a superseded draft that is newer than what it
|
||||
// shows, and the source it decoded: a drag supersedes every request before it
|
||||
// finishes, and a source thrown away with them is decoded again for every tick.
|
||||
let source = if self.requests.get(&slot).map(|request| request.id) != Some(r.request_id) {
|
||||
let (source, look) = if self.requests.get(&slot).map(|request| request.id) != Some(r.request_id) {
|
||||
let newer_draft = slot.is_view()
|
||||
&& self.requests.contains_key(&slot)
|
||||
&& r.request_id >= self.epoch
|
||||
@@ -883,11 +1001,13 @@ impl Renderer {
|
||||
continue;
|
||||
}
|
||||
session.accept(&r);
|
||||
None // superseded successful drafts have no failure diagnostic
|
||||
(None, completed_look) // retain the geometry of these pixels, never borrow the newer request’s identity
|
||||
} else {
|
||||
let source = self.requests.remove(&slot).and_then(|request| request.source);
|
||||
let request = self.requests.remove(&slot);
|
||||
let look = request.as_ref().and_then(|request| request.look);
|
||||
let source = request.and_then(|request| request.source);
|
||||
session.accept(&r);
|
||||
source
|
||||
(source, look)
|
||||
};
|
||||
if slot.is_view() {
|
||||
self.shown_ids.insert(slot, r.request_id);
|
||||
@@ -949,6 +1069,7 @@ impl Renderer {
|
||||
Some(t) => {
|
||||
t.tex.set(color, egui::TextureOptions::LINEAR);
|
||||
t.pixels = pixels;
|
||||
t.look = look;
|
||||
t.key = r.key;
|
||||
t.photo = r.photo;
|
||||
t.size = [img.width, img.height];
|
||||
@@ -961,6 +1082,7 @@ impl Renderer {
|
||||
self.textures.insert(
|
||||
slot,
|
||||
Tex {
|
||||
look,
|
||||
key: r.key,
|
||||
photo: r.photo,
|
||||
tex,
|
||||
@@ -1112,7 +1234,10 @@ mod stage_budget_tests {
|
||||
let ctx = egui::Context::default();
|
||||
let mut r = Renderer::default();
|
||||
let tex = ctx.load_texture("window", egui::ColorImage::new([1, 1], vec![egui::Color32::BLACK]), Default::default());
|
||||
r.textures.insert(Slot::Region, Tex { key: 1, photo: PhotoId(1), tex, size: [1, 1], histogram: None, ms: 0.0, quick: None, pixels: None });
|
||||
r.textures.insert(
|
||||
Slot::Region,
|
||||
Tex { look: None, key: 1, photo: PhotoId(1), tex, size: [1, 1], histogram: None, ms: 0.0, quick: None, pixels: None },
|
||||
);
|
||||
let long_ago = std::time::Instant::now().checked_sub(IDLE_TRIM * 2).unwrap();
|
||||
r.idle = Some((long_ago, false));
|
||||
r.trim_when_idle(&ctx);
|
||||
|
||||
@@ -35,13 +35,37 @@ impl RenderOffload for Blocked {
|
||||
fn photo() -> Arc<Photo> {
|
||||
Arc::new(Photo::new(PhotoId(1), Source::File { path: "audit.jpg".into() }, "audit.jpg", "JPEG", 6000, 4000, "2026-01-01"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_readiness_excludes_prefetch_but_keeps_photo_and_thumbnail_work() {
|
||||
let p = photo();
|
||||
let mut session = Session::new();
|
||||
session.catalog.apply(Op::AddPhoto { photo: Box::new((*p).clone()) }).unwrap();
|
||||
// Keep real requests queued without starting native workers.
|
||||
let mut renderer = Renderer { pool: JobPool::new(0), ..Default::default() };
|
||||
renderer.prefetch(Slot::Prefetch(0), session.thumb_job(p.id, 384).unwrap(), 1);
|
||||
renderer.prefetch(Slot::Prefetch(1), session.thumb_job(p.id, 384).unwrap(), 1);
|
||||
assert_eq!(renderer.in_flight(), 2);
|
||||
assert_eq!(renderer.in_flight_without_prefetch(), 0);
|
||||
for (index, slot) in [Slot::Main, Slot::Thumb(p.id), Slot::Variant(7), Slot::Preview, Slot::ThumbQuick(p.id)].into_iter().enumerate() {
|
||||
renderer.request(slot, session.thumb_job(p.id, 384).unwrap(), 10);
|
||||
assert_eq!(renderer.in_flight_without_prefetch(), index + 1);
|
||||
assert_eq!(renderer.in_flight(), index + 3);
|
||||
}
|
||||
assert_eq!(renderer.pool.run_inline(7), 7);
|
||||
// Waiting observes a completed job without swallowing the result needed by poll.
|
||||
assert!(renderer.wait_for_worker(std::time::Duration::ZERO));
|
||||
renderer.poll(&egui::Context::default(), &mut session);
|
||||
assert_eq!(renderer.in_flight_without_prefetch(), 0);
|
||||
assert_eq!(renderer.in_flight(), 0);
|
||||
}
|
||||
#[test]
|
||||
fn snapshot_tracks_cow_edits_undo_reload_and_replacement() {
|
||||
let mut catalog = Catalog::default();
|
||||
catalog.apply(Op::AddPhoto { photo: Box::new((*photo()).clone()) }).unwrap();
|
||||
let old = catalog.photo(PhotoId(1)).unwrap().clone();
|
||||
let mut r = Renderer::default();
|
||||
r.remember_thumb(&old, 384, 77, false);
|
||||
r.remember_thumb(&old, 384, 77, false, 10);
|
||||
r.pending.insert(Slot::Thumb(PhotoId(1)), (77, 10));
|
||||
assert!(r.thumb_current(&old, 384, 10));
|
||||
assert!(!r.thumb_current(&old, 512, 10));
|
||||
@@ -77,7 +101,7 @@ fn queue_promotions_running_work_and_cancellation() {
|
||||
let key = job.key;
|
||||
let mut r = Renderer::default();
|
||||
r.set_offload(Box::new(Blocked));
|
||||
r.remember_thumb(&p, 384, key, false);
|
||||
r.remember_thumb(&p, 384, key, false, 5);
|
||||
r.request(slot, job, 5);
|
||||
assert!(r.thumb_current(&p, 384, 5));
|
||||
assert!(!r.thumb_current(&p, 384, 10));
|
||||
@@ -110,7 +134,7 @@ fn queued_standin_promotes_from_prefetch_to_visible_without_restarting() {
|
||||
let slot = Slot::ThumbQuick(p.id);
|
||||
// Deterministic queue: no native worker starts.
|
||||
let mut r = Renderer { pool: JobPool::new(0), ..Default::default() };
|
||||
r.remember_thumb(&p, 384, quick.key, true);
|
||||
r.remember_thumb(&p, 384, quick.key, true, 5);
|
||||
r.request_quick(slot, quick.clone(), 6); // grid prefetch: 5 + 1
|
||||
r.request_quick(Slot::ThumbQuick(PhotoId(2)), other, 9); // filmstrip: 8 + 1
|
||||
let request_id = r.requests[&slot].id;
|
||||
@@ -138,7 +162,7 @@ fn queued_standin_demotes_when_it_leaves_the_visible_grid() {
|
||||
let (_, other) = queued_quick_thumbnail(PhotoId(2));
|
||||
let slot = Slot::ThumbQuick(p.id);
|
||||
let mut r = Renderer { pool: JobPool::new(0), ..Default::default() };
|
||||
r.remember_thumb(&p, 384, quick.key, true);
|
||||
r.remember_thumb(&p, 384, quick.key, true, 10);
|
||||
r.request_quick(slot, quick.clone(), 11);
|
||||
r.request_quick(Slot::ThumbQuick(PhotoId(2)), other, 9);
|
||||
assert!(!r.thumb_current(&p, 384, 5));
|
||||
@@ -153,7 +177,7 @@ fn failures_retry_after_revision_and_quick_only_is_not_final() {
|
||||
let p = photo();
|
||||
let slot = Slot::Thumb(p.id);
|
||||
let mut r = Renderer::default();
|
||||
r.remember_thumb(&p, 384, 77, false);
|
||||
r.remember_thumb(&p, 384, 77, false, 10);
|
||||
r.pending.insert(Slot::ThumbQuick(p.id), (77, 11));
|
||||
assert!(!r.thumb_current(&p, 384, 10));
|
||||
r.failed.insert(slot, (77, "no decoder".into(), 0, p.id));
|
||||
@@ -164,6 +188,36 @@ fn failures_retry_after_revision_and_quick_only_is_not_final() {
|
||||
assert!(!r.thumb_current(&p, 384, 10));
|
||||
}
|
||||
|
||||
/// A larger texture only serves a smaller bucket when it is the recorded request's picture.
|
||||
/// This isolates the key guard: under a `contains_key`-only check the first assertion below
|
||||
/// would return `true`.
|
||||
#[test]
|
||||
fn larger_texture_for_another_key_does_not_count_as_current() {
|
||||
let p = photo();
|
||||
let slot = Slot::Thumb(p.id);
|
||||
let ctx = egui::Context::default();
|
||||
let tex = |key| Tex {
|
||||
look: None,
|
||||
key,
|
||||
photo: p.id,
|
||||
tex: ctx.load_texture(format!("thumb-{key}"), egui::ColorImage::new([1, 1], vec![egui::Color32::BLACK]), Default::default()),
|
||||
size: [1, 1],
|
||||
histogram: None,
|
||||
ms: 0.0,
|
||||
quick: None,
|
||||
pixels: None,
|
||||
};
|
||||
let mut r = Renderer::default();
|
||||
// A completed larger bucket: the remembered request is bucket 512, key 77.
|
||||
r.remember_thumb(&p, 512, 77, false, 10);
|
||||
// Another key's pixels (for example the picture from before an edit) may still be
|
||||
// displayed, but they are not the remembered request's picture.
|
||||
r.textures.insert(slot, tex(66));
|
||||
assert!(!r.thumb_current(&p, 256, 10), "a mismatched texture key must not serve a smaller bucket");
|
||||
r.textures.insert(slot, tex(77));
|
||||
assert!(r.thumb_current(&p, 256, 10), "the recorded key's larger texture serves the smaller bucket");
|
||||
}
|
||||
|
||||
/// Issue #2682: writes to other photos in the background (faces found, metadata read) moved
|
||||
/// the catalog revision on and sent every undecodable file back to the decoder, 37 times each
|
||||
/// in 154 s of idle. A failure is tried again only when its own photo changes, when the
|
||||
@@ -253,6 +307,15 @@ impl RenderOffload for Manual {
|
||||
fn fixture() -> (crate::LightcraftApp, std::rc::Rc<std::cell::RefCell<Work>>, egui::Context) {
|
||||
fixture_with(Session::new(), (*photo()).clone())
|
||||
}
|
||||
/// An unedited raw: its embedded camera preview is its final thumbnail (the grid's quick path).
|
||||
fn raw_quick_fixture() -> (crate::LightcraftApp, std::rc::Rc<std::cell::RefCell<Work>>, egui::Context) {
|
||||
let mut s = Session::new();
|
||||
s.media.preview_loader = Some(Arc::new(|_, _| Some(lightcraft_raster::Rgba8 { width: 4, height: 4, data: vec![[200, 100, 50, 255]; 16] })));
|
||||
let mut raw = (*photo()).clone();
|
||||
raw.kind = lightcraft_catalog::MediaKind::Raw;
|
||||
raw.develop = Arc::new(raw.camera_defaults());
|
||||
fixture_with(s, raw)
|
||||
}
|
||||
fn fixture_with(mut s: Session, photo: Photo) -> (crate::LightcraftApp, std::rc::Rc<std::cell::RefCell<Work>>, egui::Context) {
|
||||
s.media.file_loader = Some(Arc::new(|_, _| {
|
||||
let mut image = lightcraft_raster::Rgb32f::new(8, 8);
|
||||
@@ -587,16 +650,65 @@ fn loupe_shows_superseded_drafts_newer_than_its_pixels_during_a_drag() {
|
||||
let keys: Vec<u64> = jobs.iter().map(|j| j.key).collect();
|
||||
let mut jobs = jobs.into_iter();
|
||||
let (first, second, last) = (jobs.next().unwrap(), jobs.next().unwrap(), jobs.next().unwrap());
|
||||
// the first draft lands while the newest is still rendering: the loupe moves on
|
||||
finish(&mut app, &work, &ctx, Slot::Main, first.run());
|
||||
// A decoded embedded preview can differ from the catalog's square metadata. Its
|
||||
// superseded colour-only draft must retain the identity that enables aspect correction.
|
||||
let mut first_result = first.run();
|
||||
first_result.rendered.as_mut().unwrap().image = lightcraft_raster::Rgba8::new(8, 4);
|
||||
finish(&mut app, &work, &ctx, Slot::Main, first_result);
|
||||
assert_eq!(app.renderer.textures[&Slot::Main].size, [8, 4]);
|
||||
assert_eq!(app.renderer.textures.get(&Slot::Main).map(|t| t.key), Some(keys[0]));
|
||||
assert!(app.renderer.is_pending(Slot::Main));
|
||||
assert_eq!(
|
||||
app.renderer.textures[&Slot::Main].look,
|
||||
Some((geometry_look(&lightcraft_develop::DevelopSettings::default()), true)),
|
||||
"colour-only superseded pixels retain their geometry"
|
||||
);
|
||||
let current_look = geometry_look(&last.settings);
|
||||
finish(&mut app, &work, &ctx, Slot::Main, last.run());
|
||||
assert_eq!(app.renderer.textures[&Slot::Main].look, Some((current_look, true)));
|
||||
assert_eq!(app.renderer.textures.get(&Slot::Main).map(|t| t.key), Some(keys[2]));
|
||||
// a draft older than the pixels shown never replaces them
|
||||
finish(&mut app, &work, &ctx, Slot::Main, second.run());
|
||||
assert_eq!(app.renderer.textures.get(&Slot::Main).map(|t| t.key), Some(keys[2]));
|
||||
assert!(!app.renderer.is_pending(Slot::Main));
|
||||
assert!(app.renderer.offload_looks.is_empty(), "completed offload identities are released");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn superseded_crop_keeps_its_own_geometry_instead_of_the_latest_requests() {
|
||||
let (mut app, work, ctx) = fixture();
|
||||
let old = app.session.loupe_job(PhotoId(1), 8, 8, true).unwrap().draft();
|
||||
let old_look = geometry_look(&old.settings);
|
||||
app.renderer.request(Slot::Main, old, 100);
|
||||
let (_, old) = take_job(&work);
|
||||
let mut settings = lightcraft_develop::DevelopSettings::default();
|
||||
settings.crop.geometry.rect = lightcraft_geom::Rect::new(0.0, 0.0, 0.5, 1.0);
|
||||
let new_look = geometry_look(&settings);
|
||||
app.session.catalog.apply(Op::SetDevelop { id: PhotoId(1), settings: Arc::new(settings), label: "crop".into(), edited: None }).unwrap();
|
||||
let current = app.session.loupe_job(PhotoId(1), 8, 8, true).unwrap().draft();
|
||||
app.renderer.request(Slot::Main, current, 100);
|
||||
let (_, current) = take_job(&work);
|
||||
finish(&mut app, &work, &ctx, Slot::Main, old.run());
|
||||
assert_eq!(app.renderer.textures[&Slot::Main].look, Some((old_look, true)));
|
||||
assert_ne!(old_look, new_look);
|
||||
finish(&mut app, &work, &ctx, Slot::Main, current.run());
|
||||
assert_eq!(app.renderer.textures[&Slot::Main].look, Some((new_look, true)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_grading_keeps_the_completed_textures_geometry_identity() {
|
||||
let (mut app, work, ctx) = fixture();
|
||||
let mut settings = lightcraft_develop::DevelopSettings::default();
|
||||
settings.grading.shadows.sat = 40.0;
|
||||
settings.crop.geometry.rect = lightcraft_geom::Rect::new(0.1, 0.2, 0.8, 0.7);
|
||||
app.session.catalog.apply(Op::SetDevelop { id: PhotoId(1), settings: Arc::new(settings.clone()), label: "grade".into(), edited: None }).unwrap();
|
||||
let expected = geometry_look(&settings);
|
||||
let job = app.session.loupe_job(PhotoId(1), 8, 8, true).unwrap().with_hidden_grading([true, false, false]);
|
||||
assert_ne!(job.settings.hash64(), settings.hash64(), "the view really hides a nonzero wheel");
|
||||
app.renderer.request(Slot::Main, job, 100);
|
||||
let (slot, job) = take_job(&work);
|
||||
finish(&mut app, &work, &ctx, slot, job.run());
|
||||
assert_eq!(app.renderer.textures[&Slot::Main].look, Some((expected, true)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -711,6 +823,300 @@ fn undo_to_existing_texture_supersedes_pending_edit() {
|
||||
assert_eq!(app.renderer.thumb(PhotoId(1)).unwrap().pixels.as_ref().unwrap().pixels, original.pixels);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cells_at_different_thumbnail_buckets_keep_one_pending_request() {
|
||||
let (mut app, work, ctx) = fixture();
|
||||
let id = PhotoId(1);
|
||||
let slot = Slot::Thumb(id);
|
||||
// A print page's cells show the same photo at different sizes. Before any texture arrives,
|
||||
// each frame's other bucket used to replace (and restart) the pending job.
|
||||
crate::panels::grid::request_thumb(&mut app, id, 256, 10);
|
||||
let built = app.renderer.thumb_jobs_built;
|
||||
assert_eq!(built, 1);
|
||||
let (submitted, pending) = take_job(&work);
|
||||
assert_eq!(submitted, slot);
|
||||
for _ in 0..12 {
|
||||
crate::panels::grid::request_thumb(&mut app, id, 512, 10);
|
||||
crate::panels::grid::request_thumb(&mut app, id, 384, 8);
|
||||
crate::panels::grid::request_thumb(&mut app, id, 256, 10);
|
||||
}
|
||||
assert_eq!(app.renderer.thumb_jobs_built, built, "another bucket must not replace the pending thumbnail");
|
||||
assert!(work.borrow().jobs.is_empty(), "the pending render is not restarted");
|
||||
assert_eq!(app.renderer.pending[&slot].0, pending.key);
|
||||
let pending_key = pending.key;
|
||||
finish(&mut app, &work, &ctx, slot, pending.run());
|
||||
assert_eq!(app.renderer.textures[&slot].key, pending_key);
|
||||
assert_eq!(app.renderer.thumb_inputs[&id].1, 256, "the completed request's bucket is remembered");
|
||||
assert!(!app.renderer.is_pending(slot));
|
||||
|
||||
// With a completed 256 thumbnail, the larger cell asks for its bucket once; the smaller
|
||||
// cells then wait behind it instead of replacing it back every frame.
|
||||
let built = app.renderer.thumb_jobs_built;
|
||||
crate::panels::grid::request_thumb(&mut app, id, 512, 10);
|
||||
assert_eq!(app.renderer.thumb_jobs_built, built + 1, "a larger bucket still upgrades");
|
||||
let (submitted, larger) = take_job(&work);
|
||||
assert_eq!(submitted, slot);
|
||||
assert_ne!(larger.key, pending_key);
|
||||
let larger_key = app.renderer.pending[&slot].0;
|
||||
assert_eq!(larger_key, larger.key);
|
||||
for _ in 0..12 {
|
||||
crate::panels::grid::request_thumb(&mut app, id, 512, 10);
|
||||
crate::panels::grid::request_thumb(&mut app, id, 384, 10);
|
||||
crate::panels::grid::request_thumb(&mut app, id, 256, 10);
|
||||
}
|
||||
assert_eq!(app.renderer.thumb_jobs_built, built + 1, "smaller cells keep the larger render in flight");
|
||||
assert!(work.borrow().jobs.is_empty(), "the larger render is not restarted");
|
||||
assert_eq!(app.renderer.pending[&slot].0, larger_key);
|
||||
assert_eq!(app.renderer.thumb_inputs[&id].1, 512, "the in-flight bucket is still the larger one");
|
||||
finish(&mut app, &work, &ctx, slot, larger.run());
|
||||
assert_eq!(app.renderer.textures[&slot].key, larger_key);
|
||||
assert_eq!(app.renderer.thumb_inputs[&id].1, 512);
|
||||
assert!(!app.renderer.is_pending(slot));
|
||||
|
||||
// Once the largest visible bucket is complete, every smaller cell reuses those pixels.
|
||||
// The 8x8 fixture texture cannot show its requested edge, so the remembered bucket, key
|
||||
// and photo identity are what prove no render was built. Repeated frames at all sizes
|
||||
// (the old bug alternated 256/512 forever) must settle with nothing further requested.
|
||||
let built = app.renderer.thumb_jobs_built;
|
||||
for _ in 0..24 {
|
||||
crate::panels::grid::request_thumb(&mut app, id, 256, 10);
|
||||
crate::panels::grid::request_thumb(&mut app, id, 384, 10);
|
||||
crate::panels::grid::request_thumb(&mut app, id, 512, 10);
|
||||
crate::panels::grid::request_thumb(&mut app, id, 384, 8);
|
||||
}
|
||||
assert_eq!(app.renderer.thumb_jobs_built, built, "completed larger thumbnails serve smaller cells");
|
||||
assert!(work.borrow().jobs.is_empty());
|
||||
assert!(!app.renderer.is_pending(slot));
|
||||
assert_eq!(app.renderer.textures[&slot].key, larger_key);
|
||||
assert_eq!(app.renderer.thumb_inputs[&id].1, 512);
|
||||
assert_eq!(app.renderer.thumb_inputs[&id].2, larger_key);
|
||||
|
||||
// The renderer remembers the photo through a `Weak<Photo>` only. That is enough for
|
||||
// `Catalog`'s `Arc::make_mut` to copy the photo on edit (std: an existing weak pointer
|
||||
// forces a new allocation even with a single strong reference), so the identity below is
|
||||
// invalidated without this test holding an extra `Arc<Photo>`.
|
||||
let remembered = app.renderer.thumb_inputs[&id].0.clone();
|
||||
let remembered_ptr = remembered.as_ptr();
|
||||
assert!(remembered.upgrade().is_some(), "the remembered Weak points at the live catalog photo");
|
||||
assert_eq!(Arc::strong_count(app.session.catalog.photo(id).unwrap()), 1, "the catalog is the only strong owner; the renderer keeps just a Weak");
|
||||
|
||||
// An edit is a different photo/content key: the completed larger picture must not be
|
||||
// reused for a smaller cell, and the replacement is requested as before.
|
||||
let mut edit = lightcraft_develop::DevelopSettings::default();
|
||||
edit.light.exposure = 3.0;
|
||||
app.session.catalog.apply(Op::SetDevelop { id, settings: Arc::new(edit), label: "edit".into(), edited: None }).unwrap();
|
||||
let current = app.session.catalog.photo(id).unwrap();
|
||||
assert_ne!(Arc::as_ptr(current), remembered_ptr, "make_mut moved the edited photo to a new allocation");
|
||||
assert_eq!(remembered.strong_count(), 0, "the renderer's Weak now points at the old, unowned allocation");
|
||||
assert_eq!(Arc::strong_count(current), 1, "the edit left one strong owner; no extra Arc was needed");
|
||||
let built = app.renderer.thumb_jobs_built;
|
||||
crate::panels::grid::request_thumb(&mut app, id, 256, 10);
|
||||
assert_eq!(app.renderer.thumb_jobs_built, built + 1, "an edited photo is requested again");
|
||||
let (submitted, edited) = take_job(&work);
|
||||
assert_eq!(submitted, slot);
|
||||
assert_ne!(edited.key, larger_key);
|
||||
let edited_key = edited.key;
|
||||
finish(&mut app, &work, &ctx, slot, edited.run());
|
||||
assert_eq!(app.renderer.textures[&slot].key, edited_key);
|
||||
assert_eq!(app.renderer.thumb_inputs[&id].1, 256);
|
||||
assert!(!app.renderer.is_pending(slot));
|
||||
}
|
||||
|
||||
/// The remembered larger bucket is not a picture by itself: once its texture is evicted, a
|
||||
/// smaller cell must render again rather than trust the stale record.
|
||||
#[test]
|
||||
fn an_evicted_larger_thumbnail_is_rebuilt_for_a_smaller_cell() {
|
||||
let (mut app, work, ctx) = fixture();
|
||||
let id = PhotoId(1);
|
||||
let slot = Slot::Thumb(id);
|
||||
crate::panels::grid::request_thumb(&mut app, id, 512, 10);
|
||||
let (submitted, larger) = take_job(&work);
|
||||
assert_eq!(submitted, slot);
|
||||
let larger_key = larger.key;
|
||||
finish(&mut app, &work, &ctx, slot, larger.run());
|
||||
assert_eq!(app.renderer.textures[&slot].key, larger_key);
|
||||
assert_eq!(app.renderer.thumb_inputs[&id].1, 512);
|
||||
assert!(!app.renderer.is_pending(slot));
|
||||
|
||||
// An LRU trim drops the pixels; the remembered bucket/key must not stand in for them.
|
||||
app.renderer.textures.remove(&slot);
|
||||
let built = app.renderer.thumb_jobs_built;
|
||||
crate::panels::grid::request_thumb(&mut app, id, 256, 10);
|
||||
assert_eq!(app.renderer.thumb_jobs_built, built + 1, "an evicted larger thumbnail is rebuilt");
|
||||
let (submitted, rebuilt) = take_job(&work);
|
||||
assert_eq!(submitted, slot);
|
||||
assert_ne!(rebuilt.key, larger_key);
|
||||
assert_eq!(app.renderer.pending[&slot].0, rebuilt.key);
|
||||
assert_eq!(app.renderer.thumb_inputs[&id].1, 256);
|
||||
let rebuilt_key = rebuilt.key;
|
||||
finish(&mut app, &work, &ctx, slot, rebuilt.run());
|
||||
assert_eq!(app.renderer.textures[&slot].key, rebuilt_key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_coalesced_thumbnail_promotes_once_for_a_higher_priority_caller() {
|
||||
let mut s = Session::new();
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new((*photo()).clone()) }).unwrap();
|
||||
let mut app = crate::LightcraftApp::new(s, crate::Services::default());
|
||||
app.renderer.pool = JobPool::new(0); // deterministic: nothing starts
|
||||
let slot = Slot::Thumb(PhotoId(1));
|
||||
crate::panels::grid::request_thumb(&mut app, PhotoId(1), 256, 5);
|
||||
assert_eq!(app.renderer.pending[&slot].1, 5);
|
||||
let first = app.renderer.pending[&slot].0;
|
||||
// An on-screen caller at another bucket takes over the off-screen request once...
|
||||
crate::panels::grid::request_thumb(&mut app, PhotoId(1), 512, 10);
|
||||
let promoted = app.renderer.pending[&slot];
|
||||
assert_eq!(promoted.1, 10);
|
||||
assert_ne!(promoted.0, first, "the promoted bucket is the one rendered");
|
||||
assert_eq!(app.renderer.thumb_jobs_built, 2, "one promotion");
|
||||
// ...and the caller it replaced then waits behind that priority instead of replacing it
|
||||
// back. Equal priorities also keep the request in flight (the print cells' case).
|
||||
for _ in 0..6 {
|
||||
crate::panels::grid::request_thumb(&mut app, PhotoId(1), 256, 5);
|
||||
crate::panels::grid::request_thumb(&mut app, PhotoId(1), 384, 8);
|
||||
crate::panels::grid::request_thumb(&mut app, PhotoId(1), 512, 10);
|
||||
}
|
||||
assert_eq!(app.renderer.pending[&slot], promoted);
|
||||
assert_eq!(app.renderer.pool.queued(), 1);
|
||||
assert_eq!(app.renderer.thumb_jobs_built, 2, "a promoted request is not replaced in turn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stale_texture_does_not_disable_cross_bucket_coalescing() {
|
||||
let (mut app, work, ctx) = fixture();
|
||||
let id = PhotoId(1);
|
||||
let slot = Slot::Thumb(id);
|
||||
crate::panels::grid::request_thumb(&mut app, id, 256, 10);
|
||||
let (_, first) = take_job(&work);
|
||||
finish(&mut app, &work, &ctx, slot, first.run());
|
||||
let old_key = app.renderer.textures[&slot].key;
|
||||
|
||||
// The edit leaves the old texture displayed until the replacement renders.
|
||||
let mut edit = lightcraft_develop::DevelopSettings::default();
|
||||
edit.light.exposure = 2.0;
|
||||
app.session.catalog.apply(Op::SetDevelop { id, settings: Arc::new(edit), label: "edit".into(), edited: None }).unwrap();
|
||||
let built = app.renderer.thumb_jobs_built;
|
||||
crate::panels::grid::request_thumb(&mut app, id, 384, 10);
|
||||
assert_eq!(app.renderer.thumb_jobs_built, built + 1);
|
||||
let (_, edited) = take_job(&work);
|
||||
let new_key = app.renderer.pending[&slot].0;
|
||||
assert_ne!(new_key, old_key);
|
||||
assert_eq!(app.renderer.textures[&slot].key, old_key, "the old picture stays until the replacement arrives");
|
||||
|
||||
// The stale texture is not the recorded request's picture: other buckets must still keep
|
||||
// the replacement in flight rather than restarting it every frame.
|
||||
for _ in 0..12 {
|
||||
crate::panels::grid::request_thumb(&mut app, id, 256, 10);
|
||||
crate::panels::grid::request_thumb(&mut app, id, 512, 10);
|
||||
}
|
||||
assert_eq!(app.renderer.thumb_jobs_built, built + 1, "a stale texture must not disable coalescing");
|
||||
assert_eq!(app.renderer.pending[&slot].0, new_key);
|
||||
assert!(work.borrow().jobs.is_empty());
|
||||
|
||||
// Once the recorded key has arrived, smaller cells reuse it; a larger cell upgrades.
|
||||
finish(&mut app, &work, &ctx, slot, edited.run());
|
||||
assert_eq!(app.renderer.textures[&slot].key, new_key);
|
||||
crate::panels::grid::request_thumb(&mut app, id, 256, 10);
|
||||
assert_eq!(app.renderer.thumb_jobs_built, built + 1);
|
||||
assert!(!app.renderer.is_pending(slot));
|
||||
crate::panels::grid::request_thumb(&mut app, id, 512, 10);
|
||||
assert_eq!(app.renderer.thumb_jobs_built, built + 2);
|
||||
assert_ne!(app.renderer.pending[&slot].0, new_key, "the larger bucket is requested once");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_quick_preview_coalesces_across_buckets_without_priority_ping_pong() {
|
||||
let mut s = Session::new();
|
||||
s.media.preview_loader = Some(Arc::new(|_, _| Some(lightcraft_raster::Rgba8 { width: 4, height: 4, data: vec![[200, 100, 50, 255]; 16] })));
|
||||
let mut raw = (*photo()).clone();
|
||||
raw.kind = lightcraft_catalog::MediaKind::Raw;
|
||||
raw.develop = Arc::new(raw.camera_defaults());
|
||||
let (mut app, work, ctx) = fixture_with(s, raw);
|
||||
app.renderer.pool = JobPool::new(0); // deterministic quick renders via run_inline
|
||||
let id = PhotoId(1);
|
||||
let quick = Slot::ThumbQuick(id);
|
||||
let full = Slot::Thumb(id);
|
||||
|
||||
crate::panels::grid::request_thumb(&mut app, id, 384, 5);
|
||||
assert_eq!(app.renderer.pending[&quick].1, 6);
|
||||
let first = app.renderer.pending[&quick].0;
|
||||
assert!(work.borrow().jobs.is_empty(), "the stand-in renders before the real thumbnail");
|
||||
|
||||
// The on-screen caller at another bucket promotes the queued stand-in once.
|
||||
crate::panels::grid::request_thumb(&mut app, id, 512, 10);
|
||||
let promoted = app.renderer.pending[&quick];
|
||||
assert_eq!(promoted.1, 11);
|
||||
assert_ne!(promoted.0, first);
|
||||
assert_eq!(app.renderer.thumb_jobs_built, 2);
|
||||
|
||||
// The replaced caller waits for it; neither bucket rebuilds the queued stand-in.
|
||||
for _ in 0..6 {
|
||||
crate::panels::grid::request_thumb(&mut app, id, 384, 5);
|
||||
crate::panels::grid::request_thumb(&mut app, id, 512, 10);
|
||||
}
|
||||
assert_eq!(app.renderer.pending[&quick], promoted);
|
||||
assert_eq!(app.renderer.pool.queued(), 1);
|
||||
assert_eq!(app.renderer.thumb_jobs_built, 2);
|
||||
|
||||
assert_eq!(app.renderer.pool.run_inline(1), 1);
|
||||
app.renderer.poll(&ctx, &mut app.session);
|
||||
assert_eq!(app.renderer.textures[&quick].key, promoted.0);
|
||||
assert!(!app.renderer.is_pending(quick));
|
||||
|
||||
// An unedited raw's embedded preview is final. Repeating its bucket must not queue
|
||||
// a full sensor render behind it, and a smaller cell reuses a preview that arrived
|
||||
// at a larger bucket instead of asking for another one every frame.
|
||||
let built = app.renderer.thumb_jobs_built;
|
||||
crate::panels::grid::request_thumb(&mut app, id, 512, 10);
|
||||
assert!(!app.renderer.is_pending(full));
|
||||
assert!(work.borrow().jobs.is_empty());
|
||||
assert_eq!(app.renderer.thumb_jobs_built, built);
|
||||
for _ in 0..6 {
|
||||
crate::panels::grid::request_thumb(&mut app, id, 384, 10);
|
||||
crate::panels::grid::request_thumb(&mut app, id, 256, 10);
|
||||
}
|
||||
assert!(!app.renderer.is_pending(full));
|
||||
assert!(!app.renderer.is_pending(quick));
|
||||
assert!(work.borrow().jobs.is_empty(), "the embedded preview is not rebuilt for a smaller cell");
|
||||
assert_eq!(app.renderer.thumb_jobs_built, built);
|
||||
assert_eq!(app.renderer.textures[&quick].key, promoted.0);
|
||||
assert_eq!(app.renderer.embedded_thumbs[&id].1, 512, "the embedded preview's bucket is remembered");
|
||||
}
|
||||
|
||||
/// The embedded preview is final only at the bucket whose pixels actually arrived. A larger
|
||||
/// preview that was evicted, or whose job failed at that key, must not make a smaller cell
|
||||
/// believe it is covered: that cell asks for its own quick render.
|
||||
#[test]
|
||||
fn missing_or_failed_larger_embedded_preview_does_not_cover_a_smaller_cell() {
|
||||
for failed_larger in [false, true] {
|
||||
let (mut app, _work, ctx) = raw_quick_fixture();
|
||||
app.renderer.pool = JobPool::new(0); // deterministic quick renders via run_inline
|
||||
let id = PhotoId(1);
|
||||
let quick = Slot::ThumbQuick(id);
|
||||
crate::panels::grid::request_thumb(&mut app, id, 512, 10);
|
||||
assert_eq!(app.renderer.pool.run_inline(1), 1);
|
||||
app.renderer.poll(&ctx, &mut app.session);
|
||||
assert!(app.renderer.textures.contains_key(&quick));
|
||||
assert_eq!(app.renderer.embedded_thumbs[&id].1, 512);
|
||||
let key = app.renderer.embedded_thumbs[&id].2;
|
||||
assert_eq!(app.renderer.textures[&quick].key, key);
|
||||
|
||||
// The larger picture is gone: evicted, or its job failed at that key.
|
||||
app.renderer.textures.remove(&quick);
|
||||
if failed_larger {
|
||||
app.renderer.failed.insert(quick, (key, "no embedded preview".into(), app.renderer.catalog_rev, id));
|
||||
}
|
||||
let built = app.renderer.thumb_jobs_built;
|
||||
crate::panels::grid::request_thumb(&mut app, id, 256, 10);
|
||||
assert_eq!(app.renderer.thumb_jobs_built, built + 1, "a smaller cell must request its own quick render");
|
||||
let smaller_key = app.renderer.pending[&quick].0;
|
||||
assert_ne!(smaller_key, key);
|
||||
assert_eq!(app.renderer.pool.run_inline(1), 1);
|
||||
app.renderer.poll(&ctx, &mut app.session);
|
||||
assert_eq!(app.renderer.textures[&quick].key, smaller_key);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_clear_repairs_same_key_bad_pixels_and_rejects_old_same_key_job() {
|
||||
let (mut app, work, ctx) = fixture();
|
||||
|
||||
@@ -315,6 +315,28 @@ impl Drop for SyncRun {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tests: a synchronize that stays running while the returned sender lives, so a drop that is
|
||||
/// released when another barrier goes meets a running Synchronize Folder deterministically
|
||||
/// (without waiting for a real folder's walk). The caller clears `sync_run` when done.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn stall_run(app: &mut LightcraftApp, name: &str) -> Box<dyn std::any::Any> {
|
||||
let progress = Arc::new(SyncProgress::default());
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let guard = app.session.activity.start("sync", "Synchronizing folder", Cancel::Flag(progress.files.cancel.clone()));
|
||||
guard.detail(name);
|
||||
app.sync_run = Some(SyncRun {
|
||||
name: name.to_string(),
|
||||
progress,
|
||||
crashed: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
rx,
|
||||
commit: None,
|
||||
stopping: false,
|
||||
not_saved: Vec::new(),
|
||||
guard,
|
||||
});
|
||||
Box::new(tx)
|
||||
}
|
||||
|
||||
/// Commit what the worker readied (called every frame); when it is done, one undo step and a
|
||||
/// word on what happened.
|
||||
pub fn poll_run(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
|
||||
@@ -82,3 +82,6 @@ mod view_cost;
|
||||
mod wb_selector;
|
||||
mod zoom;
|
||||
mod zoom_keys;
|
||||
|
||||
pub(super) mod fixtures;
|
||||
pub(super) mod render_fixtures;
|
||||
|
||||
@@ -422,13 +422,15 @@ fn batch_merge_runs_each_set_in_turn() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut paths = Vec::new();
|
||||
// different exposures, or the import would take set B for duplicates of set A
|
||||
for (set, evs) in [("A", [-2.0, 0.0, 2.0]), ("B", [-1.5, 0.5, 2.5]), ("C", [-1.0, 1.0, 3.0])] {
|
||||
for (i, bytes) in lightcraft_merge::synth::bracket_dngs(1600, 1200, &evs).unwrap().into_iter().enumerate() {
|
||||
let p = dir.join(format!("{set}_{i}.dng"));
|
||||
std::fs::write(&p, bytes).unwrap();
|
||||
paths.push(p.to_string_lossy().to_string());
|
||||
}
|
||||
// one `bracket_dngs` call renders the procedural scene once for all nine inputs (three calls
|
||||
// would render it three times); the exposures stay different, or the import would take one
|
||||
// set for duplicates of another
|
||||
let evs = [-2.0, 0.0, 2.0, -1.5, 0.5, 2.5, -1.0, 1.0, 3.0];
|
||||
let sets = ["A", "A", "A", "B", "B", "B", "C", "C", "C"];
|
||||
for (i, (set, bytes)) in sets.into_iter().zip(lightcraft_merge::synth::bracket_dngs(1600, 1200, &evs).unwrap()).enumerate() {
|
||||
let p = dir.join(format!("{set}_{}.dng", i % 3));
|
||||
std::fs::write(&p, bytes).unwrap();
|
||||
paths.push(p.to_string_lossy().to_string());
|
||||
}
|
||||
let mut session = lightcraft_engine::Session::new().with_fs();
|
||||
let r = session.execute("library.import", &json!({"paths": paths})).unwrap();
|
||||
@@ -440,7 +442,10 @@ fn batch_merge_runs_each_set_in_turn() {
|
||||
session.execute("library.select", &json!({"ids": ids})).unwrap();
|
||||
let app = LightcraftApp::new(session, Services { png: None, ..Default::default() });
|
||||
let mut h = Headless::new(app, [1200.0, 800.0], 1.0);
|
||||
h.settle(T);
|
||||
// the merge ordering does not need the grid's nine thumbnails; one Detail render is enough
|
||||
let r = h.request("ui.set", json!({"view": "detail", "filmstrip": false}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert!(h.settle(T), "the imported photos and the active preview settle");
|
||||
let r = crate::menus::run_ui_command(&mut h.app, "merge.hdrBatch", &json!({})).unwrap().unwrap();
|
||||
assert_eq!(r["sets"], 3);
|
||||
assert_eq!(h.app.merge.batch.len(), 2, "one running, two queued");
|
||||
|
||||
@@ -7,6 +7,7 @@ use serde_json::json;
|
||||
use crate::headless::{Headless, RenderGate};
|
||||
use crate::render::Slot;
|
||||
use crate::state::{BeforeAfter, ViewMode, Zoom};
|
||||
use crate::tests_render_fixtures::{HEIGHT, RenderFixture, WIDTH};
|
||||
use crate::{LightcraftApp, Services};
|
||||
|
||||
const T: Duration = Duration::from_secs(20);
|
||||
@@ -133,18 +134,30 @@ fn assert_rect(actual: egui::Rect, expected: egui::Rect) {
|
||||
);
|
||||
}
|
||||
|
||||
type AnimationView = (RenderFixture, Headless, [egui::Rect; 2], std::rc::Rc<std::cell::Cell<bool>>);
|
||||
|
||||
/// Start with both textures ready, then hold replacement renders so worker timing cannot hide
|
||||
/// a frame of the transition or replace a cached tile under the assertions.
|
||||
fn animation_view(mode: BeforeAfter, zoom: Zoom) -> (Headless, [egui::Rect; 2], std::rc::Rc<std::cell::Cell<bool>>) {
|
||||
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });
|
||||
/// a frame of the transition or replace a cached tile under the assertions. One real 6000×4000
|
||||
/// file-backed photo keeps the render work the animation needs, without the demo's neighbours.
|
||||
fn animation_view(mode: BeforeAfter, zoom: Zoom) -> AnimationView {
|
||||
let fixture = RenderFixture::new("before-after-animation", 1);
|
||||
let app = LightcraftApp::new(fixture.session(), Services { png: None, ..Default::default() });
|
||||
let mut h = Headless::new(app, [1400.0, 900.0], 1.0);
|
||||
assert_eq!(h.app.session.catalog.len(), 1, "one real 24 MP photo");
|
||||
let r = h.request("ui.set", json!({"view": "detail", "right": "none", "filmstrip": false}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.app.ui.before_after = mode;
|
||||
let id = h.app.session.active().expect("a photo is open");
|
||||
let photo = h.app.session.catalog.photo(id).expect("it is in the catalog");
|
||||
assert_eq!((photo.width, photo.height), (WIDTH, HEIGHT), "the render fixture is a real 24 MP photo");
|
||||
// settle the single photo's source and Main render before the second pane exists: both pane
|
||||
// jobs then reuse the decoded 24 MP source instead of decoding it twice
|
||||
h.app.ui.before_after = BeforeAfter::Off;
|
||||
h.app.ui.zoom = zoom;
|
||||
h.app.ui.pan = (0.5, 0.5);
|
||||
h.app.ui.click_zoom = 300;
|
||||
assert!(h.settle(SETTLE), "baseline renders settled");
|
||||
assert!(h.settle(SETTLE), "the single photo's source and Main render settle");
|
||||
h.app.ui.before_after = mode;
|
||||
assert!(h.settle(SETTLE), "both pane renders settled");
|
||||
let before_id = h.app.renderer.textures[&Slot::Before].tex.id();
|
||||
let before = h
|
||||
.view
|
||||
@@ -162,7 +175,7 @@ fn animation_view(mode: BeforeAfter, zoom: Zoom) -> (Headless, [egui::Rect; 2],
|
||||
assert!(!before.intersects(after), "pane clips do not overlap");
|
||||
let open = std::rc::Rc::new(std::cell::Cell::new(false));
|
||||
h.app.renderer.set_offload(Box::new(RenderGate::new(open.clone())));
|
||||
(h, [before, after], open)
|
||||
(fixture, h, [before, after], open)
|
||||
}
|
||||
|
||||
fn queue_click(h: &mut Headless, pos: egui::Pos2) {
|
||||
@@ -178,7 +191,7 @@ fn queue_click(h: &mut Headless, pos: egui::Pos2) {
|
||||
}
|
||||
|
||||
fn check_click_animation(mode: BeforeAfter) {
|
||||
let (mut h, [before, after], open) = animation_view(mode, Zoom::Fit);
|
||||
let (_fx, mut h, [before, after], open) = animation_view(mode, Zoom::Fit);
|
||||
let shift = before.center() - after.center();
|
||||
let fit = h.app.image_rect.expect("Fit image");
|
||||
let id = h.app.session.active().expect("photo");
|
||||
@@ -234,7 +247,7 @@ fn both_before_after_panes_use_the_animated_rect_top_bottom() {
|
||||
}
|
||||
|
||||
fn check_region_animation(mode: BeforeAfter) {
|
||||
let (mut h, [before, after], open) = animation_view(mode, Zoom::Percent(200.0));
|
||||
let (_fx, mut h, [before, after], open) = animation_view(mode, Zoom::Percent(200.0));
|
||||
let shift = before.center() - after.center();
|
||||
let start = h.app.image_rect.expect("200 percent image");
|
||||
let tiles = [(Slot::RegionBefore, before, true), (Slot::Region, after, false)].map(|(slot, pane, is_before)| {
|
||||
|
||||
@@ -59,7 +59,7 @@ fn shift_preview_says_the_photos_capture_time_cant_be_read() {
|
||||
|
||||
let r = h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.settle(SETTLE);
|
||||
// Inspect the notification before settling advances the simulated clock past its lifetime.
|
||||
assert!(h.app.ui.dialog.is_none(), "OK closes the dialog");
|
||||
let toast = toast(&h);
|
||||
assert!(toast.contains("can't be read"), "OK says the photo was left as it was: {toast:?}");
|
||||
@@ -92,7 +92,7 @@ fn set_mode_refuses_moving_others_by_an_unreadable_time_and_repairs_it_alone() {
|
||||
open_set(&mut h, &[bad, other]);
|
||||
let r = h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.settle(SETTLE);
|
||||
// The click is consumed by request; unrelated rendering must not expire its notification.
|
||||
let toast = toast(&h);
|
||||
assert!(toast.contains("can't be read"), "OK says why nothing changed: {toast:?}");
|
||||
assert!(matches!(h.app.ui.dialog, Some(Dialog::CaptureTime { .. })), "the dialog stays open after the refusal");
|
||||
@@ -116,7 +116,8 @@ fn set_mode_refuses_moving_others_by_an_unreadable_time_and_repairs_it_alone() {
|
||||
/// button and from the control channel alike; they used to close it without a word.
|
||||
#[test]
|
||||
fn errors_are_shown_and_the_dialog_stays_open() {
|
||||
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });
|
||||
let files = crate::tests_fixtures::SyntheticLibrary::new("capture-time-errors");
|
||||
let app = files.app();
|
||||
let mut h = Headless::new(app, [1300.0, 900.0], 1.0);
|
||||
let id = h.app.session.visible_cloned()[0];
|
||||
set_captured(&mut h, id, "9999-12-31T12:00:00");
|
||||
@@ -125,9 +126,10 @@ fn errors_are_shown_and_the_dialog_stays_open() {
|
||||
Some(Dialog::CaptureTime { time, .. }) => *time = "yesterday".into(),
|
||||
other => panic!("Edit Capture Time is not open: {other:?}"),
|
||||
}
|
||||
h.settle(SETTLE);
|
||||
assert!(h.settle(SETTLE), "capture-time dialog renders settled");
|
||||
assert_eq!(h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T)["ok"], true);
|
||||
h.settle(SETTLE);
|
||||
// The request consumes the click. Inspect its short-lived notification before waiting
|
||||
// for unrelated rendering: settling advances the simulated clock and can expire the toast.
|
||||
assert!(toast(&h).contains("not a date"), "OK says the typed time isn't a date: {:?}", toast(&h));
|
||||
assert!(matches!(&h.app.ui.dialog, Some(Dialog::CaptureTime { time, .. }) if time == "yesterday"), "the dialog stays open with what was typed");
|
||||
|
||||
@@ -139,7 +141,7 @@ fn errors_are_shown_and_the_dialog_stays_open() {
|
||||
}
|
||||
other => panic!("Edit Capture Time is not open: {other:?}"),
|
||||
}
|
||||
h.settle(SETTLE);
|
||||
assert!(h.settle(SETTLE), "capture-time dialog renders settled");
|
||||
let r = h.request("ui.dialog.confirm", json!({}), T);
|
||||
assert_ne!(r["ok"], true, "the shift is refused: {r}");
|
||||
assert!(r.to_string().contains("9999"), "{r}");
|
||||
|
||||
@@ -6,6 +6,7 @@ use std::time::Duration;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::headless::Headless;
|
||||
use crate::tests_fixtures::SyntheticLibrary;
|
||||
use crate::{LightcraftApp, Services};
|
||||
|
||||
const T: Duration = Duration::from_secs(20);
|
||||
@@ -13,12 +14,26 @@ const SETTLE: Duration = Duration::from_secs(120);
|
||||
|
||||
fn crop_tool() -> Headless {
|
||||
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });
|
||||
crop_view(app, false)
|
||||
}
|
||||
|
||||
/// The crop tool on `app`, asserting the setup settle: a pixel comparison must not run on a view
|
||||
/// whose first render never finished.
|
||||
fn crop_tool_with(app: LightcraftApp) -> Headless {
|
||||
crop_view(app, true)
|
||||
}
|
||||
|
||||
fn crop_view(app: LightcraftApp, assert_settle: bool) -> Headless {
|
||||
let mut h = Headless::new(app, [1200.0, 800.0], 1.0);
|
||||
for (method, params) in [("ui.set", json!({"view": "detail"})), ("engine.execute", json!({"command": "panel.crop"}))] {
|
||||
let r = h.request(method, params, T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
}
|
||||
h.settle(SETTLE);
|
||||
if assert_settle {
|
||||
assert!(h.settle(SETTLE), "the crop view settles before the snapshot");
|
||||
} else {
|
||||
h.settle(SETTLE);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
@@ -385,11 +400,13 @@ fn the_angle_field_writes_the_angle_like_the_readout() {
|
||||
/// box once rotated: dimmed wedges inside it, doubly dark ones outside, growing with the angle.)
|
||||
#[test]
|
||||
fn rotating_the_crop_dims_nothing_inside_the_box() {
|
||||
let mut h = crop_tool();
|
||||
// a real file-backed photo: the regression is a screen-space overlay, not native-resolution detail
|
||||
let files = SyntheticLibrary::new("crop-dimming");
|
||||
let mut h = crop_tool_with(files.app());
|
||||
let plain = h.paint();
|
||||
let r = h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "crop.angle", "value": -45.0}}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.settle(SETTLE);
|
||||
assert!(h.settle(SETTLE), "the rotated crop view settles before the snapshot");
|
||||
assert_eq!(angle(&h), -45.0);
|
||||
let rotated = h.paint();
|
||||
let img = h.app.image_rect.expect("the photo on screen");
|
||||
|
||||
@@ -67,6 +67,11 @@ fn controls(h: &Headless) -> Vec<(String, egui::Rect)> {
|
||||
h.app.widgets.iter().filter(|(id, _)| named(id)).cloned().collect()
|
||||
}
|
||||
|
||||
/// Whether egui has a visible tooltip area, independent of unrelated window text.
|
||||
fn tooltip_shown(h: &Headless) -> bool {
|
||||
h.view.ctx.memory(|m| m.areas().visible_layer_ids().into_iter().any(|layer| layer.order == egui::Order::Tooltip))
|
||||
}
|
||||
|
||||
fn same(a: egui::Rect, b: egui::Rect) -> bool {
|
||||
(a.min - b.min).abs().max_elem() < 0.5 && (a.max - b.max).abs().max_elem() < 0.5
|
||||
}
|
||||
@@ -184,7 +189,10 @@ fn a_long_library_folder_is_cut_short_and_its_tooltip_has_all_of_it() {
|
||||
);
|
||||
// And resting the pointer on it shows the whole path
|
||||
assert_eq!(h.request("ui.hoverWidget", json!({"id": "text:settingsLocation"}), T)["ok"], true);
|
||||
assert!(h.step_until(T, |h| h.painted_text().iter().any(|text| text.contains(&path))), "the tooltip has the whole path");
|
||||
assert!(
|
||||
h.step_until(T, |h| tooltip_shown(h) && h.painted_text().iter().any(|text| text.contains(&path))),
|
||||
"the tooltip layer shows the whole path"
|
||||
);
|
||||
|
||||
// And the same holds for the folder Auto Import watches, which stays on one line
|
||||
let watched = h.app.run("library.autoImport", json!({"folder": path}));
|
||||
@@ -256,13 +264,23 @@ fn a_short_path_is_shown_whole_and_has_no_tooltip() {
|
||||
// Given the in-memory library, whose Location is a short sentence
|
||||
let mut h = demo([1600.0, 1000.0]);
|
||||
show(&mut h, "general");
|
||||
assert!(h.painted_text().iter().any(|text| text == "In memory — nothing is saved"), "it is all there");
|
||||
let before = h.painted_text().len();
|
||||
// When the pointer rests on it
|
||||
let location = "In memory — nothing is saved";
|
||||
assert!(h.painted_text().iter().any(|text| text == location), "it is all there");
|
||||
assert!(!tooltip_shown(&h), "no tooltip before hovering the location");
|
||||
assert_eq!(h.request("ui.hoverWidget", json!({"id": "text:settingsLocation"}), T)["ok"], true);
|
||||
let _ = h.step_until(Duration::from_secs(2), |h| h.painted_text().len() > before);
|
||||
// Then nothing more is drawn: there is nothing a tooltip could add
|
||||
assert_eq!(h.painted_text().len(), before, "no tooltip for a path that is all there");
|
||||
// Observe every frame for two simulated seconds beyond the configured tooltip delay.
|
||||
// A render completing or a toast disappearing may change other text in the window.
|
||||
let rest_until = h.time() + f64::from(h.view.ctx.global_style().interaction.tooltip_delay) + 2.0;
|
||||
assert!(!tooltip_shown(&h), "no tooltip when hovering the complete location");
|
||||
while h.time() < rest_until {
|
||||
h.step();
|
||||
assert!(!tooltip_shown(&h), "no tooltip for a path that is all there at {}", h.time());
|
||||
assert_eq!(
|
||||
h.painted_text().iter().filter(|text| text.as_str() == location).count(),
|
||||
1,
|
||||
"the full location is drawn once throughout the hover"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// How many pixels of `rect` are not `colour` (one pixel per point here).
|
||||
|
||||
@@ -96,10 +96,23 @@ fn inactive_same_mode_commands_reveal_without_closing_or_editing() {
|
||||
|
||||
#[test]
|
||||
fn crop_canvas_double_click_reveals_edit_in_actual_custom_workspace() {
|
||||
let mut h = app();
|
||||
// This exercises Canvas input and dock routing, not native-resolution rendering.
|
||||
// Keep the real file alive until after the headless renderer is dropped.
|
||||
let photo_files = crate::tests_fixtures::SyntheticLibrary::new("dock-crop-double-click");
|
||||
let mut fixture_app = photo_files.app();
|
||||
fixture_app.ui.left_panel = true;
|
||||
let mut h = Headless::new(fixture_app, [1400.0, 1000.0], 1.0);
|
||||
for _ in 0..5 {
|
||||
h.step();
|
||||
}
|
||||
h.app.ui.view = crate::state::ViewMode::Detail;
|
||||
h.app.run("panel.crop", json!({})).unwrap();
|
||||
assert!(h.settle(std::time::Duration::from_secs(120)), "the demo photo settles before pointer input");
|
||||
assert!(h.settle(std::time::Duration::from_secs(120)), "the file-backed photo settles before pointer input");
|
||||
let active = h.app.session.active().expect("the fixture photo is active");
|
||||
assert!(
|
||||
h.app.renderer.textures.get(&crate::render::Slot::Main).is_some_and(|tex| tex.photo == active && tex.size[0] > 0 && tex.size[1] > 0),
|
||||
"the actual detail render is present before Canvas input"
|
||||
);
|
||||
h.app.ui.presets = true;
|
||||
h.step();
|
||||
dock(&mut h, json!({"operation":"move","panel":"tools","target":"presets","zone":"center"}));
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
//! dropping will do; dropping photos or folders opens the import review (unless Settings ▸ Import
|
||||
//! says to import dropped files directly); a drop while a dialog is open is refused.
|
||||
|
||||
use std::sync::mpsc::Sender;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::headless::Headless;
|
||||
use crate::pick::PickRequest;
|
||||
use crate::state::Dialog;
|
||||
use crate::{LightcraftApp, Services};
|
||||
|
||||
@@ -43,18 +46,68 @@ fn write_png(path: &str, seed: u8) {
|
||||
std::fs::write(path, bytes).unwrap();
|
||||
}
|
||||
|
||||
/// A folder dialog that answers "cancelled": enough to make the launch list's New Library button
|
||||
/// and its name prompt available.
|
||||
fn services() -> Services {
|
||||
Services { png: None, pick_folder: Some(Box::new(|| -> Option<String> { None })), ..Default::default() }
|
||||
}
|
||||
|
||||
/// An empty library, a folder `trip` with two photos, a window big enough for the overlay.
|
||||
fn app(dir: &Scratch) -> Headless {
|
||||
write_png(&dir.path("trip/a.png"), 1);
|
||||
write_png(&dir.path("trip/b.png"), 2);
|
||||
let session = lightcraft_engine::Session::new().with_fs();
|
||||
let mut h = Headless::new(LightcraftApp::new(session, Services { png: None, ..Default::default() }), [1300.0, 900.0], 1.0);
|
||||
let mut h = Headless::new(LightcraftApp::new(session, services()), [1300.0, 900.0], 1.0);
|
||||
for _ in 0..4 {
|
||||
h.step();
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// Folder dialogs the host shows "elsewhere": each request is recorded with the sender that
|
||||
/// answers it, so a test can cancel or choose after the app has moved on to its next UI pass.
|
||||
type Asked = Arc<Mutex<Vec<(PickRequest, Sender<Vec<String>>)>>>;
|
||||
|
||||
/// A no-library window whose New Library and Open Another… folder dialogs are asynchronous, as on
|
||||
/// the desktop hosts that run pickers off the UI thread (and in the browser).
|
||||
fn app_with_picker(dir: &Scratch) -> (Headless, Asked) {
|
||||
write_png(&dir.path("trip/a.png"), 1);
|
||||
write_png(&dir.path("trip/b.png"), 2);
|
||||
let session = lightcraft_engine::Session::new().with_fs();
|
||||
let mut app = LightcraftApp::new(session, services());
|
||||
let asked: Asked = Default::default();
|
||||
let a = asked.clone();
|
||||
app.services.picker = Some(Box::new(move |req| {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
a.lock().unwrap().push((req, tx));
|
||||
Ok(rx)
|
||||
}));
|
||||
let mut h = Headless::new(app, [1300.0, 900.0], 1.0);
|
||||
for _ in 0..4 {
|
||||
h.step();
|
||||
}
|
||||
(h, asked)
|
||||
}
|
||||
|
||||
fn asked_dialogs(asked: &Asked) -> usize {
|
||||
asked.lock().unwrap().len()
|
||||
}
|
||||
|
||||
/// Answer the oldest folder dialog: `paths` empty cancels it.
|
||||
fn answer_picker(asked: &Asked, paths: Vec<String>) {
|
||||
let sender = asked.lock().unwrap().remove(0).1;
|
||||
sender.send(paths).unwrap();
|
||||
}
|
||||
|
||||
/// A library folder that can be chosen by Open Another…, made outside the no-library app.
|
||||
fn library_at(dir: &Scratch, rel: &str) -> String {
|
||||
let path = dir.path(rel);
|
||||
let mut session = lightcraft_engine::Session::new().with_fs();
|
||||
session.open_library(&path, false).unwrap();
|
||||
session.close_library().unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn drop_files(h: &mut Headless, paths: &[String], hover: bool) {
|
||||
let r = h.request("ui.dropFiles", json!({"paths": paths, "hover": hover}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
@@ -63,6 +116,47 @@ fn drop_files(h: &mut Headless, paths: &[String], hover: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The same window with a library already open, so a launch list over it is the real "over"
|
||||
/// case rather than a list with no library behind it.
|
||||
fn app_with_library(dir: &Scratch) -> Headless {
|
||||
write_png(&dir.path("trip/a.png"), 1);
|
||||
write_png(&dir.path("trip/b.png"), 2);
|
||||
let mut session = lightcraft_engine::Session::new().with_fs();
|
||||
session.open_library(dir.path("library"), false).unwrap();
|
||||
let mut h = Headless::new(LightcraftApp::new(session, services()), [1300.0, 900.0], 1.0);
|
||||
for _ in 0..4 {
|
||||
h.step();
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// Draw the launch list (`over_open_library`: Alt-at-launch over the library that opened, else the
|
||||
/// list with no library behind it).
|
||||
fn draw_launch_list(h: &mut Headless, over_open_library: bool) {
|
||||
let mut problem = crate::panels::library_problem::LibraryProblem::choose_later(&h.app.ui.settings);
|
||||
if over_open_library && let Some(c) = problem.chooser.as_mut() {
|
||||
c.over = Some(h.app.session.library_generation());
|
||||
}
|
||||
h.app.library_problem = Some(problem);
|
||||
assert!(h.step_until(T, |h| h.app.widgets.iter().any(|(w, _)| w == "button:libraryOpen")), "the launch list is drawn");
|
||||
}
|
||||
|
||||
/// The launch list as Alt-at-launch shows it over the library that opened.
|
||||
fn show_launch_list(h: &mut Headless) {
|
||||
draw_launch_list(h, true);
|
||||
assert!(crate::panels::library_problem::blocks_file_drops(&h.app), "the launch list owns the drop barrier");
|
||||
}
|
||||
|
||||
/// The same list with no library open: photos still go through the deliberate hold path.
|
||||
fn show_no_library_launch_list(h: &mut Headless) {
|
||||
draw_launch_list(h, false);
|
||||
assert!(!crate::panels::library_problem::blocks_file_drops(&h.app), "with no library open the drop path runs");
|
||||
}
|
||||
|
||||
fn user_presets(h: &Headless) -> usize {
|
||||
h.app.session.presets.iter().filter(|p| !p.builtin).count()
|
||||
}
|
||||
|
||||
fn overlay(h: &Headless) -> bool {
|
||||
h.app.widgets.iter().any(|(w, _)| w == "dropOverlay")
|
||||
}
|
||||
@@ -172,6 +266,497 @@ fn a_drop_and_its_hover_card_wait_behind_a_notice_prompt() {
|
||||
assert_eq!(photos(&h), 0, "the review still imports nothing before it is confirmed");
|
||||
}
|
||||
|
||||
/// A photo dropped while the launch list is over an already open library must wait in the queue:
|
||||
/// importing it behind the list would add it to the library the list is asking to replace or keep.
|
||||
#[test]
|
||||
fn a_photo_drop_waits_behind_the_launch_list_over_an_open_library() {
|
||||
let dir = Scratch::new("chooser-photo");
|
||||
let mut h = app_with_library(&dir);
|
||||
show_launch_list(&mut h);
|
||||
drop_files(&mut h, &[dir.path("trip/a.png")], true);
|
||||
assert!(!overlay(&h), "the drop card must not promise an import above the launch list");
|
||||
drop_files(&mut h, &[dir.path("trip/a.png")], false);
|
||||
assert!(h.app.ui.dialog.is_none(), "no import review opens behind the launch list");
|
||||
assert!(h.app.scan.is_none() && h.app.import.is_none(), "no scan or import starts behind the launch list");
|
||||
assert_eq!(photos(&h), 0, "nothing is imported behind the launch list");
|
||||
assert_eq!(h.app.dropped_files.len(), 1, "the drop waits for the launch list to go");
|
||||
// Esc keeps the library that is open (the launch list's own Keep), then the queued drop runs
|
||||
let r = h.request("ui.key", json!({"key": "Escape"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert!(h.step_until(SETTLE, |h| h.app.library_problem.is_none()), "the launch list closes");
|
||||
assert!(h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(Dialog::Import { .. }))), "the queued drop opens its review");
|
||||
assert_eq!(photos(&h), 0, "the review still imports nothing before it is confirmed");
|
||||
assert!(h.app.dropped_files.is_empty(), "the queued drop was consumed once the list was gone");
|
||||
}
|
||||
|
||||
/// Preset drops run a command directly (no review), so the barrier must hold those too: a preset
|
||||
/// imported behind the list would change the open library the person has not chosen to keep.
|
||||
#[test]
|
||||
fn a_preset_drop_waits_behind_the_launch_list_over_an_open_library() {
|
||||
let dir = Scratch::new("chooser-preset");
|
||||
let mut h = app_with_library(&dir);
|
||||
std::fs::write(dir.path("warm.lcpreset"), r#"{"id": "user.warm", "name": "Warm", "group": "Test", "settings": {"light": {"exposure": 0.5}}}"#)
|
||||
.unwrap();
|
||||
let before = user_presets(&h);
|
||||
show_launch_list(&mut h);
|
||||
drop_files(&mut h, &[dir.path("warm.lcpreset")], false);
|
||||
for _ in 0..10 {
|
||||
h.step();
|
||||
}
|
||||
assert_eq!(user_presets(&h), before, "no preset is imported behind the launch list");
|
||||
assert_eq!(h.app.dropped_files.len(), 1, "the preset waits for the launch list to go");
|
||||
let r = h.request("ui.key", json!({"key": "Escape"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert!(h.step_until(SETTLE, |h| h.app.library_problem.is_none()), "Keep closes the launch list");
|
||||
assert!(h.step_until(SETTLE, |h| user_presets(h) == before + 1), "the queued preset imports once the list is gone");
|
||||
assert!(h.app.dropped_files.is_empty());
|
||||
}
|
||||
|
||||
/// A queued drop must survive the launch list hiding itself behind its own New Library name prompt:
|
||||
/// that prompt does not make the list stop owning the library. Cancelling the prompt brings the
|
||||
/// list back; only after Keep (Esc) does the drop import.
|
||||
#[test]
|
||||
fn a_queued_photo_drop_survives_the_choosers_new_library_prompt_and_cancel() {
|
||||
let dir = Scratch::new("chooser-new-cancel");
|
||||
let mut h = app_with_library(&dir);
|
||||
show_launch_list(&mut h);
|
||||
drop_files(&mut h, &[dir.path("trip/a.png")], false);
|
||||
assert_eq!(h.app.dropped_files.len(), 1, "the drop waits for the launch list");
|
||||
let r = h.request("ui.clickWidget", json!({"id": "button:libraryNew"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert!(h.step_until(T, |h| matches!(h.app.ui.dialog, Some(Dialog::TextPrompt { .. }))), "New Library asks for a name");
|
||||
for _ in 0..10 {
|
||||
h.step();
|
||||
}
|
||||
assert_eq!(h.app.dropped_files.len(), 1, "the drop waits behind the name prompt too");
|
||||
assert!(matches!(h.app.ui.dialog, Some(Dialog::TextPrompt { .. })), "the prompt stays open");
|
||||
assert!(h.app.scan.is_none() && h.app.import.is_none(), "nothing starts behind the prompt");
|
||||
assert_eq!(photos(&h), 0);
|
||||
let r = h.request("ui.clickWidget", json!({"id": "button:dialogCancel"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert!(h.step_until(T, |h| h.app.ui.dialog.is_none()), "Cancel closes the name prompt");
|
||||
for _ in 0..5 {
|
||||
h.step();
|
||||
}
|
||||
assert!(h.app.widgets.iter().any(|(w, _)| w == "button:libraryOpen"), "the launch list is back");
|
||||
assert_eq!(h.app.dropped_files.len(), 1, "cancel kept the queued drop");
|
||||
assert!(h.app.ui.dialog.is_none(), "no review opens behind the returning list");
|
||||
assert_eq!(photos(&h), 0);
|
||||
let r = h.request("ui.key", json!({"key": "Escape"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert!(h.step_until(SETTLE, |h| h.app.library_problem.is_none()), "Keep closes the launch list");
|
||||
assert!(h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(Dialog::Import { .. }))), "the queued drop opens its review");
|
||||
assert!(h.app.dropped_files.is_empty(), "the queued drop was consumed once the list was gone");
|
||||
}
|
||||
|
||||
/// With no library open a drop during the list's New Library name prompt would once have hit the
|
||||
/// generic "close the open dialog" refusal and been lost. It now waits for the prompt to close,
|
||||
/// then goes through the deliberate paths: photos are held for the library chosen, presets refused.
|
||||
#[test]
|
||||
fn a_drop_during_the_no_library_name_prompt_is_held_or_refused_after_cancel() {
|
||||
let dir = Scratch::new("newlib-prompt-drop");
|
||||
std::fs::write(dir.path("warm.lcpreset"), r#"{"id": "user.warm", "name": "Warm", "group": "Test", "settings": {"light": {"exposure": 0.5}}}"#)
|
||||
.unwrap();
|
||||
let mut h = app(&dir);
|
||||
show_no_library_launch_list(&mut h);
|
||||
let r = h.request("ui.clickWidget", json!({"id": "button:libraryNew"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert!(h.step_until(T, |h| matches!(h.app.ui.dialog, Some(Dialog::TextPrompt { .. }))), "New Library asks for a name");
|
||||
drop_files(&mut h, &[dir.path("trip/a.png"), dir.path("warm.lcpreset")], false);
|
||||
assert_eq!(h.app.dropped_files.len(), 2, "the drop waits behind the name prompt");
|
||||
assert_eq!(h.app.library_problem.as_ref().map(|p| p.pending_import.len()), Some(0), "nothing is held behind the prompt");
|
||||
let r = h.request("ui.clickWidget", json!({"id": "button:dialogCancel"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert!(h.step_until(T, |h| h.app.ui.dialog.is_none()), "Cancel closes the name prompt");
|
||||
assert!(h.step_until(SETTLE, |h| h.app.dropped_files.is_empty()), "the drop is consumed once the prompt is gone");
|
||||
assert_eq!(
|
||||
h.app.library_problem.as_ref().map(|p| p.pending_import.clone()),
|
||||
Some(vec![dir.path("trip/a.png")]),
|
||||
"the photo waits for the library chosen"
|
||||
);
|
||||
assert_eq!(user_presets(&h), 0, "the preset is refused, not imported into the temporary session");
|
||||
assert_eq!(photos(&h), 0, "nothing is imported into the temporary session");
|
||||
}
|
||||
|
||||
/// The no-library New Library flow asks for a name, then for a place on the host's asynchronous
|
||||
/// folder picker. A drop queued during either step waits: it must not be consumed before the new
|
||||
/// library exists. Confirming the picker opens the library and the queued photo imports into it.
|
||||
#[test]
|
||||
fn a_queued_photo_drop_survives_the_async_new_library_picker_and_imports_on_confirmation() {
|
||||
let dir = Scratch::new("newlib-async-confirm");
|
||||
let (mut h, asked) = app_with_picker(&dir);
|
||||
show_no_library_launch_list(&mut h);
|
||||
h.app.ui.settings.import_dropped_directly = true;
|
||||
let r = h.request("ui.clickWidget", json!({"id": "button:libraryNew"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert!(h.step_until(T, |h| matches!(h.app.ui.dialog, Some(Dialog::TextPrompt { .. }))), "New Library asks for a name");
|
||||
let r = h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert!(h.step_until(T, |h| h.app.pending_picks.len() == 1), "the folder picker is up");
|
||||
drop_files(&mut h, &[dir.path("trip/a.png")], false);
|
||||
assert_eq!(asked_dialogs(&asked), 1, "one asynchronous folder dialog was started");
|
||||
assert!(h.app.session.library.is_none(), "no library exists until the picker answers");
|
||||
assert!(crate::panels::library_problem::blocks_file_drops(&h.app), "the folder picker still owns the drop barrier");
|
||||
for _ in 0..10 {
|
||||
h.step();
|
||||
}
|
||||
assert_eq!(h.app.dropped_files.len(), 1, "the drop waits through the picker");
|
||||
assert!(h.app.ui.dialog.is_none(), "the name prompt is gone; only the picker is up");
|
||||
assert_eq!(h.app.library_problem.as_ref().map(|p| p.pending_import.len()), Some(0), "nothing is held while the picker is still open");
|
||||
assert!(h.app.scan.is_none() && h.app.import.is_none() && photos(&h) == 0, "nothing starts behind the picker");
|
||||
|
||||
let parent = dir.path("new-library-parent");
|
||||
std::fs::create_dir_all(&parent).unwrap();
|
||||
answer_picker(&asked, vec![parent]);
|
||||
assert!(h.step_until(SETTLE, |h| h.app.pending_picks.is_empty()), "the picker answered");
|
||||
assert!(h.step_until(SETTLE, |h| h.app.session.library.is_some()), "the new library opens");
|
||||
assert!(
|
||||
h.step_until(SETTLE, |h| h.app.dropped_files.is_empty() && photos(h) == 1 && h.app.import.is_none()),
|
||||
"the queued photo imports into the new library"
|
||||
);
|
||||
assert_eq!(user_presets(&h), 0);
|
||||
}
|
||||
|
||||
/// Cancelling the asynchronous place picker leaves the launch list up. The queued photo then takes
|
||||
/// the no-library hold path (kept for the library chosen) instead of being lost behind the picker.
|
||||
#[test]
|
||||
fn a_queued_drop_survives_the_async_new_library_picker_cancel_then_takes_the_no_library_path() {
|
||||
let dir = Scratch::new("newlib-async-cancel");
|
||||
std::fs::write(dir.path("warm.lcpreset"), r#"{"id": "user.warm", "name": "Warm", "group": "Test", "settings": {"light": {"exposure": 0.5}}}"#)
|
||||
.unwrap();
|
||||
let (mut h, asked) = app_with_picker(&dir);
|
||||
show_no_library_launch_list(&mut h);
|
||||
let r = h.request("ui.clickWidget", json!({"id": "button:libraryNew"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert!(h.step_until(T, |h| matches!(h.app.ui.dialog, Some(Dialog::TextPrompt { .. }))), "New Library asks for a name");
|
||||
let r = h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert!(h.step_until(T, |h| h.app.pending_picks.len() == 1), "the folder picker is up");
|
||||
drop_files(&mut h, &[dir.path("trip/a.png"), dir.path("warm.lcpreset")], false);
|
||||
for _ in 0..10 {
|
||||
h.step();
|
||||
}
|
||||
assert_eq!(h.app.dropped_files.len(), 2, "both files wait through the picker");
|
||||
assert!(h.app.session.library.is_none());
|
||||
|
||||
answer_picker(&asked, vec![]);
|
||||
assert!(h.step_until(SETTLE, |h| h.app.pending_picks.is_empty()), "the picker was cancelled");
|
||||
assert!(h.step_until(SETTLE, |h| h.app.dropped_files.is_empty()), "the drop takes the no-library path now");
|
||||
assert!(h.app.session.library.is_none(), "cancel created no library");
|
||||
assert!(h.app.widgets.iter().any(|(w, _)| w == "button:libraryOpen"), "the launch list is still up");
|
||||
assert_eq!(
|
||||
h.app.library_problem.as_ref().map(|p| p.pending_import.clone()),
|
||||
Some(vec![dir.path("trip/a.png")]),
|
||||
"the photo waits for the library chosen"
|
||||
);
|
||||
assert_eq!(user_presets(&h), 0, "the preset is refused with no library");
|
||||
assert_eq!(photos(&h), 0, "nothing is imported into the temporary session");
|
||||
}
|
||||
|
||||
/// Open Another… has the same asynchronous folder picker with no library open. A queued drop must
|
||||
/// wait for it, then import into the library the person opens.
|
||||
#[test]
|
||||
fn a_queued_photo_drop_survives_the_async_open_library_picker_and_imports_on_confirmation() {
|
||||
let dir = Scratch::new("open-async-confirm");
|
||||
let target = library_at(&dir, "chosen-library");
|
||||
let (mut h, asked) = app_with_picker(&dir);
|
||||
show_no_library_launch_list(&mut h);
|
||||
h.app.ui.settings.import_dropped_directly = true;
|
||||
let r = h.request("ui.clickWidget", json!({"id": "button:libraryOpenAnother"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert!(h.step_until(T, |h| h.app.pending_picks.len() == 1), "the folder picker is up");
|
||||
drop_files(&mut h, &[dir.path("trip/a.png")], false);
|
||||
assert_eq!(asked_dialogs(&asked), 1);
|
||||
assert!(h.app.session.library.is_none());
|
||||
assert!(crate::panels::library_problem::blocks_file_drops(&h.app), "the folder picker still owns the drop barrier");
|
||||
for _ in 0..10 {
|
||||
h.step();
|
||||
}
|
||||
assert_eq!(h.app.dropped_files.len(), 1, "the drop waits through the picker");
|
||||
|
||||
answer_picker(&asked, vec![target.clone()]);
|
||||
assert!(h.step_until(SETTLE, |h| h.app.pending_picks.is_empty()), "the picker answered");
|
||||
assert!(h.step_until(SETTLE, |h| h.app.library_problem.is_none()), "opening the library ends the launch list");
|
||||
assert!(
|
||||
h.step_until(SETTLE, |h| h.app.dropped_files.is_empty() && photos(h) == 1 && h.app.import.is_none()),
|
||||
"the queued photo imports into the opened library"
|
||||
);
|
||||
assert_eq!(h.app.session.library.as_ref().map(|l| l.dir.to_string_lossy().into_owned()), Some(target));
|
||||
}
|
||||
|
||||
/// A library opened from the asynchronous picker can start files that were already waiting in
|
||||
/// `pending_import` (the command line, an earlier no-library drop). A drop queued while the picker
|
||||
/// was up must not be handed to the ordinary drop path while that import runs: it waits for the
|
||||
/// pending import to end and is imported too, instead of being refused and lost.
|
||||
#[test]
|
||||
fn a_queued_drop_waits_for_the_pending_import_started_when_the_picker_opens_the_library() {
|
||||
let dir = Scratch::new("pending-import-picker");
|
||||
let target = library_at(&dir, "chosen-library");
|
||||
let (mut h, asked) = app_with_picker(&dir);
|
||||
show_no_library_launch_list(&mut h);
|
||||
h.app.ui.settings.import_dropped_directly = true;
|
||||
let pending = dir.path("trip/a.png");
|
||||
h.app.library_problem.as_mut().unwrap().pending_import.push(pending);
|
||||
|
||||
let r = h.request("ui.clickWidget", json!({"id": "button:libraryOpenAnother"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert!(h.step_until(T, |h| h.app.pending_picks.len() == 1), "the folder picker is up");
|
||||
drop_files(&mut h, &[dir.path("trip/b.png")], false);
|
||||
for _ in 0..10 {
|
||||
h.step();
|
||||
}
|
||||
assert_eq!(h.app.dropped_files.len(), 1, "the drop waits through the picker");
|
||||
assert!(h.app.session.library.is_none());
|
||||
|
||||
answer_picker(&asked, vec![target.clone()]);
|
||||
assert!(h.step_until(SETTLE, |h| h.app.pending_picks.is_empty()), "the picker answered");
|
||||
assert!(h.step_until(SETTLE, |h| photos(h) == 1), "the pending import arrives first");
|
||||
assert!(h.app.import.is_some(), "the pending import still runs when the drop is released");
|
||||
assert_eq!(h.app.dropped_files.len(), 1, "the drop waits for the pending import instead of being refused");
|
||||
assert!(
|
||||
h.step_until(SETTLE, |h| photos(h) == 2 && h.app.dropped_files.is_empty() && h.app.import.is_none()),
|
||||
"both the pending file and the queued drop are imported (photos: {}, queued: {:?})",
|
||||
photos(&h),
|
||||
h.app.dropped_files.len()
|
||||
);
|
||||
assert_eq!(h.app.session.library.as_ref().map(|l| l.dir.to_string_lossy().into_owned()), Some(target));
|
||||
}
|
||||
|
||||
/// A drop queued behind the launch list may find another dialog open when the list goes (the
|
||||
/// person opened one while the barrier was up). It waits for that dialog instead of taking the
|
||||
/// ordinary "close the open dialog" refusal, so it is not lost.
|
||||
#[test]
|
||||
fn a_queued_drop_waits_for_a_dialog_opened_before_the_launch_list_closes() {
|
||||
let dir = Scratch::new("deferred-dialog");
|
||||
let mut h = app_with_library(&dir);
|
||||
show_launch_list(&mut h);
|
||||
drop_files(&mut h, &[dir.path("trip/a.png")], false);
|
||||
assert_eq!(h.app.dropped_files.len(), 1, "the drop waits for the launch list");
|
||||
h.app.library_problem = None;
|
||||
h.app.ui.dialog = Some(Dialog::WhatsNew);
|
||||
for _ in 0..10 {
|
||||
h.step();
|
||||
}
|
||||
assert!(matches!(h.app.ui.dialog, Some(Dialog::WhatsNew)), "the dialog stays");
|
||||
assert_eq!(h.app.dropped_files.len(), 1, "the deferred drop is not refused while the dialog is open");
|
||||
assert!(h.app.scan.is_none() && h.app.import.is_none(), "nothing starts behind the dialog");
|
||||
h.app.ui.dialog = None;
|
||||
assert!(h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(Dialog::Import { .. }))), "the drop opens its review once the dialog is closed");
|
||||
assert!(h.app.dropped_files.is_empty(), "the deferred drop was consumed only when safe");
|
||||
}
|
||||
|
||||
/// A drop queued behind the launch list may be released while a Synchronize Folder runs. It
|
||||
/// waits for that run to finish instead of taking `import::start_paths`'s "A folder is being
|
||||
/// synchronized" refusal and being lost. A drop made while no barrier was up still gets that
|
||||
/// refusal (see `tests_sync::a_synchronize_and_an_import_never_run_at_once`).
|
||||
#[test]
|
||||
fn a_queued_drop_waits_for_a_running_synchronize_instead_of_being_refused() {
|
||||
let dir = Scratch::new("deferred-sync");
|
||||
let mut h = app_with_library(&dir);
|
||||
h.app.ui.settings.import_dropped_directly = true;
|
||||
show_launch_list(&mut h);
|
||||
drop_files(&mut h, &[dir.path("trip/a.png")], false);
|
||||
assert_eq!(h.app.dropped_files.len(), 1, "the drop waits for the launch list");
|
||||
let _run = crate::sync::stall_run(&mut h.app, "trip");
|
||||
assert!(h.app.sync_run.is_some(), "the synchronize is running");
|
||||
h.app.library_problem = None;
|
||||
for _ in 0..10 {
|
||||
h.step();
|
||||
}
|
||||
assert!(h.app.sync_run.is_some(), "the synchronize still runs");
|
||||
assert_eq!(h.app.dropped_files.len(), 1, "the deferred drop waits for the running synchronize instead of being refused");
|
||||
assert!(!said(&h, "A folder is being synchronized"), "the deferred drop is not refused: {:?}", h.painted_text());
|
||||
assert_eq!(photos(&h), 0, "nothing is imported during the synchronize");
|
||||
h.app.sync_run = None;
|
||||
assert!(
|
||||
h.step_until(SETTLE, |h| h.app.dropped_files.is_empty() && photos(h) == 1 && h.app.import.is_none()),
|
||||
"the queued drop imports once the synchronize is done (queued: {:?}, photos: {})",
|
||||
h.app.dropped_files,
|
||||
photos(&h)
|
||||
);
|
||||
}
|
||||
|
||||
/// The same for a Lightroom catalog import: a drop queued behind the launch list waits while the
|
||||
/// task owns the catalog, instead of being refused with "Finish Lightroom catalog import…".
|
||||
#[test]
|
||||
fn a_queued_drop_waits_for_a_running_lightroom_import_instead_of_being_refused() {
|
||||
let dir = Scratch::new("deferred-lightroom");
|
||||
let mut h = app_with_library(&dir);
|
||||
h.app.ui.settings.import_dropped_directly = true;
|
||||
show_launch_list(&mut h);
|
||||
drop_files(&mut h, &[dir.path("trip/a.png")], false);
|
||||
assert_eq!(h.app.dropped_files.len(), 1, "the drop waits for the launch list");
|
||||
let task = crate::lightroom_import::stall_import(&mut h.app);
|
||||
h.app.library_problem = None;
|
||||
for _ in 0..10 {
|
||||
h.step();
|
||||
}
|
||||
assert!(h.app.lightroom.is_some(), "the Lightroom import still runs");
|
||||
assert_eq!(h.app.dropped_files.len(), 1, "the deferred drop waits for the Lightroom import instead of being refused");
|
||||
assert!(!said(&h, "Finish Lightroom catalog import"), "the deferred drop is not refused: {:?}", h.painted_text());
|
||||
assert_eq!(photos(&h), 0, "nothing is imported during the Lightroom import");
|
||||
drop(task);
|
||||
assert!(h.step_until(SETTLE, |h| h.app.lightroom.is_none()), "the Lightroom task ends");
|
||||
// the stub's disconnect reports a stopped task and opens its result window; close it so the
|
||||
// queued drop is released (a real import's result window is another dialog the guard waits for)
|
||||
h.app.ui.dialog = None;
|
||||
assert!(
|
||||
h.step_until(SETTLE, |h| h.app.dropped_files.is_empty() && photos(h) == 1 && h.app.import.is_none()),
|
||||
"the queued drop imports once the Lightroom task is done (queued: {:?}, photos: {})",
|
||||
h.app.dropped_files,
|
||||
photos(&h)
|
||||
);
|
||||
}
|
||||
|
||||
/// Cancelling Open Another…'s asynchronous picker keeps the launch list up; the queued photo is
|
||||
/// then held for the library chosen rather than dropped behind the cancelling picker.
|
||||
#[test]
|
||||
fn a_queued_photo_drop_survives_the_async_open_library_picker_cancel() {
|
||||
let dir = Scratch::new("open-async-cancel");
|
||||
let (mut h, asked) = app_with_picker(&dir);
|
||||
show_no_library_launch_list(&mut h);
|
||||
let r = h.request("ui.clickWidget", json!({"id": "button:libraryOpenAnother"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert!(h.step_until(T, |h| h.app.pending_picks.len() == 1), "the folder picker is up");
|
||||
drop_files(&mut h, &[dir.path("trip/a.png")], false);
|
||||
for _ in 0..10 {
|
||||
h.step();
|
||||
}
|
||||
assert_eq!(h.app.dropped_files.len(), 1, "the drop waits through the picker");
|
||||
|
||||
answer_picker(&asked, vec![]);
|
||||
assert!(h.step_until(SETTLE, |h| h.app.pending_picks.is_empty()), "the picker was cancelled");
|
||||
assert!(h.step_until(SETTLE, |h| h.app.dropped_files.is_empty()), "the drop takes the no-library path now");
|
||||
assert!(h.app.session.library.is_none());
|
||||
assert!(h.app.widgets.iter().any(|(w, _)| w == "button:libraryOpen"), "the launch list is still up");
|
||||
assert_eq!(
|
||||
h.app.library_problem.as_ref().map(|p| p.pending_import.clone()),
|
||||
Some(vec![dir.path("trip/a.png")]),
|
||||
"the photo waits for the library chosen"
|
||||
);
|
||||
assert_eq!(photos(&h), 0);
|
||||
}
|
||||
|
||||
/// A valid face-model drop on a no-library launch list or error window must not open its licence
|
||||
/// dialog behind that window; it is refused like a preset, while photos still take the hold path.
|
||||
#[test]
|
||||
fn a_model_drop_on_a_no_library_window_does_not_open_its_dialog_behind_it() {
|
||||
let dir = Scratch::new("model-window");
|
||||
std::fs::write(dir.path("face.onnx"), lightcraft_faces::synthetic::tiny_embedder_model(512)).unwrap();
|
||||
for (tag, problem) in [
|
||||
("error", crate::panels::library_problem::LibraryProblem::new(dir.path("Gone"), "not found")),
|
||||
("chooser", crate::panels::library_problem::LibraryProblem::choose_later(&crate::state::AppSettings::default())),
|
||||
] {
|
||||
let mut h = app(&dir);
|
||||
h.app.library_problem = Some(problem);
|
||||
for _ in 0..4 {
|
||||
h.step();
|
||||
}
|
||||
drop_files(&mut h, &[dir.path("face.onnx")], true);
|
||||
assert!(said(&h, "drop the model again"), "{tag}: the card says the model waits: {:?}", h.painted_text());
|
||||
drop_files(&mut h, &[dir.path("face.onnx")], false);
|
||||
for _ in 0..10 {
|
||||
h.step();
|
||||
}
|
||||
assert!(h.app.ui.dialog.is_none(), "{tag}: no face model dialog behind the window");
|
||||
let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default();
|
||||
assert!(toast.contains("drop the model again"), "{tag}: {toast}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A model dropped over the launch list is held like a photo/preset, then opens its licence dialog
|
||||
/// once the list is kept away.
|
||||
#[test]
|
||||
fn a_model_drop_waits_behind_the_launch_list_and_opens_after_keep() {
|
||||
let dir = Scratch::new("chooser-model");
|
||||
std::fs::write(dir.path("face.onnx"), lightcraft_faces::synthetic::tiny_embedder_model(512)).unwrap();
|
||||
let mut h = app_with_library(&dir);
|
||||
h.app.session.face_models_dir = Some(std::path::PathBuf::from(dir.path("models")));
|
||||
show_launch_list(&mut h);
|
||||
drop_files(&mut h, &[dir.path("face.onnx")], false);
|
||||
for _ in 0..10 {
|
||||
h.step();
|
||||
}
|
||||
assert!(h.app.ui.dialog.is_none(), "no licence dialog opens behind the launch list");
|
||||
assert_eq!(h.app.dropped_files.len(), 1, "the model waits for the launch list to go");
|
||||
let r = h.request("ui.key", json!({"key": "Escape"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert!(h.step_until(SETTLE, |h| h.app.library_problem.is_none()), "Keep closes the launch list");
|
||||
assert!(h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(Dialog::FaceModel { .. }))), "the queued model opens its licence dialog");
|
||||
assert!(h.app.dropped_files.is_empty());
|
||||
}
|
||||
|
||||
/// A failed switch leaves the launch list showing its error over the library that stayed open
|
||||
/// (`Chooser { over: Some(generation), .. }`, not a separate error window). A queued drop must wait
|
||||
/// there too: importing it would change the library the person has not chosen to keep.
|
||||
#[test]
|
||||
fn a_photo_drop_waits_behind_the_choosers_failed_switch_error_until_keep() {
|
||||
let dir = Scratch::new("failed-switch");
|
||||
let mut h = app_with_library(&dir);
|
||||
let mut problem = crate::panels::library_problem::LibraryProblem::choose_later(&h.app.ui.settings);
|
||||
let generation = h.app.session.library_generation();
|
||||
let Some(c) = problem.chooser.as_mut() else { panic!("the launch list has its chooser") };
|
||||
c.over = Some(generation);
|
||||
problem.path = dir.path("Gone");
|
||||
problem.error = format!("{}: the library is no longer there", dir.path("Gone"));
|
||||
h.app.library_problem = Some(problem);
|
||||
assert!(
|
||||
h.step_until(T, |h| h.app.widgets.iter().any(|(w, _)| w == "button:libraryOpen")),
|
||||
"the launch list is drawn over the library that stayed open"
|
||||
);
|
||||
assert!(said(&h, "no longer there"), "the failed switch's error is shown: {:?}", h.painted_text());
|
||||
assert!(crate::panels::library_problem::blocks_file_drops(&h.app), "the failed-switch list owns the drop barrier");
|
||||
drop_files(&mut h, &[dir.path("trip/a.png")], false);
|
||||
assert_eq!(h.app.dropped_files.len(), 1, "the drop waits for the failed-switch list");
|
||||
assert!(h.app.ui.dialog.is_none(), "no import review opens behind the list");
|
||||
assert!(h.app.scan.is_none() && h.app.import.is_none(), "nothing starts behind the list");
|
||||
assert_eq!(photos(&h), 0);
|
||||
// Esc is the list's Keep: it closes the list and leaves the library that stayed open.
|
||||
let r = h.request("ui.key", json!({"key": "Escape"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert!(h.step_until(SETTLE, |h| h.app.library_problem.is_none()), "Keep closes the launch list");
|
||||
assert!(h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(Dialog::Import { .. }))), "the queued drop opens after Keep");
|
||||
assert!(h.app.dropped_files.is_empty());
|
||||
assert_eq!(photos(&h), 0, "the review still imports nothing before it is confirmed");
|
||||
}
|
||||
|
||||
/// A drop queued while the launch list is up belongs to the library the person then opens with
|
||||
/// its Open button: the switch must not drop it, and it imports into the new library.
|
||||
#[test]
|
||||
fn a_queued_photo_drop_imports_into_the_library_opened_from_the_list() {
|
||||
let dir = Scratch::new("switch-queue");
|
||||
// Made (and flushed) by a session that is dropped before the app opens it: `close_library`
|
||||
// saves but keeps the library and its OS lock until the session is dropped, and the launch
|
||||
// list's Open would then be refused with "already open" by this same process's other descriptor.
|
||||
let second = library_at(&dir, "second");
|
||||
let mut h = app_with_library(&dir);
|
||||
h.app.ui.settings.import_dropped_directly = true;
|
||||
h.app.ui.settings.library_path = dir.path("library");
|
||||
h.app.ui.settings.recent_libraries = vec![dir.path("library"), second.clone()];
|
||||
show_launch_list(&mut h);
|
||||
drop_files(&mut h, &[dir.path("trip/a.png")], false);
|
||||
assert_eq!(h.app.dropped_files.len(), 1);
|
||||
let r = h.request("ui.clickWidget", json!({"id": "libraryRecent:1"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert_eq!(h.app.library_problem.as_ref().and_then(|p| p.chooser.as_ref()).and_then(|c| c.selected), Some(1), "the second library is selected");
|
||||
let r = h.request("ui.clickWidget", json!({"id": "button:libraryOpen"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert!(
|
||||
h.step_until(SETTLE, |h| h.app.library_problem.is_none()),
|
||||
"the switch ends the launch list; problem: {:?}",
|
||||
h.app.library_problem.as_ref().map(|p| (&p.path, &p.error))
|
||||
);
|
||||
assert!(
|
||||
h.step_until(SETTLE, |h| h.app.import.is_none() && photos(h) == 1),
|
||||
"the queued drop imports into the opened library (queued: {:?}, photos: {}, import running: {})",
|
||||
h.app.dropped_files,
|
||||
photos(&h),
|
||||
h.app.import.is_some()
|
||||
);
|
||||
assert_eq!(h.app.session.library.as_ref().map(|l| l.dir.to_string_lossy().into_owned()), Some(second));
|
||||
assert!(h.app.dropped_files.is_empty());
|
||||
}
|
||||
|
||||
/// The scan of a dropped folder finishing while another dialog is open (the face model's licence, Settings…)
|
||||
/// keeps that dialog; the review opens, with the scan's photos, once it is closed.
|
||||
#[test]
|
||||
@@ -354,3 +939,171 @@ fn photos_dropped_with_no_library_open_wait_for_one() {
|
||||
let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default();
|
||||
assert!(toast.contains("drop the presets again"), "{toast}");
|
||||
}
|
||||
|
||||
/// Finish the protected batch through the ordinary review/direct path, checking exact provenance.
|
||||
fn finish_mixed_drop_batch(h: &mut Headless, expected: &[String], direct: bool) {
|
||||
if !direct {
|
||||
assert!(h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(Dialog::Import { .. }))), "the protected batch reaches review");
|
||||
let Some(Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review") };
|
||||
let mut actual = opts.candidates.iter().map(|c| c.path.clone()).collect::<Vec<_>>();
|
||||
actual.sort();
|
||||
let mut wanted = expected.to_vec();
|
||||
wanted.sort();
|
||||
assert_eq!(actual, wanted, "review contains exactly the protected arrivals");
|
||||
assert_eq!(photos(h), 0, "review mode still requires confirmation");
|
||||
let reply = h.request("ui.dialog.confirm", json!({}), T);
|
||||
assert_eq!(reply["ok"], true, "{reply}");
|
||||
}
|
||||
assert!(h.step_until(SETTLE, |h| photos(h) == expected.len() && !crate::import::running(&h.app)), "the protected batch imports");
|
||||
let mut paths = h
|
||||
.app
|
||||
.session
|
||||
.catalog
|
||||
.photos()
|
||||
.map(|p| match &p.source {
|
||||
lightcraft_catalog::Source::File { path } => path.clone(),
|
||||
other => panic!("unexpected imported source: {other:?}"),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
paths.sort();
|
||||
let mut wanted = expected.to_vec();
|
||||
wanted.sort();
|
||||
assert_eq!(paths, wanted, "only protected files entered the catalog");
|
||||
assert!(h.app.dropped_files.is_empty(), "the protected arrivals were consumed");
|
||||
let revision = h.app.session.catalog.revision;
|
||||
// Inspect every later pass, not only the final catalog count: duplicate import suppression
|
||||
// must not disguise replay of a consumed raw-input drop.
|
||||
for _ in 0..60 {
|
||||
h.step();
|
||||
assert!(!crate::import::running(&h.app), "no replay starts another scan/import");
|
||||
assert!(h.app.ui.dialog.is_none(), "no replay opens another review");
|
||||
assert!(h.app.dropped_files.is_empty());
|
||||
assert_eq!(photos(h), expected.len());
|
||||
assert_eq!(h.app.session.catalog.revision, revision, "no later import mutates the catalog");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_drop_outside_the_barrier_is_refused_while_an_older_drop_waits_for_a_dialog() {
|
||||
for direct in [false, true] {
|
||||
let dir = Scratch::new("mixed-provenance-dialog");
|
||||
let mut h = app_with_library(&dir);
|
||||
h.app.ui.settings.import_dropped_directly = direct;
|
||||
let a = dir.path("trip/a.png");
|
||||
let b = dir.path("trip/b.png");
|
||||
show_launch_list(&mut h);
|
||||
drop_files(&mut h, std::slice::from_ref(&a), false);
|
||||
assert_eq!(h.app.dropped_files.len(), 1);
|
||||
h.app.library_problem = None;
|
||||
h.app.ui.dialog = Some(Dialog::WhatsNew);
|
||||
h.step();
|
||||
assert!(!crate::panels::library_problem::blocks_file_drops(&h.app));
|
||||
assert!(!crate::panels::notices::blocks_input(&h.app));
|
||||
drop_files(&mut h, &[b], false);
|
||||
assert!(matches!(h.app.ui.dialog, Some(Dialog::WhatsNew)), "the ordinary dialog survives B");
|
||||
assert!(h.app.scan.is_none() && h.app.import.is_none());
|
||||
assert_eq!(photos(&h), 0);
|
||||
assert!(h.app.ui.toast.as_ref().is_some_and(|t| t.0.contains("Close the open dialog")), "B is refused immediately: {:?}", h.app.ui.toast);
|
||||
assert_eq!(h.app.dropped_files.len(), 1, "B cannot inherit A's protected provenance");
|
||||
h.app.ui.dialog = None;
|
||||
finish_mixed_drop_batch(&mut h, &[a], direct);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_drop_outside_the_barrier_is_refused_while_an_older_drop_waits_for_an_import() {
|
||||
for direct in [false, true] {
|
||||
let dir = Scratch::new("mixed-provenance-import");
|
||||
let mut h = app_with_library(&dir);
|
||||
h.app.ui.settings.import_dropped_directly = direct;
|
||||
let a = dir.path("trip/a.png");
|
||||
let b = dir.path("trip/b.png");
|
||||
show_launch_list(&mut h);
|
||||
drop_files(&mut h, std::slice::from_ref(&a), false);
|
||||
let stalled = crate::import::stall_scan(&mut h.app);
|
||||
h.app.library_problem = None;
|
||||
h.step();
|
||||
assert!(!crate::panels::library_problem::blocks_file_drops(&h.app));
|
||||
assert!(!crate::panels::notices::blocks_input(&h.app));
|
||||
drop_files(&mut h, &[b], false);
|
||||
assert!(h.app.scan.is_some() && h.app.import.is_none(), "the original stalled import scan survives B");
|
||||
assert!(h.app.ui.dialog.is_none());
|
||||
assert_eq!(photos(&h), 0);
|
||||
assert!(h.app.ui.toast.as_ref().is_some_and(|t| t.0.contains("An import is running")), "B is refused immediately: {:?}", h.app.ui.toast);
|
||||
assert_eq!(h.app.dropped_files.len(), 1, "B cannot inherit A's protected provenance");
|
||||
drop(stalled); // Channel-only fixture: no worker/process to stop.
|
||||
assert!(h.step_until(T, |h| h.app.scan.is_none()), "the original stalled scan ends");
|
||||
finish_mixed_drop_batch(&mut h, &[a], direct);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_drops_really_made_behind_the_barrier_remain_protected() {
|
||||
for direct in [false, true] {
|
||||
let dir = Scratch::new("mixed-provenance-both-protected");
|
||||
let mut h = app_with_library(&dir);
|
||||
h.app.ui.settings.import_dropped_directly = direct;
|
||||
let a = dir.path("trip/a.png");
|
||||
let b = dir.path("trip/b.png");
|
||||
show_launch_list(&mut h);
|
||||
drop_files(&mut h, std::slice::from_ref(&a), false);
|
||||
drop_files(&mut h, std::slice::from_ref(&b), false);
|
||||
assert_eq!(h.app.dropped_files.len(), 2, "both arrivals really occurred behind the barrier");
|
||||
h.app.library_problem = None;
|
||||
h.app.ui.dialog = Some(Dialog::WhatsNew);
|
||||
for _ in 0..10 {
|
||||
h.step();
|
||||
}
|
||||
assert!(matches!(h.app.ui.dialog, Some(Dialog::WhatsNew)));
|
||||
assert_eq!(h.app.dropped_files.len(), 2, "both protected arrivals survive the later dialog");
|
||||
assert!(h.app.scan.is_none() && h.app.import.is_none());
|
||||
h.app.ui.dialog = None;
|
||||
finish_mixed_drop_batch(&mut h, &[a, b], direct);
|
||||
}
|
||||
}
|
||||
|
||||
/// Public take also serves web hosts: barrier reentry preserves order and consuming clears
|
||||
/// protection, so a later ordinary drop cannot inherit the consumed batch's provenance.
|
||||
#[test]
|
||||
fn public_drop_take_preserves_reentered_barrier_order_and_clears_protection() {
|
||||
let dir = Scratch::new("mixed-provenance-public-take");
|
||||
let mut h = app_with_library(&dir);
|
||||
let a = dir.path("trip/a.png");
|
||||
let b = dir.path("trip/b.png");
|
||||
show_launch_list(&mut h);
|
||||
drop_files(&mut h, std::slice::from_ref(&a), false);
|
||||
assert_eq!(h.app.dropped_files.len(), 1);
|
||||
h.app.library_problem = None;
|
||||
h.app.ui.dialog = Some(Dialog::WhatsNew);
|
||||
for _ in 0..3 {
|
||||
h.step();
|
||||
}
|
||||
assert_eq!(h.app.dropped_files.len(), 1, "A remains held behind the ordinary dialog");
|
||||
assert!(matches!(h.app.ui.dialog, Some(Dialog::WhatsNew)));
|
||||
// Reenter before any pass without a blocker can consume A.
|
||||
h.app.ui.dialog = None;
|
||||
show_launch_list(&mut h);
|
||||
drop_files(&mut h, std::slice::from_ref(&b), false);
|
||||
assert_eq!(h.app.dropped_files.len(), 2);
|
||||
assert_eq!(photos(&h), 0);
|
||||
assert!(h.app.scan.is_none() && h.app.import.is_none());
|
||||
// Match the web ownership boundary without stepping a native consuming pass.
|
||||
h.app.library_problem = None;
|
||||
let taken = h.app.take_dropped_files();
|
||||
let paths = taken.iter().map(|f| egui::DroppedFile::path(f.as_ref()).to_string_lossy().into_owned()).collect::<Vec<_>>();
|
||||
assert_eq!(paths, vec![a.clone(), b], "public take preserves arrival order through barrier reentry");
|
||||
assert!(h.app.take_dropped_files().is_empty(), "ownership transfers only once");
|
||||
h.app.ui.dialog = Some(Dialog::WhatsNew);
|
||||
drop_files(&mut h, &[a], false);
|
||||
assert!(matches!(h.app.ui.dialog, Some(Dialog::WhatsNew)));
|
||||
assert!(h.app.ui.toast.as_ref().is_some_and(|t| t.0.contains("Close the open dialog")), "consumed protection cannot shelter a later drop");
|
||||
assert!(h.app.dropped_files.is_empty());
|
||||
h.app.ui.dialog = None;
|
||||
for _ in 0..60 {
|
||||
h.step();
|
||||
assert!(h.app.dropped_files.is_empty());
|
||||
assert!(!crate::import::running(&h.app));
|
||||
assert!(h.app.ui.dialog.is_none());
|
||||
assert_eq!(photos(&h), 0, "neither taken handles nor refused arrivals replay");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,30 +31,31 @@ fn filmstrip_shows_a_cjk_file_name() {
|
||||
|
||||
#[test]
|
||||
fn filmstrip_stack_badge_expands_and_collapses_the_stack() {
|
||||
let mut session = lightcraft_engine::Session::with_demo();
|
||||
let vis = session.visible_cloned();
|
||||
// Badge routing needs three real photos, not full 24 MP demo renders.
|
||||
let library = crate::tests_fixtures::SyntheticLibrary::with_photos("filmstrip-stack-badge", 3);
|
||||
let mut app = library.app();
|
||||
let vis = app.session.visible_cloned();
|
||||
let (loose, top, member) = (vis[0], vis[1], vis[2]);
|
||||
let stack = Stack { id: StackId(1), photos: vec![top, member], collapsed: true };
|
||||
session.catalog.apply(Op::AddStack { stack }).unwrap();
|
||||
let app = LightcraftApp::new(session, Services { png: None, ..Default::default() });
|
||||
app.session.catalog.apply(Op::AddStack { stack }).unwrap();
|
||||
let mut h = Headless::new(app, [1200.0, 800.0], 1.0);
|
||||
// the loose photo is the one in the loupe: the stack isn't selected
|
||||
let r = h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [loose.0]}}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
let r = h.request("ui.set", json!({"view": "detail"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.settle(SETTLE);
|
||||
assert!(h.settle(SETTLE), "the filmstrip settles before clicking its stack badge");
|
||||
assert!(!h.app.session.visible_cloned().contains(&member), "collapsed: only the top shows");
|
||||
let badge = format!("filmstack:{}", top.0);
|
||||
let r = h.request("ui.clickWidget", json!({"id": badge}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.settle(SETTLE);
|
||||
assert!(h.settle(SETTLE), "the expanded stack settles");
|
||||
assert!(!h.app.session.catalog.stack(StackId(1)).unwrap().collapsed, "a click on the badge expands the stack");
|
||||
assert!(h.app.session.visible_cloned().contains(&member));
|
||||
assert_eq!(h.app.session.selection.active, Some(loose), "the loupe keeps its photo");
|
||||
let r = h.request("ui.clickWidget", json!({"id": badge}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.settle(SETTLE);
|
||||
assert!(h.settle(SETTLE), "the collapsed stack settles");
|
||||
assert!(h.app.session.catalog.stack(StackId(1)).unwrap().collapsed, "a second click collapses it");
|
||||
assert!(!h.app.session.visible_cloned().contains(&member));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
//! Test-only fixtures: a small, real, file-backed photo library.
|
||||
//!
|
||||
//! The demo library generates 24 MP procedural scenes, which Nextest regenerates in every
|
||||
//! process. The library/lifecycle tests only need an active detail photo whose file really
|
||||
//! decodes, so this fixture writes 64×48 gradient PNGs and imports them as the only photos.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use lightcraft_catalog::{Op, Photo, Source};
|
||||
|
||||
use crate::{LightcraftApp, Services};
|
||||
|
||||
/// A temporary folder holding small PNGs; removes only its own folder when dropped.
|
||||
///
|
||||
/// Keep this guard alive until after the [`Headless`](crate::headless::Headless) built from
|
||||
/// [`SyntheticLibrary::app`] is dropped: the renderer reads the photo file from disk.
|
||||
pub(crate) struct SyntheticLibrary {
|
||||
dir: PathBuf,
|
||||
photos: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl SyntheticLibrary {
|
||||
/// A fresh folder for `tag`, with a deterministic 64×48 gradient `a.png`.
|
||||
pub(crate) fn new(tag: &str) -> SyntheticLibrary {
|
||||
SyntheticLibrary::with_photos(tag, 1)
|
||||
}
|
||||
|
||||
/// A fresh folder for `tag`, with `n` distinct 64×48 gradient PNGs (`a.png`, `b.png`, …).
|
||||
pub(crate) fn with_photos(tag: &str, n: usize) -> SyntheticLibrary {
|
||||
assert!((1..=26).contains(&n), "fixture photo names use a through z");
|
||||
let dir = std::env::temp_dir().join(format!("lc-ui-fixture-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (w, h) = (64usize, 48usize);
|
||||
let mut photos = Vec::new();
|
||||
for i in 0..n {
|
||||
let name = format!("{}.png", char::from(b'a' + i as u8));
|
||||
let data: Vec<[u8; 4]> = (0..w * h)
|
||||
.map(|p| {
|
||||
let (x, y) = (p % w, p / w);
|
||||
[((x * 3 + i * 17) % 256) as u8, ((y * 4 + i * 9) % 256) as u8, ((x + y + i) % 256) as u8, 255]
|
||||
})
|
||||
.collect();
|
||||
let img = lightcraft_raster::Rgba8 { width: w, height: h, data };
|
||||
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
|
||||
let path = dir.join(name);
|
||||
std::fs::write(&path, png).unwrap();
|
||||
photos.push(path);
|
||||
}
|
||||
SyntheticLibrary { dir, photos }
|
||||
}
|
||||
|
||||
/// A session whose library holds only the fixture photos, the first active and selected, with
|
||||
/// the file system loaders installed so Detail really decodes the PNGs.
|
||||
pub(crate) fn app(&self) -> LightcraftApp {
|
||||
let mut session = lightcraft_engine::Session::new().with_fs();
|
||||
let mut ids = Vec::new();
|
||||
for (i, path) in self.photos.iter().enumerate() {
|
||||
let id = session.catalog.alloc_photo_id();
|
||||
// distinct capture times keep the default (newest first) view order deterministic
|
||||
let captured = format!("2026-01-01T10:{:02}:00", i % 60);
|
||||
let mut photo = Photo::new(
|
||||
id,
|
||||
Source::File { path: path.to_string_lossy().into_owned() },
|
||||
path.file_name().map_or("photo.png", |n| n.to_str().unwrap_or("photo.png")),
|
||||
"PNG",
|
||||
64,
|
||||
48,
|
||||
&captured,
|
||||
);
|
||||
photo.captured = Some(captured);
|
||||
session.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap();
|
||||
ids.push(id);
|
||||
}
|
||||
if let Some(first) = ids.first() {
|
||||
session.selection = lightcraft_engine::Selection::single(*first);
|
||||
}
|
||||
LightcraftApp::new(session, Services { png: None, ..Default::default() })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SyntheticLibrary {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.dir);
|
||||
}
|
||||
}
|
||||
@@ -6,15 +6,14 @@ use std::time::Duration;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::headless::Headless;
|
||||
use crate::{LightcraftApp, Services};
|
||||
use crate::tests_fixtures::SyntheticLibrary;
|
||||
|
||||
const T: Duration = Duration::from_secs(20);
|
||||
const SETTLE: Duration = Duration::from_secs(120);
|
||||
|
||||
fn app() -> Headless {
|
||||
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });
|
||||
let mut h = Headless::new(app, [1200.0, 800.0], 1.0);
|
||||
h.settle(SETTLE);
|
||||
fn app(library: &SyntheticLibrary) -> Headless {
|
||||
let mut h = Headless::new(library.app(), [1200.0, 800.0], 1.0);
|
||||
assert!(h.settle(SETTLE), "keyword fixture did not settle");
|
||||
h
|
||||
}
|
||||
|
||||
@@ -26,9 +25,9 @@ fn temp_dir(tag: &str) -> std::path::PathBuf {
|
||||
}
|
||||
|
||||
fn run(h: &mut Headless, command: &str, params: serde_json::Value) -> serde_json::Value {
|
||||
let r = h.request("engine.execute", json!({"command": command, "params": params}), T);
|
||||
h.settle(SETTLE);
|
||||
r
|
||||
// These commands finish synchronously, including their toast, before replying. Settling
|
||||
// unrelated photo renders afterwards advances the simulated clock and can expire it.
|
||||
h.request("engine.execute", json!({"command": command, "params": params}), T)
|
||||
}
|
||||
|
||||
fn toast(h: &Headless) -> String {
|
||||
@@ -38,7 +37,8 @@ fn toast(h: &Headless) -> String {
|
||||
/// The File menu offers Import Keywords… and Export Keywords….
|
||||
#[test]
|
||||
fn the_file_menu_offers_keyword_files() {
|
||||
let mut h = app();
|
||||
let library = SyntheticLibrary::new("keyword-files-menu");
|
||||
let mut h = app(&library);
|
||||
let r = h.request("ui.menu.list", json!({}), T);
|
||||
let items = r["result"].to_string();
|
||||
for id in ["file.importKeywords", "file.exportKeywords"] {
|
||||
@@ -50,7 +50,8 @@ fn the_file_menu_offers_keyword_files() {
|
||||
#[test]
|
||||
fn importing_keywords_says_what_it_added() {
|
||||
let dir = temp_dir("import");
|
||||
let mut h = app();
|
||||
let library = SyntheticLibrary::new("keyword-files-import");
|
||||
let mut h = app(&library);
|
||||
let path = dir.join("vocabulary.utf8");
|
||||
std::fs::write(&path, "[Places]\r\n\tPortugal\r\n\t\tLisbon\r\n").unwrap();
|
||||
let r = run(&mut h, "file.importKeywords", json!({"path": path.to_string_lossy()}));
|
||||
@@ -64,7 +65,8 @@ fn importing_keywords_says_what_it_added() {
|
||||
#[test]
|
||||
fn exporting_keywords_warns_about_capture_one() {
|
||||
let dir = temp_dir("export");
|
||||
let mut h = app();
|
||||
let library = SyntheticLibrary::new("keyword-files-export");
|
||||
let mut h = app(&library);
|
||||
let id = h.app.session.visible_cloned()[0].0;
|
||||
run(&mut h, "photo.setMeta", json!({"ids": [id], "addKeywords": ["fish, chips"]}));
|
||||
let path = dir.join("keywords.txt");
|
||||
@@ -79,7 +81,8 @@ fn exporting_keywords_warns_about_capture_one() {
|
||||
#[test]
|
||||
fn exporting_keywords_names_what_it_left_out() {
|
||||
let dir = temp_dir("export-left-out");
|
||||
let mut h = app();
|
||||
let library = SyntheticLibrary::new("keyword-files-export-left-out");
|
||||
let mut h = app(&library);
|
||||
let id = h.app.session.visible_cloned()[0].0;
|
||||
run(&mut h, "photo.setMeta", json!({"ids": [id], "addKeywords": ["Travel|[draft]"]}));
|
||||
let path = dir.join("keywords.txt");
|
||||
@@ -96,9 +99,12 @@ fn exporting_keywords_names_what_it_left_out() {
|
||||
/// A list that can't be written (a folder that isn't there) says why, as importing one does.
|
||||
#[test]
|
||||
fn exporting_keywords_says_why_it_failed() {
|
||||
let mut h = app();
|
||||
let path = std::env::temp_dir().join("lc-no-such-folder-for-keywords").join("keywords.txt");
|
||||
let library = SyntheticLibrary::new("keyword-files-export-failure");
|
||||
let mut h = app(&library);
|
||||
let dir = temp_dir("export-failure");
|
||||
let path = dir.join("missing-folder").join("keywords.txt");
|
||||
let r = run(&mut h, "file.exportKeywords", json!({"path": path.to_string_lossy()}));
|
||||
assert_eq!(r["ok"], false, "{r}");
|
||||
assert!(toast(&h).contains("keywords.txt"), "{}", toast(&h));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use serde_json::json;
|
||||
|
||||
use crate::headless::Headless;
|
||||
use crate::panels::library_problem::LibraryProblem;
|
||||
use crate::tests_fixtures::SyntheticLibrary;
|
||||
use crate::{LightcraftApp, Services};
|
||||
|
||||
const T: Duration = Duration::from_secs(20);
|
||||
@@ -163,10 +164,10 @@ fn unopenable_library_asks_instead_of_running_a_demo() {
|
||||
/// own buttons are covered: the click then reports a no-op success and the state never changes.
|
||||
#[test]
|
||||
fn a_backdrop_press_keeps_the_library_window_above_its_dim() {
|
||||
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });
|
||||
let mut h = Headless::new(app, [1000.0, 760.0], 1.0);
|
||||
let files = SyntheticLibrary::new("library-backdrop-press");
|
||||
let mut h = Headless::new(files.app(), [1000.0, 760.0], 1.0);
|
||||
h.app.library_problem = Some(LibraryProblem::new("/no/such/library", "a test error"));
|
||||
assert!(h.settle(Duration::from_secs(20)), "the library window settles");
|
||||
assert!(h.settle(Duration::from_secs(20)), "the library window settles (pending slots: {:?})", h.app.renderer.pending_slots());
|
||||
assert!(has(&h, "button:libraryTemporary"), "the window is laid out");
|
||||
// Outside the centred window: on the full-screen dim, which egui raises when pressed.
|
||||
let at = egui::pos2(10.0, 10.0);
|
||||
@@ -189,17 +190,14 @@ fn a_backdrop_press_keeps_the_library_window_above_its_dim() {
|
||||
/// handlers must stay still while it is on screen, then the dialog keeps its ordinary behaviour.
|
||||
#[test]
|
||||
fn the_library_window_blocks_settings_escape_and_the_backdrop() {
|
||||
let mut h = Headless::new(
|
||||
LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }),
|
||||
[1200.0, 900.0],
|
||||
1.0,
|
||||
);
|
||||
let files = SyntheticLibrary::new("library-blocks-settings");
|
||||
let mut h = Headless::new(files.app(), [1200.0, 900.0], 1.0);
|
||||
h.step();
|
||||
assert!(h.app.run("app.settings", json!({})).is_ok(), "Settings opens");
|
||||
assert!(h.settle(Duration::from_secs(20)), "Settings settles");
|
||||
assert!(h.settle(Duration::from_secs(20)), "Settings settles (pending slots: {:?})", h.app.renderer.pending_slots());
|
||||
h.app.ui.settings.close_dialogs_outside = true;
|
||||
h.app.library_problem = Some(LibraryProblem::new("/no/such/library", "a test error"));
|
||||
assert!(h.settle(Duration::from_secs(20)), "the library window settles");
|
||||
assert!(h.settle(Duration::from_secs(20)), "the library window settles (pending slots: {:?})", h.app.renderer.pending_slots());
|
||||
assert!(has(&h, "button:libraryRetry"), "the library window is laid out");
|
||||
// Escape belongs to the window, not to Settings behind it (the window has no Escape action
|
||||
// for this error; the launch list handles its own keys separately).
|
||||
@@ -386,14 +384,11 @@ fn alt_launch_creation_after_raw_input_cancels_an_injected_gesture() {
|
||||
/// become a background click.
|
||||
#[test]
|
||||
fn a_held_background_press_is_released_when_a_library_window_appears_after_raw_input() {
|
||||
let mut h = Headless::new(
|
||||
LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }),
|
||||
[1200.0, 900.0],
|
||||
1.0,
|
||||
);
|
||||
let files = SyntheticLibrary::new("library-after-raw-press");
|
||||
let mut h = Headless::new(files.app(), [1200.0, 900.0], 1.0);
|
||||
h.step();
|
||||
assert!(h.app.run("app.settings", json!({})).is_ok(), "Settings opens");
|
||||
assert!(h.settle(T), "Settings settles");
|
||||
assert!(h.settle(T), "Settings settles (pending slots: {:?})", h.app.renderer.pending_slots());
|
||||
let id = "check:settings.confirmDelete";
|
||||
let at = h.app.widgets.iter().rev().find(|(widget, _)| widget == id).map(|(_, rect)| rect.center()).expect("the checkbox is on screen");
|
||||
let before = h.app.ui.settings.confirm_delete;
|
||||
@@ -738,11 +733,8 @@ fn clipboard_pastes_into_the_launch_lists_own_text_prompt() {
|
||||
/// same field must accept typing again once the window is gone.
|
||||
#[test]
|
||||
fn a_settled_library_window_keeps_a_background_dialogs_text_draft_unchanged() {
|
||||
let mut h = Headless::new(
|
||||
LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }),
|
||||
[1200.0, 760.0],
|
||||
1.0,
|
||||
);
|
||||
let files = SyntheticLibrary::new("library-text-draft");
|
||||
let mut h = Headless::new(files.app(), [1200.0, 760.0], 1.0);
|
||||
h.settle(T);
|
||||
h.app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: "Trip".into(), folder: false, parent: None, include_selected: false });
|
||||
for _ in 0..4 {
|
||||
@@ -750,7 +742,7 @@ fn a_settled_library_window_keeps_a_background_dialogs_text_draft_unchanged() {
|
||||
}
|
||||
assert!(h.view.ctx.text_edit_focused(), "the dialog's field takes the focus while it is the top modal");
|
||||
h.app.library_problem = Some(LibraryProblem::new("/no/such/library", "a test error"));
|
||||
assert!(h.settle(T), "the library window settles over the dialog");
|
||||
assert!(h.settle(T), "the library window settles over the dialog (pending slots: {:?})", h.app.renderer.pending_slots());
|
||||
assert!(has(&h, "button:libraryRetry"), "the library window is laid out");
|
||||
assert!(crate::panels::library_problem::blocks_dialog_input(&h.app), "the library window owns input");
|
||||
assert!(!h.view.ctx.text_edit_focused(), "the background dialog's field must not regain the focus behind the library window");
|
||||
@@ -766,7 +758,7 @@ fn a_settled_library_window_keeps_a_background_dialogs_text_draft_unchanged() {
|
||||
assert!(!h.view.ctx.text_edit_focused(), "several settled barrier frames still leave the field unfocused");
|
||||
// Positive control: with the library window gone, the same field takes focus and typing again.
|
||||
h.app.library_problem = None;
|
||||
assert!(h.settle(T), "the dialog settles once the library window is gone");
|
||||
assert!(h.settle(T), "the dialog settles once the library window is gone (pending slots: {:?})", h.app.renderer.pending_slots());
|
||||
assert!(!crate::panels::library_problem::blocks_dialog_input(&h.app), "there is no barrier now");
|
||||
assert!(h.view.ctx.text_edit_focused(), "the dialog's field is usable again");
|
||||
h.events.push(egui::Event::Text("Holiday".into()));
|
||||
|
||||
@@ -7,6 +7,7 @@ use std::time::Duration;
|
||||
|
||||
use crate::headless::Headless;
|
||||
use crate::render::{Slot, Tex};
|
||||
use crate::tests_fixtures::SyntheticLibrary;
|
||||
use crate::{ControlResponse, LightcraftApp, MAX_PENDING_SHOTS, MAX_SHOT_BYTES, PendingShot, Services, ShotGeometry};
|
||||
use lightcraft_engine::slideshow::Item;
|
||||
|
||||
@@ -302,6 +303,42 @@ fn a_capture_completed_after_a_catalog_edit_is_still_written() {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headless_capture_finishes_once_and_releases_only_its_reservation() {
|
||||
for case in ["success", "overflow", "image-cap", "aggregate-cap"] {
|
||||
let (mut app, seen) = app_with_screenshot_files();
|
||||
let ctx = egui::Context::default();
|
||||
let path = temp_png(&format!("headless-finish-{case}"));
|
||||
let (tx, rx) = reply_channel();
|
||||
app.last_shot_geometry = Some(geometry(100.0, 100.0));
|
||||
let target = app.session.pin_write_target(&path.to_string_lossy()).expect("pin path");
|
||||
app.admit_shot(&ctx, Some(target), true, tx).expect("admit");
|
||||
let mut shot = app.pending_screenshots.pop().expect("pending shot");
|
||||
let mut image = egui::ColorImage::filled([200, 200], egui::Color32::WHITE);
|
||||
let other_reservations = if case == "aggregate-cap" { crate::MAX_SHOT_TOTAL_BYTES - shot.reserved } else { 0 };
|
||||
app.shot_bytes += other_reservations;
|
||||
if case == "overflow" {
|
||||
image.size = [usize::MAX, 2];
|
||||
} else if case == "image-cap" {
|
||||
image.size = [MAX_SHOT_BYTES, 1];
|
||||
}
|
||||
app.finish_headless_shot(&mut shot, &image);
|
||||
let reply = rx.try_recv().expect("one reply");
|
||||
assert_eq!(reply["ok"], case == "success", "{case}: {reply}");
|
||||
match case {
|
||||
"overflow" => assert_eq!(reply["error"], "ui.screenshot: the rendered image has invalid dimensions"),
|
||||
"image-cap" => assert!(reply["error"].as_str().expect("error message").contains("per-image limit")),
|
||||
"aggregate-cap" => assert!(reply["error"].as_str().expect("error message").contains("bytes reserved is over")),
|
||||
_ => {}
|
||||
}
|
||||
assert!(rx.try_recv().is_err(), "no duplicate reply");
|
||||
assert_eq!(app.shot_bytes, other_reservations, "{case}");
|
||||
assert_eq!(path.exists(), case == "success", "{case}");
|
||||
assert_eq!(seen.lock().unwrap().is_some(), case == "success", "{case}");
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_larger_than_its_reservation_is_grown_and_written() {
|
||||
// Wayland has no `inner_rect`, so on a hidden tick the reservation can be based on the old
|
||||
@@ -559,9 +596,10 @@ fn hidden_slideshow_pauses_and_keeps_its_remaining_time() {
|
||||
// Every slide is "ready": without the hidden gate, 10 hidden seconds would advance the show.
|
||||
for id in app.session.visible_cloned() {
|
||||
let tex = ctx.load_texture(format!("slide-{}", id.0), egui::ColorImage::filled([2, 2], egui::Color32::WHITE), Default::default());
|
||||
app.renderer
|
||||
.textures
|
||||
.insert(Slot::Slide(Item::Photo(id)), Tex { key: 0, photo: id, tex, size: [2, 2], histogram: None, ms: 0.0, quick: None, pixels: None });
|
||||
app.renderer.textures.insert(
|
||||
Slot::Slide(Item::Photo(id)),
|
||||
Tex { look: None, key: 0, photo: id, tex, size: [2, 2], histogram: None, ms: 0.0, quick: None, pixels: None },
|
||||
);
|
||||
}
|
||||
let first = app.ui.slideshow.as_ref().unwrap().play.current();
|
||||
let selection = app.session.active();
|
||||
@@ -990,13 +1028,14 @@ fn a_maximum_step_drag_is_delivered_past_the_five_second_stall_limit() {
|
||||
|
||||
#[test]
|
||||
fn a_click_on_a_control_under_a_nonmodal_overlay_is_refused_without_activating_the_overlay() {
|
||||
let mut h = Headless::new(app(), [1400.0, 900.0], 1.0);
|
||||
let files = SyntheticLibrary::new("control-under-overlay");
|
||||
let mut h = Headless::new(files.app(), [1400.0, 900.0], 1.0);
|
||||
let photo = h.app.session.visible_cloned()[0];
|
||||
let t = Duration::from_secs(20);
|
||||
assert_eq!(h.request("engine.execute", serde_json::json!({"command": "library.select", "params": {"ids": [photo.0]}}), t)["ok"], true);
|
||||
assert_eq!(h.request("ui.set", serde_json::json!({"view": "detail", "right": "edit"}), t)["ok"], true);
|
||||
h.app.ui.open_sections = vec!["light".to_string()];
|
||||
assert!(h.settle(Duration::from_secs(60)), "the edit panel settles");
|
||||
assert!(h.settle(Duration::from_secs(60)), "the edit panel settles (pending slots: {:?})", h.app.renderer.pending_slots());
|
||||
let id = "slider:light.exposure";
|
||||
assert!(h.app.widgets.iter().any(|(widget, _)| widget == id), "{id} is on screen");
|
||||
let before = h.app.session.develop_of(photo).expect("its develop settings").light.exposure;
|
||||
@@ -1028,12 +1067,13 @@ fn a_click_on_a_control_under_a_nonmodal_overlay_is_refused_without_activating_t
|
||||
/// interactive centre). This is the positive control the hover-only probe lacked.
|
||||
#[test]
|
||||
fn an_unobstructed_click_on_a_subrect_reaches_the_widget() {
|
||||
let mut h = Headless::new(app(), [1400.0, 900.0], 1.0);
|
||||
let files = SyntheticLibrary::new("unobstructed-subrect");
|
||||
let mut h = Headless::new(files.app(), [1400.0, 900.0], 1.0);
|
||||
let photo = h.app.session.visible_cloned()[0];
|
||||
let t = Duration::from_secs(20);
|
||||
assert_eq!(h.request("engine.execute", serde_json::json!({"command": "library.select", "params": {"ids": [photo.0]}}), t)["ok"], true);
|
||||
assert_eq!(h.request("ui.set", serde_json::json!({"view": "detail", "right": "edit"}), t)["ok"], true);
|
||||
assert!(h.settle(Duration::from_secs(60)), "the detail view settles");
|
||||
assert!(h.settle(Duration::from_secs(60)), "the detail view settles (pending slots: {:?})", h.app.renderer.pending_slots());
|
||||
let id = "star:bottom:2";
|
||||
assert!(h.app.widgets.iter().any(|(widget, _)| widget == id), "{id} is on screen");
|
||||
assert!(h.app.widget_hits.iter().any(|(widget, _)| widget == id), "{id} has a probe");
|
||||
@@ -1047,11 +1087,12 @@ fn an_unobstructed_click_on_a_subrect_reaches_the_widget() {
|
||||
/// must not turn the release into an overlay click; the pointer is released away from the target.
|
||||
#[test]
|
||||
fn an_overlay_that_appears_after_the_press_cancels_the_release() {
|
||||
let mut h = Headless::new(app(), [1000.0, 760.0], 1.0);
|
||||
let files = SyntheticLibrary::new("overlay-after-press");
|
||||
let mut h = Headless::new(files.app(), [1000.0, 760.0], 1.0);
|
||||
h.step();
|
||||
let t = Duration::from_secs(20);
|
||||
assert_eq!(h.request("ui.key", serde_json::json!({"key": ",", "cmd": true}), t)["ok"], true);
|
||||
assert!(h.settle(Duration::from_secs(60)), "the Settings dialog settles");
|
||||
assert!(h.settle(Duration::from_secs(60)), "the Settings dialog settles (pending slots: {:?})", h.app.renderer.pending_slots());
|
||||
let id = "check:settings.confirmDelete";
|
||||
let target = h.app.widgets.iter().rev().find(|(widget, _)| widget == id).map(|(_, rect)| *rect).expect("the checkbox is on screen");
|
||||
let before = h.app.ui.settings.confirm_delete;
|
||||
@@ -1096,13 +1137,14 @@ fn an_overlay_that_appears_after_the_press_cancels_the_release() {
|
||||
/// cleanup moved 8 px to suppress the click, which could start a drag and change a slider/curve.
|
||||
#[test]
|
||||
fn canceling_a_pressed_slider_releases_it_without_dragging_it() {
|
||||
let mut h = Headless::new(app(), [1400.0, 900.0], 1.0);
|
||||
let files = SyntheticLibrary::new("canceled-pressed-slider");
|
||||
let mut h = Headless::new(files.app(), [1400.0, 900.0], 1.0);
|
||||
let photo = h.app.session.visible_cloned()[0];
|
||||
let t = Duration::from_secs(20);
|
||||
assert_eq!(h.request("engine.execute", serde_json::json!({"command": "library.select", "params": {"ids": [photo.0]}}), t)["ok"], true);
|
||||
assert_eq!(h.request("ui.set", serde_json::json!({"view": "detail", "right": "edit"}), t)["ok"], true);
|
||||
h.app.ui.open_sections = vec!["light".to_string()];
|
||||
assert!(h.settle(Duration::from_secs(60)), "the edit panel settles");
|
||||
assert!(h.settle(Duration::from_secs(60)), "the edit panel settles (pending slots: {:?})", h.app.renderer.pending_slots());
|
||||
let id = "slider:light.exposure";
|
||||
let target = h.app.widgets.iter().rev().find(|(widget, _)| widget == id).map(|(_, rect)| *rect).expect("the slider is on screen");
|
||||
let before = h.app.session.develop_of(photo).expect("its develop settings").light.exposure;
|
||||
@@ -1136,11 +1178,12 @@ fn canceling_a_pressed_slider_releases_it_without_dragging_it() {
|
||||
/// makes the target invisible too: the release is refused rather than clicking through it.
|
||||
#[test]
|
||||
fn a_nonprompt_overlay_that_appears_after_the_press_cancels_the_release() {
|
||||
let mut h = Headless::new(app(), [1000.0, 760.0], 1.0);
|
||||
let files = SyntheticLibrary::new("nonprompt-overlay-after-press");
|
||||
let mut h = Headless::new(files.app(), [1000.0, 760.0], 1.0);
|
||||
h.step();
|
||||
let t = Duration::from_secs(20);
|
||||
assert_eq!(h.request("ui.key", serde_json::json!({"key": ",", "cmd": true}), t)["ok"], true);
|
||||
assert!(h.settle(Duration::from_secs(60)), "the Settings dialog settles");
|
||||
assert!(h.settle(Duration::from_secs(60)), "the Settings dialog settles (pending slots: {:?})", h.app.renderer.pending_slots());
|
||||
let id = "check:settings.confirmDelete";
|
||||
let target = h.app.widgets.iter().rev().find(|(widget, _)| widget == id).map(|(_, rect)| *rect).expect("the checkbox is on screen");
|
||||
let before = h.app.ui.settings.confirm_delete;
|
||||
@@ -1184,11 +1227,12 @@ fn a_nonprompt_overlay_that_appears_after_the_press_cancels_the_release() {
|
||||
/// `ui.clickWidget` counterpart of the press/release cancellation above.
|
||||
#[test]
|
||||
fn a_library_problem_overlay_blocks_a_control_click_in_settings() {
|
||||
let mut h = Headless::new(app(), [1000.0, 760.0], 1.0);
|
||||
let files = SyntheticLibrary::new("library-overlay-settings-click");
|
||||
let mut h = Headless::new(files.app(), [1000.0, 760.0], 1.0);
|
||||
let t = Duration::from_secs(20);
|
||||
h.step();
|
||||
assert!(h.app.run("app.settings", serde_json::json!({})).is_ok(), "Settings opens");
|
||||
assert!(h.settle(Duration::from_secs(60)), "Settings settles");
|
||||
assert!(h.settle(Duration::from_secs(60)), "Settings settles (pending slots: {:?})", h.app.renderer.pending_slots());
|
||||
let id = "check:settings.confirmDelete";
|
||||
let before = h.app.ui.settings.confirm_delete;
|
||||
h.app.library_problem = Some(crate::panels::library_problem::LibraryProblem::new("/no/such/library", "test error"));
|
||||
@@ -1476,10 +1520,15 @@ fn a_prompt_created_after_raw_input_hook_shields_a_held_background_press() {
|
||||
/// success after the prompt filters it.
|
||||
#[test]
|
||||
fn a_synthetic_press_already_delivered_is_canceled_by_a_prompt_created_after_raw_input_hook() {
|
||||
let mut h = Headless::new(app(), [1200.0, 900.0], 1.0);
|
||||
let files = SyntheticLibrary::new("press-after-raw-hook");
|
||||
let mut h = Headless::new(files.app(), [1200.0, 900.0], 1.0);
|
||||
h.step();
|
||||
assert!(h.app.run("app.settings", serde_json::json!({})).is_ok(), "Settings opens");
|
||||
assert!(h.settle(Duration::from_secs(60)), "the Settings dialog settles before its target rect is captured");
|
||||
assert!(
|
||||
h.settle(Duration::from_secs(60)),
|
||||
"the Settings dialog settles before its target rect is captured (pending slots: {:?})",
|
||||
h.app.renderer.pending_slots()
|
||||
);
|
||||
let id = "check:settings.confirmDelete";
|
||||
let target = h.app.widgets.iter().rev().find(|(widget, _)| widget == id).map(|(_, rect)| *rect).expect("the checkbox is on screen");
|
||||
let before = h.app.ui.settings.confirm_delete;
|
||||
@@ -1512,13 +1561,14 @@ fn a_synthetic_press_already_delivered_is_canceled_by_a_prompt_created_after_raw
|
||||
/// stay unchanged.
|
||||
#[test]
|
||||
fn raw_background_gestures_at_a_settled_prompt_do_not_reach_the_covered_view() {
|
||||
let mut h = Headless::new(app(), [1400.0, 900.0], 1.0);
|
||||
let files = SyntheticLibrary::new("raw-gestures-settled-prompt");
|
||||
let mut h = Headless::new(files.app(), [1400.0, 900.0], 1.0);
|
||||
let photo = h.app.session.visible_cloned()[0];
|
||||
let t = Duration::from_secs(20);
|
||||
assert_eq!(h.request("engine.execute", serde_json::json!({"command": "library.select", "params": {"ids": [photo.0]}}), t)["ok"], true);
|
||||
assert_eq!(h.request("ui.set", serde_json::json!({"view": "detail", "right": "edit"}), t)["ok"], true);
|
||||
assert!(h.app.run("app.settings", serde_json::json!({})).is_ok(), "Settings opens");
|
||||
assert!(h.settle(Duration::from_secs(60)), "the detail view and Settings settle");
|
||||
assert!(h.settle(Duration::from_secs(60)), "the detail view and Settings settle (pending slots: {:?})", h.app.renderer.pending_slots());
|
||||
let image = h.app.image_rect.expect("the detail image is on screen");
|
||||
let at = image.min + egui::vec2(12.0, 12.0);
|
||||
let before = h.app.ui.settings.confirm_delete;
|
||||
@@ -1579,13 +1629,14 @@ fn a_prompt_keeps_key_releases_and_clears_held_keys() {
|
||||
/// moves/release are discarded, the pointer is released without a click, and the slider is intact.
|
||||
#[test]
|
||||
fn a_prompt_aborts_an_in_progress_synthetic_drag() {
|
||||
let mut h = Headless::new(app(), [1400.0, 900.0], 1.0);
|
||||
let files = SyntheticLibrary::new("prompt-aborts-drag");
|
||||
let mut h = Headless::new(files.app(), [1400.0, 900.0], 1.0);
|
||||
let photo = h.app.session.visible_cloned()[0];
|
||||
let t = Duration::from_secs(20);
|
||||
assert_eq!(h.request("engine.execute", serde_json::json!({"command": "library.select", "params": {"ids": [photo.0]}}), t)["ok"], true);
|
||||
assert_eq!(h.request("ui.set", serde_json::json!({"view": "detail", "right": "edit"}), t)["ok"], true);
|
||||
h.app.ui.open_sections = vec!["light".to_string()];
|
||||
assert!(h.settle(Duration::from_secs(60)), "the edit panel settles");
|
||||
assert!(h.settle(Duration::from_secs(60)), "the edit panel settles (pending slots: {:?})", h.app.renderer.pending_slots());
|
||||
let id = "slider:light.exposure";
|
||||
let before = h.app.session.develop_of(photo).expect("its develop settings").light.exposure;
|
||||
let (tx, rx) = reply_channel();
|
||||
@@ -1736,12 +1787,13 @@ fn a_settings_notice_blocks_background_input_and_its_ok_button_works() {
|
||||
|
||||
#[test]
|
||||
fn clipping_changed_between_the_move_and_press_is_seen_in_the_latest_registry() {
|
||||
let mut h = Headless::new(app(), [1400.0, 900.0], 1.0);
|
||||
let files = SyntheticLibrary::new("clipping-changed");
|
||||
let mut h = Headless::new(files.app(), [1400.0, 900.0], 1.0);
|
||||
let photo = h.app.session.visible_cloned()[0];
|
||||
let t = Duration::from_secs(20);
|
||||
assert_eq!(h.request("engine.execute", serde_json::json!({"command": "library.select", "params": {"ids": [photo.0]}}), t)["ok"], true);
|
||||
assert_eq!(h.request("ui.set", serde_json::json!({"view": "detail", "right": "edit"}), t)["ok"], true);
|
||||
assert!(h.settle(Duration::from_secs(60)), "the detail view settles");
|
||||
assert!(h.settle(Duration::from_secs(60)), "the detail view settles (pending slots: {:?})", h.app.renderer.pending_slots());
|
||||
let id = "star:bottom:2";
|
||||
let (tx, rx) = reply_channel();
|
||||
h.app.admit_gesture(&h.view.ctx.clone(), "ui.clickWidget".into(), serde_json::json!({"id": id}), tx).expect("the click is admitted");
|
||||
@@ -1811,12 +1863,13 @@ fn queued_pointer_cleanup_requests_a_repaint_without_a_synthetic_event() {
|
||||
|
||||
#[test]
|
||||
fn a_moved_widget_cancels_before_its_first_event_and_leaves_a_later_gesture_queued() {
|
||||
let mut h = Headless::new(app(), [1000.0, 760.0], 1.0);
|
||||
let files = SyntheticLibrary::new("moved-widget-queued");
|
||||
let mut h = Headless::new(files.app(), [1000.0, 760.0], 1.0);
|
||||
h.step();
|
||||
let t = Duration::from_secs(20);
|
||||
let r = h.request("ui.key", serde_json::json!({"key": ",", "cmd": true}), t);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert!(h.settle(Duration::from_secs(60)), "the Settings dialog settles");
|
||||
assert!(h.settle(Duration::from_secs(60)), "the Settings dialog settles (pending slots: {:?})", h.app.renderer.pending_slots());
|
||||
assert!(h.app.widgets.iter().any(|(id, _)| id == "check:settings.confirmDelete"));
|
||||
assert!(h.app.widgets.iter().any(|(id, _)| id == "button:settingsStartup-2"));
|
||||
|
||||
@@ -1862,12 +1915,13 @@ fn a_moved_widget_cancels_before_its_first_event_and_leaves_a_later_gesture_queu
|
||||
|
||||
#[test]
|
||||
fn a_click_whose_target_vanishes_after_the_press_fails_and_releases_the_pointer() {
|
||||
let mut h = Headless::new(app(), [1000.0, 760.0], 1.0);
|
||||
let files = SyntheticLibrary::new("target-vanishes");
|
||||
let mut h = Headless::new(files.app(), [1000.0, 760.0], 1.0);
|
||||
h.step();
|
||||
let t = Duration::from_secs(20);
|
||||
let r = h.request("ui.key", serde_json::json!({"key": ",", "cmd": true}), t);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert!(h.settle(Duration::from_secs(60)), "the Settings dialog settles");
|
||||
assert!(h.settle(Duration::from_secs(60)), "the Settings dialog settles (pending slots: {:?})", h.app.renderer.pending_slots());
|
||||
let before = h.app.ui.settings.confirm_delete;
|
||||
|
||||
let (tx, rx) = reply_channel();
|
||||
@@ -2053,11 +2107,12 @@ fn disclosure_rows_publish_expanded_not_toggled() {
|
||||
/// widget was clipped before delivery is refused, as is one under the non-modal library window.
|
||||
#[test]
|
||||
fn hover_widget_refuses_clipped_and_covered_targets() {
|
||||
let mut h = Headless::new(app(), [1200.0, 900.0], 1.0);
|
||||
let files = SyntheticLibrary::new("hover-refuses-clipped");
|
||||
let mut h = Headless::new(files.app(), [1200.0, 900.0], 1.0);
|
||||
let t = Duration::from_secs(20);
|
||||
h.step();
|
||||
assert!(h.app.run("app.settings", serde_json::json!({})).is_ok(), "Settings opens");
|
||||
assert!(h.settle(Duration::from_secs(60)), "Settings settles");
|
||||
assert!(h.settle(Duration::from_secs(60)), "Settings settles (pending slots: {:?})", h.app.renderer.pending_slots());
|
||||
let id = "check:settings.confirmDelete";
|
||||
assert!(h.app.widget_hits.iter().any(|(widget, _)| widget == id), "{id} has a probe");
|
||||
|
||||
|
||||
@@ -2206,7 +2206,13 @@ fn recognise_faces_stays_off_until_a_model_is_chosen() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-ui-faces-toggle-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
h.app.session.face_models_dir = Some(dir.join("models"));
|
||||
// This tests the recognition toggle, not GPU attachment. Seed the fresh fixture before
|
||||
// loading a model; the mutation command also detaches the process-wide detector.
|
||||
let models = dir.join("models");
|
||||
std::fs::create_dir_all(&models).unwrap();
|
||||
std::fs::write(models.join("settings.json"), br#"{"cpuOnly":true}"#).unwrap();
|
||||
h.app.session.face_models_dir = Some(models);
|
||||
assert_eq!(h.app.session.execute("faces.gpu", &json!({})).unwrap()["cpuOnly"], true);
|
||||
let r = h.request("engine.execute", json!({"command": "app.settings", "params": {"tab": "faces"}}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.settle(SETTLE);
|
||||
|
||||
@@ -12,9 +12,10 @@ const T: Duration = Duration::from_secs(20);
|
||||
const SETTLE: Duration = Duration::from_secs(120);
|
||||
|
||||
fn app() -> Headless {
|
||||
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });
|
||||
// Profile import needs no photos. Avoid unrelated demo renders while testing its notice.
|
||||
let app = LightcraftApp::new(lightcraft_engine::Session::new(), Services { png: None, ..Default::default() });
|
||||
let mut h = Headless::new(app, [1200.0, 800.0], 1.0);
|
||||
h.settle(SETTLE);
|
||||
assert!(h.settle(SETTLE), "the empty library settles before importing profiles");
|
||||
h
|
||||
}
|
||||
|
||||
@@ -27,9 +28,9 @@ fn temp_dir(tag: &str) -> std::path::PathBuf {
|
||||
|
||||
fn import(h: &mut Headless, paths: &[&std::path::Path]) -> serde_json::Value {
|
||||
let paths: Vec<String> = paths.iter().map(|p| p.to_string_lossy().to_string()).collect();
|
||||
let r = h.request("engine.execute", json!({"command": "file.importPresets", "params": {"paths": paths}}), T);
|
||||
h.settle(SETTLE);
|
||||
r
|
||||
// Import and its toast complete synchronously before the control reply. Settling
|
||||
// unrelated rendering afterwards advances simulated time and can expire the notice.
|
||||
h.request("engine.execute", json!({"command": "file.importPresets", "params": {"paths": paths}}), T)
|
||||
}
|
||||
|
||||
fn toast(h: &Headless) -> String {
|
||||
|
||||
@@ -137,23 +137,26 @@ fn saved_limits_round_trip_and_unknown_ones_are_reset() {
|
||||
// ---- Behaviour in the running UI -------------------------------------------------------------
|
||||
|
||||
mod in_the_loupe {
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::headless::Headless;
|
||||
use crate::render::Slot;
|
||||
use crate::render::{RenderOffload, Renderer, Slot};
|
||||
use crate::tests_render_fixtures::{HEIGHT, LONG_EDGE, RenderFixture, WIDTH};
|
||||
use crate::{LightcraftApp, Services};
|
||||
use lightcraft_engine::SourceLevel;
|
||||
use lightcraft_engine::media::{RenderJob, RenderResult, SourceRef};
|
||||
|
||||
const T: Duration = Duration::from_secs(20);
|
||||
const SETTLE: Duration = Duration::from_secs(120);
|
||||
|
||||
/// The demo library in a 1400×900 window, the first photo open in Detail.
|
||||
fn detail() -> (Headless, usize) {
|
||||
detail_in([1400.0, 900.0])
|
||||
}
|
||||
|
||||
fn detail_in(size: [f32; 2]) -> (Headless, usize) {
|
||||
/// The original 24 MP procedural demo library in a 1400×900 window, the first photo open
|
||||
/// in Detail. Kept for `profile_slider_drag`, whose meaning depends on the demo's render cost
|
||||
/// and neighbour prefetch.
|
||||
fn demo_detail_in(size: [f32; 2]) -> (Headless, usize) {
|
||||
let services = Services { png: None, ..Default::default() };
|
||||
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services);
|
||||
let mut h = Headless::new(app, size, 1.0);
|
||||
@@ -168,6 +171,34 @@ mod in_the_loupe {
|
||||
(h, native)
|
||||
}
|
||||
|
||||
/// Two real 6000×4000 file-backed photos in a 1400×900 window, the first open in Detail.
|
||||
/// Destructure the returned tuple immediately, binding the fixture before `Headless`, so the
|
||||
/// app drops first. Keeping the tuple intact would drop its fixture field first.
|
||||
fn detail() -> (RenderFixture, Headless, usize) {
|
||||
detail_in([1400.0, 900.0])
|
||||
}
|
||||
|
||||
fn detail_in(size: [f32; 2]) -> (RenderFixture, Headless, usize) {
|
||||
detail_with(RenderFixture::new("preview", 2), size)
|
||||
}
|
||||
|
||||
fn detail_with(fixture: RenderFixture, size: [f32; 2]) -> (RenderFixture, Headless, usize) {
|
||||
let services = Services { png: None, ..Default::default() };
|
||||
let app = LightcraftApp::new(fixture.session(), services);
|
||||
let mut h = Headless::new(app, size, 1.0);
|
||||
let r = h.request("ui.set", json!({"view": "detail", "right": "none", "filmstrip": false}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.settle(SETTLE);
|
||||
let (native, dims) = {
|
||||
let id = h.app.session.active().expect("a photo is open");
|
||||
let p = h.app.session.catalog.photo(id).expect("it is in the catalog");
|
||||
(p.width.max(p.height) as usize, (p.width, p.height))
|
||||
};
|
||||
assert_eq!(dims, (WIDTH, HEIGHT), "the render fixture is a real 24 MP landscape 3:2 photo");
|
||||
assert_eq!(native, LONG_EDGE, "the render fixture is a real 24 MP photo");
|
||||
(fixture, h, native)
|
||||
}
|
||||
|
||||
fn rendered_long_edge(h: &Headless) -> usize {
|
||||
let t = h.app.renderer.textures.get(&Slot::Main).expect("the loupe rendered");
|
||||
t.size[0].max(t.size[1])
|
||||
@@ -178,7 +209,7 @@ mod in_the_loupe {
|
||||
// cut from the photo's own pixels
|
||||
#[test]
|
||||
fn one_to_one_is_a_canvas_sized_frame_plus_a_native_window() {
|
||||
let (mut h, native) = detail();
|
||||
let (_fx, mut h, native) = detail();
|
||||
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
|
||||
h.settle(SETTLE);
|
||||
let main = rendered_long_edge(&h);
|
||||
@@ -195,7 +226,7 @@ mod in_the_loupe {
|
||||
// overlay swaps the window for one with the other look
|
||||
#[test]
|
||||
fn a_mask_overlay_at_one_to_one_is_a_window_and_keeps_zoom_and_pan() {
|
||||
let (mut h, native) = detail();
|
||||
let (_fx, mut h, native) = detail();
|
||||
for (command, params) in [("panel.masking", json!({})), ("mask.add", json!({"kind": "radial", "center": [0.4, 0.5], "rx": 0.2, "ry": 0.2}))] {
|
||||
let r = h.request("engine.execute", json!({"command": command, "params": params}), T);
|
||||
assert_eq!(r["ok"], true, "{command}: {r}");
|
||||
@@ -233,7 +264,7 @@ mod in_the_loupe {
|
||||
// size (slower, and its detail stops at the frame render's resolution)
|
||||
#[test]
|
||||
fn an_all_masks_overlay_at_one_to_one_is_a_window() {
|
||||
let (mut h, native) = detail();
|
||||
let (_fx, mut h, native) = detail();
|
||||
for (command, params) in [
|
||||
("panel.masking", json!({})),
|
||||
("mask.add", json!({"kind": "radial", "center": [0.4, 0.5], "rx": 0.2, "ry": 0.2})),
|
||||
@@ -266,7 +297,7 @@ mod in_the_loupe {
|
||||
// photo's full size; switching the proof off swaps the window back to the plain look
|
||||
#[test]
|
||||
fn soft_proofing_at_one_to_one_is_a_window() {
|
||||
let (mut h, native) = detail();
|
||||
let (_fx, mut h, native) = detail();
|
||||
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
|
||||
h.settle(SETTLE);
|
||||
let plain = h.app.region_view.expect("a window render without the proof");
|
||||
@@ -288,7 +319,7 @@ mod in_the_loupe {
|
||||
// window render carrying the overlay, over a canvas-sized whole frame, as without it
|
||||
#[test]
|
||||
fn visualize_spots_at_one_to_one_is_a_window() {
|
||||
let (mut h, native) = detail();
|
||||
let (_fx, mut h, native) = detail();
|
||||
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
|
||||
h.settle(SETTLE);
|
||||
let plain = h.app.region_view.expect("a window render without the overlay");
|
||||
@@ -306,7 +337,7 @@ mod in_the_loupe {
|
||||
// Given the user capped the preview at 1600 px, then 1:1 is rendered no larger than that
|
||||
#[test]
|
||||
fn an_explicit_limit_still_caps_one_to_one() {
|
||||
let (mut h, native) = detail();
|
||||
let (_fx, mut h, native) = detail();
|
||||
assert!(native > 1600, "the demo photo must be larger than the cap for this to mean anything (is {native})");
|
||||
h.app.ui.settings.preview_limit = 1600;
|
||||
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
|
||||
@@ -321,7 +352,7 @@ mod in_the_loupe {
|
||||
// Given a fit view, the whole-frame render is all there is: no second render, no original decoded
|
||||
#[test]
|
||||
fn a_fit_view_makes_no_window_render() {
|
||||
let (h, _) = detail();
|
||||
let (_fx, h, _) = detail();
|
||||
assert_eq!(region_tile(&h), None);
|
||||
assert_eq!(h.app.region_view, None);
|
||||
}
|
||||
@@ -330,7 +361,7 @@ mod in_the_loupe {
|
||||
// rendered on its own at the zoom scale and drawn over the whole-frame render
|
||||
#[test]
|
||||
fn a_capped_one_to_one_view_gets_a_sharp_window() {
|
||||
let (mut h, native) = detail();
|
||||
let (_fx, mut h, native) = detail();
|
||||
h.app.ui.settings.preview_limit = 1600;
|
||||
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
|
||||
h.settle(SETTLE);
|
||||
@@ -345,7 +376,7 @@ mod in_the_loupe {
|
||||
// Given 800 % zoom (a frame of 48000 px), the window is rendered and bounded
|
||||
#[test]
|
||||
fn eight_to_one_renders_only_what_is_visible() {
|
||||
let (mut h, native) = detail();
|
||||
let (_fx, mut h, native) = detail();
|
||||
h.app.ui.zoom = crate::state::Zoom::Percent(800.0);
|
||||
h.settle(SETTLE);
|
||||
let (w, hh) = region_tile(&h).expect("a window render");
|
||||
@@ -361,7 +392,7 @@ mod in_the_loupe {
|
||||
// same place of the frame (a shift, a flip or a wrong scale would not)
|
||||
#[test]
|
||||
fn the_window_lines_up_with_the_whole_frame_render() {
|
||||
let (mut h, _) = detail();
|
||||
let (_fx, mut h, _) = detail();
|
||||
h.app.renderer.keep_pixels = true;
|
||||
h.app.ui.settings.preview_limit = 1600;
|
||||
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
|
||||
@@ -369,26 +400,31 @@ mod in_the_loupe {
|
||||
let view = h.app.region_view.expect("a window render");
|
||||
let tile = h.app.renderer.textures.get(&Slot::Region).and_then(|t| t.pixels.clone()).expect("tile pixels");
|
||||
let main = h.app.renderer.textures.get(&Slot::Main).and_then(|t| t.pixels.clone()).expect("main pixels");
|
||||
let (mut sum, mut n) = (0.0f32, 0.0f32);
|
||||
for j in (8..tile.size[1] - 8).step_by(37) {
|
||||
for i in (8..tile.size[0] - 8).step_by(37) {
|
||||
// the same point of the frame in the whole-frame render
|
||||
let u = (view.window.x + i) as f32 / view.full.0 as f32;
|
||||
let v = (view.window.y + j) as f32 / view.full.1 as f32;
|
||||
let (mx, my) =
|
||||
(((u * main.size[0] as f32) as usize).min(main.size[0] - 1), ((v * main.size[1] as f32) as usize).min(main.size[1] - 1));
|
||||
let (a, b) = (tile.pixels[j * tile.size[0] + i], main.pixels[my * main.size[0] + mx]);
|
||||
sum += (0..3).map(|k| (a.to_array()[k] as f32 - b.to_array()[k] as f32).abs()).sum::<f32>() / 3.0;
|
||||
n += 1.0;
|
||||
let mean_difference = |offset: usize| {
|
||||
let (mut sum, mut n) = (0.0f32, 0.0f32);
|
||||
for j in (8..tile.size[1] - 8).step_by(37) {
|
||||
for i in (8..tile.size[0] - 8).step_by(37) {
|
||||
// the same point of the frame in the whole-frame render
|
||||
let u = (view.window.x + i + offset) as f32 / view.full.0 as f32;
|
||||
let v = (view.window.y + j) as f32 / view.full.1 as f32;
|
||||
let (mx, my) =
|
||||
(((u * main.size[0] as f32) as usize).min(main.size[0] - 1), ((v * main.size[1] as f32) as usize).min(main.size[1] - 1));
|
||||
let (a, b) = (tile.pixels[j * tile.size[0] + i], main.pixels[my * main.size[0] + mx]);
|
||||
sum += (0..3).map(|k| (a.to_array()[k] as f32 - b.to_array()[k] as f32).abs()).sum::<f32>() / 3.0;
|
||||
n += 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(sum / n < 6.0, "mean difference {:.2} / 255 between the window and the frame render", sum / n);
|
||||
sum / n
|
||||
};
|
||||
let difference = mean_difference(0);
|
||||
assert!(difference < 6.0, "mean difference {:.2} / 255 between the window and the frame render", difference);
|
||||
assert!(mean_difference(16) > 6.0, "the fixture must expose a 16-pixel horizontal misalignment");
|
||||
}
|
||||
|
||||
// Given a Fit view, a slider drag (whose Main draft is at 0.6 scale) does not start window renders
|
||||
#[test]
|
||||
fn a_slider_drag_at_fit_makes_no_window_render() {
|
||||
let (mut h, _) = detail();
|
||||
let (_fx, mut h, _) = detail();
|
||||
h.app.session.begin_interaction("Exposure").unwrap();
|
||||
for _ in 0..5 {
|
||||
h.step();
|
||||
@@ -400,7 +436,7 @@ mod in_the_loupe {
|
||||
// Given a zoomed view with a window render, when I zoom back to Fit, then the window's texture is freed
|
||||
#[test]
|
||||
fn the_window_texture_is_freed_when_it_is_not_needed() {
|
||||
let (mut h, _) = detail();
|
||||
let (_fx, mut h, _) = detail();
|
||||
h.app.ui.settings.preview_limit = 1600;
|
||||
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
|
||||
h.settle(SETTLE);
|
||||
@@ -415,7 +451,7 @@ mod in_the_loupe {
|
||||
// too (while one is held the app never counts as idle and keeps the GPU copy of the original)
|
||||
#[test]
|
||||
fn leaving_a_zoomed_detail_view_frees_its_windows() {
|
||||
let (mut h, _) = detail();
|
||||
let (_fx, mut h, _) = detail();
|
||||
h.app.ui.before_after = crate::state::BeforeAfter::SideBySide;
|
||||
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
|
||||
h.settle(SETTLE);
|
||||
@@ -430,7 +466,7 @@ mod in_the_loupe {
|
||||
// is no longer current and is replaced by one for the new look
|
||||
#[test]
|
||||
fn an_edit_replaces_the_window() {
|
||||
let (mut h, _) = detail();
|
||||
let (_fx, mut h, _) = detail();
|
||||
h.app.ui.settings.preview_limit = 1600;
|
||||
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
|
||||
h.settle(SETTLE);
|
||||
@@ -446,7 +482,7 @@ mod in_the_loupe {
|
||||
// Given a photo with spot removal, a zoomed view still gets its sharp window
|
||||
#[test]
|
||||
fn a_photo_with_spots_gets_a_window_too() {
|
||||
let (mut h, _) = detail();
|
||||
let (_fx, mut h, _) = detail();
|
||||
let id = h.app.session.active().unwrap();
|
||||
let mut s = (*h.app.session.develop_of(id).unwrap()).clone();
|
||||
s.spots.push(lightcraft_develop::Spot {
|
||||
@@ -466,7 +502,7 @@ mod in_the_loupe {
|
||||
// I zoom to 1:1 and 8:1, then no texture the loupe holds is larger than that
|
||||
#[test]
|
||||
fn no_loupe_texture_exceeds_the_gpu_limit() {
|
||||
let (mut h, _) = detail();
|
||||
let (_fx, mut h, _) = detail();
|
||||
h.max_texture_side = 2048;
|
||||
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
|
||||
h.settle(SETTLE);
|
||||
@@ -486,7 +522,7 @@ mod in_the_loupe {
|
||||
// the whole canvas: no strip of the magnified whole-frame render beside it
|
||||
#[test]
|
||||
fn a_window_covers_a_canvas_wider_than_2048_px() {
|
||||
let (mut h, native) = detail_in([2600.0, 1200.0]);
|
||||
let (_fx, mut h, native) = detail_in([2600.0, 1200.0]);
|
||||
assert!(native > 2600, "the photo is wider than the canvas at 1:1: {native}");
|
||||
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
|
||||
h.settle(SETTLE);
|
||||
@@ -507,7 +543,7 @@ mod in_the_loupe {
|
||||
// canvas, not the zoom: a draft of the whole frame at canvas scale and a draft of the window
|
||||
#[test]
|
||||
fn a_slider_drag_when_zoomed_in_renders_canvas_sized_drafts() {
|
||||
let (mut h, native) = detail();
|
||||
let (_fx, mut h, native) = detail();
|
||||
h.app.ui.zoom = crate::state::Zoom::Percent(400.0);
|
||||
h.settle(SETTLE);
|
||||
h.app.session.begin_interaction("Exposure").unwrap();
|
||||
@@ -542,7 +578,7 @@ mod in_the_loupe {
|
||||
// the GPU device up front (the loupe never waits for it): with one, it is the earlier
|
||||
// drag's time that makes this drag small
|
||||
let _ = lightcraft_engine::gpu::available();
|
||||
let (mut h, _) = detail();
|
||||
let (_fx, mut h, _) = detail();
|
||||
let id = h.app.session.active().expect("a photo is open");
|
||||
h.app.drag_scale.remember_slow(id, "Exposure");
|
||||
let (main, small) = drag_exposure(&mut h);
|
||||
@@ -556,7 +592,7 @@ mod in_the_loupe {
|
||||
// the GPU device up front (the loupe never waits for it): with one, this is the case where
|
||||
// the GPU is there and the render still goes to the CPU
|
||||
let _ = lightcraft_engine::gpu::available();
|
||||
let (mut h, _) = detail();
|
||||
let (_fx, mut h, _) = detail();
|
||||
let lut = lightcraft_pipeline::ProofLut::new(2, vec![[0.5, 0.5, 0.5, 0.0]; 8]).expect("a valid table");
|
||||
h.app.ui.proof.profile = lightcraft_pipeline::register_proof_profile(0x5eed_9f01, lut);
|
||||
h.app.ui.soft_proof = true;
|
||||
@@ -573,7 +609,7 @@ mod in_the_loupe {
|
||||
// Before is not a 2560 px stand-in beside a full resolution After
|
||||
#[test]
|
||||
fn side_by_side_at_one_to_one_compares_like_with_like() {
|
||||
let (mut h, native) = detail();
|
||||
let (_fx, mut h, native) = detail();
|
||||
h.app.ui.before_after = crate::state::BeforeAfter::SideBySide;
|
||||
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
|
||||
h.settle(SETTLE);
|
||||
@@ -592,7 +628,7 @@ mod in_the_loupe {
|
||||
// beside the After's); both windows still arrive
|
||||
#[test]
|
||||
fn side_by_side_at_one_to_one_decodes_the_original_once() {
|
||||
let (mut h, _) = detail();
|
||||
let (_fx, mut h, _) = detail();
|
||||
let id = h.app.session.active().expect("a photo is open");
|
||||
h.app.ui.before_after = crate::state::BeforeAfter::SideBySide;
|
||||
h.app.ui.zoom = crate::state::Zoom::Percent(100.0);
|
||||
@@ -612,7 +648,7 @@ mod in_the_loupe {
|
||||
// Given the Before view alone (\), only the Before gets a window
|
||||
#[test]
|
||||
fn the_original_view_windows_only_the_before() {
|
||||
let (mut h, _) = detail();
|
||||
let (_fx, mut h, _) = detail();
|
||||
h.app.ui.before_after = crate::state::BeforeAfter::Original;
|
||||
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
|
||||
h.settle(SETTLE);
|
||||
@@ -624,7 +660,7 @@ mod in_the_loupe {
|
||||
// Given a wipe (Split), both sides are windows, drawn over the same rect
|
||||
#[test]
|
||||
fn a_wipe_windows_both_sides() {
|
||||
let (mut h, _) = detail();
|
||||
let (_fx, mut h, _) = detail();
|
||||
h.app.ui.before_after = crate::state::BeforeAfter::Split;
|
||||
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
|
||||
h.settle(SETTLE);
|
||||
@@ -635,7 +671,7 @@ mod in_the_loupe {
|
||||
// Given the Before view turned off, its window is freed
|
||||
#[test]
|
||||
fn leaving_before_after_frees_the_before_window() {
|
||||
let (mut h, _) = detail();
|
||||
let (_fx, mut h, _) = detail();
|
||||
h.app.ui.before_after = crate::state::BeforeAfter::SideBySide;
|
||||
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
|
||||
h.settle(SETTLE);
|
||||
@@ -651,7 +687,7 @@ mod in_the_loupe {
|
||||
// no stream of renders of every size in between, and the sharp window arrives once it stops
|
||||
#[test]
|
||||
fn a_pinch_holds_the_render_sizes_until_it_stops() {
|
||||
let (mut h, native) = detail();
|
||||
let (_fx, mut h, native) = detail();
|
||||
let main_at_fit = rendered_long_edge(&h);
|
||||
let at = h.app.canvas_rect.unwrap().center();
|
||||
h.request("ui.move", json!({"x": at.x, "y": at.y}), T);
|
||||
@@ -674,7 +710,7 @@ mod in_the_loupe {
|
||||
// differently, #280), no window is rendered: its coordinates are the frame's
|
||||
#[test]
|
||||
fn no_window_over_a_picture_that_is_not_the_frame() {
|
||||
let (mut h, _) = detail();
|
||||
let (_fx, mut h, _) = detail();
|
||||
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
|
||||
h.settle(SETTLE);
|
||||
assert!(h.app.region_view.is_some());
|
||||
@@ -699,7 +735,7 @@ mod in_the_loupe {
|
||||
([2800.0, 1700.0], 400.0, false),
|
||||
([1400.0, 900.0], 100.0, true),
|
||||
] {
|
||||
let (mut h, native) = detail_in(size);
|
||||
let (mut h, native) = demo_detail_in(size);
|
||||
h.app.ui.soft_proof = proof;
|
||||
h.app.ui.zoom = if percent == 0.0 { crate::state::Zoom::Fit } else { crate::state::Zoom::Percent(percent) };
|
||||
h.settle(SETTLE);
|
||||
@@ -739,25 +775,188 @@ mod in_the_loupe {
|
||||
}
|
||||
}
|
||||
|
||||
// Given a slider drag that outpaces the renders (each tick supersedes the last), the original
|
||||
// the window was cut from is kept once it is decoded: not decoded again for every tick
|
||||
#[test]
|
||||
fn a_drag_keeps_the_decoded_original_even_when_every_result_is_superseded() {
|
||||
let (mut h, _) = detail();
|
||||
assert_eq!(h.app.session.memory_report().full_source.bytes, 0, "fit needed no original");
|
||||
h.app.ui.zoom = crate::state::Zoom::Percent(400.0);
|
||||
h.app.session.begin_interaction("Exposure").unwrap();
|
||||
let mut ticks = 0;
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
|
||||
while !h.app.renderer.textures.contains_key(&Slot::Region) && std::time::Instant::now() < deadline {
|
||||
let v = (ticks % 40) as f64 * 0.02;
|
||||
h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": v}}), T);
|
||||
/// Work held by a test offload. [`RegionGate`] keeps `Slot::Region` jobs here; [`Held`]
|
||||
/// keeps every job. `finished` is what the renderer's next `poll` collects.
|
||||
#[derive(Default)]
|
||||
struct OffloadWork {
|
||||
jobs: Vec<RenderJob>,
|
||||
finished: Vec<(Slot, RenderResult, f64)>,
|
||||
}
|
||||
|
||||
fn take_job(work: &Rc<RefCell<OffloadWork>>) -> RenderJob {
|
||||
let mut w = work.borrow_mut();
|
||||
assert!(!w.jobs.is_empty(), "a render job was handed to the offload");
|
||||
w.jobs.remove(0)
|
||||
}
|
||||
|
||||
/// The next window job the UI asked for, stepping a few frames if the request is still a frame
|
||||
/// away (deterministic frames, no wall-clock wait).
|
||||
fn next_region_job(h: &mut Headless, work: &Rc<RefCell<OffloadWork>>) -> RenderJob {
|
||||
for _ in 0..3 {
|
||||
if !work.borrow().jobs.is_empty() {
|
||||
break;
|
||||
}
|
||||
h.step();
|
||||
ticks += 1;
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
assert!(h.app.renderer.textures.contains_key(&Slot::Region), "a window arrived");
|
||||
assert!(h.app.session.memory_report().full_source.bytes > 0, "the original stayed in the cache while the drag went on");
|
||||
take_job(work)
|
||||
}
|
||||
|
||||
fn deliver(work: &Rc<RefCell<OffloadWork>>, slot: Slot, result: RenderResult) {
|
||||
work.borrow_mut().finished.push((slot, result, 0.0));
|
||||
}
|
||||
|
||||
/// Holds the window jobs and runs every other job synchronously, returning it through
|
||||
/// `finished`: the UI keeps its real `request_window` / `defer_window` / draft path, but the
|
||||
/// window's original has not arrived until the test says so.
|
||||
struct RegionGate(Rc<RefCell<OffloadWork>>);
|
||||
|
||||
impl RenderOffload for RegionGate {
|
||||
fn try_start(&mut self, slot: Slot, job: RenderJob) -> Option<RenderJob> {
|
||||
if slot == Slot::Region {
|
||||
self.0.borrow_mut().jobs.push(job);
|
||||
} else {
|
||||
let result = job.run();
|
||||
self.0.borrow_mut().finished.push((slot, result, 0.0));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn finished(&mut self) -> Vec<(Slot, RenderResult, f64)> {
|
||||
std::mem::take(&mut self.0.borrow_mut().finished)
|
||||
}
|
||||
}
|
||||
|
||||
/// Holds every job for the test to run and deliver explicitly (the standalone collector test).
|
||||
struct Held(Rc<RefCell<OffloadWork>>);
|
||||
|
||||
impl RenderOffload for Held {
|
||||
fn try_start(&mut self, _slot: Slot, job: RenderJob) -> Option<RenderJob> {
|
||||
self.0.borrow_mut().jobs.push(job);
|
||||
None
|
||||
}
|
||||
|
||||
fn finished(&mut self) -> Vec<(Slot, RenderResult, f64)> {
|
||||
std::mem::take(&mut self.0.borrow_mut().finished)
|
||||
}
|
||||
}
|
||||
|
||||
// Given a 400 % view of a real 24 MP photo, when the first window is decoded and accepted and
|
||||
// later drag ticks supersede each other, then the original stays in the cache and every later
|
||||
// window is cut from it. The first accepted result is the actual Detail wiring's job: the
|
||||
// window `request_window` asked for (respecting `defer_window`), drafted while the drag runs.
|
||||
#[test]
|
||||
fn a_drag_keeps_the_decoded_original_after_the_window_is_accepted() {
|
||||
let (_fx, mut h, native) = detail();
|
||||
let id = h.app.session.active().expect("a photo");
|
||||
assert_eq!(native, WIDTH as usize);
|
||||
assert_eq!(h.app.session.memory_report().full_source.bytes, 0, "fit needed no original");
|
||||
h.app.session.media.set_on_screen(Some(id));
|
||||
let work = Rc::new(RefCell::new(OffloadWork::default()));
|
||||
h.app.renderer.set_offload(Box::new(RegionGate(work.clone())));
|
||||
let discarded = h.app.renderer.discarded;
|
||||
h.app.ui.zoom = crate::state::Zoom::Percent(400.0);
|
||||
h.app.session.begin_interaction("Exposure").expect("a drag");
|
||||
|
||||
let first = next_region_job(&mut h, &work);
|
||||
let (first_key, first_request) = (first.key, first.request_id);
|
||||
let view = h.app.region_view.expect("Detail asked for a window");
|
||||
assert_eq!(view.photo, id);
|
||||
assert_eq!(view.full, (WIDTH as usize, HEIGHT as usize), "at 400 % the window's frame is the photo's own pixels");
|
||||
assert!(view.window.w >= 1300 && view.window.h >= 700, "the window covers the canvas: {:?}", view.window);
|
||||
assert!(view.window.x + view.window.w <= view.full.0 && view.window.y + view.window.h <= view.full.1, "inside the frame: {:?}", view.window);
|
||||
assert_eq!(first.request.window, Some(view.window), "the job is the window on screen");
|
||||
assert_eq!((first.request.max_w, first.request.max_h), view.full, "the job renders the frame the view uses");
|
||||
assert_eq!(first.request.quality, lightcraft_pipeline::Quality::Draft, "a drag drafts the window");
|
||||
assert_eq!(first.level, SourceLevel::Full);
|
||||
assert!(matches!(&first.source, SourceRef::File { .. }), "the first window decodes the real file");
|
||||
assert_ne!(first_request, 0, "the renderer gave the request its own identity");
|
||||
|
||||
// An edit changes the desired window key while the first original is still held.
|
||||
// Detail must defer a second decode until this first result has populated the cache.
|
||||
let reply = h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.5}}), T);
|
||||
assert_eq!(reply["ok"], true);
|
||||
h.step();
|
||||
assert!(work.borrow().jobs.is_empty(), "a changed window waits for the first original");
|
||||
assert_eq!(h.app.renderer.wanted(Slot::Region), Some(first_key));
|
||||
assert_eq!(h.app.session.memory_report().full_source.bytes, 0);
|
||||
|
||||
deliver(&work, Slot::Region, first.run());
|
||||
h.step();
|
||||
assert!(h.app.region_view.is_some());
|
||||
assert_eq!(h.app.renderer.textures.get(&Slot::Region).map(|t| t.key), Some(first_key), "the first window arrived");
|
||||
assert!(h.app.session.memory_report().full_source.bytes >= WIDTH as usize * HEIGHT as usize * 3 * 4);
|
||||
assert!(h.app.session.media.has_source(id, SourceLevel::Full), "the original was accepted before any supersession");
|
||||
|
||||
let second = next_region_job(&mut h, &work);
|
||||
let (second_key, second_request) = (second.key, second.request_id);
|
||||
assert_ne!(second_key, first_key);
|
||||
assert_ne!(second_request, first_request);
|
||||
assert_eq!(h.app.renderer.wanted(Slot::Region), Some(second_key), "the newer window is pending");
|
||||
assert!(matches!(&second.source, SourceRef::Loaded(_)), "the newer window reuses the accepted original");
|
||||
|
||||
let reply = h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 1.0}}), T);
|
||||
assert_eq!(reply["ok"], true);
|
||||
let third = next_region_job(&mut h, &work);
|
||||
assert_ne!(third.key, second_key);
|
||||
assert_eq!(h.app.renderer.wanted(Slot::Region), Some(third.key));
|
||||
|
||||
deliver(&work, Slot::Region, second.run());
|
||||
h.step();
|
||||
assert_eq!(h.app.renderer.textures.get(&Slot::Region).map(|t| t.key), Some(second_key), "the superseded draft still reached the screen");
|
||||
assert_eq!(h.app.renderer.wanted(Slot::Region), Some(third.key), "the newest request is still pending");
|
||||
assert_eq!(h.app.renderer.discarded, discarded, "the superseded draft was accepted, not discarded");
|
||||
assert!(h.app.session.memory_report().full_source.bytes >= WIDTH as usize * HEIGHT as usize * 3 * 4);
|
||||
assert!(matches!(&third.source, SourceRef::Loaded(_)), "the pending window still reuses the original");
|
||||
}
|
||||
|
||||
// The collector's accept branch on its own: a superseded window result still carries the
|
||||
// original it decoded, and `Renderer::poll` must insert it (`session.accept(&r)` at
|
||||
// render.rs:911) although a newer request for the same slot is pending. The Detail wiring
|
||||
// above can only reach this once the first original is held; this standalone test proves the
|
||||
// cache reuse without depending on UI timing.
|
||||
#[test]
|
||||
fn a_superseded_window_result_keeps_the_original_it_decoded() {
|
||||
let fixture = RenderFixture::new("preview-limit-superseded", 1);
|
||||
let mut session = fixture.session();
|
||||
let id = session.active().expect("a photo");
|
||||
session.media.set_on_screen(Some(id));
|
||||
let (full_w, full_h) = (WIDTH as usize, HEIGHT as usize);
|
||||
let window = lightcraft_pipeline::PixelWindow { x: 1000, y: 800, w: 1300, h: 700 };
|
||||
let next_window = lightcraft_pipeline::PixelWindow { x: 1200, y: 800, w: 1300, h: 700 };
|
||||
let mut renderer = Renderer::with_worker_threads(1);
|
||||
let work = Rc::new(RefCell::new(OffloadWork::default()));
|
||||
renderer.set_offload(Box::new(Held(work.clone())));
|
||||
let ctx = egui::Context::default();
|
||||
|
||||
let first = session.region_job(id, full_w, full_h, window, true).expect("the first window");
|
||||
renderer.request(Slot::Region, first, 100);
|
||||
let first = take_job(&work);
|
||||
let old_key = first.key;
|
||||
let old_request = first.request_id;
|
||||
|
||||
let second = session.region_job(id, full_w, full_h, next_window, true).expect("the newer window");
|
||||
renderer.request(Slot::Region, second, 100);
|
||||
let second = take_job(&work);
|
||||
let new_key = second.key;
|
||||
assert_ne!(new_key, old_key, "the newer window has its own identity");
|
||||
assert_eq!(renderer.wanted(Slot::Region), Some(new_key));
|
||||
|
||||
let old = first.run();
|
||||
assert!(old.rendered.is_ok(), "{:?}", old.rendered.as_ref().err());
|
||||
assert_eq!(old.request_id, old_request);
|
||||
assert_eq!(old.level, SourceLevel::Full);
|
||||
let loaded = old.loaded.as_ref().expect("the first window decoded the original");
|
||||
assert_eq!((loaded.image.width, loaded.image.height), (WIDTH as usize, HEIGHT as usize));
|
||||
deliver(&work, Slot::Region, old);
|
||||
renderer.poll(&ctx, &mut session);
|
||||
|
||||
assert_eq!(renderer.textures.get(&Slot::Region).map(|t| t.key), Some(old_key));
|
||||
assert_eq!(renderer.wanted(Slot::Region), Some(new_key));
|
||||
assert_eq!(renderer.discarded, 0, "the superseded result was accepted, not discarded");
|
||||
assert!(session.memory_report().full_source.bytes >= full_w * full_h * 3 * 4);
|
||||
assert!(session.media.has_source(id, SourceLevel::Full));
|
||||
let reused = session.region_job(id, full_w, full_h, window, true).expect("another window");
|
||||
assert!(matches!(&reused.source, SourceRef::Loaded(_)), "the accepted original is reused");
|
||||
}
|
||||
|
||||
// Given an original bigger than the cache budget (a 64 MP raw's 797 MB exceeds the default
|
||||
@@ -765,7 +964,7 @@ mod in_the_loupe {
|
||||
// grid it is let go
|
||||
#[test]
|
||||
fn a_zoomed_original_over_the_budget_stays_until_the_view_changes() {
|
||||
let (mut h, _) = detail();
|
||||
let (_fx, mut h, _) = detail();
|
||||
h.app.session.media.set_budget(16 << 20);
|
||||
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
|
||||
h.settle(SETTLE);
|
||||
@@ -794,7 +993,7 @@ mod in_the_loupe {
|
||||
#[test]
|
||||
fn a_wipe_paints_the_before_window_over_its_stand_in() {
|
||||
for mode in [crate::state::BeforeAfter::Split, crate::state::BeforeAfter::SplitTopBottom] {
|
||||
let (mut h, _) = detail();
|
||||
let (_fx, mut h, _) = detail();
|
||||
h.app.ui.before_after = mode;
|
||||
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
|
||||
h.settle(SETTLE);
|
||||
@@ -821,7 +1020,7 @@ mod in_the_loupe {
|
||||
// everywhere (not only left of the line)
|
||||
#[test]
|
||||
fn holding_backslash_in_a_wipe_paints_the_before_window_across_the_canvas() {
|
||||
let (mut h, _) = detail();
|
||||
let (_fx, mut h, _) = detail();
|
||||
h.app.ui.before_after = crate::state::BeforeAfter::Split;
|
||||
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
|
||||
h.settle(SETTLE);
|
||||
@@ -848,7 +1047,7 @@ mod in_the_loupe {
|
||||
// margin (a window is wider than what its pane shows) does not spill into the neighbour
|
||||
#[test]
|
||||
fn side_by_side_windows_stay_in_their_panes() {
|
||||
let (mut h, _) = detail_in([1800.0, 900.0]);
|
||||
let (_fx, mut h, _) = detail_in([1800.0, 900.0]);
|
||||
h.app.ui.before_after = crate::state::BeforeAfter::SideBySide;
|
||||
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
|
||||
h.settle(SETTLE);
|
||||
@@ -864,7 +1063,7 @@ mod in_the_loupe {
|
||||
// apart: the job key is the same, and one tile must not replace the other
|
||||
#[test]
|
||||
fn identical_before_and_after_keep_a_tile_each() {
|
||||
let (mut h, _) = detail();
|
||||
let (_fx, mut h, _) = detail();
|
||||
h.app.ui.before_after = crate::state::BeforeAfter::SideBySide;
|
||||
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
|
||||
h.settle(SETTLE);
|
||||
@@ -875,7 +1074,7 @@ mod in_the_loupe {
|
||||
// to the whole frame at the drawn size, and tries windows again when the look changes
|
||||
#[test]
|
||||
fn a_refused_window_falls_back_to_a_full_size_render() {
|
||||
let (mut h, native) = detail();
|
||||
let (_fx, mut h, native) = detail();
|
||||
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
|
||||
h.settle(SETTLE);
|
||||
assert!(h.app.region_view.is_some());
|
||||
@@ -894,7 +1093,7 @@ mod in_the_loupe {
|
||||
// pinch stops: a new, ever wider window every frame is not asked for
|
||||
#[test]
|
||||
fn pinching_out_asks_for_no_new_windows() {
|
||||
let (mut h, _) = detail();
|
||||
let (_fx, mut h, _) = detail();
|
||||
h.app.ui.zoom = crate::state::Zoom::Percent(400.0);
|
||||
h.settle(SETTLE);
|
||||
let key = h.app.region_view.expect("a window").key;
|
||||
@@ -910,7 +1109,7 @@ mod in_the_loupe {
|
||||
// slider drag still reuses them: they are not cleared every frame
|
||||
#[test]
|
||||
fn a_drag_over_the_stage_budget_does_not_clear_the_open_views() {
|
||||
let (mut h, _) = detail();
|
||||
let (_fx, mut h, _) = detail();
|
||||
h.app.ui.zoom = crate::state::Zoom::Percent(100.0);
|
||||
h.settle(SETTLE);
|
||||
let held = |h: &Headless| {
|
||||
@@ -947,7 +1146,7 @@ mod in_the_loupe {
|
||||
// whole-frame render, magnified and blurred, was shown)
|
||||
#[test]
|
||||
fn a_display_profile_keeps_the_sharp_window_at_one_to_one() {
|
||||
let (mut h, native) = detail();
|
||||
let (_fx, mut h, native) = detail();
|
||||
use_display_profile(&mut h);
|
||||
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
|
||||
h.settle(SETTLE);
|
||||
@@ -963,7 +1162,7 @@ mod in_the_loupe {
|
||||
// …and in Compare, each pane's window too
|
||||
#[test]
|
||||
fn a_display_profile_keeps_the_compare_windows_at_one_to_one() {
|
||||
let (mut h, _) = detail();
|
||||
let (_fx, mut h, _) = detail();
|
||||
use_display_profile(&mut h);
|
||||
let r = h.request("engine.execute", json!({"command": "view.compare"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
@@ -982,7 +1181,7 @@ mod in_the_loupe {
|
||||
// a display
|
||||
#[test]
|
||||
fn with_the_screen_profile_the_window_is_drawn() {
|
||||
let (mut h, native) = detail();
|
||||
let (_fx, mut h, native) = detail();
|
||||
h.app.services.screen_profile = Some(Box::new(|| Some(lightcraft_codecs::icc::write_named(lightcraft_codecs::NamedSpace::DisplayP3))));
|
||||
h.step();
|
||||
assert!(h.app.ui.settings.display_profile.is_empty(), "no profile chosen");
|
||||
@@ -1000,20 +1199,20 @@ mod in_the_loupe {
|
||||
/// RAM or more: room for both panes' 24 MP originals (288 MB each as linear float).
|
||||
const COMPARE_CACHE: usize = 768 << 20;
|
||||
|
||||
/// Compare view of the demo library at `zoom`, settled.
|
||||
/// Compare view of the render fixture at `zoom`, settled.
|
||||
///
|
||||
/// The session's cache budget is set here rather than taken from the process-wide memory
|
||||
/// budget, which other tests change while they run (Settings ▸ Performance ▸ 1 GB leaves a
|
||||
/// 512 MiB share, too small for two originals, so one pane's was evicted).
|
||||
fn compare_at(zoom: crate::state::Zoom) -> Headless {
|
||||
let (mut h, _) = detail();
|
||||
fn compare_at(zoom: crate::state::Zoom) -> (RenderFixture, Headless) {
|
||||
let (fixture, mut h, _) = detail();
|
||||
h.app.session.media.set_budget(COMPARE_CACHE);
|
||||
let r = h.request("engine.execute", json!({"command": "view.compare"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert_eq!(h.app.ui.view, crate::state::ViewMode::Compare);
|
||||
h.app.ui.zoom = zoom;
|
||||
h.settle(SETTLE);
|
||||
h
|
||||
(fixture, h)
|
||||
}
|
||||
|
||||
fn native_of(h: &Headless, id: lightcraft_catalog::PhotoId) -> usize {
|
||||
@@ -1025,7 +1224,7 @@ mod in_the_loupe {
|
||||
// cut from its own photo's pixels, not the 2560 px whole-frame render magnified
|
||||
#[test]
|
||||
fn compare_at_one_to_one_gets_a_sharp_window_per_pane() {
|
||||
let h = compare_at(crate::state::Zoom::Percent(100.0));
|
||||
let (_fx, h) = compare_at(crate::state::Zoom::Percent(100.0));
|
||||
let (sel, cand) = h.app.ui.compare.map(|(a, b)| (lightcraft_catalog::PhotoId(a), lightcraft_catalog::PhotoId(b))).expect("a pair");
|
||||
for (i, id) in [(0u8, sel), (1u8, cand)] {
|
||||
let whole = h.app.renderer.textures.get(&Slot::Compare(i)).expect("the whole-frame render");
|
||||
@@ -1043,7 +1242,7 @@ mod in_the_loupe {
|
||||
// Given Compare at fit, nothing more is rendered than before windows
|
||||
#[test]
|
||||
fn compare_at_fit_makes_no_window() {
|
||||
let h = compare_at(crate::state::Zoom::Fit);
|
||||
let (_fx, h) = compare_at(crate::state::Zoom::Fit);
|
||||
assert_eq!(h.app.compare_windows, [None, None]);
|
||||
assert!(!h.app.renderer.textures.contains_key(&Slot::CompareRegion(0)) && !h.app.renderer.textures.contains_key(&Slot::CompareRegion(1)));
|
||||
}
|
||||
@@ -1052,7 +1251,7 @@ mod in_the_loupe {
|
||||
// are freed, and back in Detail only one original is kept decoded
|
||||
#[test]
|
||||
fn leaving_a_zoomed_compare_frees_its_windows() {
|
||||
let mut h = compare_at(crate::state::Zoom::Percent(100.0));
|
||||
let (_fx, mut h) = compare_at(crate::state::Zoom::Percent(100.0));
|
||||
assert!(h.app.renderer.textures.contains_key(&Slot::CompareRegion(1)));
|
||||
h.app.ui.zoom = crate::state::Zoom::Fit;
|
||||
h.settle(SETTLE);
|
||||
@@ -1072,7 +1271,7 @@ mod in_the_loupe {
|
||||
// at the same place of the frame, and it is painted over that render, inside the pane
|
||||
#[test]
|
||||
fn a_compare_window_lines_up_with_its_pane() {
|
||||
let (mut h, _) = detail();
|
||||
let (_fx, mut h, _) = detail();
|
||||
h.app.renderer.keep_pixels = true;
|
||||
h.request("engine.execute", json!({"command": "view.compare"}), T);
|
||||
h.app.ui.zoom = crate::state::Zoom::Percent(100.0);
|
||||
@@ -1082,19 +1281,24 @@ mod in_the_loupe {
|
||||
let view = h.app.compare_windows[i as usize].expect("a window");
|
||||
let tile = h.app.renderer.textures.get(&Slot::CompareRegion(i)).and_then(|t| t.pixels.clone()).expect("tile pixels");
|
||||
let main = h.app.renderer.textures.get(&Slot::Compare(i)).and_then(|t| t.pixels.clone()).expect("pane pixels");
|
||||
let (mut sum, mut n) = (0.0f32, 0.0f32);
|
||||
for j in (8..tile.size[1] - 8).step_by(37) {
|
||||
for x in (8..tile.size[0] - 8).step_by(37) {
|
||||
let u = (view.window.x + x) as f32 / view.full.0 as f32;
|
||||
let v = (view.window.y + j) as f32 / view.full.1 as f32;
|
||||
let (mx, my) =
|
||||
(((u * main.size[0] as f32) as usize).min(main.size[0] - 1), ((v * main.size[1] as f32) as usize).min(main.size[1] - 1));
|
||||
let (a, b) = (tile.pixels[j * tile.size[0] + x], main.pixels[my * main.size[0] + mx]);
|
||||
sum += (0..3).map(|k| (a.to_array()[k] as f32 - b.to_array()[k] as f32).abs()).sum::<f32>() / 3.0;
|
||||
n += 1.0;
|
||||
let mean_difference = |offset: usize| {
|
||||
let (mut sum, mut n) = (0.0f32, 0.0f32);
|
||||
for j in (8..tile.size[1] - 8).step_by(37) {
|
||||
for x in (8..tile.size[0] - 8).step_by(37) {
|
||||
let u = (view.window.x + x + offset) as f32 / view.full.0 as f32;
|
||||
let v = (view.window.y + j) as f32 / view.full.1 as f32;
|
||||
let (mx, my) =
|
||||
(((u * main.size[0] as f32) as usize).min(main.size[0] - 1), ((v * main.size[1] as f32) as usize).min(main.size[1] - 1));
|
||||
let (a, b) = (tile.pixels[j * tile.size[0] + x], main.pixels[my * main.size[0] + mx]);
|
||||
sum += (0..3).map(|k| (a.to_array()[k] as f32 - b.to_array()[k] as f32).abs()).sum::<f32>() / 3.0;
|
||||
n += 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(sum / n < 6.0, "pane {i}: mean difference {:.2} / 255 between the window and the pane's render", sum / n);
|
||||
sum / n
|
||||
};
|
||||
let difference = mean_difference(0);
|
||||
assert!(difference < 6.0, "pane {i}: mean difference {:.2} / 255 between the window and the pane's render", difference);
|
||||
assert!(mean_difference(16) > 6.0, "the fixture must expose a 16-pixel horizontal misalignment");
|
||||
let (whole, window) = (drawn(&h, Slot::Compare(i)), drawn(&h, Slot::CompareRegion(i)));
|
||||
assert!(!whole.is_empty() && !window.is_empty(), "pane {i}: both are painted");
|
||||
assert!(window[0].0 > whole[0].0, "pane {i}: the window is painted over the whole-frame render");
|
||||
@@ -1109,7 +1313,7 @@ mod in_the_loupe {
|
||||
// drag on the active photo asks for no new window until it ends
|
||||
#[test]
|
||||
fn reference_view_at_one_to_one_gets_windows_and_a_drag_keeps_them() {
|
||||
let (mut h, _) = detail();
|
||||
let (_fx, mut h, _) = detail();
|
||||
let r = h.request("engine.execute", json!({"command": "photo.setReference"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
let r = h.request("ui.key", json!({"key": "r", "shift": true}), T);
|
||||
|
||||
@@ -8,6 +8,7 @@ use serde_json::{Value, json};
|
||||
|
||||
use crate::headless::Headless;
|
||||
use crate::state::Dialog;
|
||||
use crate::tests_fixtures::SyntheticLibrary;
|
||||
use crate::{LightcraftApp, Services};
|
||||
|
||||
const T: Duration = Duration::from_secs(30);
|
||||
@@ -402,8 +403,14 @@ fn print_job_colour_controls_reach_the_job_and_the_pdf() {
|
||||
|
||||
#[test]
|
||||
fn the_print_preview_does_not_rebuild_thumbnails_every_frame() {
|
||||
let mut h = demo(Services { png: None, ..Default::default() });
|
||||
// This counts thumbnail requests across print-cell sizes, not native-image throughput.
|
||||
// Keep the demo's twelve-photo contact sheet with real files that decode cheaply.
|
||||
let library = SyntheticLibrary::with_photos("print-thumbnail-reuse", 12);
|
||||
let mut h = Headless::new(library.app(), [1400.0, 900.0], 1.0);
|
||||
assert!(h.settle(T), "the print thumbnail fixture settles");
|
||||
assert_eq!(h.app.session.catalog.len(), 12, "the contact sheet retains twelve photos");
|
||||
let before = h.app.renderer.thumb_jobs_built;
|
||||
assert!(before > 0, "the fixture exercises actual thumbnail jobs");
|
||||
cmd(&mut h, "dialog.print", json!({}));
|
||||
for style in ["contactSheet", "picturePackage", "singleImage"] {
|
||||
cmd(&mut h, "print.set", json!({"layout": {"style": style}}));
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
//! Test-only, real, file-backed photo fixtures for the render tests.
|
||||
//!
|
||||
//! The input/modal tests use a tiny `SyntheticLibrary` (64×48) that cannot exercise native-size
|
||||
//! decoding, a 1300×700 region window, a >16 MiB decoded original or Compare's two originals.
|
||||
//! These fixtures write real landscape 3:2 PNGs at the demo library's 24 MP size and register
|
||||
//! them as ordinary `Source::File` photos, so preview, Before/After and slideshow tests decode
|
||||
//! and render through the same path as a user's files.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock, Weak};
|
||||
|
||||
use lightcraft_catalog::{Op, Photo, Source};
|
||||
use lightcraft_codecs::{EncodeImage, EncodeMeta, Samples, encode_png};
|
||||
use lightcraft_engine::{Selection, Session};
|
||||
|
||||
/// The demo library's photo size: landscape 3:2, 24 MP.
|
||||
pub(crate) const WIDTH: u32 = 6000;
|
||||
pub(crate) const HEIGHT: u32 = 4000;
|
||||
/// The long edge the native-resolution tests look for.
|
||||
pub(crate) const LONG_EDGE: usize = WIDTH as usize;
|
||||
/// The pixel count of every fixture photo.
|
||||
pub(crate) const MEGAPIXELS: u64 = WIDTH as u64 * HEIGHT as u64;
|
||||
|
||||
static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0);
|
||||
type FixtureCache = Mutex<HashMap<(String, usize), Weak<FixtureFiles>>>;
|
||||
/// One generated set per (tag, count) in this test process: parallel tests share the same
|
||||
/// read-only PNGs instead of each encoding 2–3 24 MP images (and spiking memory).
|
||||
static FIXTURES: OnceLock<FixtureCache> = OnceLock::new();
|
||||
|
||||
struct FixtureFiles {
|
||||
dir: PathBuf,
|
||||
paths: Vec<String>,
|
||||
sizes: Vec<u64>,
|
||||
}
|
||||
|
||||
impl Drop for FixtureFiles {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns (with other users) a temporary directory of real PNGs. Keep it alive until after the
|
||||
/// [`crate::headless::Headless`] built from [`RenderFixture::session`] has dropped: the renderer
|
||||
/// reads these paths lazily.
|
||||
pub(crate) struct RenderFixture {
|
||||
files: Arc<FixtureFiles>,
|
||||
}
|
||||
|
||||
impl RenderFixture {
|
||||
/// Write (or reuse) `count` deterministic, smooth, non-neutral landscape gradient PNGs.
|
||||
pub(crate) fn new(tag: &str, count: usize) -> Self {
|
||||
assert!(count > 0, "a render fixture needs at least one photo");
|
||||
let key = (tag.to_string(), count);
|
||||
let cache = FIXTURES.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
let mut cache = cache.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(files) = cache.get(&key).and_then(Weak::upgrade) {
|
||||
return RenderFixture { files };
|
||||
}
|
||||
let files = Arc::new(build_files(tag, count));
|
||||
cache.insert(key, Arc::downgrade(&files));
|
||||
RenderFixture { files }
|
||||
}
|
||||
|
||||
/// A session with `with_fs` loaders, one default-edited `Source::File` photo per fixture,
|
||||
/// distinct ids and captured times in a stable order, with the first visible photo selected.
|
||||
pub(crate) fn session(&self) -> Session {
|
||||
let mut session = Session::new().with_fs();
|
||||
for (i, path) in self.files.paths.iter().enumerate() {
|
||||
let id = session.catalog.alloc_photo_id();
|
||||
let name = format!("RENDER_{i:04}.PNG");
|
||||
let mut photo = Photo::new(id, Source::File { path: path.clone() }, &name, "PNG", WIDTH, HEIGHT, "2026-01-01T10:00:00");
|
||||
// Distinct captured times keep `visible_cloned()` deterministic (the default sort is
|
||||
// capture date, newest first) and make every PhotoId its own file.
|
||||
photo.captured = Some(format!("2026-01-01T10:{:02}:{:02}", i / 60, i % 60));
|
||||
photo.file_size = self.files.sizes.get(i).copied().unwrap_or(0);
|
||||
session.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).expect("add a render fixture photo");
|
||||
}
|
||||
session.keep_active_in_view();
|
||||
let first = session.visible_cloned().first().copied().expect("the fixture has a visible photo");
|
||||
session.selection = Selection::single(first);
|
||||
session
|
||||
}
|
||||
|
||||
pub(crate) fn paths(&self) -> &[String] {
|
||||
&self.files.paths
|
||||
}
|
||||
}
|
||||
|
||||
fn build_files(tag: &str, count: usize) -> FixtureFiles {
|
||||
let n = NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join(format!("lightcraft-render-fixtures-{tag}-{}-{n}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("create the render fixture directory");
|
||||
|
||||
let mut paths = Vec::with_capacity(count);
|
||||
let mut sizes = Vec::with_capacity(count);
|
||||
for i in 0..count {
|
||||
let path = dir.join(format!("render-{i:02}.png"));
|
||||
let size = write_gradient(&path, i);
|
||||
paths.push(path.to_string_lossy().into_owned());
|
||||
sizes.push(size);
|
||||
}
|
||||
FixtureFiles { dir, paths, sizes }
|
||||
}
|
||||
|
||||
/// A smooth, non-wrapping, non-neutral gradient: R rises with x, G with y, B falls with x. The
|
||||
/// superimposed spatial waves make shifted pixels distinguishable in alignment regressions. The
|
||||
/// centre stays non-neutral, so the white-balance picker sees a real colour and an
|
||||
/// exposure lift of 1.5 EV is unmistakably brighter. `variant` shifts it slightly so no two
|
||||
/// fixture files have the same bytes.
|
||||
fn write_gradient(path: &Path, variant: usize) -> u64 {
|
||||
let w = WIDTH as usize;
|
||||
let h = HEIGHT as usize;
|
||||
let shift_r = (variant % 7) as f32 * 4.0;
|
||||
let shift_b = (variant % 5) as f32 * 3.0;
|
||||
let r_base: Vec<u8> = (0..w)
|
||||
.map(|x| {
|
||||
let u = x as f32 / (w - 1) as f32;
|
||||
clamp8(80.0 + 120.0 * u + shift_r + 44.0 * (std::f32::consts::TAU * x as f32 / 160.0).sin())
|
||||
})
|
||||
.collect();
|
||||
let b_base: Vec<u8> = (0..w)
|
||||
.map(|x| {
|
||||
let u = x as f32 / (w - 1) as f32;
|
||||
clamp8(40.0 + 100.0 * (1.0 - u) + shift_b + 22.0 * (std::f32::consts::TAU * x as f32 / 160.0).sin())
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut bytes = vec![0u8; w * h * 4];
|
||||
for (y, row) in bytes.chunks_exact_mut(w * 4).enumerate() {
|
||||
let v = y as f32 / (h - 1) as f32;
|
||||
let wave = (std::f32::consts::TAU * y as f32 / 192.0).sin();
|
||||
let g = clamp8(60.0 + 100.0 * v + 44.0 * wave);
|
||||
for (x, pixel) in row.as_chunks_mut::<4>().0.iter_mut().enumerate() {
|
||||
pixel[0] = r_base[x];
|
||||
pixel[1] = g;
|
||||
pixel[2] = clamp8(f32::from(b_base[x]) + 22.0 * wave);
|
||||
pixel[3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
let image = EncodeImage::new(WIDTH, HEIGHT, 4, Samples::U8(&bytes));
|
||||
let encoded = encode_png(&image, &EncodeMeta::default()).expect("encode a render fixture PNG");
|
||||
std::fs::write(path, &encoded).expect("write a render fixture PNG");
|
||||
encoded.len() as u64
|
||||
}
|
||||
|
||||
fn clamp8(v: f32) -> u8 {
|
||||
v.clamp(0.0, 255.0).round() as u8
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The fixture must stay a real 24 MP landscape 3:2 PNG: a fast little image would silently
|
||||
/// weaken every render assertion that depends on native pixels and a >16 MiB decoded source.
|
||||
#[test]
|
||||
fn render_fixtures_are_real_24mp_landscape_pngs() {
|
||||
assert_eq!((WIDTH, HEIGHT), (6000, 4000));
|
||||
assert_eq!(MEGAPIXELS, 24_000_000, "the demo photos these replace are 24 MP");
|
||||
let fixture = RenderFixture::new("preview", 2);
|
||||
assert_eq!(fixture.paths().len(), 2);
|
||||
for path in fixture.paths() {
|
||||
let bytes = std::fs::read(path).expect("read the fixture PNG");
|
||||
let header = lightcraft_codecs::read_header(&bytes).expect("read the fixture PNG header");
|
||||
assert_eq!((header.width, header.height), (WIDTH, HEIGHT), "{path}");
|
||||
assert_eq!(header.format, lightcraft_codecs::Format::Png, "{path}");
|
||||
// A full-resolution decode is width × height linear RGB f32 samples, well over the
|
||||
// 16 MiB budget `a_zoomed_original_over_the_budget...` checks.
|
||||
let decoded_bytes = u64::from(header.width) * u64::from(header.height) * 3 * 4;
|
||||
assert!(decoded_bytes > (16 << 20), "{path}: a decoded source of {decoded_bytes} bytes");
|
||||
}
|
||||
// The files are distinct so each PhotoId is its own original.
|
||||
assert_ne!(fixture.paths()[0], fixture.paths()[1]);
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,18 @@
|
||||
//! prepared ahead off the UI thread, transitions only between ready slides, keys and controls.
|
||||
//! Simulated time: every headless frame is 1/60 s, so nothing here sleeps.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
|
||||
use lightcraft_catalog::PhotoId;
|
||||
use lightcraft_engine::slideshow::Item;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::headless::Headless;
|
||||
use crate::headless::{Headless, RenderGate};
|
||||
use crate::render::Slot;
|
||||
use crate::tests_fixtures::SyntheticLibrary;
|
||||
use crate::tests_render_fixtures::{HEIGHT, RenderFixture, WIDTH};
|
||||
use crate::{LightcraftApp, Services};
|
||||
|
||||
const T: Duration = Duration::from_secs(20);
|
||||
@@ -27,6 +31,31 @@ fn grid_sized(size: [f32; 2]) -> Headless {
|
||||
h
|
||||
}
|
||||
|
||||
/// The slideshow's 1000×700 window over `count` real 6000×4000 file-backed photos. Used only by
|
||||
/// `next_slides_are_prepared_before_they_are_due`, where the 24 MP decode/render is the point:
|
||||
/// destructure the tuple immediately with the fixture binding first, so the renderer drops first.
|
||||
fn fixture_grid(count: usize, open: Rc<Cell<bool>>) -> (RenderFixture, Headless) {
|
||||
let fixture = RenderFixture::new("slideshow", count);
|
||||
assert_eq!(fixture.paths().len(), count);
|
||||
for (index, path) in fixture.paths().iter().enumerate() {
|
||||
assert!(std::path::Path::new(path).is_file(), "the fixture photo exists");
|
||||
assert!(!fixture.paths()[..index].contains(path), "each fixture photo has its own path");
|
||||
}
|
||||
let services = Services { png: None, ..Default::default() };
|
||||
let app = LightcraftApp::new(fixture.session(), services);
|
||||
let mut h = Headless::new(app, [1000.0, 700.0], 1.0);
|
||||
// Control native grid work before its first frame too: asynchronous setup must not
|
||||
// consume the wall-clock budget before the slideshow scheduling assertions begin.
|
||||
assert!(open.get(), "the native grid setup gate starts open");
|
||||
h.app.renderer.set_offload(Box::new(RenderGate::new(open)));
|
||||
assert!(h.settle(SETTLE), "the grid renders settle before controlling slide renders");
|
||||
assert_eq!(h.app.session.catalog.len(), count, "the slideshow fixture photos");
|
||||
let id = h.app.session.active().expect("a photo is open");
|
||||
let photo = h.app.session.catalog.photo(id).expect("it is in the catalog");
|
||||
assert_eq!((photo.width, photo.height), (WIDTH, HEIGHT), "the slideshow fixture is a real 24 MP photo");
|
||||
(fixture, h)
|
||||
}
|
||||
|
||||
fn run(h: &mut Headless, command: &str, params: serde_json::Value) -> serde_json::Value {
|
||||
let r = h.request("engine.execute", json!({"command": command, "params": params}), T);
|
||||
assert_eq!(r["ok"], true, "{command}: {r}");
|
||||
@@ -61,12 +90,24 @@ fn slides_held(h: &Headless) -> usize {
|
||||
|
||||
#[test]
|
||||
fn slideshow_advances_pauses_and_ends() {
|
||||
let mut h = grid();
|
||||
// This tests the automatic playback clock and controls; native 24 MP rendering
|
||||
// is exercised separately by next_slides_are_prepared_before_they_are_due.
|
||||
let library = SyntheticLibrary::with_photos("slideshow-automatic-controls", 3);
|
||||
let mut h = Headless::new(library.app(), [1000.0, 700.0], 1.0);
|
||||
assert!(h.settle(SETTLE), "the automatic playback fixture settles");
|
||||
let first = h.app.session.visible_cloned()[0];
|
||||
run(&mut h, "view.slideshow", json!({"interval": 0.5, "fadeDuration": 0}));
|
||||
// The fixture selects its first imported photo; newest-first view order differs.
|
||||
// Establish the same first-in-view starting selection as the original demo test.
|
||||
run(&mut h, "library.select", json!({"ids": [first.0]}));
|
||||
assert_eq!(h.app.session.active(), Some(first), "the first visible photo is selected before playback");
|
||||
run(&mut h, "view.slideshow", json!({"interval": 0.5, "fadeDuration": 0, "manual": false}));
|
||||
assert!(!play(&h).settings().manual, "playback advances automatically");
|
||||
assert!(h.app.ui.fullscreen && h.app.ui.slideshow.is_some());
|
||||
assert_eq!(h.app.session.active(), Some(first), "starts at the first photo in view");
|
||||
assert!(h.step_until(SETTLE, |h| h.app.session.active() != Some(first)), "the next photo comes up and is selected");
|
||||
let advanced = photo_up(&h);
|
||||
assert_eq!(h.app.session.active(), Some(advanced), "selection follows automatic playback");
|
||||
assert!(has_slide(&h, advanced), "automatic playback shows a rendered next photo");
|
||||
// Space pauses: nothing moves
|
||||
let r = h.request("ui.key", json!({"key": "space"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
@@ -98,33 +139,54 @@ fn turning_off_the_full_screen_preview_frees_the_slides() {
|
||||
|
||||
#[test]
|
||||
fn next_slides_are_prepared_before_they_are_due() {
|
||||
let mut h = grid();
|
||||
// a long slide: anything rendered for the next ones is rendered ahead of time
|
||||
run(&mut h, "view.slideshow", json!({"interval": 60, "fadeDuration": 1, "preparePreviews": true, "seed": 7}));
|
||||
let open = Rc::new(Cell::new(true));
|
||||
let (fx, mut h) = fixture_grid(8, open.clone());
|
||||
// Grid setup is complete. Hold photo slide jobs until their ahead requests are
|
||||
// observable; opening the gate runs the actual native decode/render synchronously.
|
||||
open.set(false);
|
||||
let reply = run(&mut h, "view.slideshow", json!({"interval": 60, "fadeDuration": 1, "preparePreviews": true, "seed": 7}));
|
||||
assert_eq!(reply["settings"]["preparePreviews"], true);
|
||||
assert_eq!(reply["settings"]["slideDuration"], 60.0);
|
||||
let ahead = play(&h).upcoming(crate::panels::slideshow::AHEAD);
|
||||
assert_eq!(ahead.len(), 2);
|
||||
assert_eq!(ahead.len(), crate::panels::slideshow::AHEAD);
|
||||
let start = h.time();
|
||||
// the slide up and the next two; their renders can finish in any order
|
||||
// The current photo and next two are queued before any slide render completes.
|
||||
let current = photo_up(&h);
|
||||
for item in std::iter::once(Item::Photo(current)).chain(ahead.iter().copied()) {
|
||||
assert!(h.app.renderer.is_pending(Slot::Slide(item)), "requested before it is due: {item:?}");
|
||||
assert!(!has_slide(&h, item), "not rendered before the gate opens: {item:?}");
|
||||
}
|
||||
assert!(play(&h).remaining(h.time()) > 59.0, "the first slide is not due before rendering");
|
||||
// The gate executes the actual decode/render work synchronously when polled.
|
||||
open.set(true);
|
||||
assert!(
|
||||
h.step_until(SETTLE, |h| has_slide(h, current) && ahead.iter().all(|id| has_slide(h, *id))),
|
||||
"the slide up and the next two slides are rendered"
|
||||
);
|
||||
assert!(h.time() - start < 60.0, "before the first is due");
|
||||
assert_eq!(photo_up(&h), current, "still on the first slide");
|
||||
h.settle(SETTLE);
|
||||
assert!(play(&h).remaining(h.time()) > 59.0, "renders are accepted before the first slide is due");
|
||||
assert!(h.settle(SETTLE), "enabled-prefetch renders settle");
|
||||
drop(h);
|
||||
drop(fx);
|
||||
|
||||
// off: only the slide up is rendered until the next one is due
|
||||
let mut h = grid();
|
||||
run(&mut h, "view.slideshow", json!({"interval": 60, "preparePreviews": false}));
|
||||
let open = Rc::new(Cell::new(true));
|
||||
let (_fx2, mut h) = fixture_grid(8, open.clone());
|
||||
open.set(false);
|
||||
let reply = run(&mut h, "view.slideshow", json!({"interval": 60, "preparePreviews": false}));
|
||||
assert_eq!(reply["settings"]["preparePreviews"], false);
|
||||
let next = play(&h).upcoming(1)[0];
|
||||
let current = photo_up(&h);
|
||||
assert!(h.step_until(SETTLE, |h| has_slide(h, current)));
|
||||
for _ in 0..30 {
|
||||
h.step();
|
||||
}
|
||||
assert!(h.app.renderer.is_pending(Slot::Slide(Item::Photo(current))), "the current slide is requested");
|
||||
assert!(!has_slide(&h, next) && !h.app.renderer.is_pending(Slot::Slide(next)), "not prepared");
|
||||
h.settle(SETTLE);
|
||||
open.set(true);
|
||||
assert!(h.step_until(SETTLE, |h| has_slide(h, current)), "the current slide renders once allowed");
|
||||
assert!(!has_slide(&h, next) && !h.app.renderer.is_pending(Slot::Slide(next)), "still not prepared");
|
||||
assert!(h.settle(SETTLE), "disabled-prefetch renders settle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -201,10 +263,40 @@ fn a_manual_slideshow_waits_for_the_arrow_keys() {
|
||||
// the Settings checkbox turns it on
|
||||
run(&mut h, "view.slideshowOptions", json!({}));
|
||||
h.step();
|
||||
reveal(&mut h, "check:settings.slideshowManual");
|
||||
let target = reveal(&mut h, "check:settings.slideshowManual");
|
||||
let diagnose = |phase: &str, h: &Headless| {
|
||||
let id = "check:settings.slideshowManual";
|
||||
let rect = latest_widget_rect(h, id);
|
||||
let hit = crate::control::widget_hit(&h.app, id);
|
||||
let visible = rect.is_some_and(|r| crate::control::widget_target_visible(&h.view.ctx, hit, r.center()));
|
||||
eprintln!(
|
||||
"manual-checkbox {phase}: manual={} time={} frames={} target={target:?} rect={rect:?} room={:?} hit={hit:?} visible={visible} input={:?} dialog={:?} notices={:?} quit_prompt={} notice_barrier={} library_problem={} library_barrier={} in_flight={} pending={:?} toast={:?}",
|
||||
h.app.session.slideshow.manual,
|
||||
h.time(),
|
||||
h.frames(),
|
||||
latest_widget_rect(h, "settings:pageRoom"),
|
||||
h.view.ctx.input(|i| (i.is_scrolling(), i.modifiers, i.keys_down.clone(), i.pointer.any_down())),
|
||||
h.app.ui.dialog,
|
||||
h.app.notices,
|
||||
h.app.quit_prompt.is_some(),
|
||||
crate::panels::notices::blocks_input(&h.app),
|
||||
h.app.library_problem.is_some(),
|
||||
crate::panels::library_problem::blocks_file_drops(&h.app),
|
||||
h.app.renderer.in_flight(),
|
||||
h.app.renderer.pending_slots(),
|
||||
h.app.ui.toast,
|
||||
);
|
||||
};
|
||||
diagnose("before-click", &h);
|
||||
assert!(!h.app.session.slideshow.manual, "the checkbox starts unchecked before the real click");
|
||||
let journal_before = h.app.session.journal.len();
|
||||
let r = h.request("ui.clickWidget", json!({"id": "check:settings.slideshowManual"}), T);
|
||||
diagnose("after-request", &h);
|
||||
eprintln!("manual-checkbox reply={r} journal_since_click={:?}", h.app.session.journal.get(journal_before..));
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.step();
|
||||
diagnose("after-existing-step", &h);
|
||||
eprintln!("manual-checkbox journal_after_existing_step={:?}", h.app.session.journal.get(journal_before..));
|
||||
assert!(h.app.session.slideshow.manual, "the checkbox goes through slideshow.playback");
|
||||
h.settle(SETTLE);
|
||||
}
|
||||
@@ -440,7 +532,9 @@ fn a_composed_slide_shows_during_playback() {
|
||||
let mut h = grid();
|
||||
let r = run(&mut h, "slideshow.layout", json!({"margins": 0.1, "backdropColor": "#ff0000"}));
|
||||
assert_eq!(r["marginTop"], 0.1);
|
||||
run(&mut h, "view.slideshow", json!({"interval": 60, "fadeDuration": 0}));
|
||||
// This checks composed pixels and explicit navigation. Rendering can take enough
|
||||
// headless frames to pass any automatic interval before the next slide is ready.
|
||||
run(&mut h, "view.slideshow", json!({"interval": 60, "fadeDuration": 0, "manual": true}));
|
||||
let current = photo_up(&h);
|
||||
assert!(h.step_until(SETTLE, |h| has_slide(h, current)), "the slide is composed and ready");
|
||||
h.step();
|
||||
@@ -457,8 +551,10 @@ fn a_composed_slide_shows_during_playback() {
|
||||
// the next slide is composed too, and stepping shows it
|
||||
let next = play(&h).upcoming(1)[0];
|
||||
assert!(h.step_until(SETTLE, |h| has_slide(h, next)));
|
||||
assert_eq!(photo_up(&h), current, "rendering ahead keeps the inspected slide up");
|
||||
run(&mut h, "slideshow.next", json!({}));
|
||||
h.step();
|
||||
assert_eq!(play(&h).current(), next, "navigation shows the prepared slide");
|
||||
assert_eq!(painted(&h.paint(), 5, 5), [255, 0, 0]);
|
||||
h.settle(SETTLE);
|
||||
}
|
||||
@@ -467,10 +563,13 @@ fn a_composed_slide_shows_during_playback() {
|
||||
fn the_default_layout_plays_the_photo_as_before() {
|
||||
let mut h = grid();
|
||||
assert!(h.app.session.slide_layout.is_plain());
|
||||
run(&mut h, "view.slideshow", json!({"interval": 60, "fadeDuration": 0}));
|
||||
// Inspect one photo's aspect and backdrop without automatic playback advancing
|
||||
// the simulated clock past it while the asynchronous renderer catches up.
|
||||
run(&mut h, "view.slideshow", json!({"interval": 60, "fadeDuration": 0, "manual": true}));
|
||||
let current = photo_up(&h);
|
||||
assert!(h.step_until(SETTLE, |h| has_slide(h, current)));
|
||||
h.step();
|
||||
assert_eq!(photo_up(&h), current, "the inspected photo stays up while rendering");
|
||||
// the photo's own render (its aspect, not the screen's), fitted on black, as before
|
||||
let tex = h.app.renderer.textures.get(&Slot::Slide(current.into())).unwrap();
|
||||
let aspect = h.app.session.slide_photo_aspect(current).unwrap();
|
||||
|
||||
@@ -41,6 +41,73 @@ fn size_of(h: &Headless) -> (f32, f32) {
|
||||
(p.width as f32, p.height as f32)
|
||||
}
|
||||
|
||||
/// Keep an old same-photo texture on screen while the replacement is deliberately held.
|
||||
/// Geometry must follow the edit immediately, independently of renderer throughput.
|
||||
#[test]
|
||||
fn a_pending_crop_or_rotation_does_not_reuse_the_old_textures_aspect() {
|
||||
use crate::render::{RenderOffload, Slot, Tex, geometry_look};
|
||||
use crate::state::{RightPanel, Zoom};
|
||||
|
||||
struct Held;
|
||||
impl RenderOffload for Held {
|
||||
fn try_start(&mut self, _: Slot, job: lightcraft_engine::RenderJob) -> Option<lightcraft_engine::RenderJob> {
|
||||
Some(job)
|
||||
}
|
||||
fn finished(&mut self) -> Vec<(Slot, lightcraft_engine::media::RenderResult, f64)> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
let fixture = crate::tests_fixtures::SyntheticLibrary::new("zoom-stale-geometry");
|
||||
let mut h = Headless::new(fixture.app(), [1400.0, 900.0], 1.0);
|
||||
h.app.renderer.set_offload(Box::new(Held));
|
||||
let response = h.request("ui.set", json!({"view": "detail", "right": "none", "filmstrip": false}), T);
|
||||
assert_eq!(response["ok"], true, "{response}");
|
||||
h.step();
|
||||
h.app.ui.zoom = Zoom::Percent(100.0);
|
||||
let id = h.app.session.active().expect("fixture photo");
|
||||
let old_look = geometry_look(&h.app.session.develop_of(id).expect("settings"));
|
||||
let tex = h.view.ctx.load_texture("old loupe", egui::ColorImage::filled([4, 3], egui::Color32::BLACK), Default::default());
|
||||
h.app.renderer.textures.insert(
|
||||
Slot::Main,
|
||||
Tex { look: Some((old_look, true)), key: 0, photo: id, tex, size: [64, 48], histogram: None, ms: 0.0, quick: None, pixels: None },
|
||||
);
|
||||
edit(&mut h, |d| d.crop.geometry.rect = lightcraft_geom::Rect::new(0.25, 0.2, 0.75, 0.2 + 1.0 / 3.0));
|
||||
for (rotate, expected) in [(false, egui::vec2(32.0, 16.0)), (true, egui::vec2(16.0, 32.0))] {
|
||||
if rotate {
|
||||
edit(&mut h, |d| {
|
||||
d.orientation = lightcraft_geom::Orientation::Rotate90;
|
||||
// Crop coordinates live in the oriented frame, as in library.rotateRight.
|
||||
d.crop.geometry.rect = lightcraft_geom::Orientation::Rotate90.map_norm_rect(d.crop.geometry.rect);
|
||||
});
|
||||
}
|
||||
h.app.ui.zoom_anim = false;
|
||||
h.step();
|
||||
let shown = h.app.image_rect.expect("loupe geometry");
|
||||
assert!((shown.size() - expected).abs().max_elem() < 0.01, "pending edit: {shown:?}, expected {expected:?}");
|
||||
assert_eq!(h.app.renderer.textures[&Slot::Main].look, Some((old_look, true)), "old pixels remain visible");
|
||||
assert!(h.app.renderer.is_pending(Slot::Main), "the edited render is still pending");
|
||||
}
|
||||
// Crop mode shows the full oriented image; leaving it must immediately restore the crop.
|
||||
h.app.ui.right = RightPanel::Crop;
|
||||
h.app.ui.zoom_anim = false;
|
||||
h.step();
|
||||
assert!((h.app.image_rect.expect("crop tool geometry").size() - egui::vec2(48.0, 64.0)).abs().max_elem() < 0.01);
|
||||
h.app.ui.right = RightPanel::None;
|
||||
h.app.ui.zoom_anim = false;
|
||||
h.step();
|
||||
assert!((h.app.image_rect.expect("restored crop geometry").size() - egui::vec2(16.0, 32.0)).abs().max_elem() < 0.01);
|
||||
// An actual current-settings render can still correct metadata that differs from the
|
||||
// decoded image (unsupported RAWs may provide only a differently cropped embedded JPEG).
|
||||
let current = geometry_look(&h.app.session.develop_of(id).expect("settings"));
|
||||
let tex = h.app.renderer.textures.get_mut(&Slot::Main).expect("old texture");
|
||||
tex.look = Some((current, true));
|
||||
tex.size = [16, 48];
|
||||
h.step();
|
||||
let shown = h.app.image_rect.expect("current render geometry");
|
||||
assert!((shown.size() - egui::vec2(16.0, 48.0)).abs().max_elem() < 0.01, "current pixels correct metadata: {shown:?}");
|
||||
}
|
||||
|
||||
// Given an uncropped photo, 100 % is its pixel size
|
||||
#[test]
|
||||
fn an_uncropped_photo_at_100_is_its_pixel_size() {
|
||||
|
||||
@@ -42,13 +42,22 @@ fn settle_photo(h: &mut Harness<'_, LightcraftApp>) {
|
||||
let started = Instant::now();
|
||||
let mut quiet = 0;
|
||||
while quiet < 4 {
|
||||
h.run_steps(2);
|
||||
if h.state().renderer.in_flight() == 0 && h.state().loupe_shown.is_some_and(|(_, source)| source == "render") {
|
||||
if h.state().renderer.in_flight_without_prefetch() > 0 {
|
||||
h.state_mut().renderer.wait_for_worker(Duration::from_millis(250));
|
||||
}
|
||||
h.run_steps(1);
|
||||
// Neighbouring-photo prefetch does not contribute pixels to this capture.
|
||||
if h.state().renderer.in_flight_without_prefetch() == 0 && h.state().loupe_shown.is_some_and(|(_, source)| source == "render") {
|
||||
quiet += 1;
|
||||
} else {
|
||||
quiet = 0;
|
||||
}
|
||||
assert!(started.elapsed() < Duration::from_secs(30), "the synthetic photo and panel thumbnails must finish rendering before capture");
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_secs(120),
|
||||
"the synthetic photo and panel thumbnails must finish rendering before capture: loupe {:?}, pending {:?}",
|
||||
h.state().loupe_shown,
|
||||
h.state().renderer.pending_slots()
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
# From-source Flatpak packaging
|
||||
|
||||
`ai.storyteller.lightcraft.yml` builds LightCraft and `lightcraft-cli` from this
|
||||
repository inside the Flatpak SDK. Flatpak builders have no network access, so
|
||||
`cargo-sources.json` lists every crate and git source with a fixed checksum or
|
||||
commit. Regenerate it after changing `Cargo.lock`:
|
||||
|
||||
```sh
|
||||
./packaging/linux/flatpak/generate-cargo-sources.py
|
||||
flatpak-builder --user --install --force-clean build-flatpak packaging/linux/flatpak/ai.storyteller.lightcraft.yml
|
||||
flatpak run ai.storyteller.lightcraft
|
||||
```
|
||||
|
||||
## Regeneration wrapper
|
||||
|
||||
`generate-cargo-sources.py` uses the upstream
|
||||
[`flatpak-cargo-generator.py`](https://github.com/flatpak/flatpak-builder-tools/blob/9d86d336a39b9af38b86f114b6ce5820fe260f91/cargo/flatpak-cargo-generator.py)
|
||||
pinned to commit `9d86d336a39b9af38b86f114b6ce5820fe260f91` with SHA-256
|
||||
`0a2db6be87d75910facef28ab46d4d6460802e8419ab850d0caa6a364d26b380`. It downloads
|
||||
the script, verifies that hash, applies the two reviewed patches below, and
|
||||
invokes it with the current Python interpreter. The upstream script is not
|
||||
vendored; it is MIT-licensed
|
||||
([upstream licence](https://github.com/flatpak/flatpak-builder-tools/blob/9d86d336a39b9af38b86f114b6ce5820fe260f91/LICENSE)).
|
||||
The patched script's hash is also checked before execution.
|
||||
|
||||
Requirements: Python 3.9+ with `aiohttp` and `tomlkit` (`PyYAML` is optional and
|
||||
only needed for upstream's YAML output), plus `git` and network access to fetch
|
||||
the generator and git dependencies. Crate archive URLs and checksums are read
|
||||
from Cargo.lock; Flatpak downloads those archives when materializing the sources. The generator clones git
|
||||
repositories into `$XDG_CACHE_HOME/lightcraft-flatpak-cargo` by default, or the
|
||||
directory passed with `--cache-dir`; the wrapper refuses a cache inside the
|
||||
checkout or `CARGO_TARGET_DIR`. `cargo-sources.json` is written to a temporary
|
||||
file and atomically renamed only after the generator succeeds, so a failed run
|
||||
leaves the previous inventory untouched.
|
||||
|
||||
The regenerated inventory keeps the temporary file’s owner-only permissions (0600 on POSIX).
|
||||
Run the generator and Flatpak builder as the same user; a cross-user packaging workflow
|
||||
should explicitly install the inventory with its required ownership and permissions.
|
||||
|
||||
Generation is capped at 30 minutes and the generator's stdin is `/dev/null`, so
|
||||
an interactive credential prompt fails instead of hanging. On POSIX systems
|
||||
(Linux is the supported Flatpak packaging platform) the generator starts in its
|
||||
own session; at the cap, the wrapper kills that generator's process group,
|
||||
including git clone/fetch descendants, and reaps its leader, so no orphan keeps
|
||||
writing into the cache. On Windows only the direct generator process is
|
||||
terminated; its git descendants are not tree-killed, and the timeout is
|
||||
otherwise the same. The timeout path runs before the atomic rename, so the
|
||||
existing inventory and the temporary-file cleanup behave exactly like any other
|
||||
generation failure.
|
||||
|
||||
Because the generator no longer shares the wrapper's terminal session, the
|
||||
wrapper also handles Ctrl-C (`SIGINT`) and `SIGTERM` while it runs: the signal
|
||||
is recorded, the generator's process group is killed and reaped like the timeout
|
||||
path, the temporary file and script directory are removed, and only then is the
|
||||
signal replayed with the handler that was installed before generation started
|
||||
(`KeyboardInterrupt` for `SIGINT`, the previous or default action for
|
||||
`SIGTERM`). A cancelled `flatpak-builder` run or an interactive Ctrl-C therefore
|
||||
leaves neither a detached generator or git clone nor a modified inventory. The
|
||||
temporary handlers are installed before the generator is spawned, so a signal
|
||||
that arrives while the child is being created cannot strand the new session
|
||||
either. A cancellation delivered while those handlers are installed is recorded
|
||||
once (the first signal wins) and replayed after cleanup with the caller's
|
||||
restored handler. Signals that cannot be handled by this process at all
|
||||
(`SIGKILL`, an OOM kill, or a wrapper crash) cannot be cleaned up: the generator
|
||||
runs in its own session, so it may survive the wrapper, and any temporary output
|
||||
or script directory it had created remains. A hard termination between creating
|
||||
the temporary directory or output file and installing the handlers, or after
|
||||
`run_generator` returns while the output is being validated and renamed, can
|
||||
likewise leave those temporary paths behind (the complete temporary output in
|
||||
the latter case), because the default signal action performs no cleanup.
|
||||
|
||||
## Why two patches
|
||||
|
||||
1. **Keep pinned revisions distinct.** Upstream keys `[source]` replacement
|
||||
entries by the canonical repository URL and then adds `rev`/`tag`/`branch`.
|
||||
Two different revisions of the same repository therefore overwrite each
|
||||
other. The patch keys the entry by the full source URL without its `#`
|
||||
fragment, so `https://github.com/storytold/craft-libs?rev=...` remains a
|
||||
separate source for each craft-libs revision.
|
||||
2. **Include `[patch.unused]`.** `Cargo.lock` records patched packages that are
|
||||
not in the resolved graph under `patch.unused`. Upstream iterates only
|
||||
`package`, which omits crates such as `egui_demo_lib`; `--locked` offline
|
||||
dependency resolution then fails because Cargo still loads those patched manifests. The
|
||||
patch also walks `patch.unused` entries.
|
||||
|
||||
No third patch is needed: upstream's `git_repo_name()` already appends the
|
||||
first seven characters of the commit, so the two craft-libs checkouts get
|
||||
different destinations.
|
||||
|
||||
## Offline validation performed
|
||||
|
||||
The committed inventory was generated from the current `Cargo.lock` and then
|
||||
validated without contacting the network: every git/archive/shell/inline entry
|
||||
was materialized as Flatpak would, crate archives were checked against their
|
||||
recorded SHA-256 values, and the generated Cargo config was pointed at the
|
||||
materialized vendor directory. With an isolated `CARGO_HOME` and
|
||||
`CARGO_NET_OFFLINE=true`, `cargo --config <generated-config> metadata --offline
|
||||
--locked --format-version 1` exited 0. The two craft-libs revisions
|
||||
(`82a24b006eefa036b43c09a67fb9a3fbd17ffe63` and
|
||||
`25da1314d443171b4adeaad2cd9454b273f35c6f`) are present as separate git sources
|
||||
and `[source]` entries. The checked-in `cargo-sources.json` has SHA-256
|
||||
`c7f515d1178bb79b2cabdfd419ad375a04ff5d84ba9d08b1b34970a460cd0f2c`.
|
||||
|
||||
This validates the source inventory and offline metadata resolution only. A
|
||||
full `flatpak-builder` build against the `org.freedesktop.Sdk` 25.08 runtime and
|
||||
Rust extension is still required before release; it was not performed here.
|
||||
|
||||
Run the wrapper failure-path and cancellation regression tests (the real
|
||||
process-group cases, including Ctrl-C/`SIGTERM` cancellation of a live
|
||||
generator session, run on Linux only and are skipped elsewhere) with:
|
||||
|
||||
```sh
|
||||
python3 -B packaging/linux/flatpak/test_generate_cargo_sources.py
|
||||
```
|
||||
|
||||
The cancellation and timeout tests check process liveness and a frozen
|
||||
heartbeat before their teardown kills anything. With
|
||||
`LIGHTCRAFT_FLATPAK_TEST_BREAK_KILL=1` the group kill is a deliberate no-op and
|
||||
those tests must fail; run that variant to confirm they do not silently pass on
|
||||
a broken kill path.
|
||||
@@ -2,11 +2,14 @@
|
||||
#
|
||||
# Flatpak builds run offline, so the crate sources must be vendored first. From the repo root:
|
||||
#
|
||||
# curl -fsSLO https://raw.githubusercontent.com/flatpak/flatpak-builder-tools/master/cargo/flatpak-cargo-generator.py
|
||||
# python3 flatpak-cargo-generator.py Cargo.lock -o packaging/linux/flatpak/cargo-sources.json
|
||||
# ./packaging/linux/flatpak/generate-cargo-sources.py
|
||||
# flatpak-builder --user --install --force-clean build-flatpak packaging/linux/flatpak/ai.storyteller.lightcraft.yml
|
||||
# flatpak run ai.storyteller.lightcraft
|
||||
#
|
||||
# The wrapper downloads the pinned upstream flatpak-cargo-generator, verifies its checksum,
|
||||
# applies the two reviewed patches described in packaging/linux/flatpak/README.md, and writes
|
||||
# cargo-sources.json atomically. That README also covers dependencies and offline validation.
|
||||
#
|
||||
# For Flathub, replace the `dir` source with a `git` source pinned to a release tag and commit.
|
||||
#
|
||||
# ai.storyteller.lightcraft.bundle.yml (same directory) builds the .flatpak attached to every
|
||||
|
||||
File diff suppressed because one or more lines are too long
+398
@@ -0,0 +1,398 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regenerate cargo-sources.json for the from-source Flatpak manifest.
|
||||
|
||||
The upstream flatpak-cargo-generator is downloaded from a pinned commit and
|
||||
verified before use; the full script is intentionally not vendored here. Two
|
||||
small, reviewed patches are applied in memory:
|
||||
|
||||
1. Key cargo source replacements by the full source URL (including ?rev=...)
|
||||
instead of only the repository URL, so two different revisions of the same
|
||||
repository stay distinct.
|
||||
2. Include Cargo.lock's [patch.unused] entries, so crates such as egui_demo_lib
|
||||
are vendored even though they are not in the resolved package graph.
|
||||
|
||||
This only produces and validates the source inventory; a full Flatpak SDK
|
||||
build is still required. See README.md in this directory for details.
|
||||
|
||||
The generator runs with stdin connected to /dev/null and is capped at
|
||||
GENERATOR_TIMEOUT_SECONDS. On POSIX (the Flatpak build path) it starts in its
|
||||
own session, so a timeout, Ctrl-C (SIGINT) or SIGTERM terminates the generator
|
||||
and its git descendants without touching any other process; the recorded signal
|
||||
is replayed with the handler that was installed before generation started, so
|
||||
the wrapper keeps its normal cancellation behaviour. On Windows only the direct
|
||||
generator process is terminated; descendants are not tree-killed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from urllib.request import urlopen
|
||||
|
||||
UPSTREAM_COMMIT = "9d86d336a39b9af38b86f114b6ce5820fe260f91"
|
||||
UPSTREAM_URL = (
|
||||
"https://raw.githubusercontent.com/flatpak/flatpak-builder-tools/"
|
||||
f"{UPSTREAM_COMMIT}/cargo/flatpak-cargo-generator.py"
|
||||
)
|
||||
UPSTREAM_SHA256 = "0a2db6be87d75910facef28ab46d4d6460802e8419ab850d0caa6a364d26b380"
|
||||
PATCHED_SHA256 = "1e7c61d98d18e88156fb8cd4b91f526e04a6c504b185ae7b12f775ec1dd48512"
|
||||
|
||||
# Allow cold git caches while bounding stalled inventory regeneration.
|
||||
GENERATOR_TIMEOUT_SECONDS = 30 * 60
|
||||
|
||||
# Signals that cancel regeneration without an exception of their own.
|
||||
CANCELLATION_SIGNALS = (signal.SIGINT, signal.SIGTERM)
|
||||
|
||||
PATCHES = (
|
||||
(
|
||||
"preserve pinned source identities",
|
||||
''' elif branch:
|
||||
assert len(branch) == 1
|
||||
cargo_vendored_entry[repo_url]["branch"] = branch[0]
|
||||
|
||||
logging.info("Adding package %s from %s", name, repo_url)
|
||||
''',
|
||||
''' elif branch:
|
||||
assert len(branch) == 1
|
||||
cargo_vendored_entry[repo_url]["branch"] = branch[0]
|
||||
|
||||
# Preserve distinct pinned source identities from the same repository.
|
||||
if rev or tag or branch:
|
||||
source_key = source.removeprefix("git+").split("#", 1)[0]
|
||||
cargo_vendored_entry = {source_key: cargo_vendored_entry[repo_url]}
|
||||
|
||||
logging.info("Adding package %s from %s", name, repo_url)
|
||||
''',
|
||||
),
|
||||
(
|
||||
"include Cargo.lock patch.unused entries",
|
||||
''' pkg_coros = [
|
||||
get_package_sources(p, cargo_lock, git_repos) for p in cargo_lock["package"]
|
||||
]
|
||||
''',
|
||||
''' pkg_coros = [
|
||||
get_package_sources(p, cargo_lock, git_repos)
|
||||
for p in cargo_lock["package"] + cargo_lock.get("patch", {}).get("unused", [])
|
||||
]
|
||||
''',
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def sha256(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def download_upstream() -> bytes:
|
||||
with urlopen(UPSTREAM_URL, timeout=60) as response:
|
||||
data = response.read(1_000_001)
|
||||
if len(data) > 1_000_000:
|
||||
raise SystemExit("error: upstream generator is unexpectedly large")
|
||||
actual = sha256(data)
|
||||
if actual != UPSTREAM_SHA256:
|
||||
raise SystemExit(
|
||||
"error: upstream generator checksum mismatch\n"
|
||||
f" expected {UPSTREAM_SHA256}\n"
|
||||
f" actual {actual}\n"
|
||||
f" url {UPSTREAM_URL}"
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
def patched_upstream(data: bytes) -> bytes:
|
||||
text = data.decode("utf-8")
|
||||
for name, old, new in PATCHES:
|
||||
count = text.count(old)
|
||||
if count != 1:
|
||||
raise SystemExit(
|
||||
f"error: patch {name!r} matched {count} times, expected exactly once; "
|
||||
"the pinned upstream script or patch set changed"
|
||||
)
|
||||
text = text.replace(old, new, 1)
|
||||
patched = text.encode("utf-8")
|
||||
actual = sha256(patched)
|
||||
if actual != PATCHED_SHA256:
|
||||
raise SystemExit(
|
||||
"error: patched generator checksum mismatch\n"
|
||||
f" expected {PATCHED_SHA256}\n"
|
||||
f" actual {actual}"
|
||||
)
|
||||
return patched
|
||||
|
||||
|
||||
def _kill_generator(process: subprocess.Popen) -> None:
|
||||
"""Signal only our generator session; never waits or reaps.
|
||||
|
||||
The reaped check is a plain ``returncode`` read: calling ``Popen.poll`` or
|
||||
``Popen.kill`` would wait for the child, which is not safe inside a POSIX
|
||||
signal handler. On POSIX the generator is a session leader, so its PID is
|
||||
also the ID of a process group that contains no process we do not own.
|
||||
Killing that group stops git clones/fetches the generator started. On
|
||||
Windows only the direct generator process is killed; its descendants may
|
||||
survive.
|
||||
"""
|
||||
if process.returncode is not None:
|
||||
return
|
||||
if os.name == "posix":
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
except OSError:
|
||||
# The group is already gone; still try the direct child in case it
|
||||
# was not a group leader.
|
||||
try:
|
||||
os.kill(process.pid, signal.SIGKILL)
|
||||
except OSError:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
process.kill()
|
||||
except OSError:
|
||||
# The child exited between the timeout and the kill.
|
||||
pass
|
||||
|
||||
|
||||
def _terminate_generator(process: subprocess.Popen) -> None:
|
||||
"""Stop only our generator session and reap its leader.
|
||||
|
||||
Only called outside signal handlers; the follow-up ``wait`` reaps the
|
||||
session leader so no zombie or live generator outlives the wrapper.
|
||||
``_kill_generator`` skips a child that was already reaped, so this cannot
|
||||
signal a recycled PID.
|
||||
"""
|
||||
_kill_generator(process)
|
||||
try:
|
||||
process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
print(
|
||||
f"warning: generator process {process.pid} did not exit after SIGKILL",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
class _GeneratorRun:
|
||||
"""State shared with the temporary cancellation signal handlers.
|
||||
|
||||
Because the generator runs in its own session, Ctrl-C in the terminal or a
|
||||
SIGTERM sent to the wrapper no longer reaches it. These handlers record the
|
||||
signal and stop exactly that session without waiting; ``run_generator``
|
||||
then restores the previous handlers and reports the cancellation, so
|
||||
``main`` can clean up before the signal is replayed with the caller's own
|
||||
behaviour. The wait that follows always reaps the leader.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.process = None
|
||||
self.received = None
|
||||
self.killed = False
|
||||
|
||||
def cancel(self, signum, _frame) -> None:
|
||||
"""Record the first SIGINT/SIGTERM and stop the generator's session."""
|
||||
if self.received is None:
|
||||
self.received = signum
|
||||
process = self.process
|
||||
# ``_kill_generator`` reads only ``returncode`` and uses raw POSIX
|
||||
# signals, so it is safe in a handler. The first signal does the kill;
|
||||
# later signals must not overwrite the recorded cancellation.
|
||||
if process is not None and not self.killed:
|
||||
self.killed = True
|
||||
_kill_generator(process)
|
||||
|
||||
|
||||
class _Cancelled(Exception):
|
||||
"""A cancellation signal arrived while the generator was running."""
|
||||
|
||||
def __init__(self, signum: int) -> None:
|
||||
super().__init__(signum)
|
||||
self.signum = signum
|
||||
|
||||
|
||||
def _replay_cancellation(signum: int) -> None:
|
||||
"""Re-deliver a recorded cancellation through the restored handler.
|
||||
|
||||
Called once the temporary handlers are restored and the temporary file and
|
||||
directory are gone. ``raise_signal`` invokes exactly the handler currently
|
||||
installed (``KeyboardInterrupt`` for the default SIGINT action, the default
|
||||
terminating action for SIGTERM, or ``SIG_IGN``), so no handler-kind
|
||||
branching is needed.
|
||||
"""
|
||||
signal.raise_signal(int(signum))
|
||||
|
||||
|
||||
def run_generator(
|
||||
*, script: Path, cargo_lock: Path, temp_output: str, cwd: Path, environment: dict
|
||||
) -> int:
|
||||
"""Run the upstream generator in its own session; return its exit status.
|
||||
|
||||
A cancellation or any other error stops the generator's whole session
|
||||
before it is reported, so no detached generator or git process survives.
|
||||
"""
|
||||
run = _GeneratorRun()
|
||||
previous_handlers = {}
|
||||
try:
|
||||
# Install the handlers before Popen: a signal that arrives while the
|
||||
# child is being spawned (or between Popen returning and the assignment
|
||||
# below) must not leave an unmanaged session behind. It is recorded,
|
||||
# and the newly created process is stopped as soon as its PID is known.
|
||||
# Installing inside the outer try means a partial installation is
|
||||
# restored even if a later signal.signal() call fails unexpectedly.
|
||||
for signum in CANCELLATION_SIGNALS:
|
||||
try:
|
||||
previous_handlers[signum] = signal.signal(signum, run.cancel)
|
||||
except (OSError, ValueError):
|
||||
# signal.signal() only works in the main thread of the main
|
||||
# interpreter; the exception cleanup below still stops the
|
||||
# detached generator on KeyboardInterrupt elsewhere.
|
||||
pass
|
||||
process = subprocess.Popen(
|
||||
[sys.executable, str(script), str(cargo_lock), "-o", temp_output],
|
||||
cwd=str(cwd),
|
||||
env=environment,
|
||||
stdin=subprocess.DEVNULL,
|
||||
start_new_session=os.name == "posix",
|
||||
)
|
||||
run.process = process
|
||||
if run.received is not None:
|
||||
_terminate_generator(process)
|
||||
try:
|
||||
process.wait(timeout=GENERATOR_TIMEOUT_SECONDS)
|
||||
except subprocess.TimeoutExpired:
|
||||
_terminate_generator(process)
|
||||
if run.received is None:
|
||||
raise
|
||||
# A cancellation arrived while reaping the timed-out session; it
|
||||
# is reported below as the cancellation it is.
|
||||
except BaseException:
|
||||
# KeyboardInterrupt without a temporary handler (non-main thread)
|
||||
# or any other error: never leave the generator detached.
|
||||
_terminate_generator(process)
|
||||
raise
|
||||
returncode = process.returncode
|
||||
finally:
|
||||
for signum, handler in previous_handlers.items():
|
||||
signal.signal(signum, handler)
|
||||
# Read the recorded signal after restoring the handlers: a signal that
|
||||
# arrives between the child being reaped and the restoration above is
|
||||
# recorded by run.cancel and must still be reported, not silently dropped.
|
||||
received = run.received
|
||||
if received is not None:
|
||||
raise _Cancelled(received)
|
||||
return returncode
|
||||
|
||||
|
||||
def default_cache_dir() -> Path:
|
||||
base = os.environ.get("XDG_CACHE_HOME")
|
||||
if base:
|
||||
return Path(base) / "lightcraft-flatpak-cargo"
|
||||
return Path.home() / ".cache" / "lightcraft-flatpak-cargo"
|
||||
|
||||
|
||||
def ensure_outside_source(cache: Path, repo_root: Path) -> None:
|
||||
cache = cache.resolve()
|
||||
if cache == repo_root or repo_root in cache.parents:
|
||||
raise SystemExit(f"error: cache directory must be outside the source checkout: {cache}")
|
||||
target = os.environ.get("CARGO_TARGET_DIR")
|
||||
if target:
|
||||
target_path = Path(target)
|
||||
# Cargo runs in repo_root, even when this wrapper was invoked elsewhere.
|
||||
if not target_path.is_absolute():
|
||||
target_path = repo_root / target_path
|
||||
target_path = target_path.resolve()
|
||||
if cache == target_path or target_path in cache.parents:
|
||||
raise SystemExit(f"error: cache directory must be outside CARGO_TARGET_DIR: {cache}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
repo_root = Path(__file__).resolve().parents[3]
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"cargo_lock",
|
||||
nargs="?",
|
||||
default=str(repo_root / "Cargo.lock"),
|
||||
help="Cargo.lock to read (default: repository Cargo.lock)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-o",
|
||||
"--output",
|
||||
default=str(repo_root / "packaging/linux/flatpak/cargo-sources.json"),
|
||||
help="output path (default: packaging/linux/flatpak/cargo-sources.json)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--cache-dir",
|
||||
default=None,
|
||||
help=(
|
||||
"git clone cache root (default: $XDG_CACHE_HOME/lightcraft-flatpak-cargo, "
|
||||
"or ~/.cache/lightcraft-flatpak-cargo)"
|
||||
),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
cargo_lock = Path(args.cargo_lock).resolve()
|
||||
output = Path(args.output).resolve()
|
||||
cache = Path(args.cache_dir).resolve() if args.cache_dir else default_cache_dir().resolve()
|
||||
if not cargo_lock.is_file():
|
||||
raise SystemExit(f"error: Cargo.lock not found: {cargo_lock}")
|
||||
ensure_outside_source(cache, repo_root)
|
||||
cache.mkdir(parents=True, exist_ok=True)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
generator = patched_upstream(download_upstream())
|
||||
|
||||
cancelled = None
|
||||
with tempfile.TemporaryDirectory(prefix="lightcraft-flatpak-generator-") as tmpdir:
|
||||
script = Path(tmpdir) / "flatpak-cargo-generator.py"
|
||||
script.write_bytes(generator)
|
||||
|
||||
descriptor, temp_output = tempfile.mkstemp(
|
||||
prefix=f".{output.name}.", suffix=".tmp", dir=output.parent
|
||||
)
|
||||
os.close(descriptor)
|
||||
try:
|
||||
environment = os.environ.copy()
|
||||
environment["XDG_CACHE_HOME"] = str(cache)
|
||||
returncode = run_generator(
|
||||
script=script,
|
||||
cargo_lock=cargo_lock,
|
||||
temp_output=temp_output,
|
||||
cwd=repo_root,
|
||||
environment=environment,
|
||||
)
|
||||
if returncode != 0:
|
||||
return returncode
|
||||
if os.path.getsize(temp_output) == 0:
|
||||
raise SystemExit("error: generator produced an empty file")
|
||||
with open(temp_output, "r", encoding="utf-8") as handle:
|
||||
sources = json.load(handle)
|
||||
if not isinstance(sources, list) or not sources:
|
||||
raise SystemExit("error: generated sources are not a non-empty list")
|
||||
# Keep the owner-only mkstemp mode through the atomic replacement.
|
||||
os.replace(temp_output, output)
|
||||
except subprocess.TimeoutExpired as error:
|
||||
raise SystemExit(
|
||||
f"error: inventory generation exceeded {GENERATOR_TIMEOUT_SECONDS} seconds; "
|
||||
"the existing inventory was not changed"
|
||||
) from error
|
||||
except _Cancelled as event:
|
||||
# Replayed below, after the temporary file and script directory are
|
||||
# removed, so cancellation leaves no leftovers.
|
||||
cancelled = event
|
||||
finally:
|
||||
if os.path.exists(temp_output):
|
||||
os.unlink(temp_output)
|
||||
|
||||
if cancelled is not None:
|
||||
_replay_cancellation(cancelled.signum)
|
||||
raise SystemExit(f"error: generator cancelled by signal {cancelled.signum}")
|
||||
|
||||
print(f"wrote {output}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,483 @@
|
||||
"""Failure-path and cancellation checks for the LightCraft inventory wrapper."""
|
||||
import importlib.util
|
||||
import io
|
||||
import hashlib
|
||||
import stat
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.dont_write_bytecode = True
|
||||
SOURCE = Path(__file__).resolve().with_name('generate-cargo-sources.py')
|
||||
spec = importlib.util.spec_from_file_location('inventory_wrapper', SOURCE)
|
||||
wrapper = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(wrapper)
|
||||
|
||||
# Deliberate sabotage for checking that the cancellation tests are meaningful.
|
||||
# Run with LIGHTCRAFT_FLATPAK_TEST_BREAK_KILL=1: the process-group kill becomes
|
||||
# a no-op and the cancellation/timeout tests must fail, proving they observe
|
||||
# real liveness instead of relying on teardown.
|
||||
BREAK_KILL = os.environ.get('LIGHTCRAFT_FLATPAK_TEST_BREAK_KILL') == '1'
|
||||
if BREAK_KILL:
|
||||
wrapper._kill_generator = lambda process: None
|
||||
|
||||
# The process-group tests use POSIX killpg plus /proc to tell an unreaped
|
||||
# zombie descendant from a running one; both are Linux-specific here, so they
|
||||
# are skipped explicitly rather than pretending to be portable POSIX.
|
||||
LINUX_ONLY = sys.platform.startswith('linux')
|
||||
LINUX_SKIP = 'process-group cancellation is verified on Linux (/proc liveness, killpg)'
|
||||
|
||||
# Process identity recorded with each PID, so teardown never kills a recycled
|
||||
# PID: /proc/<pid>/stat starttime is monotonic per PID on Linux.
|
||||
PYTHON_IDENTITY = (
|
||||
'def write_identity(path, pid):\n'
|
||||
' stat = pathlib.Path(f"/proc/{pid}/stat").read_text()\n'
|
||||
' starttime = stat.rsplit(")", 1)[1].split()[19]\n'
|
||||
' path.write_text(f"{pid} {starttime}")\n'
|
||||
)
|
||||
|
||||
# A descendant that keeps writing into the cache until it is killed.
|
||||
DESCENDANT = (
|
||||
'import os, pathlib, sys, time\n'
|
||||
+ PYTHON_IDENTITY +
|
||||
'heartbeat, marker, pidfile = (pathlib.Path(arg) for arg in sys.argv[1:])\n'
|
||||
'write_identity(pidfile, os.getpid())\n'
|
||||
'with heartbeat.open("ab", buffering=0) as handle:\n'
|
||||
' for _ in range(1000):\n'
|
||||
' handle.write(b"x")\n'
|
||||
' time.sleep(0.02)\n'
|
||||
'marker.write_bytes(b"descendant survived")\n'
|
||||
)
|
||||
|
||||
|
||||
def detached_generator():
|
||||
"""Fake generator: starts a heartbeating descendant, then waits."""
|
||||
return (
|
||||
'import os, pathlib, subprocess, sys, time\n'
|
||||
+ PYTHON_IDENTITY +
|
||||
f'CHILD = {DESCENDANT!r}\n'
|
||||
'root = pathlib.Path(sys.argv[-1]).parent\n'
|
||||
'write_identity(root / "generator.pid", os.getpid())\n'
|
||||
'child_script = root / "descendant.py"\n'
|
||||
'child_script.write_text(CHILD)\n'
|
||||
'subprocess.Popen([sys.executable, str(child_script), str(root / "cache" / "heartbeat"), str(root / "cache" / "after-timeout"), str(root / "descendant.pid")])\n'
|
||||
'deadline = time.monotonic() + 10\n'
|
||||
'while time.monotonic() < deadline:\n'
|
||||
' heartbeat = root / "cache" / "heartbeat"\n'
|
||||
' if heartbeat.exists() and heartbeat.stat().st_size:\n'
|
||||
' break\n'
|
||||
' time.sleep(0.01)\n'
|
||||
'else:\n'
|
||||
' raise SystemExit("descendant never wrote a heartbeat")\n'
|
||||
'(root / "ready").write_bytes(b"handshake")\n'
|
||||
'time.sleep(30)\n'
|
||||
).encode()
|
||||
|
||||
|
||||
# Stands in for `python3 generate-cargo-sources.py` so a real signal can be
|
||||
# delivered to the wrapper while the detached generator is running. The fake
|
||||
# payload keeps the test offline and the timeout long enough to be cancelled.
|
||||
DRIVER = '''\
|
||||
import importlib.util
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
source, fixture, lock, output, cache = sys.argv[1:6]
|
||||
spec = importlib.util.spec_from_file_location("inventory_wrapper", source)
|
||||
wrapper = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(wrapper)
|
||||
if os.environ.get("LIGHTCRAFT_FLATPAK_TEST_BREAK_KILL") == "1":
|
||||
wrapper._kill_generator = lambda process: None
|
||||
payload = Path(fixture).read_bytes()
|
||||
wrapper.download_upstream = lambda: payload
|
||||
wrapper.patched_upstream = lambda data: data
|
||||
wrapper.GENERATOR_TIMEOUT_SECONDS = 300
|
||||
sys.argv = ["generate", lock, "-o", output, "--cache-dir", cache]
|
||||
try:
|
||||
raise SystemExit(wrapper.main())
|
||||
except KeyboardInterrupt:
|
||||
Path(fixture).with_name("interrupted").write_bytes(b"KeyboardInterrupt")
|
||||
raise SystemExit(130)
|
||||
'''
|
||||
|
||||
|
||||
class InventoryFailures(unittest.TestCase):
|
||||
def test_download_accepts_verified_bytes(self):
|
||||
payload = b'verified fixture'
|
||||
with patch.object(wrapper, 'urlopen', return_value=io.BytesIO(payload)), patch.object(wrapper, 'UPSTREAM_SHA256', hashlib.sha256(payload).hexdigest()):
|
||||
self.assertEqual(wrapper.download_upstream(), payload)
|
||||
|
||||
def test_patch_applies_both_changes_and_checks_result_hash(self):
|
||||
source = '\n'.join(old for _, old, _ in wrapper.PATCHES).encode()
|
||||
expected = '\n'.join(new for _, _, new in wrapper.PATCHES).encode()
|
||||
with patch.object(wrapper, 'PATCHED_SHA256', hashlib.sha256(expected).hexdigest()):
|
||||
self.assertEqual(wrapper.patched_upstream(source), expected)
|
||||
with patch.object(wrapper, 'PATCHED_SHA256', '0' * 64):
|
||||
with self.assertRaises(SystemExit):
|
||||
wrapper.patched_upstream(source)
|
||||
|
||||
def test_success_atomically_replaces_inventory_with_owner_only_file(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp).resolve()
|
||||
lock = root / 'Cargo.lock'
|
||||
lock.write_text('version = 4\n')
|
||||
output = root / 'sources.json'
|
||||
output.write_bytes(b'old inventory')
|
||||
payload = b'[{"type": "archive", "url": "https://example.invalid/source"}]'
|
||||
def generate(*args, **kwargs):
|
||||
temporary = Path(kwargs['temp_output'])
|
||||
self.assertEqual(output.read_bytes(), b'old inventory')
|
||||
if os.name == 'posix':
|
||||
self.assertEqual(stat.S_IMODE(temporary.stat().st_mode), 0o600)
|
||||
temporary.write_bytes(payload)
|
||||
return 0
|
||||
args = ['generate', str(lock), '-o', str(output), '--cache-dir', str(root / 'cache')]
|
||||
with patch.object(sys, 'argv', args), patch.object(wrapper, 'download_upstream', return_value=b'fixture'), patch.object(wrapper, 'patched_upstream', return_value=b'fixture'), patch.object(wrapper, 'run_generator', side_effect=generate):
|
||||
self.assertEqual(wrapper.main(), 0)
|
||||
self.assertEqual(output.read_bytes(), payload)
|
||||
if os.name == 'posix':
|
||||
self.assertEqual(stat.S_IMODE(output.stat().st_mode), 0o600)
|
||||
self.assertEqual(list(root.glob('.sources.json.*.tmp')), [])
|
||||
|
||||
def test_download_rejects_changed_bytes(self):
|
||||
with patch.object(wrapper, 'urlopen', return_value=io.BytesIO(b'changed upstream')):
|
||||
with self.assertRaisesRegex(SystemExit, 'checksum mismatch'):
|
||||
wrapper.download_upstream()
|
||||
|
||||
def test_download_rejects_excessive_size(self):
|
||||
with patch.object(wrapper, 'urlopen', return_value=io.BytesIO(b'x' * 1_000_001)):
|
||||
with self.assertRaisesRegex(SystemExit, 'unexpectedly large'):
|
||||
wrapper.download_upstream()
|
||||
|
||||
def test_patch_refuses_unrecognized_source(self):
|
||||
with self.assertRaisesRegex(SystemExit, 'matched 0 times'):
|
||||
wrapper.patched_upstream(b'not the expected generator')
|
||||
|
||||
def test_cache_cannot_be_inside_checkout_or_target(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp).resolve()
|
||||
for cache in (root / 'repo', root / 'repo/subdir', root / 'target', root / 'target/subdir'):
|
||||
with patch.dict(os.environ, {'CARGO_TARGET_DIR': str(root / 'target')}):
|
||||
with self.assertRaises(SystemExit):
|
||||
wrapper.ensure_outside_source(cache, root / 'repo')
|
||||
|
||||
def test_relative_target_uses_generator_working_directory(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp).resolve()
|
||||
# The wrapper can be invoked elsewhere; its child runs in repo_root.
|
||||
elsewhere = root / 'elsewhere/deeper'
|
||||
elsewhere.mkdir(parents=True)
|
||||
previous = Path.cwd()
|
||||
try:
|
||||
os.chdir(elsewhere)
|
||||
with patch.dict(os.environ, {'CARGO_TARGET_DIR': '../target'}):
|
||||
with self.assertRaisesRegex(SystemExit, 'outside CARGO_TARGET_DIR'):
|
||||
wrapper.ensure_outside_source(root / 'target/cache', root / 'repo')
|
||||
finally:
|
||||
os.chdir(previous)
|
||||
|
||||
def test_timed_out_generator_preserves_inventory(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp).resolve()
|
||||
lock = root / 'Cargo.lock'
|
||||
lock.write_text('version = 4\n')
|
||||
output = root / 'sources.json'
|
||||
output.write_bytes(b'original inventory')
|
||||
generator = b'import pathlib, sys, time\npathlib.Path(sys.argv[-1]).write_bytes(b"partial")\ntime.sleep(30)\n'
|
||||
args = ['generate', str(lock), '-o', str(output), '--cache-dir', str(root / 'cache')]
|
||||
with patch.object(sys, 'argv', args), patch.object(wrapper, 'download_upstream', return_value=generator), patch.object(wrapper, 'patched_upstream', side_effect=lambda data: data), patch.object(wrapper, 'GENERATOR_TIMEOUT_SECONDS', 0.2):
|
||||
with self.assertRaisesRegex(SystemExit, 'existing inventory was not changed'):
|
||||
wrapper.main()
|
||||
self.assertEqual(output.read_bytes(), b'original inventory')
|
||||
self.assertEqual(list(root.glob('.sources.json.*.tmp')), [])
|
||||
|
||||
def test_generator_signal_handlers_are_restored(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp).resolve()
|
||||
script = root / 'generator.py'
|
||||
script.write_text(
|
||||
'import pathlib, sys\n'
|
||||
'pathlib.Path(sys.argv[-1]).write_text("[]")\n'
|
||||
)
|
||||
lock = root / 'Cargo.lock'
|
||||
lock.write_text('version = 4\n')
|
||||
temp_output = root / 'sources.json'
|
||||
watched = (signal.SIGINT, signal.SIGTERM)
|
||||
before = {number: signal.getsignal(number) for number in watched}
|
||||
returncode = wrapper.run_generator(
|
||||
script=script,
|
||||
cargo_lock=lock,
|
||||
temp_output=str(temp_output),
|
||||
cwd=root,
|
||||
environment=os.environ.copy(),
|
||||
)
|
||||
after = {number: signal.getsignal(number) for number in watched}
|
||||
self.assertEqual(returncode, 0)
|
||||
self.assertEqual(temp_output.read_text(), '[]')
|
||||
self.assertEqual(before, after)
|
||||
|
||||
def test_signal_after_wait_is_not_lost_during_handler_restore(self):
|
||||
root = self._new_temp_root()
|
||||
script = root / 'generator.py'
|
||||
script.write_text(
|
||||
'import pathlib, sys\n'
|
||||
'pathlib.Path(sys.argv[-1]).write_text("[]")\n'
|
||||
)
|
||||
lock = root / 'Cargo.lock'
|
||||
lock.write_text('version = 4\n')
|
||||
temp_output = root / 'sources.json'
|
||||
watched = (signal.SIGINT, signal.SIGTERM)
|
||||
before = {number: signal.getsignal(number) for number in watched}
|
||||
real_signal = signal.signal
|
||||
calls = {'count': 0}
|
||||
captured = {}
|
||||
|
||||
def restore_with_signal(signum, handler):
|
||||
calls['count'] += 1
|
||||
if calls['count'] <= len(watched):
|
||||
captured['handler'] = handler
|
||||
elif calls['count'] == len(watched) + 1:
|
||||
# The child has been reaped and run_generator is restoring the
|
||||
# handlers. Invoke the still-installed run.cancel handler now:
|
||||
# it records the cancellation after run_generator's old
|
||||
# snapshot point, so only the post-restoration read can report
|
||||
# it. This is deterministic, unlike racing a real signal.
|
||||
captured['handler'](signal.SIGINT, None)
|
||||
return real_signal(signum, handler)
|
||||
|
||||
environment = os.environ.copy()
|
||||
output_path = str(temp_output)
|
||||
try:
|
||||
with patch.object(wrapper.signal, 'signal', side_effect=restore_with_signal):
|
||||
with self.assertRaises(wrapper._Cancelled) as caught:
|
||||
wrapper.run_generator(
|
||||
script=script,
|
||||
cargo_lock=lock,
|
||||
temp_output=output_path,
|
||||
cwd=root,
|
||||
environment=environment,
|
||||
)
|
||||
finally:
|
||||
for number in watched:
|
||||
real_signal(number, before[number])
|
||||
self.assertEqual(caught.exception.signum, signal.SIGINT)
|
||||
|
||||
@unittest.skipUnless(LINUX_ONLY, LINUX_SKIP)
|
||||
def test_timed_out_generator_stops_descendants(self):
|
||||
root = self._new_temp_root()
|
||||
lock = root / 'Cargo.lock'
|
||||
lock.write_text('version = 4\n')
|
||||
output = root / 'sources.json'
|
||||
output.write_bytes(b'original inventory')
|
||||
args = ['generate', str(lock), '-o', str(output), '--cache-dir', str(root / 'cache')]
|
||||
try:
|
||||
with patch.object(sys, 'argv', args), patch.object(wrapper, 'download_upstream', return_value=detached_generator()), patch.object(wrapper, 'patched_upstream', side_effect=lambda data: data), patch.object(wrapper, 'GENERATOR_TIMEOUT_SECONDS', 3.0):
|
||||
with self.assertRaisesRegex(SystemExit, 'existing inventory was not changed'):
|
||||
wrapper.main()
|
||||
|
||||
# Assert liveness and a frozen heartbeat before any teardown kill,
|
||||
# so a broken group kill cannot be masked by the test's cleanup.
|
||||
self.assertTrue((root / 'ready').exists())
|
||||
self._assert_generator_session_gone(root)
|
||||
self.assertEqual(output.read_bytes(), b'original inventory')
|
||||
self.assertEqual(list(root.glob('.sources.json.*.tmp')), [])
|
||||
except BaseException:
|
||||
self._preserve_failure_evidence(root, 'timeout')
|
||||
raise
|
||||
|
||||
@unittest.skipUnless(LINUX_ONLY, LINUX_SKIP)
|
||||
def test_sigint_cancellation_stops_generator_session(self):
|
||||
self._assert_cancellation(signal.SIGINT, expected_returncode=130)
|
||||
|
||||
@unittest.skipUnless(LINUX_ONLY, LINUX_SKIP)
|
||||
def test_sigterm_cancellation_stops_generator_session(self):
|
||||
self._assert_cancellation(signal.SIGTERM, expected_returncode=-signal.SIGTERM)
|
||||
|
||||
def _assert_cancellation(self, number, *, expected_returncode):
|
||||
root = self._new_temp_root()
|
||||
driver, output = self._start_generation_driver(root)
|
||||
try:
|
||||
driver.send_signal(number)
|
||||
returncode = driver.wait(timeout=30)
|
||||
self.assertEqual(returncode, expected_returncode, (root / 'driver.err').read_text())
|
||||
if number == signal.SIGINT:
|
||||
# KeyboardInterrupt reached the wrapper's caller after cleanup.
|
||||
self.assertEqual((root / 'interrupted').read_bytes(), b'KeyboardInterrupt')
|
||||
else:
|
||||
# SIGTERM kept its default terminating action.
|
||||
self.assertFalse((root / 'interrupted').exists())
|
||||
|
||||
# Assert the process group is dead and the heartbeat has stopped
|
||||
# before any teardown kill. Cleanup is registered with addCleanup,
|
||||
# so it runs after these assertions (also when they fail).
|
||||
self._assert_generator_session_gone(root)
|
||||
self.assertEqual(output.read_bytes(), b'original inventory')
|
||||
self.assertEqual(list(root.glob('.sources.json.*.tmp')), [])
|
||||
except BaseException:
|
||||
self._preserve_failure_evidence(root, f'cancel-{int(number)}')
|
||||
raise
|
||||
|
||||
def _new_temp_root(self):
|
||||
temp = tempfile.TemporaryDirectory(prefix='lightcraft-flatpak-test-')
|
||||
self.addCleanup(temp.cleanup)
|
||||
root = Path(temp.name).resolve()
|
||||
# Registered after temp.cleanup, so addCleanup's LIFO order stops any
|
||||
# surviving test processes before their evidence directory disappears.
|
||||
self.addCleanup(self._stop_pids, root)
|
||||
return root
|
||||
|
||||
def _start_generation_driver(self, root):
|
||||
lock = root / 'Cargo.lock'
|
||||
lock.write_text('version = 4\n')
|
||||
output = root / 'sources.json'
|
||||
output.write_bytes(b'original inventory')
|
||||
fixture = root / 'generator-fixture.py'
|
||||
fixture.write_bytes(detached_generator())
|
||||
driver_path = root / 'driver.py'
|
||||
driver_path.write_text(DRIVER)
|
||||
environment = {
|
||||
key: value for key, value in os.environ.items() if key != 'CARGO_TARGET_DIR'
|
||||
}
|
||||
stdout = (root / 'driver.out').open('wb')
|
||||
stderr = (root / 'driver.err').open('wb')
|
||||
self.addCleanup(stdout.close)
|
||||
self.addCleanup(stderr.close)
|
||||
driver = subprocess.Popen(
|
||||
[
|
||||
sys.executable, '-B', str(driver_path), str(SOURCE), str(fixture),
|
||||
str(lock), str(output), str(root / 'cache'),
|
||||
],
|
||||
cwd=str(root),
|
||||
env=environment,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
)
|
||||
self.addCleanup(self._stop_driver, driver, root)
|
||||
deadline = time.monotonic() + 20
|
||||
while not (root / 'ready').exists():
|
||||
if driver.poll() is not None:
|
||||
self.fail(
|
||||
'driver exited before the generator was ready:\n'
|
||||
+ (root / 'driver.err').read_text()
|
||||
)
|
||||
if time.monotonic() > deadline:
|
||||
self.fail('driver did not start the generator descendant')
|
||||
time.sleep(0.02)
|
||||
return driver, output
|
||||
|
||||
def _stop_driver(self, driver, root):
|
||||
if driver.poll() is None:
|
||||
driver.kill()
|
||||
try:
|
||||
driver.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
self._stop_pids(root)
|
||||
|
||||
@staticmethod
|
||||
def _read_identity(root, name):
|
||||
try:
|
||||
pid_text, starttime = (root / name).read_text().split()
|
||||
return int(pid_text), starttime
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _identity_is_running(pid, starttime):
|
||||
"""True only for the recorded process, never for a recycled PID."""
|
||||
try:
|
||||
stat = Path(f'/proc/{pid}/stat').read_text()
|
||||
except OSError:
|
||||
return False
|
||||
fields = stat.rsplit(')', 1)[1].split()
|
||||
return len(fields) > 19 and fields[0] not in ('Z', 'X') and fields[19] == starttime
|
||||
|
||||
@classmethod
|
||||
def _stop_pids(cls, root):
|
||||
for name in ('generator.pid', 'descendant.pid'):
|
||||
identity = cls._read_identity(root, name)
|
||||
if identity is None:
|
||||
continue
|
||||
pid, starttime = identity
|
||||
if not cls._identity_is_running(pid, starttime):
|
||||
continue
|
||||
try:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
except OSError:
|
||||
pass
|
||||
# Let teardown observe the kill before the evidence directory is
|
||||
# removed; identity checks make this a no-op for unrelated PIDs.
|
||||
deadline = time.monotonic() + 2
|
||||
while time.monotonic() < deadline:
|
||||
if not any(
|
||||
identity is not None and cls._identity_is_running(*identity)
|
||||
for identity in (cls._read_identity(root, name) for name in ('generator.pid', 'descendant.pid'))
|
||||
):
|
||||
break
|
||||
time.sleep(0.02)
|
||||
|
||||
@staticmethod
|
||||
def _preserve_failure_evidence(root, label):
|
||||
destination = Path(tempfile.mkdtemp(prefix=f'lightcraft-flatpak-{label}-'))
|
||||
for path in root.rglob('*'):
|
||||
if not path.is_file():
|
||||
continue
|
||||
target = destination / path.relative_to(root)
|
||||
try:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(path, target)
|
||||
except OSError:
|
||||
pass
|
||||
print(f'failure evidence preserved in {destination}', file=sys.stderr)
|
||||
|
||||
def _assert_generator_session_gone(self, root):
|
||||
for name in ('generator.pid', 'descendant.pid'):
|
||||
identity = self._read_identity(root, name)
|
||||
self.assertIsNotNone(identity, f'{name} was not recorded')
|
||||
pid, starttime = identity
|
||||
deadline = time.monotonic() + 2
|
||||
while self._identity_is_running(pid, starttime) and time.monotonic() < deadline:
|
||||
time.sleep(0.02)
|
||||
self.assertFalse(
|
||||
self._identity_is_running(pid, starttime),
|
||||
f'{name} process {pid} is still running',
|
||||
)
|
||||
|
||||
heartbeat = (root / 'cache' / 'heartbeat').read_bytes()
|
||||
self.assertTrue(heartbeat)
|
||||
time.sleep(0.5)
|
||||
self.assertEqual((root / 'cache' / 'heartbeat').read_bytes(), heartbeat)
|
||||
self.assertFalse((root / 'cache' / 'after-timeout').exists())
|
||||
|
||||
def test_failed_or_invalid_generation_preserves_inventory(self):
|
||||
for code, payload in [(17, b'partial'), (0, b''), (0, b'invalid json'), (0, b'[]'), (0, b'{}')]:
|
||||
with self.subTest(code=code, payload=payload), tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp).resolve()
|
||||
lock = root / 'Cargo.lock'
|
||||
lock.write_text('version = 4\n')
|
||||
output = root / 'sources.json'
|
||||
output.write_bytes(b'original inventory')
|
||||
def generate(*args, **kwargs):
|
||||
Path(kwargs['temp_output']).write_bytes(payload)
|
||||
return code
|
||||
args = ['generate', str(lock), '-o', str(output), '--cache-dir', str(root / 'cache')]
|
||||
with patch.object(sys, 'argv', args), patch.object(wrapper, 'download_upstream', return_value=b'fixture'), patch.object(wrapper, 'patched_upstream', return_value=b'fixture'), patch.object(wrapper, 'run_generator', side_effect=generate):
|
||||
if code:
|
||||
self.assertEqual(wrapper.main(), code)
|
||||
else:
|
||||
with self.assertRaises((SystemExit, ValueError)):
|
||||
wrapper.main()
|
||||
self.assertEqual(output.read_bytes(), b'original inventory')
|
||||
self.assertEqual(list(root.glob('.sources.json.*.tmp')), [])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main(verbosity=2)
|
||||
@@ -1,3 +1,9 @@
|
||||
sonar.projectKey=storytold_lightcraft
|
||||
sonar.organization=storytold
|
||||
sonar.coverage.exclusions=**/tools/**,**/docs/**,**/examples/**
|
||||
|
||||
# Keep packaging tests analyzed as tests, outside production coverage metrics.
|
||||
sonar.tests=packaging/linux/flatpak
|
||||
sonar.test.inclusions=packaging/linux/flatpak/test_*.py
|
||||
sonar.exclusions=packaging/linux/flatpak/test_*.py
|
||||
sonar.python.coverage.reportPaths=python-coverage.xml
|
||||
|
||||
Reference in New Issue
Block a user