Files
lightcraft/crates/ui-egui/src/tests_model_setup.rs
T
Claude d7f868fc6d LR-EDIT-DETAIL-DENOISE: dismissing the model setup turns AI Denoise back off (#922)
Turning on AI Denoise without a model opens Settings > AI Denoise and
keeps a pending request, so the switch showed checked and Settings
offered "Cancel pending action". Closing the setup with no model
download or installation running now drops that request: the switch
goes back off and nothing claims to be pending. A download started
before closing still turns AI Denoise on when it finishes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
2026-10-11 12:00:15 +00:00

224 lines
9.4 KiB
Rust

//! Synthetic setup flows: model installation must resume the requested action on its original targets.
use std::path::{Path, PathBuf};
use lightcraft_catalog::{MediaKind, Op, Photo, PhotoId, Source};
use lightcraft_denoise::manifest::{DenoiserManifest, Domain, Gain};
use lightcraft_engine::Session;
use serde_json::json;
use crate::{LightcraftApp, Services, model_setup, state::Dialog};
fn setup(name: &str) -> (LightcraftApp, PathBuf, PhotoId) {
let dir = std::env::temp_dir().join(format!("lc-model-flow-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut s = Session::new().with_fs();
s.open_library(dir.join("library"), true).unwrap();
s.set_denoise_models_dir(Some(dir.join("denoise")));
s.execute("denoise.settings", &json!({"runOn": "cpu"})).unwrap();
let id = PhotoId(s.catalog.photos().map(|p| p.id.0).max().unwrap() + 1);
let mut raw = Photo::new(
id,
Source::File { path: dir.join("synthetic.dng").to_string_lossy().into_owned() },
"synthetic.dng",
"DNG",
128,
128,
"2026-10-07",
);
raw.kind = MediaKind::Raw;
s.commit("fixture", Op::AddPhoto { photo: Box::new(raw) }).unwrap();
(LightcraftApp::new(s, Services::default()), dir, id)
}
fn denoise_file(dir: &Path) -> PathBuf {
let source = dir.join("source");
std::fs::create_dir_all(&source).unwrap();
let bytes = lightcraft_denoise::synthetic::smoothing_onnx();
std::fs::write(source.join("model.onnx"), &bytes).unwrap();
let m = DenoiserManifest {
id: "test-smoothing".into(),
name: "Synthetic smoothing".into(),
version: "1".into(),
licence: Default::default(),
source: None,
sha256: None,
size_bytes: Some(bytes.len() as u64),
provenance: "Synthetic tests".into(),
domain: Domain::BayerToRgb,
tile: 64,
overlap: 16,
gain: Gain::None,
};
std::fs::write(source.join("denoise-model.json"), serde_json::to_vec(&m).unwrap()).unwrap();
source.join("model.onnx")
}
fn install_denoise(app: &mut LightcraftApp, dir: &Path) {
app.run("denoise.models.install", json!({"path": denoise_file(dir), "acknowledged": true})).unwrap();
}
#[test]
fn accepting_a_local_model_installs_in_background_and_resumes_the_pending_action() {
let (mut app, dir, raw) = setup("local-install");
let path = denoise_file(&dir);
app.run("denoise.toggle", json!({"id": raw.0, "enabled": true})).unwrap();
let info = app.run("denoise.models.inspect", json!({"path": path})).unwrap();
assert!(crate::panels::denoise::install(&mut app, &info, false).is_err());
assert!(!dir.join("denoise/test-smoothing/model.onnx").exists());
let result = crate::panels::denoise::install(&mut app, &info, true).unwrap();
assert_eq!(result["started"], "local-install");
assert_eq!(app.ui.dialog, Some(Dialog::Settings { tab: "denoise".into() }));
let ctx = egui::Context::default();
for _ in 0..600 {
crate::panels::denoise::pump(&mut app, &ctx);
if app.session.denoise_photo_state(raw) != lightcraft_engine::denoise::PhotoState::NoModel {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
resume(&mut app);
assert_eq!(amount(&app, raw), 50.0);
assert!(dir.join("denoise/test-smoothing/model.onnx").is_file());
assert!(path.is_file(), "a user-supplied original is preserved");
drop(app);
let _ = std::fs::remove_dir_all(dir);
}
fn amount(app: &LightcraftApp, id: PhotoId) -> f64 {
app.session.develop_of(id).unwrap().enhance.denoise
}
fn resume(app: &mut LightcraftApp) {
model_setup::pump(app, &egui::Context::default());
}
#[test]
fn denoise_installation_enables_only_the_requesting_photo_once_and_is_undoable() {
let (mut app, dir, raw) = setup("denoise");
let other = app.session.active().unwrap();
app.session.auto_sync = true;
let r = app.run("denoise.toggle", json!({"id": raw.0, "enabled": true})).unwrap();
assert_eq!(r["setupRequired"], true);
assert_eq!(app.ui.dialog, Some(Dialog::Settings { tab: "denoise".into() }));
assert_eq!(amount(&app, raw), 0.0, "setup alone never changes the photo");
app.run("library.select", json!({"ids": [other.0]})).unwrap();
let undo = app.session.undo.len();
install_denoise(&mut app, &dir);
resume(&mut app);
assert_eq!(amount(&app, raw), 50.0);
assert_eq!(amount(&app, other), 0.0);
assert_eq!(app.session.active(), Some(other));
assert_eq!(app.session.undo.len(), undo + 1);
resume(&mut app);
assert_eq!(app.session.undo.len(), undo + 1, "a later frame cannot apply it again");
app.run("edit.undo", json!({})).unwrap();
assert_eq!(amount(&app, raw), 0.0);
app.run("denoise.toggle", json!({"id": raw.0, "enabled": true})).unwrap();
app.run("develop.set", json!({"ids": [raw.0], "control": "enhance.denoise", "value": 75})).unwrap();
app.run("denoise.toggle", json!({"id": raw.0, "enabled": true})).unwrap();
assert_eq!(amount(&app, raw), 75.0, "enabling an already chosen amount keeps it");
app.run("denoise.toggle", json!({"id": raw.0, "enabled": false})).unwrap();
assert_eq!(amount(&app, raw), 75.0, "switching off keeps the Amount");
assert_eq!(app.session.develop_of(raw).unwrap().denoise_amount(), 0.0, "but mixes nothing in");
drop(app);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn cancelled_denoise_requests_do_not_apply_after_installation() {
for (name, command, params) in [
("cancel-action", "modelSetup.cancel", json!({"kind": "denoise"})),
("cancel-toggle", "denoise.toggle", json!({"enabled": false})),
("cancel-download", "denoise.models.downloadCancel", json!({"id": "rawnind-bayer"})),
] {
let (mut app, dir, raw) = setup(name);
app.run("library.select", json!({"ids": [raw.0]})).unwrap();
app.run("denoise.toggle", json!({"enabled": true})).unwrap();
app.run(command, params).unwrap();
install_denoise(&mut app, &dir);
resume(&mut app);
assert_eq!(amount(&app, raw), 0.0, "{name}");
drop(app);
let _ = std::fs::remove_dir_all(dir);
}
}
#[test]
fn pending_actions_do_not_cross_libraries_or_relinked_sources() {
for changed in ["library", "source"] {
let (mut app, dir, raw) = setup(changed);
app.run("denoise.toggle", json!({"id": raw.0, "enabled": true})).unwrap();
if changed == "library" {
app.session.open_library(dir.join("other-library"), true).unwrap();
} else {
app.session
.commit(
"relink",
Op::Relink {
id: raw,
file_name: "another.dng".into(),
source: Source::File { path: dir.join("another.dng").to_string_lossy().into_owned() },
format: Some("DNG".into()),
},
)
.unwrap();
}
install_denoise(&mut app, &dir);
resume(&mut app);
assert!(!model_setup::denoise_requested(&app, raw));
assert!(app.session.catalog.photos().all(|p| p.develop.enhance.denoise == 0.0));
assert!(app.ui.status.contains("cancelled"));
drop(app);
let _ = std::fs::remove_dir_all(dir);
}
}
#[test]
fn hostile_or_inapplicable_toggle_arguments_are_errors_without_changes() {
let (mut app, dir, raw) = setup("hostile");
let jpeg = app.session.active().unwrap();
for params in [json!({"id": "wrong"}), json!({"id": u64::MAX}), json!({"id": raw.0, "enabled": 1}), json!({"id": jpeg.0, "enabled": true})] {
assert!(app.run("denoise.toggle", params).is_err());
assert_eq!(amount(&app, raw), 0.0);
}
assert!(app.run("modelSetup.cancel", json!({"kind": "nonsense"})).is_err());
drop(app);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn model_installation_waits_for_an_active_slider_gesture_to_finish() {
let (mut app, dir, raw) = setup("interaction");
app.run("denoise.toggle", json!({"id": raw.0, "enabled": true})).unwrap();
app.session.begin_interaction("Exposure").unwrap();
install_denoise(&mut app, &dir);
resume(&mut app);
assert_eq!(amount(&app, raw), 0.0);
assert!(app.session.interaction.is_some());
app.session.end_interaction().unwrap();
resume(&mut app);
assert_eq!(amount(&app, raw), 50.0);
drop(app);
let _ = std::fs::remove_dir_all(dir);
}
/// Issue #922: closing the setup without installing a model turns the AI Denoise switch back off
/// and drops the "Cancel pending action" offer; while the setup stays open the request waits.
#[test]
fn dismissing_the_setup_without_a_model_leaves_ai_denoise_off() {
let (mut app, dir, raw) = setup("dismissed");
let r = app.run("denoise.toggle", json!({"id": raw.0, "enabled": true})).unwrap();
assert_eq!(r["setupRequired"], true);
resume(&mut app);
assert!(model_setup::denoise_requested(&app, raw), "the open setup keeps the request");
app.ui.dialog = None;
resume(&mut app);
assert!(!model_setup::denoise_requested(&app, raw), "a dismissed setup leaves nothing pending");
assert!(app.ui.status.contains("cancelled"), "{}", app.ui.status);
// installing later changes nothing; asking again offers the setup again
install_denoise(&mut app, &dir);
resume(&mut app);
assert_eq!(amount(&app, raw), 0.0);
drop(app);
let _ = std::fs::remove_dir_all(dir);
}