use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::sync::{Arc, Mutex};
use std::time::Instant;
use crate::errors::{AppError, Result};
use crate::utils::display::truncate_url_for_display;
use crate::utils::settings::Settings;
#[derive(Clone, Debug)]
pub struct DownloadProgress {
pub display_name: String,
pub phase: String,
pub percent: f64,
pub speed: Option<String>,
pub eta: Option<String>,
pub downloaded_bytes: Option<u64>,
pub total_bytes: Option<u64>,
pub fragment_index: Option<u32>,
pub fragment_count: Option<u32>,
pub last_update: Instant,
}
impl Default for DownloadProgress {
fn default() -> Self {
Self {
display_name: String::new(),
phase: "downloading".to_string(),
percent: 0.0,
speed: None,
eta: None,
downloaded_bytes: None,
total_bytes: None,
fragment_index: None,
fragment_count: None,
last_update: Instant::now(),
}
}
}
impl DownloadProgress {
pub fn new(url: &str) -> Self {
Self {
display_name: truncate_url_for_display(url),
..Default::default()
}
}
}
#[derive(Clone)]
pub struct UiSnapshot {
pub progress: f64,
pub completed_tasks: usize,
pub total_tasks: usize,
pub initial_total_tasks: usize,
pub started: bool,
pub paused: bool,
pub completed: bool,
pub queue: VecDeque<String>,
pub active_downloads: Vec<DownloadProgress>,
pub logs: Vec<String>,
pub concurrent: usize,
pub toast: Option<String>,
pub use_ascii_indicators: bool,
pub total_retries: usize,
pub failed_count: usize,
}
pub type FileLockGuard<'a> = std::sync::MutexGuard<'a, ()>;
#[derive(Default)]
struct DownloadStats {
total_tasks: usize,
completed_tasks: usize,
progress: f64,
initial_total_tasks: usize,
}
#[derive(Default)]
struct DownloadQueues {
queue: VecDeque<String>,
active_downloads: HashMap<String, DownloadProgress>,
}
#[derive(Default)]
struct AppFlags {
paused: bool,
shutdown: bool,
started: bool,
force_quit: bool,
completed: bool,
notification_sent: bool,
}
pub enum StateMessage {
AddToQueue(String),
AddActiveDownload(String),
RemoveActiveDownload(String),
IncrementCompleted,
SetPaused(bool),
SetStarted(bool),
SetShutdown(bool),
SetForceQuit(bool),
SetCompleted(bool),
UpdateProgress,
LoadLinks(Vec<String>),
UpdateDownloadProgress {
url: String,
progress: DownloadProgress,
},
AddFailedDownload(String),
}
#[derive(Clone)]
pub struct AppState {
stats: Arc<Mutex<DownloadStats>>,
queues: Arc<Mutex<DownloadQueues>>,
flags: Arc<Mutex<AppFlags>>,
logs: Arc<Mutex<VecDeque<String>>>,
concurrent: Arc<Mutex<usize>>,
settings: Arc<Mutex<Settings>>,
file_lock: Arc<Mutex<()>>,
toast: Arc<Mutex<Option<(String, Instant)>>>,
total_retries: Arc<Mutex<usize>>,
failed_downloads: Arc<Mutex<Vec<String>>>,
tx: Sender<StateMessage>,
rx: Arc<Mutex<Receiver<StateMessage>>>,
}
impl AppState {
pub fn new() -> Self {
let (tx, rx) = channel();
let settings = Settings::load().unwrap_or_default();
let state = AppState {
stats: Arc::new(Mutex::new(DownloadStats::default())),
queues: Arc::new(Mutex::new(DownloadQueues::default())),
flags: Arc::new(Mutex::new(AppFlags::default())),
logs: Arc::new(Mutex::new(VecDeque::from([
"Welcome! Press 'S' to start downloads".to_string(),
"Press 'Q' to quit, 'Shift+Q' to force quit".to_string(),
]))),
concurrent: Arc::new(Mutex::new(settings.concurrent_downloads)),
settings: Arc::new(Mutex::new(settings)),
file_lock: Arc::new(Mutex::new(())),
toast: Arc::new(Mutex::new(None)),
total_retries: Arc::new(Mutex::new(0)),
failed_downloads: Arc::new(Mutex::new(Vec::new())),
tx,
rx: Arc::new(Mutex::new(rx)),
};
let state_clone = state.clone();
std::thread::spawn(move || {
state_clone.process_messages();
});
state
}
fn process_messages(&self) {
loop {
let rx = match self.rx.lock() {
Ok(rx) => rx,
Err(err) => {
eprintln!("Message processor: mutex poisoned, exiting: {}", err);
break;
}
};
let message = match rx.recv() {
Ok(msg) => msg,
Err(_) => {
break;
}
};
drop(rx);
match message {
StateMessage::AddToQueue(url) => {
if let Err(err) = self.handle_add_to_queue(url) {
eprintln!("Error adding to queue: {}", err);
}
}
StateMessage::AddActiveDownload(url) => {
if let Err(err) = self.handle_add_active_download(url) {
eprintln!("Error adding active download: {}", err);
}
}
StateMessage::RemoveActiveDownload(url) => {
if let Err(err) = self.handle_remove_active_download(&url) {
eprintln!("Error removing active download: {}", err);
}
}
StateMessage::UpdateDownloadProgress { url, progress } => {
if let Err(err) = self.handle_update_download_progress(&url, progress) {
eprintln!("Error updating download progress: {}", err);
}
}
StateMessage::IncrementCompleted => {
if let Err(err) = self.handle_increment_completed() {
eprintln!("Error incrementing completed: {}", err);
}
}
StateMessage::UpdateProgress => {
if let Err(err) = self.update_progress() {
eprintln!("Error updating progress: {}", err);
}
}
StateMessage::SetPaused(value) => {
if let Err(err) = self.handle_set_paused(value) {
eprintln!("Error setting paused: {}", err);
}
}
StateMessage::SetStarted(value) => {
if let Err(err) = self.handle_set_started(value) {
eprintln!("Error setting started: {}", err);
}
}
StateMessage::SetShutdown(value) => {
if let Err(err) = self.handle_set_shutdown(value) {
eprintln!("Error setting shutdown: {}", err);
}
}
StateMessage::SetForceQuit(value) => {
if let Err(err) = self.handle_set_force_quit(value) {
eprintln!("Error setting force quit: {}", err);
}
}
StateMessage::SetCompleted(value) => {
if let Err(err) = self.handle_set_completed(value) {
eprintln!("Error setting completed: {}", err);
}
}
StateMessage::LoadLinks(links) => {
if let Err(err) = self.handle_load_links(links) {
eprintln!("Error loading links: {}", err);
}
}
StateMessage::AddFailedDownload(url) => {
if let Err(err) = self.handle_add_failed_download(url) {
eprintln!("Error adding failed download: {}", err);
}
}
}
}
}
fn handle_add_to_queue(&self, url: String) -> Result<()> {
let mut queues = self.queues.lock()?;
queues.queue.push_back(url);
let mut stats = self.stats.lock()?;
stats.total_tasks += 1;
stats.initial_total_tasks += 1;
Ok(())
}
fn handle_add_active_download(&self, url: String) -> Result<()> {
let mut queues = self.queues.lock()?;
let progress = DownloadProgress::new(&url);
queues.active_downloads.insert(url, progress);
Ok(())
}
fn handle_remove_active_download(&self, url: &str) -> Result<()> {
let mut queues = self.queues.lock()?;
queues.active_downloads.remove(url);
Ok(())
}
fn handle_update_download_progress(&self, url: &str, progress: DownloadProgress) -> Result<()> {
let mut queues = self.queues.lock()?;
if let Some(existing) = queues.active_downloads.get_mut(url) {
*existing = progress;
}
Ok(())
}
pub fn refresh_all_download_timestamps(&self) -> Result<()> {
let mut queues = self.queues.lock()?;
for progress in queues.active_downloads.values_mut() {
progress.last_update = Instant::now();
}
Ok(())
}
fn handle_increment_completed(&self) -> Result<()> {
let mut stats = self.stats.lock()?;
stats.completed_tasks += 1;
self.tx
.send(StateMessage::UpdateProgress)
.map_err(|e| AppError::Channel(e.to_string()))?;
Ok(())
}
fn handle_set_paused(&self, value: bool) -> Result<()> {
let mut flags = self.flags.lock()?;
flags.paused = value;
Ok(())
}
fn handle_set_started(&self, value: bool) -> Result<()> {
let mut flags = self.flags.lock()?;
flags.started = value;
Ok(())
}
fn handle_set_shutdown(&self, value: bool) -> Result<()> {
let mut flags = self.flags.lock()?;
flags.shutdown = value;
Ok(())
}
fn handle_set_force_quit(&self, value: bool) -> Result<()> {
let mut flags = self.flags.lock()?;
flags.force_quit = value;
Ok(())
}
fn handle_set_completed(&self, value: bool) -> Result<()> {
let mut flags = self.flags.lock()?;
flags.completed = value;
Ok(())
}
fn handle_load_links(&self, links: Vec<String>) -> Result<()> {
let mut queues = self.queues.lock()?;
queues.queue = VecDeque::from(links);
let queue_len = queues.queue.len();
drop(queues);
let mut stats = self.stats.lock()?;
stats.total_tasks = queue_len;
stats.initial_total_tasks = queue_len;
Ok(())
}
fn handle_add_failed_download(&self, url: String) -> Result<()> {
let mut failed = self.failed_downloads.lock()?;
if !failed.contains(&url) {
failed.push(url);
}
Ok(())
}
pub fn send(&self, message: StateMessage) -> Result<()> {
self.tx
.send(message)
.map_err(|e| AppError::Channel(e.to_string()))?;
Ok(())
}
pub fn add_log(&self, message: String) -> Result<()> {
let mut logs = self.logs.lock()?;
logs.push_back(message);
while logs.len() > 1000 {
logs.pop_front();
}
Ok(())
}
pub fn log_error(&self, context: &str, error: impl std::fmt::Display) -> Result<()> {
self.add_log(format!("[ERROR] {}: {}", context, error))
}
pub fn update_progress(&self) -> Result<()> {
let mut stats = self.stats.lock()?;
if stats.initial_total_tasks > 0 {
stats.progress = stats.completed_tasks as f64 / stats.initial_total_tasks as f64;
} else {
stats.progress = 0.0;
}
let flags = self.flags.lock()?;
let is_completed = stats.completed_tasks == stats.initial_total_tasks
&& stats.initial_total_tasks > 0
&& flags.started
&& !flags.completed;
drop(flags);
if is_completed {
self.send(StateMessage::SetCompleted(true))?;
}
Ok(())
}
pub fn pop_queue(&self) -> Result<Option<String>> {
let mut queues = self.queues.lock()?;
Ok(queues.queue.pop_front())
}
pub fn get_queue(&self) -> Result<VecDeque<String>> {
let queues = self.queues.lock()?;
Ok(queues.queue.clone())
}
pub fn remove_from_queue(&self, index: usize) -> Result<Option<String>> {
let mut queues = self.queues.lock()?;
if index < queues.queue.len() {
let removed = queues.queue.remove(index);
if removed.is_some() {
let mut stats = self.stats.lock()?;
if stats.total_tasks > 0 {
stats.total_tasks -= 1;
}
if stats.initial_total_tasks > 0 {
stats.initial_total_tasks -= 1;
}
}
Ok(removed)
} else {
Ok(None)
}
}
pub fn swap_queue_items(&self, index_a: usize, index_b: usize) -> Result<bool> {
let mut queues = self.queues.lock()?;
if index_a < queues.queue.len() && index_b < queues.queue.len() && index_a != index_b {
queues.queue.swap(index_a, index_b);
Ok(true)
} else {
Ok(false)
}
}
pub fn get_active_downloads(&self) -> Result<HashSet<String>> {
let queues = self.queues.lock()?;
Ok(queues.active_downloads.keys().cloned().collect())
}
pub fn is_paused(&self) -> Result<bool> {
let flags = self.flags.lock()?;
Ok(flags.paused)
}
pub fn is_started(&self) -> Result<bool> {
let flags = self.flags.lock()?;
Ok(flags.started)
}
pub fn is_completed(&self) -> Result<bool> {
let flags = self.flags.lock()?;
Ok(flags.completed)
}
pub fn is_shutdown(&self) -> Result<bool> {
let flags = self.flags.lock()?;
Ok(flags.shutdown)
}
pub fn is_force_quit(&self) -> Result<bool> {
let flags = self.flags.lock()?;
Ok(flags.force_quit)
}
pub fn is_notification_sent(&self) -> Result<bool> {
let flags = self.flags.lock()?;
Ok(flags.notification_sent)
}
pub fn set_notification_sent(&self, value: bool) -> Result<()> {
let mut flags = self.flags.lock()?;
flags.notification_sent = value;
Ok(())
}
pub fn get_concurrent(&self) -> Result<usize> {
let concurrent = self.concurrent.lock()?;
Ok(*concurrent)
}
pub fn set_concurrent(&self, value: usize) -> Result<()> {
let mut concurrent = self.concurrent.lock()?;
*concurrent = value;
Ok(())
}
pub fn reset_for_new_run(&self) -> Result<()> {
let reset_stats = self.settings.lock()?.reset_stats_on_new_batch;
let mut flags = self.flags.lock()?;
flags.paused = false;
flags.started = false;
flags.completed = false;
flags.notification_sent = false;
flags.shutdown = false;
flags.force_quit = false;
drop(flags);
let queue_len = if reset_stats {
self.queues.lock()?.queue.len()
} else {
0 };
let mut stats = self.stats.lock()?;
stats.completed_tasks = 0;
stats.progress = 0.0;
if reset_stats {
stats.total_tasks = queue_len;
stats.initial_total_tasks = queue_len;
}
drop(stats);
self.reset_retries()?;
self.clear_toast()?;
{
let mut failed = self.failed_downloads.lock()?;
failed.clear();
}
Ok(())
}
pub fn clear_logs(&self) -> Result<()> {
let mut logs = self.logs.lock()?;
logs.clear();
logs.push_back("Logs cleared".to_string());
Ok(())
}
pub fn acquire_file_lock(&self) -> Result<FileLockGuard<'_>> {
self.file_lock.lock().map_err(AppError::from)
}
pub fn get_settings(&self) -> Result<Settings> {
let settings = self.settings.lock()?;
Ok(settings.clone())
}
pub fn update_settings(&self, new_settings: Settings) -> Result<()> {
let mut settings = self.settings.lock()?;
*settings = new_settings;
Ok(())
}
pub fn show_toast(&self, message: impl Into<String>) -> Result<()> {
let mut toast = self.toast.lock()?;
*toast = Some((message.into(), Instant::now()));
Ok(())
}
pub fn clear_toast(&self) -> Result<()> {
let mut toast = self.toast.lock()?;
*toast = None;
Ok(())
}
pub fn increment_retries(&self) -> Result<()> {
let mut retries = self.total_retries.lock()?;
*retries += 1;
Ok(())
}
pub fn reset_retries(&self) -> Result<()> {
let mut retries = self.total_retries.lock()?;
*retries = 0;
Ok(())
}
pub fn take_failed_downloads(&self) -> Result<Vec<String>> {
let mut failed = self.failed_downloads.lock()?;
Ok(failed.drain(..).collect())
}
pub fn get_ui_snapshot(&self) -> Result<UiSnapshot> {
let stats = self.stats.lock()?;
let progress = stats.progress;
let completed_tasks = stats.completed_tasks;
let total_tasks = stats.total_tasks;
let initial_total_tasks = stats.initial_total_tasks;
drop(stats);
let flags = self.flags.lock()?;
let started = flags.started;
let paused = flags.paused;
let completed = flags.completed;
drop(flags);
let queues = self.queues.lock()?;
let queue = queues.queue.clone();
let active_downloads: Vec<DownloadProgress> =
queues.active_downloads.values().cloned().collect();
drop(queues);
let logs = self.logs.lock()?;
let logs_vec: Vec<String> = logs.iter().cloned().collect();
drop(logs);
let concurrent = *self.concurrent.lock()?;
let toast = {
let mut toast_guard = self.toast.lock()?;
if let Some((msg, time)) = toast_guard.as_ref() {
if time.elapsed().as_secs() < 3 {
Some(msg.clone())
} else {
*toast_guard = None;
None
}
} else {
None
}
};
let settings = self.settings.lock()?;
let use_ascii_indicators = settings.use_ascii_indicators;
drop(settings);
let total_retries = *self.total_retries.lock()?;
let failed_count = self.failed_downloads.lock()?.len();
Ok(UiSnapshot {
progress,
completed_tasks,
total_tasks,
initial_total_tasks,
started,
paused,
completed,
queue,
active_downloads,
logs: logs_vec,
concurrent,
toast,
use_ascii_indicators,
total_retries,
failed_count,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::thread;
use std::time::Duration;
fn wait_for_processing() {
thread::sleep(Duration::from_millis(50));
}
#[test]
fn test_new_creates_default_state() {
let state = AppState::new();
assert!(!state.is_paused().unwrap());
assert!(!state.is_started().unwrap());
assert!(!state.is_shutdown().unwrap());
assert!(!state.is_force_quit().unwrap());
assert!(!state.is_completed().unwrap());
}
#[test]
fn test_new_creates_empty_queue() {
let state = AppState::new();
let queue = state.get_queue().unwrap();
assert!(queue.is_empty());
}
#[test]
fn test_new_has_welcome_logs() {
let state = AppState::new();
let snapshot = state.get_ui_snapshot().unwrap();
assert!(snapshot.logs.len() >= 2);
assert!(snapshot.logs[0].contains("Welcome"));
assert!(snapshot.logs[1].contains("quit"));
}
#[test]
fn test_new_loads_settings() {
let state = AppState::new();
let settings = state.get_settings().unwrap();
assert!(settings.concurrent_downloads > 0);
}
#[test]
fn test_pop_queue_empty() {
let state = AppState::new();
let result = state.pop_queue().unwrap();
assert!(result.is_none());
}
#[test]
fn test_pop_queue_returns_front() {
let state = AppState::new();
state
.send(StateMessage::LoadLinks(vec![
"url1".to_string(),
"url2".to_string(),
"url3".to_string(),
]))
.unwrap();
wait_for_processing();
let first = state.pop_queue().unwrap();
assert_eq!(first, Some("url1".to_string()));
let second = state.pop_queue().unwrap();
assert_eq!(second, Some("url2".to_string()));
}
#[test]
fn test_get_queue_returns_copy() {
let state = AppState::new();
state
.send(StateMessage::LoadLinks(vec![
"url1".to_string(),
"url2".to_string(),
]))
.unwrap();
wait_for_processing();
let queue = state.get_queue().unwrap();
assert_eq!(queue.len(), 2);
assert_eq!(queue[0], "url1");
assert_eq!(queue[1], "url2");
}
#[test]
fn test_remove_from_queue_valid_index() {
let state = AppState::new();
state
.send(StateMessage::LoadLinks(vec![
"url1".to_string(),
"url2".to_string(),
"url3".to_string(),
]))
.unwrap();
wait_for_processing();
let removed = state.remove_from_queue(1).unwrap();
assert_eq!(removed, Some("url2".to_string()));
let queue = state.get_queue().unwrap();
assert_eq!(queue.len(), 2);
assert_eq!(queue[0], "url1");
assert_eq!(queue[1], "url3");
}
#[test]
fn test_remove_from_queue_invalid_index() {
let state = AppState::new();
state
.send(StateMessage::LoadLinks(vec!["url1".to_string()]))
.unwrap();
wait_for_processing();
let removed = state.remove_from_queue(5).unwrap();
assert!(removed.is_none());
}
#[test]
fn test_swap_queue_items_valid() {
let state = AppState::new();
state
.send(StateMessage::LoadLinks(vec![
"url1".to_string(),
"url2".to_string(),
"url3".to_string(),
]))
.unwrap();
wait_for_processing();
let success = state.swap_queue_items(0, 2).unwrap();
assert!(success);
let queue = state.get_queue().unwrap();
assert_eq!(queue[0], "url3");
assert_eq!(queue[2], "url1");
}
#[test]
fn test_swap_queue_items_same_index() {
let state = AppState::new();
state
.send(StateMessage::LoadLinks(vec![
"url1".to_string(),
"url2".to_string(),
]))
.unwrap();
wait_for_processing();
let success = state.swap_queue_items(0, 0).unwrap();
assert!(!success);
}
#[test]
fn test_swap_queue_items_invalid_index() {
let state = AppState::new();
state
.send(StateMessage::LoadLinks(vec!["url1".to_string()]))
.unwrap();
wait_for_processing();
let success = state.swap_queue_items(0, 5).unwrap();
assert!(!success);
}
#[test]
fn test_set_paused() {
let state = AppState::new();
state.send(StateMessage::SetPaused(true)).unwrap();
wait_for_processing();
assert!(state.is_paused().unwrap());
state.send(StateMessage::SetPaused(false)).unwrap();
wait_for_processing();
assert!(!state.is_paused().unwrap());
}
#[test]
fn test_set_started() {
let state = AppState::new();
state.send(StateMessage::SetStarted(true)).unwrap();
wait_for_processing();
assert!(state.is_started().unwrap());
state.send(StateMessage::SetStarted(false)).unwrap();
wait_for_processing();
assert!(!state.is_started().unwrap());
}
#[test]
fn test_set_shutdown() {
let state = AppState::new();
state.send(StateMessage::SetShutdown(true)).unwrap();
wait_for_processing();
assert!(state.is_shutdown().unwrap());
}
#[test]
fn test_set_force_quit() {
let state = AppState::new();
state.send(StateMessage::SetForceQuit(true)).unwrap();
wait_for_processing();
assert!(state.is_force_quit().unwrap());
}
#[test]
fn test_set_completed() {
let state = AppState::new();
state.send(StateMessage::SetCompleted(true)).unwrap();
wait_for_processing();
assert!(state.is_completed().unwrap());
}
#[test]
fn test_increment_completed() {
let state = AppState::new();
state
.send(StateMessage::LoadLinks(vec![
"url1".to_string(),
"url2".to_string(),
]))
.unwrap();
state.send(StateMessage::SetStarted(true)).unwrap();
wait_for_processing();
state.send(StateMessage::IncrementCompleted).unwrap();
wait_for_processing();
let snapshot = state.get_ui_snapshot().unwrap();
assert_eq!(snapshot.completed_tasks, 1);
assert!((snapshot.progress - 0.5).abs() < 0.01);
}
#[test]
fn test_update_progress_calculation() {
let state = AppState::new();
state
.send(StateMessage::LoadLinks(vec![
"url1".to_string(),
"url2".to_string(),
"url3".to_string(),
"url4".to_string(),
]))
.unwrap();
state.send(StateMessage::SetStarted(true)).unwrap();
wait_for_processing();
state.send(StateMessage::IncrementCompleted).unwrap();
state.send(StateMessage::IncrementCompleted).unwrap();
wait_for_processing();
let snapshot = state.get_ui_snapshot().unwrap();
assert!((snapshot.progress - 0.5).abs() < 0.01);
}
#[test]
fn test_auto_completion_detection() {
let state = AppState::new();
state
.send(StateMessage::LoadLinks(vec![
"url1".to_string(),
"url2".to_string(),
]))
.unwrap();
state.send(StateMessage::SetStarted(true)).unwrap();
wait_for_processing();
state.send(StateMessage::IncrementCompleted).unwrap();
state.send(StateMessage::IncrementCompleted).unwrap();
wait_for_processing();
thread::sleep(Duration::from_millis(100));
let snapshot = state.get_ui_snapshot().unwrap();
assert!(snapshot.completed);
assert!((snapshot.progress - 1.0).abs() < 0.01);
}
#[test]
fn test_progress_zero_when_no_tasks() {
let state = AppState::new();
state.send(StateMessage::UpdateProgress).unwrap();
wait_for_processing();
let snapshot = state.get_ui_snapshot().unwrap();
assert!((snapshot.progress - 0.0).abs() < 0.01);
}
#[test]
fn test_add_active_download() {
let state = AppState::new();
state
.send(StateMessage::AddActiveDownload(
"https://example.com/video".to_string(),
))
.unwrap();
wait_for_processing();
let active = state.get_active_downloads().unwrap();
assert!(active.contains("https://example.com/video"));
}
#[test]
fn test_remove_active_download() {
let state = AppState::new();
state
.send(StateMessage::AddActiveDownload(
"https://example.com/video".to_string(),
))
.unwrap();
wait_for_processing();
state
.send(StateMessage::RemoveActiveDownload(
"https://example.com/video".to_string(),
))
.unwrap();
wait_for_processing();
let active = state.get_active_downloads().unwrap();
assert!(!active.contains("https://example.com/video"));
}
#[test]
fn test_update_download_progress() {
let state = AppState::new();
let url = "https://youtube.com/watch?v=abc123".to_string();
state
.send(StateMessage::AddActiveDownload(url.clone()))
.unwrap();
wait_for_processing();
let progress = DownloadProgress {
display_name: "Test Video".to_string(),
phase: "downloading".to_string(),
percent: 50.0,
speed: Some("1.5MiB/s".to_string()),
eta: Some("00:02:30".to_string()),
downloaded_bytes: Some(1024 * 1024),
total_bytes: Some(2 * 1024 * 1024),
fragment_index: None,
fragment_count: None,
last_update: Instant::now(),
};
state
.send(StateMessage::UpdateDownloadProgress {
url: url.clone(),
progress,
})
.unwrap();
wait_for_processing();
let snapshot = state.get_ui_snapshot().unwrap();
assert_eq!(snapshot.active_downloads.len(), 1);
let download = &snapshot.active_downloads[0];
assert_eq!(download.display_name, "Test Video");
assert!((download.percent - 50.0).abs() < 0.01);
}
#[test]
fn test_multiple_active_downloads() {
let state = AppState::new();
state
.send(StateMessage::AddActiveDownload("url1".to_string()))
.unwrap();
state
.send(StateMessage::AddActiveDownload("url2".to_string()))
.unwrap();
state
.send(StateMessage::AddActiveDownload("url3".to_string()))
.unwrap();
wait_for_processing();
let active = state.get_active_downloads().unwrap();
assert_eq!(active.len(), 3);
}
#[test]
fn test_add_log() {
let state = AppState::new();
state.add_log("Test log message".to_string()).unwrap();
let snapshot = state.get_ui_snapshot().unwrap();
assert!(
snapshot
.logs
.iter()
.any(|log| log.contains("Test log message"))
);
}
#[test]
fn test_log_limit_1000() {
let state = AppState::new();
for i in 0..1100 {
state.add_log(format!("Log message {}", i)).unwrap();
}
let snapshot = state.get_ui_snapshot().unwrap();
assert!(snapshot.logs.len() <= 1000);
assert!(
!snapshot
.logs
.iter()
.any(|log| log.contains("Log message 0"))
);
assert!(
snapshot
.logs
.iter()
.any(|log| log.contains("Log message 1099"))
);
}
#[test]
fn test_clear_logs() {
let state = AppState::new();
state.add_log("Test log 1".to_string()).unwrap();
state.add_log("Test log 2".to_string()).unwrap();
state.clear_logs().unwrap();
let snapshot = state.get_ui_snapshot().unwrap();
assert_eq!(snapshot.logs.len(), 1);
assert!(snapshot.logs[0].contains("Logs cleared"));
}
#[test]
fn test_log_error() {
let state = AppState::new();
state.log_error("Download", "Connection timeout").unwrap();
let snapshot = state.get_ui_snapshot().unwrap();
assert!(snapshot.logs.iter().any(|log| {
log.contains("[ERROR]")
&& log.contains("Download")
&& log.contains("Connection timeout")
}));
}
#[test]
fn test_show_toast() {
let state = AppState::new();
state.show_toast("Test notification").unwrap();
let snapshot = state.get_ui_snapshot().unwrap();
assert_eq!(snapshot.toast, Some("Test notification".to_string()));
}
#[test]
fn test_show_toast_accepts_string() {
let state = AppState::new();
state.show_toast(String::from("String toast")).unwrap();
let snapshot = state.get_ui_snapshot().unwrap();
assert_eq!(snapshot.toast, Some("String toast".to_string()));
}
#[test]
fn test_clear_toast() {
let state = AppState::new();
state.show_toast("Test notification").unwrap();
state.clear_toast().unwrap();
let snapshot = state.get_ui_snapshot().unwrap();
assert!(snapshot.toast.is_none());
}
#[test]
fn test_toast_auto_expiry() {
let state = AppState::new();
{
let mut toast = state.toast.lock().unwrap();
*toast = Some((
"Old toast".to_string(),
Instant::now() - Duration::from_secs(5),
));
}
let snapshot = state.get_ui_snapshot().unwrap();
assert!(snapshot.toast.is_none());
}
#[test]
fn test_get_settings() {
let state = AppState::new();
let settings = state.get_settings().unwrap();
assert!(settings.concurrent_downloads > 0);
}
#[test]
fn test_update_settings() {
let state = AppState::new();
let mut new_settings = state.get_settings().unwrap();
new_settings.concurrent_downloads = 8;
new_settings.write_subtitles = true;
state.update_settings(new_settings).unwrap();
let updated = state.get_settings().unwrap();
assert_eq!(updated.concurrent_downloads, 8);
assert!(updated.write_subtitles);
}
#[test]
fn test_ui_snapshot_captures_all_state() {
let state = AppState::new();
state
.send(StateMessage::LoadLinks(vec![
"url1".to_string(),
"url2".to_string(),
]))
.unwrap();
state.send(StateMessage::SetStarted(true)).unwrap();
state.send(StateMessage::SetPaused(true)).unwrap();
state
.send(StateMessage::AddActiveDownload("url1".to_string()))
.unwrap();
wait_for_processing();
state.add_log("Test log".to_string()).unwrap();
state.show_toast("Test toast").unwrap();
state.set_concurrent(6).unwrap();
let snapshot = state.get_ui_snapshot().unwrap();
assert!(snapshot.started);
assert!(snapshot.paused);
assert!(!snapshot.completed);
assert_eq!(snapshot.queue.len(), 2);
assert_eq!(snapshot.active_downloads.len(), 1);
assert!(snapshot.logs.iter().any(|l| l.contains("Test log")));
assert_eq!(snapshot.toast, Some("Test toast".to_string()));
assert_eq!(snapshot.concurrent, 6);
assert_eq!(snapshot.initial_total_tasks, 2);
}
#[test]
fn test_ui_snapshot_includes_retry_count() {
let state = AppState::new();
state.increment_retries().unwrap();
state.increment_retries().unwrap();
let snapshot = state.get_ui_snapshot().unwrap();
assert_eq!(snapshot.total_retries, 2);
}
#[test]
fn test_concurrent_get_settings() {
let state = AppState::new();
let handles: Vec<_> = (0..10)
.map(|_| {
let state_clone = state.clone();
thread::spawn(move || {
for _ in 0..100 {
let _ = state_clone.get_settings().unwrap();
}
})
})
.collect();
for handle in handles {
handle.join().unwrap();
}
}
#[test]
fn test_concurrent_flag_access() {
let state = AppState::new();
let handles: Vec<_> = (0..5)
.map(|i| {
let state_clone = state.clone();
thread::spawn(move || {
for _ in 0..50 {
state_clone
.send(StateMessage::SetPaused(i % 2 == 0))
.unwrap();
let _ = state_clone.is_paused().unwrap();
}
})
})
.collect();
for handle in handles {
handle.join().unwrap();
}
}
#[test]
fn test_concurrent_queue_access() {
let state = AppState::new();
state
.send(StateMessage::LoadLinks(vec![
"url1".to_string(),
"url2".to_string(),
"url3".to_string(),
"url4".to_string(),
"url5".to_string(),
]))
.unwrap();
wait_for_processing();
let handles: Vec<_> = (0..3)
.map(|_| {
let state_clone = state.clone();
thread::spawn(move || {
let _ = state_clone.pop_queue().unwrap();
let _ = state_clone.get_queue().unwrap();
})
})
.collect();
for handle in handles {
handle.join().unwrap();
}
}
#[test]
fn test_concurrent_log_writes() {
let state = AppState::new();
let handles: Vec<_> = (0..5)
.map(|i| {
let state_clone = state.clone();
thread::spawn(move || {
for j in 0..20 {
state_clone
.add_log(format!("Thread {} log {}", i, j))
.unwrap();
}
})
})
.collect();
for handle in handles {
handle.join().unwrap();
}
let snapshot = state.get_ui_snapshot().unwrap();
assert!(snapshot.logs.len() >= 100);
}
#[test]
fn test_reset_for_new_run() {
let state = AppState::new();
state.send(StateMessage::SetStarted(true)).unwrap();
state.send(StateMessage::SetPaused(true)).unwrap();
state.send(StateMessage::SetCompleted(true)).unwrap();
wait_for_processing();
state.increment_retries().unwrap();
state.show_toast("Test").unwrap();
state.reset_for_new_run().unwrap();
assert!(!state.is_paused().unwrap());
assert!(!state.is_started().unwrap());
assert!(!state.is_completed().unwrap());
assert!(!state.is_shutdown().unwrap());
assert!(!state.is_force_quit().unwrap());
let snapshot = state.get_ui_snapshot().unwrap();
assert!(snapshot.toast.is_none());
assert_eq!(snapshot.total_retries, 0);
}
#[test]
fn test_acquire_file_lock() {
let state = AppState::new();
let _lock = state.acquire_file_lock().unwrap();
}
#[test]
fn test_set_concurrent() {
let state = AppState::new();
state.set_concurrent(12).unwrap();
assert_eq!(state.get_concurrent().unwrap(), 12);
}
#[test]
fn test_increment_and_reset_retries() {
let state = AppState::new();
state.increment_retries().unwrap();
state.increment_retries().unwrap();
state.increment_retries().unwrap();
let snapshot = state.get_ui_snapshot().unwrap();
assert_eq!(snapshot.total_retries, 3);
state.reset_retries().unwrap();
let snapshot = state.get_ui_snapshot().unwrap();
assert_eq!(snapshot.total_retries, 0);
}
#[test]
fn test_refresh_all_download_timestamps() {
let state = AppState::new();
state
.send(StateMessage::AddActiveDownload("url1".to_string()))
.unwrap();
state
.send(StateMessage::AddActiveDownload("url2".to_string()))
.unwrap();
wait_for_processing();
state.refresh_all_download_timestamps().unwrap();
}
#[test]
fn test_download_progress_default() {
let progress = DownloadProgress::default();
assert!(progress.display_name.is_empty());
assert_eq!(progress.phase, "downloading");
assert!((progress.percent - 0.0).abs() < 0.01);
assert!(progress.speed.is_none());
assert!(progress.eta.is_none());
}
#[test]
fn test_download_progress_new_youtube() {
let progress = DownloadProgress::new("https://www.youtube.com/watch?v=dQw4w9WgXcQ");
assert!(progress.display_name.contains("dQw4w9WgXcQ"));
}
#[test]
fn test_download_progress_new_other_url() {
let progress = DownloadProgress::new("https://example.com/video.mp4");
assert!(progress.display_name.contains("video.mp4"));
}
#[test]
fn test_load_links_replaces_queue() {
let state = AppState::new();
state
.send(StateMessage::LoadLinks(vec!["old_url".to_string()]))
.unwrap();
wait_for_processing();
state
.send(StateMessage::LoadLinks(vec![
"new1".to_string(),
"new2".to_string(),
]))
.unwrap();
wait_for_processing();
let queue = state.get_queue().unwrap();
assert_eq!(queue.len(), 2);
assert_eq!(queue[0], "new1");
assert_eq!(queue[1], "new2");
}
#[test]
fn test_load_links_updates_stats() {
let state = AppState::new();
state
.send(StateMessage::LoadLinks(vec![
"url1".to_string(),
"url2".to_string(),
"url3".to_string(),
]))
.unwrap();
wait_for_processing();
let snapshot = state.get_ui_snapshot().unwrap();
assert_eq!(snapshot.total_tasks, 3);
assert_eq!(snapshot.initial_total_tasks, 3);
}
#[test]
fn test_add_to_queue() {
let state = AppState::new();
state
.send(StateMessage::AddToQueue("url1".to_string()))
.unwrap();
state
.send(StateMessage::AddToQueue("url2".to_string()))
.unwrap();
wait_for_processing();
let queue = state.get_queue().unwrap();
assert_eq!(queue.len(), 2);
let snapshot = state.get_ui_snapshot().unwrap();
assert_eq!(snapshot.initial_total_tasks, 2);
}
#[test]
fn test_notification_not_sent_initially() {
let state = AppState::new();
assert!(!state.is_notification_sent().unwrap());
}
#[test]
fn test_notification_can_be_marked_as_sent() {
let state = AppState::new();
state.set_notification_sent(true).unwrap();
assert!(state.is_notification_sent().unwrap());
}
#[test]
fn test_notification_flag_resets_on_new_run() {
let state = AppState::new();
state.set_notification_sent(true).unwrap();
assert!(state.is_notification_sent().unwrap());
state.reset_for_new_run().unwrap();
assert!(!state.is_notification_sent().unwrap());
}
#[test]
fn test_notification_lifecycle_across_multiple_runs() {
let state = AppState::new();
assert!(!state.is_notification_sent().unwrap());
state.set_notification_sent(true).unwrap();
assert!(state.is_notification_sent().unwrap());
assert!(state.is_notification_sent().unwrap());
state.reset_for_new_run().unwrap();
assert!(!state.is_notification_sent().unwrap());
state.set_notification_sent(true).unwrap();
assert!(state.is_notification_sent().unwrap());
}
#[test]
fn test_notification_flag_independent_of_completion_state() {
let state = AppState::new();
state.send(StateMessage::SetCompleted(true)).unwrap();
wait_for_processing();
assert!(state.is_completed().unwrap());
assert!(!state.is_notification_sent().unwrap());
state.set_notification_sent(true).unwrap();
assert!(state.is_notification_sent().unwrap());
}
#[test]
fn test_new_batch_resets_counters_by_default() {
let state = AppState::new();
let mut settings = state.get_settings().unwrap();
settings.reset_stats_on_new_batch = true;
state.update_settings(settings).unwrap();
state
.send(StateMessage::LoadLinks(vec![
"url1".to_string(),
"url2".to_string(),
"url3".to_string(),
]))
.unwrap();
state.send(StateMessage::SetStarted(true)).unwrap();
state.send(StateMessage::IncrementCompleted).unwrap();
state.send(StateMessage::IncrementCompleted).unwrap();
state.send(StateMessage::LoadLinks(vec![])).unwrap();
wait_for_processing();
let snapshot = state.get_ui_snapshot().unwrap();
assert_eq!(snapshot.completed_tasks, 2);
state.reset_for_new_run().unwrap();
let snapshot = state.get_ui_snapshot().unwrap();
assert_eq!(snapshot.total_tasks, 0);
assert_eq!(snapshot.initial_total_tasks, 0);
assert_eq!(snapshot.completed_tasks, 0);
}
#[test]
fn test_new_batch_preserves_counters_when_cumulative_mode_enabled() {
let state = AppState::new();
let mut settings = state.get_settings().unwrap();
settings.reset_stats_on_new_batch = false;
state.update_settings(settings).unwrap();
state
.send(StateMessage::LoadLinks(vec![
"url1".to_string(),
"url2".to_string(),
"url3".to_string(),
]))
.unwrap();
state.send(StateMessage::SetStarted(true)).unwrap();
state.send(StateMessage::IncrementCompleted).unwrap();
state.send(StateMessage::IncrementCompleted).unwrap();
wait_for_processing();
let snapshot = state.get_ui_snapshot().unwrap();
assert_eq!(snapshot.total_tasks, 3);
assert_eq!(snapshot.initial_total_tasks, 3);
assert_eq!(snapshot.completed_tasks, 2);
state.reset_for_new_run().unwrap();
let snapshot = state.get_ui_snapshot().unwrap();
assert_eq!(snapshot.total_tasks, 3);
assert_eq!(snapshot.initial_total_tasks, 3);
assert_eq!(snapshot.completed_tasks, 0);
}
#[test]
fn test_cumulative_mode_accumulates_across_batches() {
let state = AppState::new();
let mut settings = state.get_settings().unwrap();
settings.reset_stats_on_new_batch = false;
state.update_settings(settings).unwrap();
state
.send(StateMessage::LoadLinks(vec![
"url1".to_string(),
"url2".to_string(),
"url3".to_string(),
]))
.unwrap();
state.send(StateMessage::SetStarted(true)).unwrap();
state.send(StateMessage::IncrementCompleted).unwrap();
state.send(StateMessage::IncrementCompleted).unwrap();
state.send(StateMessage::IncrementCompleted).unwrap();
wait_for_processing();
let snapshot = state.get_ui_snapshot().unwrap();
assert_eq!(snapshot.initial_total_tasks, 3);
assert_eq!(snapshot.completed_tasks, 3);
state.reset_for_new_run().unwrap();
state
.send(StateMessage::AddToQueue("url4".to_string()))
.unwrap();
state
.send(StateMessage::AddToQueue("url5".to_string()))
.unwrap();
wait_for_processing();
let snapshot = state.get_ui_snapshot().unwrap();
assert_eq!(snapshot.initial_total_tasks, 5);
assert_eq!(snapshot.total_tasks, 5);
assert_eq!(snapshot.completed_tasks, 0);
}
#[test]
fn test_per_session_mode_fresh_count_each_batch() {
let state = AppState::new();
let mut settings = state.get_settings().unwrap();
settings.reset_stats_on_new_batch = true;
state.update_settings(settings).unwrap();
state
.send(StateMessage::LoadLinks(vec![
"url1".to_string(),
"url2".to_string(),
"url3".to_string(),
]))
.unwrap();
state.send(StateMessage::SetStarted(true)).unwrap();
state.send(StateMessage::IncrementCompleted).unwrap();
state.send(StateMessage::IncrementCompleted).unwrap();
state.send(StateMessage::IncrementCompleted).unwrap();
state.send(StateMessage::LoadLinks(vec![])).unwrap();
wait_for_processing();
let snapshot = state.get_ui_snapshot().unwrap();
assert_eq!(snapshot.completed_tasks, 3);
state.reset_for_new_run().unwrap();
let snapshot = state.get_ui_snapshot().unwrap();
assert_eq!(snapshot.initial_total_tasks, 0);
assert_eq!(snapshot.total_tasks, 0);
assert_eq!(snapshot.completed_tasks, 0);
state
.send(StateMessage::AddToQueue("url4".to_string()))
.unwrap();
state
.send(StateMessage::AddToQueue("url5".to_string()))
.unwrap();
wait_for_processing();
let snapshot = state.get_ui_snapshot().unwrap();
assert_eq!(snapshot.initial_total_tasks, 2);
assert_eq!(snapshot.total_tasks, 2);
}
#[test]
fn test_switching_modes_mid_session() {
let state = AppState::new();
state
.send(StateMessage::LoadLinks(vec![
"url1".to_string(),
"url2".to_string(),
]))
.unwrap();
state.send(StateMessage::SetStarted(true)).unwrap();
state.send(StateMessage::IncrementCompleted).unwrap();
wait_for_processing();
let mut settings = state.get_settings().unwrap();
settings.reset_stats_on_new_batch = false;
state.update_settings(settings).unwrap();
state.reset_for_new_run().unwrap();
let snapshot = state.get_ui_snapshot().unwrap();
assert_eq!(snapshot.initial_total_tasks, 2);
assert_eq!(snapshot.total_tasks, 2);
let mut settings = state.get_settings().unwrap();
settings.reset_stats_on_new_batch = true;
state.update_settings(settings).unwrap();
state.reset_for_new_run().unwrap();
let snapshot = state.get_ui_snapshot().unwrap();
assert_eq!(snapshot.initial_total_tasks, 2);
assert_eq!(snapshot.total_tasks, 2);
}
#[test]
fn test_failed_downloads_initially_empty() {
let state = AppState::new();
let snapshot = state.get_ui_snapshot().unwrap();
assert_eq!(snapshot.failed_count, 0);
}
#[test]
fn test_add_failed_download() {
let state = AppState::new();
state
.send(StateMessage::AddFailedDownload(
"https://example.com/video1".to_string(),
))
.unwrap();
wait_for_processing();
let snapshot = state.get_ui_snapshot().unwrap();
assert_eq!(snapshot.failed_count, 1);
}
#[test]
fn test_add_failed_download_no_duplicates() {
let state = AppState::new();
state
.send(StateMessage::AddFailedDownload(
"https://example.com/video1".to_string(),
))
.unwrap();
state
.send(StateMessage::AddFailedDownload(
"https://example.com/video1".to_string(),
))
.unwrap();
wait_for_processing();
let snapshot = state.get_ui_snapshot().unwrap();
assert_eq!(snapshot.failed_count, 1);
}
#[test]
fn test_add_multiple_failed_downloads() {
let state = AppState::new();
state
.send(StateMessage::AddFailedDownload(
"https://example.com/video1".to_string(),
))
.unwrap();
state
.send(StateMessage::AddFailedDownload(
"https://example.com/video2".to_string(),
))
.unwrap();
state
.send(StateMessage::AddFailedDownload(
"https://example.com/video3".to_string(),
))
.unwrap();
wait_for_processing();
let snapshot = state.get_ui_snapshot().unwrap();
assert_eq!(snapshot.failed_count, 3);
}
#[test]
fn test_take_failed_downloads_drains() {
let state = AppState::new();
state
.send(StateMessage::AddFailedDownload(
"https://example.com/video1".to_string(),
))
.unwrap();
state
.send(StateMessage::AddFailedDownload(
"https://example.com/video2".to_string(),
))
.unwrap();
wait_for_processing();
let failed = state.take_failed_downloads().unwrap();
assert_eq!(failed.len(), 2);
assert!(failed.contains(&"https://example.com/video1".to_string()));
assert!(failed.contains(&"https://example.com/video2".to_string()));
let snapshot = state.get_ui_snapshot().unwrap();
assert_eq!(snapshot.failed_count, 0);
}
#[test]
fn test_take_failed_downloads_empty() {
let state = AppState::new();
let failed = state.take_failed_downloads().unwrap();
assert!(failed.is_empty());
}
#[test]
fn test_reset_for_new_run_clears_failed_downloads() {
let state = AppState::new();
state
.send(StateMessage::AddFailedDownload(
"https://example.com/video1".to_string(),
))
.unwrap();
wait_for_processing();
let snapshot = state.get_ui_snapshot().unwrap();
assert_eq!(snapshot.failed_count, 1);
state.reset_for_new_run().unwrap();
let snapshot = state.get_ui_snapshot().unwrap();
assert_eq!(snapshot.failed_count, 0);
}
#[test]
fn test_ui_snapshot_includes_failed_count() {
let state = AppState::new();
state
.send(StateMessage::AddFailedDownload("url1".to_string()))
.unwrap();
state
.send(StateMessage::AddFailedDownload("url2".to_string()))
.unwrap();
wait_for_processing();
let snapshot = state.get_ui_snapshot().unwrap();
assert_eq!(snapshot.failed_count, 2);
}
#[test]
fn test_all_state_messages_are_processed() {
let state = AppState::new();
state
.send(StateMessage::AddToQueue("https://example.com".to_string()))
.unwrap();
state
.send(StateMessage::AddActiveDownload(
"https://example.com".to_string(),
))
.unwrap();
state
.send(StateMessage::RemoveActiveDownload(
"https://example.com".to_string(),
))
.unwrap();
state.send(StateMessage::IncrementCompleted).unwrap();
state.send(StateMessage::SetPaused(true)).unwrap();
state.send(StateMessage::SetStarted(true)).unwrap();
state.send(StateMessage::SetShutdown(false)).unwrap();
state.send(StateMessage::SetForceQuit(false)).unwrap();
state.send(StateMessage::SetCompleted(false)).unwrap();
state.send(StateMessage::UpdateProgress).unwrap();
state
.send(StateMessage::LoadLinks(vec!["https://example.com".to_string()]))
.unwrap();
state
.send(StateMessage::UpdateDownloadProgress {
url: "https://example.com".to_string(),
progress: DownloadProgress::new("https://example.com"),
})
.unwrap();
state
.send(StateMessage::AddFailedDownload(
"https://example.com".to_string(),
))
.unwrap();
wait_for_processing();
let snapshot = state.get_ui_snapshot().unwrap();
assert!(snapshot.started);
}
}