From 0ed8c09395b6ea029ef77427c52be19376baf146 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 7 May 2026 21:36:39 -0400 Subject: [PATCH] Add download lifecycle controls --- crates/ely_browser_core/src/error.rs | 5 +- .../ely_browser_core/src/state/downloads.rs | 53 ++++++++ crates/ely_browser_core/tests/downloads.rs | 119 +++++++++++++++++- crates/ely_domain/src/download.rs | 81 ++++++++++++ crates/ely_domain/src/error.rs | 6 + 5 files changed, 261 insertions(+), 3 deletions(-) diff --git a/crates/ely_browser_core/src/error.rs b/crates/ely_browser_core/src/error.rs index aceedb4..f8b6ee4 100644 --- a/crates/ely_browser_core/src/error.rs +++ b/crates/ely_browser_core/src/error.rs @@ -1,4 +1,4 @@ -use ely_domain::{DomainError, ProfileId, SpaceId, TabId}; +use ely_domain::{DomainError, DownloadId, ProfileId, SpaceId, TabId}; use thiserror::Error; #[derive(Clone, Debug, Error, Eq, PartialEq)] @@ -15,6 +15,9 @@ pub enum CoreError { #[error("profile not found: {id}")] ProfileNotFound { id: ProfileId }, + #[error("download not found: {id}")] + DownloadNotFound { id: DownloadId }, + #[error("favorite limit reached: {limit}")] FavoriteLimitReached { limit: usize }, diff --git a/crates/ely_browser_core/src/state/downloads.rs b/crates/ely_browser_core/src/state/downloads.rs index 956317c..c2d9f3d 100644 --- a/crates/ely_browser_core/src/state/downloads.rs +++ b/crates/ely_browser_core/src/state/downloads.rs @@ -25,6 +25,49 @@ impl BrowserCore { Ok(download_id) } + pub fn pause_download(&mut self, download_id: &DownloadId) -> Result<(), CoreError> { + self.download_entry_mut(download_id)?.pause()?; + Ok(()) + } + + pub fn resume_download(&mut self, download_id: &DownloadId) -> Result<(), CoreError> { + self.download_entry_mut(download_id)?.resume()?; + Ok(()) + } + + pub fn cancel_download(&mut self, download_id: &DownloadId) -> Result<(), CoreError> { + self.download_entry_mut(download_id)?.cancel()?; + Ok(()) + } + + pub fn retry_download(&mut self, download_id: &DownloadId) -> Result<(), CoreError> { + self.download_entry_mut(download_id)?.retry()?; + Ok(()) + } + + pub fn update_download_progress( + &mut self, + download_id: &DownloadId, + received_bytes: u64, + ) -> Result<(), CoreError> { + self.download_entry_mut(download_id)?.update_progress(received_bytes)?; + Ok(()) + } + + pub fn complete_download( + &mut self, + download_id: &DownloadId, + received_bytes: u64, + ) -> Result<(), CoreError> { + self.download_entry_mut(download_id)?.complete(received_bytes)?; + Ok(()) + } + + pub fn fail_download(&mut self, download_id: &DownloadId) -> Result<(), CoreError> { + self.download_entry_mut(download_id)?.fail()?; + Ok(()) + } + pub(super) fn visible_downloads(&self) -> Vec { self.download_entries .iter() @@ -32,4 +75,14 @@ impl BrowserCore { .cloned() .collect() } + + fn download_entry_mut( + &mut self, + download_id: &DownloadId, + ) -> Result<&mut DownloadEntry, CoreError> { + self.download_entries + .iter_mut() + .find(|entry| entry.id() == download_id) + .ok_or_else(|| CoreError::DownloadNotFound { id: download_id.clone() }) + } } diff --git a/crates/ely_browser_core/tests/downloads.rs b/crates/ely_browser_core/tests/downloads.rs index a9eb147..a2d79f6 100644 --- a/crates/ely_browser_core/tests/downloads.rs +++ b/crates/ely_browser_core/tests/downloads.rs @@ -1,7 +1,7 @@ use std::error::Error; -use ely_browser_core::{BrowserCore, InitialBrowserConfig}; -use ely_domain::{ProfileKind, UrlText}; +use ely_browser_core::{BrowserCore, CoreError, InitialBrowserConfig}; +use ely_domain::{DomainError, DownloadId, DownloadState, ProfileKind, UrlText}; #[test] fn download_entries_stay_with_active_profile() -> Result<(), Box> { @@ -36,3 +36,118 @@ fn download_entries_stay_with_active_profile() -> Result<(), Box> { assert_eq!(default_snapshot.download_entries[0].file_name(), "report.pdf"); Ok(()) } + +#[test] +fn controls_download_lifecycle() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let download_id = core.record_download_started( + UrlText::parse("https://example.com/report.pdf")?, + "report.pdf", + Some(2048), + )?; + + core.update_download_progress(&download_id, 1024)?; + assert_eq!(active_download(&core)?.received_bytes(), 1024); + + core.pause_download(&download_id)?; + assert_eq!(active_download(&core)?.state(), &DownloadState::Paused); + + core.resume_download(&download_id)?; + assert_eq!(active_download(&core)?.state(), &DownloadState::InProgress); + + core.complete_download(&download_id, 2048)?; + let completed = active_download(&core)?; + assert_eq!(completed.state(), &DownloadState::Completed); + assert_eq!(completed.received_bytes(), 2048); + Ok(()) +} + +#[test] +fn retries_cancelled_download_from_zero_bytes() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let download_id = core.record_download_started( + UrlText::parse("https://example.com/archive.zip")?, + "archive.zip", + Some(4096), + )?; + + core.update_download_progress(&download_id, 1024)?; + core.cancel_download(&download_id)?; + core.retry_download(&download_id)?; + + let retried = active_download(&core)?; + assert_eq!(retried.state(), &DownloadState::InProgress); + assert_eq!(retried.received_bytes(), 0); + Ok(()) +} + +#[test] +fn rejects_invalid_download_transition() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let download_id = core.record_download_started( + UrlText::parse("https://example.com/report.pdf")?, + "report.pdf", + Some(2048), + )?; + + core.complete_download(&download_id, 2048)?; + let error = match core.pause_download(&download_id) { + Ok(()) => return Err("completed download should reject pause".into()), + Err(error) => error, + }; + + assert_eq!( + error, + CoreError::Domain(DomainError::InvalidDownloadTransition { + action: "pause", + state: "completed" + }) + ); + Ok(()) +} + +#[test] +fn rejects_progress_above_total_bytes() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let download_id = core.record_download_started( + UrlText::parse("https://example.com/report.pdf")?, + "report.pdf", + Some(2048), + )?; + + let error = match core.update_download_progress(&download_id, 4096) { + Ok(()) => return Err("progress above total should be rejected".into()), + Err(error) => error, + }; + + assert_eq!( + error, + CoreError::Domain(DomainError::InvalidDownloadProgress { + received_bytes: 4096, + total_bytes: 2048, + }) + ); + Ok(()) +} + +#[test] +fn rejects_unknown_download_id() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let download_id = DownloadId::new(); + + let error = match core.cancel_download(&download_id) { + Ok(()) => return Err("unknown download should be rejected".into()), + Err(error) => error, + }; + + assert_eq!(error, CoreError::DownloadNotFound { id: download_id }); + Ok(()) +} + +fn active_download(core: &BrowserCore) -> Result> { + core.snapshot()? + .download_entries + .into_iter() + .next() + .ok_or_else(|| "download entry should exist".into()) +} diff --git a/crates/ely_domain/src/download.rs b/crates/ely_domain/src/download.rs index f9ff7b0..7e0b9b5 100644 --- a/crates/ely_domain/src/download.rs +++ b/crates/ely_domain/src/download.rs @@ -49,6 +49,52 @@ impl DownloadEntry { }) } + pub fn pause(&mut self) -> Result<(), DomainError> { + self.require_state("pause", &[DownloadState::InProgress])?; + self.state = DownloadState::Paused; + Ok(()) + } + + pub fn resume(&mut self) -> Result<(), DomainError> { + self.require_state("resume", &[DownloadState::Paused])?; + self.state = DownloadState::InProgress; + Ok(()) + } + + pub fn cancel(&mut self) -> Result<(), DomainError> { + self.require_state("cancel", &[DownloadState::InProgress, DownloadState::Paused])?; + self.state = DownloadState::Cancelled; + Ok(()) + } + + pub fn retry(&mut self) -> Result<(), DomainError> { + self.require_state("retry", &[DownloadState::Cancelled, DownloadState::Failed])?; + self.state = DownloadState::InProgress; + self.received_bytes = 0; + Ok(()) + } + + pub fn update_progress(&mut self, received_bytes: u64) -> Result<(), DomainError> { + self.require_state("update progress", &[DownloadState::InProgress])?; + self.validate_received_bytes(received_bytes)?; + self.received_bytes = received_bytes; + Ok(()) + } + + pub fn complete(&mut self, received_bytes: u64) -> Result<(), DomainError> { + self.require_state("complete", &[DownloadState::InProgress])?; + self.validate_received_bytes(received_bytes)?; + self.received_bytes = received_bytes; + self.state = DownloadState::Completed; + Ok(()) + } + + pub fn fail(&mut self) -> Result<(), DomainError> { + self.require_state("fail", &[DownloadState::InProgress, DownloadState::Paused])?; + self.state = DownloadState::Failed; + Ok(()) + } + #[must_use] pub fn id(&self) -> &DownloadId { &self.id @@ -88,4 +134,39 @@ impl DownloadEntry { pub fn started_at(&self) -> SystemTime { self.started_at } + + fn require_state( + &self, + action: &'static str, + allowed_states: &[DownloadState], + ) -> Result<(), DomainError> { + if allowed_states.iter().any(|state| state == &self.state) { + return Ok(()); + } + + Err(DomainError::InvalidDownloadTransition { action, state: self.state.as_str() }) + } + + fn validate_received_bytes(&self, received_bytes: u64) -> Result<(), DomainError> { + if let Some(total_bytes) = self.total_bytes + && received_bytes > total_bytes + { + return Err(DomainError::InvalidDownloadProgress { received_bytes, total_bytes }); + } + + Ok(()) + } +} + +impl DownloadState { + #[must_use] + pub fn as_str(&self) -> &'static str { + match self { + Self::InProgress => "in_progress", + Self::Paused => "paused", + Self::Completed => "completed", + Self::Cancelled => "cancelled", + Self::Failed => "failed", + } + } } diff --git a/crates/ely_domain/src/error.rs b/crates/ely_domain/src/error.rs index da6913d..1ad3f12 100644 --- a/crates/ely_domain/src/error.rs +++ b/crates/ely_domain/src/error.rs @@ -10,4 +10,10 @@ pub enum DomainError { #[error("invalid command query")] InvalidCommand, + + #[error("cannot {action} download while state is {state}")] + InvalidDownloadTransition { action: &'static str, state: &'static str }, + + #[error("download progress {received_bytes} exceeds total {total_bytes}")] + InvalidDownloadProgress { received_bytes: u64, total_bytes: u64 }, }