use std::path::{Path, PathBuf};
use iced::{Size, Theme};
use iced_test::simulator;
use stitchpane_core::codec::{self, EncodeOptions, Format};
use stitchpane_core::model::{ItemId, Step};
use super::*;
struct Scratch(PathBuf);
impl Scratch {
fn new(name: &str) -> Self {
let dir =
std::env::temp_dir().join(format!("stitchpane-iced-{name}-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("scratch dir");
Self(dir)
}
fn image(&self, name: &str, w: u32, h: u32, exif: Option<Vec<u8>>) -> PathBuf {
let path = self.0.join(name);
let pixels = ::image::RgbaImage::from_pixel(w, h, ::image::Rgba([200, 40, 40, 255]));
let options = EncodeOptions {
format: Format::from_path(&path).expect("format"),
quality: 90,
dpi: None,
exif,
};
codec::write(&pixels, &path, &options).expect("write image");
path
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn exif_make() -> Vec<u8> {
let mut b = b"MM\0\x2a\0\0\0\x08".to_vec();
b.extend_from_slice(&[0, 1]);
b.extend_from_slice(&[0x01, 0x0F, 0, 2, 0, 0, 0, 4]);
b.extend_from_slice(b"Cam\0");
b.extend_from_slice(&[0, 0, 0, 0]);
b
}
fn app_with(paths: &[&Path]) -> App {
let (mut app, _) = App::new(paths.iter().map(|p| p.to_path_buf()).collect());
let _ = app.update(Message::Resized(Size::new(1024.0, 640.0)));
app
}
fn present(app: &mut App) {
while let Some(id) = uploading_ids(app).first().copied() {
let _ = app.update(Message::FrameReady(id, None));
}
}
fn shown_id(app: &App) -> Option<ImageId> {
app.frames.shown().map(|f| f.handle.id())
}
fn uploading_ids(app: &App) -> Vec<ImageId> {
app.frames
.uploading
.iter()
.map(|(_, f)| f.handle.id())
.collect()
}
fn waiting_id(app: &App) -> Option<ImageId> {
app.frames.next.as_ref().map(|f| f.handle.id())
}
fn pointer_move(app: &mut App, x: f32) {
let _ = app.update(Message::PointerMove(IPoint::new(x, 300.0)));
}
fn click(app: &mut App, label: &str) {
let messages: Vec<Message> = {
let mut ui = simulator(app.view());
ui.click(label)
.unwrap_or_else(|e| panic!("cannot click {label:?}: {e:?}"));
ui.into_messages().collect()
};
for message in messages {
let _ = app.update(message);
}
}
fn assert_shows(app: &App, label: &str) {
let mut ui = simulator(app.view());
if let Err(error) = ui.find(label) {
panic!("{label:?} is not shown: {error}");
}
}
fn assert_hides(app: &App, label: &str) {
let mut ui = simulator(app.view());
let error = ui.find(label).map(|_| ()).expect_err("label is shown");
assert!(
matches!(&error, iced_test::Error::SelectorNotFound { selector } if selector.contains(label)),
"{error}"
);
}
fn item(app: &App, id: u32) -> stitchpane_core::model::Item {
*app.editor
.project()
.state()
.item(ItemId(id))
.expect("image")
}
#[test]
fn toolbar_undo_and_redo() {
let dir = Scratch::new("undo");
let a = dir.image("a.png", 40, 30, None);
let mut app = app_with(&[&a]);
assert!(app.editor.apply(Step::MoveItem {
item: ItemId(1),
x: 5,
y: 5
}));
click(&mut app, "Undo");
assert_eq!((item(&app, 1).x, item(&app, 1).y), (0, 0));
click(&mut app, "Redo");
assert_eq!((item(&app, 1).x, item(&app, 1).y), (5, 5));
}
#[test]
fn undo_tooltip_names_the_step() {
let dir = Scratch::new("tooltip");
let a = dir.image("a.png", 40, 30, None);
let mut app = app_with(&[&a]);
assert!(app.editor.apply(Step::MoveItem {
item: ItemId(1),
x: 5,
y: 5
}));
let _ = app.update(Message::PointerMove(IPoint::new(500.0, 300.0)));
let mut ui = simulator(app.view());
ui.point_at(IPoint::new(0.0, 0.0));
if let Err(error) = ui.click("Undo") {
panic!("Undo is not clickable: {error}");
}
assert_eq!(app.editor.undo_label().as_deref(), Some("Undo Move image"));
}
#[test]
fn context_menu_starts_cropping() {
let dir = Scratch::new("menu");
let a = dir.image("a.png", 400, 300, None);
let mut app = app_with(&[&a]);
let _ = app.update(Message::PointerDown(
IPoint::new(512.0, 320.0),
PointerButton::Secondary,
));
assert_shows(&app, "Resize…");
click(&mut app, "Crop");
assert!(app.editor.crop_tool().is_some());
assert_shows(&app, "Apply");
let _ = app.update(Message::CropField(2, String::new()));
assert_eq!(app.crop_fields[2], "");
let _ = app.update(Message::CropField(2, "100".to_owned()));
click(&mut app, "Apply");
assert!(app.editor.crop_tool().is_none());
assert_eq!(item(&app, 1).width, 100);
}
#[test]
fn exif_notice_opens_viewer_and_strips() {
let dir = Scratch::new("exif");
let a = dir.image("a.jpg", 40, 30, Some(exif_make()));
let mut app = app_with(&[&a]);
click(&mut app, "ⓘ Contains EXIF metadata");
assert!(matches!(app.modal, Some(Modal::Exif { .. })));
assert_shows(&app, "Camera");
click(&mut app, "Strip metadata");
assert!(app.modal.is_none());
assert_hides(&app, "ⓘ Contains EXIF metadata");
}
#[test]
fn closing_with_changes_asks_to_save() {
let dir = Scratch::new("close");
let a = dir.image("a.png", 40, 30, None);
let mut app = app_with(&[&a]);
assert!(app.editor.apply(Step::MoveItem {
item: ItemId(1),
x: 5,
y: 5
}));
let _ = app.update(Message::CloseRequested(window::Id::unique()));
assert_shows(&app, "Discard");
click(&mut app, "Cancel");
assert!(app.modal.is_none());
}
#[test]
fn opening_several_images_asks_for_the_canvas() {
let dir = Scratch::new("canvas");
let a = dir.image("a.png", 100, 50, None);
let b = dir.image("b.png", 40, 80, None);
let mut app = app_with(&[]);
let _ = app.update(Message::Picked(dialogs::Purpose::Open, vec![a, b]));
assert_shows(&app, "New composition — canvas size");
let _ = app.update(Message::Modal(dialogs::ModalMessage::Choice(1)));
click(&mut app, "Create");
assert_eq!(app.editor.project().state().canvas_size(), (100, 130));
}
#[test]
fn exporting_over_a_source_asks_first() {
let dir = Scratch::new("export");
let a = dir.image("a.png", 40, 30, None);
let mut app = app_with(&[&a]);
let _ = app.update(Message::Picked(dialogs::Purpose::Export, vec![a.clone()]));
assert_shows(&app, "Overwrite");
click(&mut app, "Overwrite");
assert_shows(&app, "EXIF metadata from");
click(&mut app, "Export");
assert!(app.modal.is_none());
}
#[test]
fn dropping_files_adds_images() {
let dir = Scratch::new("drop");
let a = dir.image("a.png", 40, 30, None);
let b = dir.image("b.png", 20, 20, None);
let mut app = app_with(&[&a]);
let c = dir.image("c.png", 20, 20, None);
let _ = app.update(Message::FileDropped(b));
let _ = app.update(Message::FileDropped(c));
let _ = app.update(Message::FlushDrops);
assert_eq!(app.editor.project().state().items.len(), 3);
}
#[test]
fn snapshot_for_inspection() {
let Ok(dir) = std::env::var("STITCHPANE_SNAPSHOT_DIR") else {
return;
};
let scratch = Scratch::new("snap");
let a = scratch.image("a.png", 400, 300, None);
let b = scratch.image("b.png", 300, 300, None);
let mut app = app_with(&[&a, &b]);
let _ = app.update(Message::PointerDown(
IPoint::new(300.0, 560.0),
PointerButton::Secondary,
));
present(&mut app);
let mut ui = simulator(app.view());
let snapshot = ui.snapshot(&Theme::Light).expect("snapshot");
let path = format!("{dir}/iced-snap.png");
let _ = std::fs::remove_file(&path);
snapshot.matches_image(path).expect("write");
}
#[test]
fn edit_menu_names_the_steps() {
let dir = Scratch::new("editmenu");
let a = dir.image("a.png", 40, 30, None);
let mut app = app_with(&[&a]);
assert!(app.editor.apply(Step::MoveItem {
item: ItemId(1),
x: 5,
y: 5
}));
click(&mut app, "Edit");
assert_shows(&app, "Undo Move image (Ctrl+Z)");
click(&mut app, "Undo Move image (Ctrl+Z)");
assert_eq!((item(&app, 1).x, item(&app, 1).y), (0, 0));
}
#[test]
fn save_button_saves() {
let dir = Scratch::new("save");
let a = dir.image("a.png", 40, 30, None);
let mut app = app_with(&[&a]);
app.editor
.save_as(&dir.0.join("p.stitchpane"))
.expect("save");
assert!(app.editor.apply(Step::MoveItem {
item: ItemId(1),
x: 5,
y: 5
}));
click(&mut app, "Save");
assert!(!app.editor.is_dirty());
}
#[test]
fn file_dialogs_remember_the_folder() {
let dir = Scratch::new("folder");
let a = dir.image("a.png", 40, 30, None);
std::fs::create_dir_all(dir.0.join("sub")).expect("sub dir");
let b = dir.image("sub/b.png", 20, 20, None);
let mut app = app_with(&[&a]);
assert_eq!(app.editor.dialog_folder(), Some(dir.0.as_path()));
let _ = app.update(Message::Picked(dialogs::Purpose::AddImages, vec![b]));
assert_eq!(
app.editor.dialog_folder(),
Some(dir.0.join("sub").as_path())
);
}
#[test]
fn resampling_happens_in_the_background() {
let dir = Scratch::new("background");
let a = dir.image("a.png", 400, 300, None);
let mut app = app_with(&[&a]);
assert!(app.editor.apply(Step::ResizeItem {
item: ItemId(1),
x: 0,
y: 0,
width: 200,
height: 150,
filter: stitchpane_core::model::Filter::Lanczos3,
}));
let _ = app.update(Message::Resized(Size::new(1024.0, 640.0)));
assert!(app.editor.background_busy());
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
while app.editor.background_busy() {
assert!(std::time::Instant::now() < deadline);
let _ = app.update(Message::PollBackground);
std::thread::sleep(std::time::Duration::from_millis(5));
}
}
#[test]
fn status_bar_zoom_opens_the_zoom_menu() {
let dir = Scratch::new("zoom");
let a = dir.image("a.png", 400, 300, None);
let mut app = app_with(&[&a]);
let label = app.editor.zoom_text();
click(&mut app, &label);
assert_shows(&app, "Zoom in");
click(&mut app, "100%");
assert_eq!(app.editor.zoom_text(), "zoom 100%");
}
#[test]
fn smear_bar_sets_the_brush_and_strokes_record_steps() {
let dir = Scratch::new("smear");
let a = dir.image("a.png", 400, 300, None);
let mut app = app_with(&[&a]);
let _ = app.update(Message::PointerDown(
IPoint::new(512.0, 320.0),
PointerButton::Secondary,
));
click(&mut app, "Smear");
assert_shows(&app, "Done");
let _ = app.update(Message::SmearField(0, "40".to_owned()));
let _ = app.update(Message::SmearChoice("Blur".to_owned()));
let tool = app.editor.smear_tool().expect("smear tool");
assert_eq!((tool.size, tool.kind.label()), (40.0, "Blur"));
let _ = app.update(Message::PointerDown(
IPoint::new(500.0, 320.0),
PointerButton::Primary,
));
let _ = app.update(Message::PointerMove(IPoint::new(540.0, 320.0)));
let _ = app.update(Message::PointerUp(IPoint::new(560.0, 320.0)));
assert_eq!(app.editor.undo_label().as_deref(), Some("Undo Smear"));
click(&mut app, "Done");
assert!(app.editor.smear_tool().is_none());
}
#[test]
fn delete_removes_the_selected_image() {
let dir = Scratch::new("remove");
let a = dir.image("a.png", 40, 30, None);
let b = dir.image("b.png", 20, 20, None);
let mut app = app_with(&[&a]);
let _ = app.update(Message::Picked(dialogs::Purpose::AddImages, vec![b]));
assert_eq!(app.editor.project().state().items.len(), 2);
let _ = app.update(Message::Key(
keyboard::Key::Named(Named::Delete),
keyboard::Modifiers::default(),
));
assert_eq!(app.editor.project().state().items.len(), 1);
}
#[test]
fn canvas_keeps_the_shown_frame_until_a_newer_is_ready() {
let dir = Scratch::new("frames");
let a = dir.image("a.png", 400, 300, None);
let mut app = app_with(&[&a]);
present(&mut app);
let first = shown_id(&app).expect("a frame is shown");
pointer_move(&mut app, 500.0);
pointer_move(&mut app, 510.0);
let [older, newer] = uploading_ids(&app)[..] else {
panic!("two frames are uploading");
};
assert_eq!(shown_id(&app), Some(first));
let _ = app.update(Message::FrameReady(first, None));
assert_eq!(shown_id(&app), Some(first));
assert_eq!(uploading_ids(&app), [older, newer]);
let _ = app.update(Message::FrameReady(newer, None));
assert_eq!(shown_id(&app), Some(newer));
let _ = app.update(Message::FrameReady(older, None));
assert_eq!(shown_id(&app), Some(newer));
assert!(uploading_ids(&app).is_empty());
}
#[test]
fn canvas_uploads_several_frames_at_once() {
let dir = Scratch::new("uploads");
let a = dir.image("a.png", 400, 300, None);
let mut app = app_with(&[&a]);
present(&mut app);
for i in 0..MAX_UPLOADS {
pointer_move(&mut app, 500.0 + i as f32);
assert_eq!(uploading_ids(&app).len(), i + 1);
}
let first = uploading_ids(&app)[0];
pointer_move(&mut app, 600.0);
pointer_move(&mut app, 610.0);
let newest = waiting_id(&app).expect("a frame waits");
assert_eq!(uploading_ids(&app).len(), MAX_UPLOADS);
let _ = app.update(Message::FrameReady(first, None));
assert_eq!(shown_id(&app), Some(first));
assert_eq!(uploading_ids(&app).last(), Some(&newest));
assert_eq!(uploading_ids(&app).len(), MAX_UPLOADS);
assert!(waiting_id(&app).is_none());
present(&mut app);
assert_eq!(shown_id(&app), Some(newest));
}
#[test]
fn light_bar_edits_and_applies_the_settings() {
let dir = Scratch::new("light");
let a = dir.image("a.png", 400, 300, None);
let mut app = app_with(&[&a]);
let _ = app.update(Message::PointerDown(
IPoint::new(512.0, 320.0),
PointerButton::Secondary,
));
click(&mut app, "Adjust light");
assert_shows(&app, "Reset");
let _ = app.update(Message::LightField(0, "1.5".to_owned()));
assert_eq!(app.editor.light_tool().map(|t| t.light.exposure), Some(1.5));
let _ = app.update(Message::LightField(0, "9".to_owned()));
assert_eq!(app.light_fields[0], "9");
assert_eq!(app.editor.light_tool().map(|t| t.light.exposure), Some(1.5));
let _ = app.update(Message::LightField(0, "1.5".to_owned()));
click(&mut app, "Apply");
assert!(app.editor.light_tool().is_none());
assert_eq!(item(&app, 1).light.map(|l| l.exposure), Some(1.5));
assert_eq!(
app.editor.undo_label().as_deref(),
Some("Undo Adjust light")
);
}
#[test]
fn dodge_bar_sets_the_brush() {
let dir = Scratch::new("dodge");
let a = dir.image("a.png", 400, 300, None);
let mut app = app_with(&[&a]);
let _ = app.update(Message::PointerDown(
IPoint::new(512.0, 320.0),
PointerButton::Secondary,
));
click(&mut app, "Dodge and burn");
assert_shows(&app, "Done");
let _ = app.update(Message::DodgeField(0, "30".to_owned()));
let _ = app.update(Message::DodgeKind("Burn".to_owned()));
let _ = app.update(Message::DodgeRange("Highlights".to_owned()));
let tool = app.editor.dodge_tool().expect("dodge and burn tool");
assert_eq!(
(tool.size, tool.kind, tool.range),
(30.0, StrokeKind::Burn, ToneRange::Highlights)
);
click(&mut app, "Done");
assert!(app.editor.dodge_tool().is_none());
}
#[test]
fn file_menu_quit_asks_to_save() {
let dir = Scratch::new("quit");
let a = dir.image("a.png", 40, 30, None);
let mut app = app_with(&[&a]);
assert!(app.editor.apply(Step::MoveItem {
item: ItemId(1),
x: 5,
y: 5
}));
click(&mut app, "File");
click(&mut app, "Quit (Ctrl+Q)");
assert!(matches!(
app.modal,
Some(Modal::ConfirmDiscard(dialogs::AfterDiscard::Quit))
));
assert_shows(&app, "Discard");
}
#[test]
fn quitting_after_adding_to_the_empty_canvas_asks_to_save() {
let dir = Scratch::new("addquit");
let a = dir.image("a.png", 40, 30, None);
let mut app = app_with(&[]);
let _ = app.update(Message::Picked(dialogs::Purpose::AddImages, vec![a]));
let _ = app.update(Message::Command(Command::Quit));
assert!(matches!(
app.modal,
Some(Modal::ConfirmDiscard(dialogs::AfterDiscard::Quit))
));
}