use std::{
path::PathBuf,
sync::{
atomic::{AtomicBool, Ordering},
Arc,
},
};
use tokio::sync::mpsc;
use crate::domain::{DriveInfo, ImageInfo};
use tui_file_explorer::{FileExplorer, Theme};
use super::storage::TuiStorage;
use super::theme::{all_app_themes, TuiPalette};
pub use flashkraft_core::FlashUpdate as FlashEvent;
#[derive(Debug, Clone)]
pub struct UsbEntry {
pub name: String,
pub size_bytes: u64,
pub is_dir: bool,
pub depth: usize,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub enum AppScreen {
#[default]
SelectImage,
BrowseImage,
SelectDrive,
DriveInfo,
ConfirmFlash,
Flashing,
Complete,
Error,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub enum InputMode {
#[default]
Normal,
Editing,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClipOp {
Copy,
Cut,
}
#[derive(Debug, Clone)]
pub struct FileClipboard {
pub path: std::path::PathBuf,
pub op: ClipOp,
}
#[derive(Debug, Default, Clone)]
pub enum FileOpMode {
#[default]
Normal,
ConfirmDelete(std::path::PathBuf),
ConfirmOverwrite {
src: std::path::PathBuf,
dst: std::path::PathBuf,
op: ClipOp,
},
}
pub struct App {
pub screen: AppScreen,
pub image_input: String,
pub image_cursor: usize,
pub input_mode: InputMode,
pub selected_image: Option<ImageInfo>,
pub available_drives: Vec<DriveInfo>,
pub drive_cursor: usize,
pub selected_drive: Option<DriveInfo>,
pub drives_loading: bool,
pub drives_rx: Option<mpsc::UnboundedReceiver<Vec<DriveInfo>>>,
pub hotplug_rx: Option<mpsc::UnboundedReceiver<()>>,
pub flash_progress: f32,
pub flash_bytes: u64,
pub flash_speed: f32,
pub flash_stage: String,
pub flash_log: Vec<String>,
pub cancel_token: Arc<AtomicBool>,
pub flash_rx: Option<mpsc::UnboundedReceiver<FlashEvent>>,
pub verify_progress: Option<f32>,
pub verify_speed: f32,
pub verify_phase: &'static str,
pub usb_contents: Vec<UsbEntry>,
pub contents_scroll: usize,
pub file_explorer: FileExplorer,
pub explorer_themes: Vec<(String, Theme)>,
pub app_themes: Vec<(String, TuiPalette)>,
pub explorer_theme_idx: usize,
pub show_app_theme_panel: bool,
pub show_browse_options: bool,
pub show_browse_editor: bool,
pub app_theme_panel_cursor: usize,
pub storage: TuiStorage,
pub file_clipboard: Option<FileClipboard>,
pub file_op_mode: FileOpMode,
pub file_op_status: String,
pub error_message: String,
pub tick_count: u64,
pub should_quit: bool,
}
impl App {
pub fn new() -> Self {
let start_dir = dirs::home_dir()
.or_else(|| std::env::current_dir().ok())
.unwrap_or_else(|| PathBuf::from("/"));
Self {
screen: AppScreen::SelectImage,
image_input: String::new(),
image_cursor: 0,
input_mode: InputMode::Editing, selected_image: None,
available_drives: Vec::new(),
drive_cursor: 0,
selected_drive: None,
drives_loading: false,
drives_rx: None,
hotplug_rx: None,
flash_progress: 0.0,
flash_bytes: 0,
flash_speed: 0.0,
flash_stage: "Initialising…".to_string(),
flash_log: Vec::new(),
cancel_token: Arc::new(AtomicBool::new(false)),
flash_rx: None,
verify_progress: None,
verify_speed: 0.0,
verify_phase: "",
usb_contents: Vec::new(),
contents_scroll: 0,
file_explorer: FileExplorer::new(start_dir, vec!["iso".into(), "img".into()]),
explorer_themes: Theme::all_presets()
.into_iter()
.map(|(name, _, t)| (name.to_string(), t))
.collect(),
app_themes: all_app_themes(),
explorer_theme_idx: {
let storage = TuiStorage::open();
let saved = storage.load_theme();
saved
.and_then(|name| {
Theme::all_presets()
.into_iter()
.position(|(n, _, _)| n == name)
})
.unwrap_or(0)
},
show_app_theme_panel: false,
show_browse_options: false,
show_browse_editor: false,
app_theme_panel_cursor: 0,
storage: TuiStorage::open(),
file_clipboard: None,
file_op_mode: FileOpMode::Normal,
file_op_status: String::new(),
error_message: String::new(),
tick_count: 0,
should_quit: false,
}
}
pub fn poll_hotplug(&mut self) {
let mut rx = match self.hotplug_rx.take() {
Some(r) => r,
None => return,
};
let mut triggered = false;
while rx.try_recv().is_ok() {
triggered = true;
}
self.hotplug_rx = Some(rx);
if triggered && !self.drives_loading {
let flashing = matches!(self.screen, AppScreen::Flashing);
if !flashing {
use tokio::sync::mpsc;
self.drives_loading = true;
self.available_drives.clear();
self.drive_cursor = 0;
let (tx, new_rx) = mpsc::unbounded_channel::<Vec<crate::domain::DriveInfo>>();
self.drives_rx = Some(new_rx);
tokio::spawn(async move {
let drives = crate::core::commands::load_drives().await;
let _ = tx.send(drives);
});
}
}
}
pub fn poll_drives(&mut self) {
let mut rx = match self.drives_rx.take() {
Some(r) => r,
None => return,
};
if let Ok(drives) = rx.try_recv() {
self.available_drives = drives;
self.drives_loading = false;
self.drive_cursor = 0;
} else {
self.drives_rx = Some(rx);
}
}
pub fn poll_flash(&mut self) {
let mut rx = match self.flash_rx.take() {
Some(r) => r,
None => return,
};
loop {
match rx.try_recv() {
Ok(event) => self.apply_flash_event(event),
Err(mpsc::error::TryRecvError::Empty) => {
self.flash_rx = Some(rx);
break;
}
Err(mpsc::error::TryRecvError::Disconnected) => {
break;
}
}
}
}
fn apply_flash_event(&mut self, event: FlashEvent) {
match event {
FlashEvent::Progress {
progress,
bytes_written,
speed_mb_s,
} => {
self.flash_progress = (progress * 0.80).clamp(0.0, 0.80);
self.flash_bytes = bytes_written;
self.flash_speed = speed_mb_s;
}
FlashEvent::VerifyProgress {
phase,
overall,
bytes_read: _,
total_bytes: _,
speed_mb_s,
} => {
self.verify_progress = Some(overall);
self.verify_speed = speed_mb_s;
self.verify_phase = phase;
let bar = 0.92 + overall * 0.08;
if bar > self.flash_progress {
self.flash_progress = bar;
}
}
FlashEvent::Message(s) => {
let syncing_str = flashkraft_core::FlashStage::Syncing.to_string();
let rereading_str = flashkraft_core::FlashStage::Rereading.to_string();
let verifying_str = flashkraft_core::FlashStage::Verifying.to_string();
let floor: f32 = if s == syncing_str {
flashkraft_core::FlashStage::Syncing.progress_floor()
} else if s == rereading_str {
flashkraft_core::FlashStage::Rereading.progress_floor()
} else if s == verifying_str {
flashkraft_core::FlashStage::Verifying.progress_floor()
} else {
0.0
};
if floor > self.flash_progress {
self.flash_progress = floor;
}
self.flash_stage = s.clone();
self.push_log(s);
}
FlashEvent::Completed => {
self.flash_progress = 1.0;
self.flash_stage = "Complete!".to_string();
self.push_log("Flash operation completed successfully.".to_string());
self.scan_usb_contents();
self.screen = AppScreen::Complete;
}
FlashEvent::Failed(err) => {
self.error_message = err;
self.screen = AppScreen::Error;
}
}
}
fn push_log(&mut self, msg: String) {
const MAX_LOG: usize = 200;
self.flash_log.push(msg);
if self.flash_log.len() > MAX_LOG {
self.flash_log.drain(0..self.flash_log.len() - MAX_LOG);
}
}
pub fn drive_up(&mut self) {
if self.drive_cursor > 0 {
self.drive_cursor -= 1;
}
}
pub fn drive_down(&mut self) {
if !self.available_drives.is_empty() && self.drive_cursor < self.available_drives.len() - 1
{
self.drive_cursor += 1;
}
}
pub fn contents_up(&mut self) {
if self.contents_scroll > 0 {
self.contents_scroll -= 1;
}
}
pub fn contents_down(&mut self) {
if !self.usb_contents.is_empty()
&& self.contents_scroll < self.usb_contents.len().saturating_sub(1)
{
self.contents_scroll += 1;
}
}
pub fn image_insert(&mut self, c: char) {
let byte_pos = self
.image_input
.char_indices()
.nth(self.image_cursor)
.map(|(i, _)| i)
.unwrap_or(self.image_input.len());
self.image_input.insert(byte_pos, c);
self.image_cursor += 1;
}
pub fn image_backspace(&mut self) {
if self.image_cursor == 0 {
return;
}
let byte_pos = self
.image_input
.char_indices()
.nth(self.image_cursor - 1)
.map(|(i, _)| i)
.unwrap_or(self.image_input.len());
self.image_input.remove(byte_pos);
self.image_cursor -= 1;
}
pub fn image_cursor_left(&mut self) {
if self.image_cursor > 0 {
self.image_cursor -= 1;
}
}
pub fn image_cursor_right(&mut self) {
let len = self.image_input.chars().count();
if self.image_cursor < len {
self.image_cursor += 1;
}
}
pub fn confirm_image(&mut self) -> Result<(), String> {
let path = PathBuf::from(self.image_input.trim());
if !path.exists() {
return Err(format!("File not found: {}", path.display()));
}
if !path.is_file() {
return Err(format!("Not a file: {}", path.display()));
}
let info = ImageInfo::from_path(path);
if info.size_mb == 0.0 {
return Err("Image file appears to be empty.".to_string());
}
self.selected_image = Some(info);
self.input_mode = InputMode::Normal;
self.screen = AppScreen::SelectDrive;
Ok(())
}
pub fn confirm_drive(&mut self) -> Result<(), String> {
let drive = self
.available_drives
.get(self.drive_cursor)
.cloned()
.ok_or_else(|| "No drive selected.".to_string())?;
if drive.is_system {
return Err(format!(
"{} is a system drive and cannot be used as a flash target.",
drive.name
));
}
if drive.is_read_only {
return Err(format!("{} is read-only.", drive.name));
}
self.selected_drive = Some(drive);
self.screen = AppScreen::DriveInfo;
Ok(())
}
pub fn advance_to_confirm(&mut self) {
self.screen = AppScreen::ConfirmFlash;
}
pub fn begin_flash(&mut self) -> Result<(), String> {
let image = self
.selected_image
.as_ref()
.ok_or("No image selected.")?
.clone();
let drive = self
.selected_drive
.as_ref()
.ok_or("No drive selected.")?
.clone();
self.flash_progress = 0.0;
self.flash_bytes = 0;
self.flash_speed = 0.0;
self.flash_stage = "Starting…".to_string();
self.flash_log.clear();
self.cancel_token = Arc::new(AtomicBool::new(false));
self.verify_progress = None;
self.verify_speed = 0.0;
self.verify_phase = "";
let (tx, rx) = mpsc::unbounded_channel::<FlashEvent>();
self.flash_rx = Some(rx);
let cancel = self.cancel_token.clone();
tokio::spawn(crate::tui::flash_runner::run_flash(
image.path,
PathBuf::from(&drive.device_path),
cancel,
tx,
));
self.screen = AppScreen::Flashing;
Ok(())
}
pub fn cancel_flash(&mut self) {
self.cancel_token.store(true, Ordering::SeqCst);
self.error_message = "Flash operation cancelled by user.".to_string();
self.screen = AppScreen::Error;
}
pub fn reset(&mut self) {
*self = Self::new();
}
pub fn go_back(&mut self) {
match self.screen {
AppScreen::BrowseImage => {
self.screen = AppScreen::SelectImage;
self.input_mode = InputMode::Editing;
}
AppScreen::SelectDrive => {
self.screen = AppScreen::SelectImage;
self.input_mode = InputMode::Editing;
}
AppScreen::DriveInfo => {
self.screen = AppScreen::SelectDrive;
}
AppScreen::ConfirmFlash => {
self.screen = AppScreen::DriveInfo;
}
_ => {}
}
}
pub fn open_file_explorer(&mut self) {
let typed = PathBuf::from(self.image_input.trim());
let start_dir = if typed.is_dir() {
typed
} else if let Some(parent) = typed.parent().filter(|p| p.is_dir()) {
parent.to_path_buf()
} else {
dirs::home_dir()
.or_else(|| std::env::current_dir().ok())
.unwrap_or_else(|| PathBuf::from("/"))
};
self.file_explorer.navigate_to(start_dir);
self.screen = AppScreen::BrowseImage;
}
pub fn apply_explorer_selection(&mut self, path: PathBuf) {
self.image_input = path.to_string_lossy().to_string();
self.image_cursor = self.image_input.chars().count();
self.input_mode = InputMode::Editing;
self.screen = AppScreen::SelectImage;
}
pub fn current_explorer_theme(&self) -> &Theme {
&self.explorer_themes[self.explorer_theme_idx].1
}
pub fn palette(&self) -> &TuiPalette {
&self.app_themes[self.explorer_theme_idx].1
}
pub fn current_theme_name(&self) -> &str {
&self.app_themes[self.explorer_theme_idx].0
}
pub fn next_explorer_theme(&mut self) {
self.explorer_theme_idx = (self.explorer_theme_idx + 1) % self.explorer_themes.len();
self.persist_theme();
}
pub fn prev_explorer_theme(&mut self) {
let n = self.explorer_themes.len();
self.explorer_theme_idx = (self.explorer_theme_idx + n - 1) % n;
self.persist_theme();
}
pub fn open_app_theme_panel(&mut self) {
self.app_theme_panel_cursor = self.explorer_theme_idx;
self.show_app_theme_panel = true;
}
pub fn close_app_theme_panel(&mut self) {
self.show_app_theme_panel = false;
}
pub fn theme_panel_up(&mut self) {
if self.app_theme_panel_cursor > 0 {
self.app_theme_panel_cursor -= 1;
}
}
pub fn theme_panel_down(&mut self) {
let last = self.explorer_themes.len().saturating_sub(1);
if self.app_theme_panel_cursor < last {
self.app_theme_panel_cursor += 1;
}
}
pub fn theme_panel_confirm(&mut self) {
self.explorer_theme_idx = self.app_theme_panel_cursor;
self.show_app_theme_panel = false;
self.persist_theme();
}
fn persist_theme(&self) {
let name = &self.explorer_themes[self.explorer_theme_idx].0;
self.storage.save_theme(name);
}
pub fn explorer_yank(&mut self, op: ClipOp) {
if let Some(entry) = self.file_explorer.current_entry() {
let label = match op {
ClipOp::Copy => "Yanked",
ClipOp::Cut => "Cut",
};
self.file_op_status = format!("{}: {}", label, entry.name);
self.file_clipboard = Some(FileClipboard {
path: entry.path.clone(),
op,
});
}
}
pub fn explorer_initiate_paste(&mut self) {
let Some(clip) = self.file_clipboard.clone() else {
self.file_op_status = "Clipboard is empty.".to_string();
return;
};
let dst = self
.file_explorer
.current_dir
.join(clip.path.file_name().unwrap_or_default());
if dst.exists() {
self.file_op_mode = FileOpMode::ConfirmOverwrite {
src: clip.path,
dst,
op: clip.op,
};
} else {
self.explorer_do_paste(&clip.path.clone(), &dst.clone(), clip.op);
}
}
pub fn explorer_do_paste(&mut self, src: &std::path::Path, dst: &std::path::Path, op: ClipOp) {
match explorer_fs_copy(src, dst) {
Ok(()) => {
if op == ClipOp::Cut {
if let Err(e) = explorer_fs_delete(src) {
self.file_op_status = format!("Cut: copy OK but delete failed: {e}");
} else {
self.file_clipboard = None;
self.file_op_status = format!(
"Moved to {}",
dst.file_name().unwrap_or_default().to_string_lossy()
);
}
} else {
self.file_op_status = format!(
"Copied to {}",
dst.file_name().unwrap_or_default().to_string_lossy()
);
}
}
Err(e) => self.file_op_status = format!("Paste failed: {e}"),
}
self.file_op_mode = FileOpMode::Normal;
self.file_explorer.reload();
}
pub fn explorer_initiate_delete(&mut self) {
if let Some(entry) = self.file_explorer.current_entry() {
self.file_op_mode = FileOpMode::ConfirmDelete(entry.path.clone());
}
}
pub fn explorer_do_delete(&mut self, path: std::path::PathBuf) {
match explorer_fs_delete(&path) {
Ok(()) => {
self.file_op_status = format!(
"Deleted: {}",
path.file_name().unwrap_or_default().to_string_lossy()
)
}
Err(e) => self.file_op_status = format!("Delete failed: {e}"),
}
self.file_op_mode = FileOpMode::Normal;
self.file_explorer.reload();
}
fn scan_usb_contents(&mut self) {
self.usb_contents.clear();
let device_path = match &self.selected_drive {
Some(d) => d.device_path.clone(),
None => return,
};
let mount_point = find_mount_point(&device_path);
if let Some(mp) = mount_point {
let root = PathBuf::from(&mp);
let mut entries = Vec::new();
collect_entries(&root, 0, &mut entries, 3); self.usb_contents = entries;
} else {
self.usb_contents.push(UsbEntry {
name: "(device not mounted — re-plug to browse)".to_string(),
size_bytes: 0,
is_dir: false,
depth: 0,
});
}
}
pub fn image_size_bytes(&self) -> u64 {
self.selected_image
.as_ref()
.map(|i| (i.size_mb * 1024.0 * 1024.0) as u64)
.unwrap_or(0)
}
pub fn drive_size_bytes(&self) -> u64 {
self.selected_drive
.as_ref()
.map(|d| (d.size_gb * 1024.0 * 1024.0 * 1024.0) as u64)
.unwrap_or(0)
}
}
impl Default for App {
fn default() -> Self {
Self::new()
}
}
fn find_mount_point(device_path: &str) -> Option<String> {
let device_base = std::path::Path::new(device_path)
.file_name()?
.to_string_lossy()
.to_string();
let mounts_content = std::fs::read_to_string("/proc/mounts")
.or_else(|_| std::fs::read_to_string("/etc/mtab"))
.ok()?;
for line in mounts_content.lines() {
let mut parts = line.split_whitespace();
let dev = parts.next().unwrap_or("");
let mp = parts.next().unwrap_or("");
if mp.is_empty() || dev.is_empty() {
continue;
}
let dev_base = std::path::Path::new(dev)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
if dev_base == device_base || dev_base.starts_with(&device_base) {
if !mp.starts_with('/') || mp == "/" {
continue;
}
return Some(mp.to_string());
}
}
None
}
fn collect_entries(dir: &PathBuf, depth: usize, out: &mut Vec<UsbEntry>, max_depth: usize) {
if depth > max_depth {
return;
}
let read_result = match std::fs::read_dir(dir) {
Ok(r) => r,
Err(_) => return,
};
let mut entries: Vec<_> = read_result.flatten().collect();
entries.sort_by_key(|e| {
let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
(!is_dir, e.file_name().to_string_lossy().to_lowercase())
});
for entry in entries {
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
if depth == 0 && name.starts_with('.') {
continue;
}
let meta = path.metadata();
let (is_dir, size) = meta
.as_ref()
.map(|m| (m.is_dir(), m.len()))
.unwrap_or((false, 0));
out.push(UsbEntry {
name,
size_bytes: size,
is_dir,
depth,
});
if is_dir && depth < max_depth {
collect_entries(&path, depth + 1, out, max_depth);
}
}
}
fn explorer_fs_copy(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> {
use std::fs;
if src.is_dir() {
fs::create_dir_all(dst)?;
for entry in fs::read_dir(src)?.flatten() {
explorer_fs_copy(&entry.path(), &dst.join(entry.file_name()))?;
}
} else {
if let Some(parent) = dst.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(src, dst)?;
}
Ok(())
}
fn explorer_fs_delete(path: &std::path::Path) -> std::io::Result<()> {
if path.is_dir() {
std::fs::remove_dir_all(path)
} else {
std::fs::remove_file(path)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::sync::mpsc;
fn make_drive(name: &str, device: &str, system: bool, ro: bool) -> DriveInfo {
DriveInfo::with_constraints(
name.into(),
format!("/media/{name}"),
16.0,
device.into(),
system,
ro,
)
}
fn make_image(size_mb: f64) -> ImageInfo {
ImageInfo {
path: PathBuf::from("/tmp/test_image.img"),
name: "test_image.img".into(),
size_mb,
}
}
#[test]
fn test_new_initial_state() {
let app = App::new();
assert_eq!(app.screen, AppScreen::SelectImage);
assert_eq!(app.input_mode, InputMode::Editing);
assert!(app.image_input.is_empty());
assert_eq!(app.image_cursor, 0);
assert!(app.available_drives.is_empty());
assert!(app.selected_image.is_none());
assert!(app.selected_drive.is_none());
assert!(!app.should_quit);
assert_eq!(app.flash_progress, 0.0);
assert_eq!(app.flash_bytes, 0);
assert_eq!(app.flash_speed, 0.0);
assert!(app.flash_log.is_empty());
assert_eq!(app.tick_count, 0);
assert!(app.error_message.is_empty());
assert!(app.usb_contents.is_empty());
assert_eq!(app.contents_scroll, 0);
assert!(!app.drives_loading);
}
#[test]
fn test_default_equals_new() {
let a = App::new();
let b = App::default();
assert_eq!(a.screen, b.screen);
assert_eq!(a.input_mode, b.input_mode);
assert_eq!(a.image_input, b.image_input);
}
#[test]
fn test_image_insert_appends_and_advances_cursor() {
let mut app = App::new();
app.image_insert('h');
app.image_insert('i');
assert_eq!(app.image_input, "hi");
assert_eq!(app.image_cursor, 2);
}
#[test]
fn test_image_insert_at_middle_position() {
let mut app = App::new();
for c in "abcd".chars() {
app.image_insert(c);
}
app.image_cursor = 2;
app.image_insert('X');
assert_eq!(app.image_input, "abXcd");
assert_eq!(app.image_cursor, 3);
}
#[test]
fn test_image_insert_unicode_chars() {
let mut app = App::new();
app.image_insert('→');
app.image_insert('∞');
assert_eq!(app.image_input, "→∞");
assert_eq!(app.image_cursor, 2);
}
#[test]
fn test_image_backspace_deletes_previous_char() {
let mut app = App::new();
for c in "hello".chars() {
app.image_insert(c);
}
app.image_backspace();
assert_eq!(app.image_input, "hell");
assert_eq!(app.image_cursor, 4);
}
#[test]
fn test_image_backspace_at_start_is_noop() {
let mut app = App::new();
app.image_insert('a');
app.image_cursor = 0;
app.image_backspace();
assert_eq!(app.image_input, "a");
assert_eq!(app.image_cursor, 0);
}
#[test]
fn test_image_backspace_empty_string_is_noop() {
let mut app = App::new();
app.image_backspace(); assert!(app.image_input.is_empty());
assert_eq!(app.image_cursor, 0);
}
#[test]
fn test_image_cursor_left_clamps_at_zero() {
let mut app = App::new();
app.image_cursor_left(); assert_eq!(app.image_cursor, 0);
}
#[test]
fn test_image_cursor_left_decrements() {
let mut app = App::new();
for c in "abc".chars() {
app.image_insert(c);
}
app.image_cursor_left();
assert_eq!(app.image_cursor, 2);
}
#[test]
fn test_image_cursor_right_clamps_at_end() {
let mut app = App::new();
for c in "abc".chars() {
app.image_insert(c);
}
app.image_cursor_right();
assert_eq!(app.image_cursor, 3);
}
#[test]
fn test_image_cursor_right_increments() {
let mut app = App::new();
for c in "abc".chars() {
app.image_insert(c);
}
app.image_cursor = 0;
app.image_cursor_right();
assert_eq!(app.image_cursor, 1);
}
#[test]
fn test_image_cursor_full_left_right_round_trip() {
let mut app = App::new();
let text = "/home/user/ubuntu.iso";
for c in text.chars() {
app.image_insert(c);
}
let end = text.chars().count();
assert_eq!(app.image_cursor, end);
for _ in 0..end {
app.image_cursor_left();
}
assert_eq!(app.image_cursor, 0);
for _ in 0..end {
app.image_cursor_right();
}
assert_eq!(app.image_cursor, end);
}
#[test]
fn test_confirm_image_nonexistent_file_returns_err() {
let mut app = App::new();
app.image_input = "/nonexistent/path/does_not_exist.iso".into();
let result = app.confirm_image();
assert!(result.is_err(), "expected Err for missing file");
let msg = result.unwrap_err();
assert!(
msg.contains("not found") || msg.contains("Not a file") || msg.contains("File"),
"error should mention the file: {msg}"
);
assert_eq!(app.screen, AppScreen::SelectImage);
assert!(app.selected_image.is_none());
}
#[test]
fn test_confirm_image_real_file_advances_screen() {
use std::io::Write;
let path = std::env::temp_dir().join("fk_test_confirm_image.img");
{
let mut f = std::fs::File::create(&path).expect("create temp");
f.write_all(&[0xABu8; 2048]).expect("write");
}
let mut app = App::new();
app.image_input = path.to_string_lossy().into();
let result = app.confirm_image();
let _ = std::fs::remove_file(&path);
assert!(
result.is_ok(),
"expected Ok for real file, got: {:?}",
result
);
assert_eq!(app.screen, AppScreen::SelectDrive);
assert_eq!(app.input_mode, InputMode::Normal);
let img = app.selected_image.expect("image should be set");
assert_eq!(img.name, "fk_test_confirm_image.img");
assert!(img.size_mb > 0.0);
}
#[test]
fn test_confirm_image_directory_returns_err() {
let mut app = App::new();
app.image_input = std::env::temp_dir().to_string_lossy().into();
let result = app.confirm_image();
assert!(result.is_err(), "expected Err for directory path");
assert_eq!(app.screen, AppScreen::SelectImage);
}
#[test]
fn test_drive_up_clamps_at_zero() {
let mut app = App::new();
app.available_drives = vec![
make_drive("d0", "/dev/sdb", false, false),
make_drive("d1", "/dev/sdc", false, false),
];
app.drive_cursor = 0;
app.drive_up(); assert_eq!(app.drive_cursor, 0);
}
#[test]
fn test_drive_down_increments() {
let mut app = App::new();
app.available_drives = vec![
make_drive("d0", "/dev/sdb", false, false),
make_drive("d1", "/dev/sdc", false, false),
make_drive("d2", "/dev/sdd", false, false),
];
app.drive_down();
assert_eq!(app.drive_cursor, 1);
app.drive_down();
assert_eq!(app.drive_cursor, 2);
}
#[test]
fn test_drive_down_clamps_at_last() {
let mut app = App::new();
app.available_drives = vec![
make_drive("d0", "/dev/sdb", false, false),
make_drive("d1", "/dev/sdc", false, false),
];
app.drive_cursor = 1; app.drive_down();
assert_eq!(app.drive_cursor, 1);
}
#[test]
fn test_drive_up_decrements() {
let mut app = App::new();
app.available_drives = vec![
make_drive("d0", "/dev/sdb", false, false),
make_drive("d1", "/dev/sdc", false, false),
];
app.drive_cursor = 1;
app.drive_up();
assert_eq!(app.drive_cursor, 0);
}
#[test]
fn test_drive_navigation_on_empty_list() {
let mut app = App::new();
app.drive_up(); app.drive_down(); assert_eq!(app.drive_cursor, 0);
}
#[test]
fn test_confirm_drive_ok_advances_to_drive_info() {
let mut app = App::new();
app.screen = AppScreen::SelectDrive;
app.available_drives = vec![make_drive("usb0", "/dev/sdb", false, false)];
app.drive_cursor = 0;
let result = app.confirm_drive();
assert!(result.is_ok());
assert_eq!(app.screen, AppScreen::DriveInfo);
let drive = app.selected_drive.expect("drive should be set");
assert_eq!(drive.name, "usb0");
assert_eq!(drive.device_path, "/dev/sdb");
}
#[test]
fn test_confirm_drive_system_drive_rejected() {
let mut app = App::new();
app.screen = AppScreen::SelectDrive;
app.available_drives = vec![make_drive("sysdrv", "/dev/sda", true, false)];
app.drive_cursor = 0;
let result = app.confirm_drive();
assert!(result.is_err());
let msg = result.unwrap_err();
assert!(
msg.contains("system") || msg.contains("sysdrv"),
"error should mention system: {msg}"
);
assert_eq!(app.screen, AppScreen::SelectDrive);
assert!(app.selected_drive.is_none());
}
#[test]
fn test_confirm_drive_readonly_rejected() {
let mut app = App::new();
app.screen = AppScreen::SelectDrive;
app.available_drives = vec![make_drive("ro_usb", "/dev/sdb", false, true)];
app.drive_cursor = 0;
let result = app.confirm_drive();
assert!(result.is_err());
let msg = result.unwrap_err();
assert!(
msg.contains("read-only") || msg.contains("ro_usb"),
"error should mention read-only: {msg}"
);
}
#[test]
fn test_confirm_drive_empty_list_returns_err() {
let mut app = App::new();
app.screen = AppScreen::SelectDrive;
let result = app.confirm_drive();
assert!(result.is_err());
assert!(app.selected_drive.is_none());
}
#[test]
fn test_confirm_drive_cursor_out_of_bounds_returns_err() {
let mut app = App::new();
app.screen = AppScreen::SelectDrive;
app.available_drives = vec![make_drive("usb0", "/dev/sdb", false, false)];
app.drive_cursor = 99;
let result = app.confirm_drive();
assert!(result.is_err());
}
#[test]
fn test_advance_to_confirm() {
let mut app = App::new();
app.screen = AppScreen::DriveInfo;
app.advance_to_confirm();
assert_eq!(app.screen, AppScreen::ConfirmFlash);
}
#[test]
fn test_go_back_confirm_flash_to_drive_info() {
let mut app = App::new();
app.screen = AppScreen::ConfirmFlash;
app.go_back();
assert_eq!(app.screen, AppScreen::DriveInfo);
}
#[test]
fn test_go_back_drive_info_to_select_drive() {
let mut app = App::new();
app.screen = AppScreen::DriveInfo;
app.go_back();
assert_eq!(app.screen, AppScreen::SelectDrive);
}
#[test]
fn test_go_back_select_drive_to_select_image_and_editing() {
let mut app = App::new();
app.screen = AppScreen::SelectDrive;
app.input_mode = InputMode::Normal;
app.go_back();
assert_eq!(app.screen, AppScreen::SelectImage);
assert_eq!(
app.input_mode,
InputMode::Editing,
"go_back from SelectDrive should re-enable editing mode"
);
}
#[test]
fn test_go_back_is_noop_from_flashing() {
let mut app = App::new();
app.screen = AppScreen::Flashing;
app.go_back();
assert_eq!(app.screen, AppScreen::Flashing);
}
#[test]
fn test_go_back_is_noop_from_complete() {
let mut app = App::new();
app.screen = AppScreen::Complete;
app.go_back();
assert_eq!(app.screen, AppScreen::Complete);
}
#[test]
fn test_go_back_is_noop_from_error() {
let mut app = App::new();
app.screen = AppScreen::Error;
app.go_back();
assert_eq!(app.screen, AppScreen::Error);
}
#[test]
fn test_go_back_is_noop_from_select_image() {
let mut app = App::new();
app.screen = AppScreen::SelectImage;
app.go_back();
assert_eq!(app.screen, AppScreen::SelectImage);
}
#[test]
fn test_apply_flash_event_progress_updates_fields() {
let mut app = App::new();
app.apply_flash_event(FlashEvent::Progress {
progress: 0.42,
bytes_written: 1_048_576,
speed_mb_s: 28.5,
});
assert!((app.flash_progress - 0.42 * 0.80).abs() < 1e-5);
assert_eq!(app.flash_bytes, 1_048_576);
assert_eq!(app.flash_speed, 28.5);
}
#[test]
fn test_apply_flash_event_progress_overwrites_previous() {
let mut app = App::new();
app.apply_flash_event(FlashEvent::Progress {
progress: 0.2,
bytes_written: 512,
speed_mb_s: 10.0,
});
app.apply_flash_event(FlashEvent::Progress {
progress: 0.7,
bytes_written: 2048,
speed_mb_s: 35.0,
});
assert!((app.flash_progress - 0.7 * 0.80).abs() < 1e-5);
assert_eq!(app.flash_bytes, 2048);
assert_eq!(app.flash_speed, 35.0);
}
#[test]
fn test_apply_flash_event_stage_sets_label_and_logs() {
let mut app = App::new();
app.apply_flash_event(FlashEvent::Message("Writing image to device…".to_string()));
assert_eq!(app.flash_stage, "Writing image to device…");
assert!(app
.flash_log
.contains(&"Writing image to device…".to_string()));
}
#[test]
fn test_apply_flash_event_log_appends_to_log() {
let mut app = App::new();
app.apply_flash_event(FlashEvent::Message("SHA-256 verified ✓".to_string()));
assert!(app.flash_log.contains(&"SHA-256 verified ✓".to_string()));
}
#[test]
fn test_apply_flash_event_multiple_logs_accumulate() {
let mut app = App::new();
for i in 0..5 {
app.apply_flash_event(FlashEvent::Message(format!("log {i}")));
}
assert_eq!(app.flash_log.len(), 5);
}
#[test]
fn test_apply_flash_event_completed_sets_full_progress_and_screen() {
let mut app = App::new();
app.screen = AppScreen::Flashing;
app.flash_progress = 0.9;
app.apply_flash_event(FlashEvent::Completed);
assert_eq!(app.flash_progress, 1.0);
assert_eq!(app.screen, AppScreen::Complete);
assert!(!app.flash_log.is_empty());
assert!(app
.flash_log
.iter()
.any(|l| l.contains("complet") || l.contains("success")));
}
#[test]
fn test_apply_flash_event_failed_sets_error_screen() {
let mut app = App::new();
app.screen = AppScreen::Flashing;
app.apply_flash_event(FlashEvent::Failed("Write failed: I/O error".to_string()));
assert_eq!(app.screen, AppScreen::Error);
assert_eq!(app.error_message, "Write failed: I/O error");
}
#[test]
fn test_push_log_enforces_max_capacity() {
let mut app = App::new();
for i in 0..250 {
app.apply_flash_event(FlashEvent::Message(format!("log line {i}")));
}
assert!(
app.flash_log.len() <= 200,
"log should be capped at 200, got {}",
app.flash_log.len()
);
assert_eq!(
app.flash_log.last().unwrap(),
"log line 249",
"most-recent log entry must be at the tail"
);
}
#[test]
fn test_push_log_exactly_at_limit_does_not_trim() {
let mut app = App::new();
for i in 0..200 {
app.apply_flash_event(FlashEvent::Message(format!("entry {i}")));
}
assert_eq!(app.flash_log.len(), 200);
}
#[test]
fn test_poll_drives_applies_result_and_clears_loading() {
let (tx, rx) = mpsc::unbounded_channel::<Vec<DriveInfo>>();
let drives = vec![
make_drive("sdb", "/dev/sdb", false, false),
make_drive("sdc", "/dev/sdc", false, false),
];
tx.send(drives).unwrap();
let mut app = App::new();
app.drives_loading = true;
app.drives_rx = Some(rx);
app.poll_drives();
assert_eq!(app.available_drives.len(), 2);
assert!(!app.drives_loading, "loading flag should be cleared");
assert_eq!(app.drive_cursor, 0, "cursor should reset");
assert!(
app.drives_rx.is_none(),
"one-shot channel should be consumed"
);
}
#[test]
fn test_poll_drives_keeps_receiver_when_channel_empty() {
let (_tx, rx) = mpsc::unbounded_channel::<Vec<DriveInfo>>();
let mut app = App::new();
app.drives_loading = true;
app.drives_rx = Some(rx);
app.poll_drives();
assert!(app.drives_loading, "loading should stay true");
assert!(app.drives_rx.is_some(), "receiver must be retained");
}
#[test]
fn test_poll_drives_does_nothing_when_no_receiver() {
let mut app = App::new();
app.poll_drives(); assert!(app.available_drives.is_empty());
}
#[test]
fn test_poll_flash_applies_progress_event() {
let (tx, rx) = mpsc::unbounded_channel::<FlashEvent>();
tx.send(FlashEvent::Progress {
progress: 0.5,
bytes_written: 1024,
speed_mb_s: 22.0,
})
.unwrap();
let mut app = App::new();
app.flash_rx = Some(rx);
app.poll_flash();
assert!((app.flash_progress - 0.5 * 0.80).abs() < 1e-5);
assert_eq!(app.flash_bytes, 1024);
assert_eq!(app.flash_speed, 22.0);
}
#[test]
fn test_poll_flash_drains_multiple_events() {
let (tx, rx) = mpsc::unbounded_channel::<FlashEvent>();
let writing_str = flashkraft_core::FlashStage::Writing.to_string();
let second_msg = "Chunk 1 written".to_string();
tx.send(FlashEvent::Message(writing_str.clone())).unwrap();
tx.send(FlashEvent::Message(second_msg.clone())).unwrap();
tx.send(FlashEvent::Progress {
progress: 0.25,
bytes_written: 256,
speed_mb_s: 15.0,
})
.unwrap();
let mut app = App::new();
app.flash_rx = Some(rx);
app.poll_flash();
assert_eq!(app.flash_stage, second_msg);
assert!((app.flash_progress - 0.25 * 0.80).abs() < 1e-5);
assert!(app.flash_log.len() >= 2);
assert!(app.flash_log.contains(&writing_str));
assert!(app.flash_log.contains(&second_msg));
}
#[test]
fn test_poll_flash_keeps_receiver_when_channel_empty() {
let (_tx, rx) = mpsc::unbounded_channel::<FlashEvent>();
let mut app = App::new();
app.flash_rx = Some(rx);
app.poll_flash();
assert!(
app.flash_rx.is_some(),
"receiver must be retained while channel is open"
);
}
#[test]
fn test_poll_flash_drops_receiver_when_disconnected() {
let (tx, rx) = mpsc::unbounded_channel::<FlashEvent>();
drop(tx);
let mut app = App::new();
app.flash_rx = Some(rx);
app.poll_flash();
assert!(
app.flash_rx.is_none(),
"receiver should be dropped after disconnect"
);
}
#[test]
fn test_begin_flash_without_image_returns_err() {
let mut app = App::new();
app.selected_drive = Some(make_drive("usb0", "/dev/sdb", false, false));
let result = app.begin_flash();
assert!(result.is_err());
let msg = result.unwrap_err();
assert!(
msg.contains("image"),
"error should mention missing image: {msg}"
);
}
#[test]
fn test_begin_flash_without_drive_returns_err() {
let mut app = App::new();
app.selected_image = Some(make_image(512.0));
let result = app.begin_flash();
assert!(result.is_err());
let msg = result.unwrap_err();
assert!(
msg.contains("drive"),
"error should mention missing drive: {msg}"
);
}
#[test]
fn test_begin_flash_without_both_returns_err() {
let mut app = App::new();
let result = app.begin_flash();
assert!(result.is_err());
}
#[test]
fn test_cancel_flash_sets_cancel_token() {
use std::sync::atomic::Ordering;
let mut app = App::new();
app.screen = AppScreen::Flashing;
assert!(
!app.cancel_token.load(Ordering::SeqCst),
"cancel token must start as false"
);
app.cancel_flash();
assert!(
app.cancel_token.load(Ordering::SeqCst),
"cancel token must be set after cancel_flash"
);
}
#[test]
fn test_cancel_flash_transitions_to_error_screen() {
let mut app = App::new();
app.screen = AppScreen::Flashing;
app.cancel_flash();
assert_eq!(app.screen, AppScreen::Error);
}
#[test]
fn test_cancel_flash_sets_error_message() {
let mut app = App::new();
app.screen = AppScreen::Flashing;
app.cancel_flash();
assert!(!app.error_message.is_empty(), "error_message must be set");
assert!(
app.error_message.to_lowercase().contains("cancel"),
"error message should mention cancellation: {}",
app.error_message
);
}
#[test]
fn test_reset_returns_to_factory_defaults() {
let mut app = App::new();
app.screen = AppScreen::Complete;
app.image_input = "/some/path.iso".into();
app.image_cursor = 5;
app.flash_progress = 0.8;
app.flash_bytes = 999_999;
app.flash_speed = 42.0;
app.flash_stage = "Done".into();
app.flash_log = vec!["a".into(), "b".into(), "c".into()];
app.tick_count = 1234;
app.error_message = "previous error".into();
app.should_quit = false;
app.selected_image = Some(make_image(100.0));
app.selected_drive = Some(make_drive("usb0", "/dev/sdb", false, false));
app.reset();
assert_eq!(app.screen, AppScreen::SelectImage);
assert!(app.image_input.is_empty(), "image_input must be cleared");
assert_eq!(app.image_cursor, 0);
assert_eq!(app.flash_progress, 0.0);
assert_eq!(app.flash_bytes, 0);
assert_eq!(app.flash_speed, 0.0);
assert!(app.flash_log.is_empty());
assert_eq!(app.tick_count, 0);
assert!(app.selected_image.is_none());
assert!(app.selected_drive.is_none());
assert!(!app.should_quit);
}
#[test]
fn test_image_size_bytes_returns_zero_when_no_image() {
let app = App::new();
assert_eq!(app.image_size_bytes(), 0);
}
#[test]
fn test_image_size_bytes_converts_mb_to_bytes() {
let mut app = App::new();
app.selected_image = Some(make_image(512.0)); let expected = (512.0_f64 * 1024.0 * 1024.0) as u64;
assert!(
(app.image_size_bytes() as i64 - expected as i64).abs() <= 1,
"expected ~{expected} bytes, got {}",
app.image_size_bytes()
);
}
#[test]
fn test_drive_size_bytes_returns_zero_when_no_drive() {
let app = App::new();
assert_eq!(app.drive_size_bytes(), 0);
}
#[test]
fn test_drive_size_bytes_converts_gb_to_bytes() {
let mut app = App::new();
app.selected_drive = Some(make_drive("usb", "/dev/sdb", false, false));
let expected = (16.0_f64 * 1024.0 * 1024.0 * 1024.0) as u64;
assert!(
(app.drive_size_bytes() as i64 - expected as i64).abs() <= 1,
"expected ~{expected} bytes, got {}",
app.drive_size_bytes()
);
}
fn make_usb_entries(n: usize) -> Vec<UsbEntry> {
(0..n)
.map(|i| UsbEntry {
name: format!("file_{i:02}.txt"),
size_bytes: (i as u64 + 1) * 512,
is_dir: i % 4 == 0,
depth: 0,
})
.collect()
}
#[test]
fn test_contents_up_clamps_at_zero() {
let mut app = App::new();
app.usb_contents = make_usb_entries(5);
app.contents_scroll = 0;
app.contents_up(); assert_eq!(app.contents_scroll, 0);
}
#[test]
fn test_contents_down_increments_scroll() {
let mut app = App::new();
app.usb_contents = make_usb_entries(5);
app.contents_down();
assert_eq!(app.contents_scroll, 1);
app.contents_down();
assert_eq!(app.contents_scroll, 2);
}
#[test]
fn test_contents_down_clamps_at_last_entry() {
let mut app = App::new();
app.usb_contents = make_usb_entries(3); app.contents_scroll = 2; app.contents_down();
assert_eq!(app.contents_scroll, 2, "should clamp at len-1");
}
#[test]
fn test_contents_up_decrements_scroll() {
let mut app = App::new();
app.usb_contents = make_usb_entries(5);
app.contents_scroll = 3;
app.contents_up();
assert_eq!(app.contents_scroll, 2);
}
#[test]
fn test_contents_scroll_on_empty_list_is_noop() {
let mut app = App::new();
app.contents_up(); app.contents_down(); assert_eq!(app.contents_scroll, 0);
}
#[test]
fn test_poll_flash_completed_event_via_channel() {
let (tx, rx) = mpsc::unbounded_channel::<FlashEvent>();
tx.send(FlashEvent::Completed).unwrap();
let mut app = App::new();
app.screen = AppScreen::Flashing;
app.flash_rx = Some(rx);
app.poll_flash();
assert_eq!(app.screen, AppScreen::Complete);
assert_eq!(app.flash_progress, 1.0);
}
#[test]
fn test_poll_flash_failed_event_via_channel() {
let (tx, rx) = mpsc::unbounded_channel::<FlashEvent>();
tx.send(FlashEvent::Failed("Disk full".to_string()))
.unwrap();
let mut app = App::new();
app.screen = AppScreen::Flashing;
app.flash_rx = Some(rx);
app.poll_flash();
assert_eq!(app.screen, AppScreen::Error);
assert_eq!(app.error_message, "Disk full");
}
#[test]
fn test_tick_count_wraps_on_overflow() {
let mut app = App::new();
app.tick_count = u64::MAX;
app.tick_count = app.tick_count.wrapping_add(1);
assert_eq!(app.tick_count, 0, "tick_count should wrap to 0 at u64::MAX");
}
#[test]
fn test_app_screen_default_is_select_image() {
assert_eq!(AppScreen::default(), AppScreen::SelectImage);
}
#[test]
fn test_input_mode_default_is_normal() {
assert_eq!(InputMode::default(), InputMode::Normal);
}
#[test]
fn test_app_screen_equality() {
assert_eq!(AppScreen::SelectImage, AppScreen::SelectImage);
assert_ne!(AppScreen::SelectImage, AppScreen::SelectDrive);
assert_ne!(AppScreen::Complete, AppScreen::Error);
}
#[test]
fn test_open_file_explorer_transitions_to_browse_image() {
let mut app = App::new();
app.open_file_explorer();
assert_eq!(app.screen, AppScreen::BrowseImage);
}
#[test]
fn test_open_file_explorer_uses_typed_dir_when_valid() {
let tmp = tempfile::tempdir().unwrap();
let iso = tmp.path().join("test.iso");
std::fs::write(&iso, b"x").unwrap();
let mut app = App::new();
app.image_input = iso.to_string_lossy().to_string();
app.open_file_explorer();
assert_eq!(app.file_explorer.current_dir, tmp.path());
assert_eq!(app.screen, AppScreen::BrowseImage);
}
#[test]
fn test_open_file_explorer_uses_dir_input_directly() {
let tmp = tempfile::tempdir().unwrap();
let mut app = App::new();
app.image_input = tmp.path().to_string_lossy().to_string();
app.open_file_explorer();
assert_eq!(app.file_explorer.current_dir, tmp.path());
assert_eq!(app.screen, AppScreen::BrowseImage);
}
#[test]
fn test_open_file_explorer_falls_back_when_input_invalid() {
let mut app = App::new();
app.image_input = "/this/path/does/not/exist/at/all.iso".to_string();
app.open_file_explorer();
assert_eq!(app.screen, AppScreen::BrowseImage);
assert!(app.file_explorer.current_dir.is_dir());
}
#[test]
fn test_open_file_explorer_empty_input_falls_back() {
let mut app = App::new();
app.image_input = String::new();
app.open_file_explorer();
assert_eq!(app.screen, AppScreen::BrowseImage);
assert!(app.file_explorer.current_dir.is_dir());
}
#[test]
fn test_apply_explorer_selection_populates_image_input() {
let mut app = App::new();
let path = PathBuf::from("/some/path/ubuntu.iso");
app.apply_explorer_selection(path.clone());
assert_eq!(app.image_input, "/some/path/ubuntu.iso");
}
#[test]
fn test_apply_explorer_selection_sets_cursor_to_end() {
let mut app = App::new();
let path = PathBuf::from("/some/ubuntu.iso");
app.apply_explorer_selection(path.clone());
let expected_len = path.to_string_lossy().chars().count();
assert_eq!(app.image_cursor, expected_len);
}
#[test]
fn test_apply_explorer_selection_returns_to_select_image() {
let mut app = App::new();
app.screen = AppScreen::BrowseImage;
app.apply_explorer_selection(PathBuf::from("/some/ubuntu.iso"));
assert_eq!(app.screen, AppScreen::SelectImage);
}
#[test]
fn test_apply_explorer_selection_sets_editing_mode() {
let mut app = App::new();
app.input_mode = InputMode::Normal;
app.apply_explorer_selection(PathBuf::from("/some/ubuntu.iso"));
assert_eq!(app.input_mode, InputMode::Editing);
}
#[test]
fn test_apply_explorer_selection_unicode_path() {
let mut app = App::new();
let path = PathBuf::from("/home/utilisateur/téléchargements/debian.iso");
app.apply_explorer_selection(path.clone());
let expected_len = path.to_string_lossy().chars().count();
assert_eq!(app.image_cursor, expected_len);
assert_eq!(app.image_input, path.to_string_lossy().as_ref());
}
#[test]
fn test_go_back_browse_image_to_select_image() {
let mut app = App::new();
app.screen = AppScreen::BrowseImage;
app.go_back();
assert_eq!(app.screen, AppScreen::SelectImage);
}
#[test]
fn test_go_back_browse_image_restores_editing_mode() {
let mut app = App::new();
app.screen = AppScreen::BrowseImage;
app.input_mode = InputMode::Normal;
app.go_back();
assert_eq!(app.input_mode, InputMode::Editing);
}
}