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:
insideTheFlux
2026-10-26 21:33:10 -07:00
co-authored by Claude Opus 5.5
57 changed files with 13099 additions and 438 deletions
+7 -1
View File
@@ -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
+49 -37
View File
@@ -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())
+5
View File
@@ -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())
}
+1 -7
View File
@@ -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();
+3
View File
@@ -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 }
+391
View File
@@ -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);
}
}
+4
View File
@@ -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;
+38 -9
View File
@@ -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());
+1 -1
View File
@@ -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";
+140 -3
View File
@@ -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);
+43 -4
View File
@@ -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
+100 -27
View File
@@ -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, &region(0.1, Some("Ann")).rect), "the named photo is done first: {r}");
assert!(s.faces.index.contains(a.0, &region(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, &region(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<_>>();
+50 -6
View File
@@ -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
View File
@@ -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.
+85 -7
View File
@@ -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);
+1
View File
@@ -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)]
+9 -3
View File
@@ -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);
+44 -4
View File
@@ -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
+20 -25
View File
@@ -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);
+17 -9
View File
@@ -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);
+11 -7
View File
@@ -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());
+33 -5
View File
@@ -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);
+6
View File
@@ -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,
+6 -3
View File
@@ -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
+11 -2
View File
@@ -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
View File
@@ -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);
+413 -7
View File
@@ -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();
+22
View File
@@ -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) {
+3
View File
@@ -82,3 +82,6 @@ mod view_cost;
mod wb_selector;
mod zoom;
mod zoom_keys;
pub(super) mod fixtures;
pub(super) mod render_fixtures;
+13 -8
View File
@@ -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");
+21 -8
View File
@@ -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)| {
+8 -6
View File
@@ -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}");
+20 -3
View File
@@ -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");
+25 -7
View File
@@ -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).
+15 -2
View File
@@ -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"}));
+754 -1
View File
@@ -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");
}
}
+8 -7
View File
@@ -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));
}
+86
View File
@@ -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);
}
}
+20 -14
View File
@@ -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);
}
+15 -23
View File
@@ -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()));
+84 -29
View File
@@ -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");
+7 -1
View File
@@ -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);
+6 -5
View File
@@ -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 {
+301 -97
View File
@@ -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 -1
View File
@@ -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}}));
+179
View File
@@ -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]);
}
}
+115 -16
View File
@@ -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();
+67
View File
@@ -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() {
+12 -3
View File
@@ -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));
}
}
+120
View File
@@ -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
View File
@@ -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)
+6
View File
@@ -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