From 24276d172b4ddfb839f264c41879a9546aa8682f Mon Sep 17 00:00:00 2001 From: Savanni D'Gerinel Date: Thu, 1 Feb 2024 10:12:35 -0500 Subject: [PATCH] Introduce the RecordProvider interface DayDetailViewModel needs testing. I've worked out an improved API, and a set of tests to go along with it, and those can be made more easily with a mockable RecordProvider. So, in addition to stubbing out a bunch of tests, I've also created RecordProvider, mocked it, and implemented it for App. --- Cargo.lock | 50 ++++-- fitnesstrax/app/Cargo.toml | 1 + fitnesstrax/app/src/app.rs | 68 +++++-- fitnesstrax/app/src/app_window.rs | 4 +- fitnesstrax/app/src/view_models/day_detail.rs | 167 ++++++++++++++++-- fitnesstrax/app/src/views/historical_view.rs | 9 +- 6 files changed, 243 insertions(+), 56 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f1f2ffb..3aa632b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -133,6 +133,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-trait" +version = "0.1.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c980ee35e870bd1a4d2c8294d4c04d0499e67bca1e4b5cefcc693c2fa00caea9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.48", +] + [[package]] name = "atoi" version = "2.0.0" @@ -420,7 +431,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.48", ] [[package]] @@ -736,7 +747,7 @@ checksum = "487585f4d0c6655fe74905e2504d8ad6908e4db67f744eb140876906c2f3175d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.48", ] [[package]] @@ -928,6 +939,7 @@ name = "fitnesstrax" version = "0.3.0" dependencies = [ "async-channel", + "async-trait", "chrono", "chrono-tz", "dimensioned 0.8.0", @@ -1132,7 +1144,7 @@ checksum = "53b153fd91e4b0147f4aced87be237c98248656bb01050b96bf3ee89220a8ddb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.48", ] [[package]] @@ -1367,7 +1379,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.48", ] [[package]] @@ -2443,7 +2455,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.48", ] [[package]] @@ -2648,7 +2660,7 @@ checksum = "4359fd9c9171ec6e8c62926d6faaf553a8dc3f64e1507e76da7911b4f6a04405" dependencies = [ "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.48", ] [[package]] @@ -2792,9 +2804,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.70" +version = "1.0.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39278fbbf5fb4f646ce651690877f89d1c5811a3d4acb27700c1cb3cdb78fd3b" +checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae" dependencies = [ "unicode-ident", ] @@ -2836,9 +2848,9 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quote" -version = "1.0.33" +version = "1.0.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" +checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" dependencies = [ "proc-macro2", ] @@ -3321,7 +3333,7 @@ checksum = "43576ca501357b9b071ac53cdc7da8ef0cbd9493d8df094cd821777ea6e894d3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.48", ] [[package]] @@ -3720,9 +3732,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.41" +version = "2.0.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c8b28c477cc3bf0e7966561e3460130e1255f7a1cf71931075f1c5e7a7e269" +checksum = "0f3531638e407dfc0814761abb7c00a5b54992b849452a0646b7f65c9f770f3f" dependencies = [ "proc-macro2", "quote", @@ -3818,7 +3830,7 @@ checksum = "01742297787513b79cf8e29d1056ede1313e2420b7b3b15d0a768b4921f549df" dependencies = [ "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.48", ] [[package]] @@ -3942,7 +3954,7 @@ checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.48", ] [[package]] @@ -4063,7 +4075,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.48", ] [[package]] @@ -4433,7 +4445,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.48", "wasm-bindgen-shared", ] @@ -4467,7 +4479,7 @@ checksum = "f0eb82fcb7930ae6219a7ecfd55b217f5f0893484b7a13022ebb2b2bf20b5283" dependencies = [ "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.48", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -4708,7 +4720,7 @@ checksum = "b3c129550b3e6de3fd0ba67ba5c81818f9805e58b8d7fee80a3a59d2c9fc601a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.48", ] [[package]] diff --git a/fitnesstrax/app/Cargo.toml b/fitnesstrax/app/Cargo.toml index 1a1de9e..3966c0c 100644 --- a/fitnesstrax/app/Cargo.toml +++ b/fitnesstrax/app/Cargo.toml @@ -8,6 +8,7 @@ edition = "2021" [dependencies] adw = { version = "0.5", package = "libadwaita", features = [ "v1_4" ] } async-channel = { version = "2.1" } +async-trait = { version = "0.1" } chrono = { version = "0.4" } chrono-tz = { version = "0.8" } dimensioned = { version = "0.8", features = [ "serde" ] } diff --git a/fitnesstrax/app/src/app.rs b/fitnesstrax/app/src/app.rs index 8dfb882..18b67ea 100644 --- a/fitnesstrax/app/src/app.rs +++ b/fitnesstrax/app/src/app.rs @@ -14,6 +14,7 @@ General Public License for more details. You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see . */ +use async_trait::async_trait; use chrono::NaiveDate; use emseries::{time_range, Record, RecordId, Series, Timestamp}; use ft_core::TraxRecord; @@ -34,6 +35,32 @@ pub enum AppError { Unhandled, } +#[derive(Debug, Error)] +pub enum ReadError { + #[error("no database loaded")] + NoDatabase, +} + +#[derive(Debug, Error)] +pub enum WriteError { + #[error("no database loaded")] + NoDatabase, + #[error("unhandled error")] + Unhandled, +} + +#[async_trait] +pub trait RecordProvider: Send + Sync { + async fn records( + &self, + start: NaiveDate, + end: NaiveDate, + ) -> Result>, ReadError>; + async fn put_record(&self, record: TraxRecord) -> Result; + async fn update_record(&self, record: Record) -> Result<(), WriteError>; + async fn delete_record(&self, id: RecordId) -> Result<(), WriteError>; +} + /// The real, headless application. This is where all of the logic will reside. #[derive(Clone)] pub struct App { @@ -57,11 +84,26 @@ impl App { } } - pub async fn records( + pub async fn open_db(&self, path: PathBuf) -> Result<(), AppError> { + let db_ref = self.database.clone(); + self.runtime + .spawn_blocking(move || { + let db = Series::open(path).map_err(|_| AppError::FailedToOpenDatabase)?; + *db_ref.write().unwrap() = Some(db); + Ok(()) + }) + .await + .unwrap() + } +} + +#[async_trait] +impl RecordProvider for App { + async fn records( &self, start: NaiveDate, end: NaiveDate, - ) -> Result>, AppError> { + ) -> Result>, ReadError> { let db = self.database.clone(); self.runtime .spawn_blocking(move || { @@ -77,14 +119,14 @@ impl App { .collect::>>(); Ok(records) } else { - Err(AppError::NoDatabase) + Err(ReadError::NoDatabase) } }) .await .unwrap() } - pub async fn put_record(&self, record: TraxRecord) -> Result { + async fn put_record(&self, record: TraxRecord) -> Result { let db = self.database.clone(); self.runtime .spawn_blocking(move || { @@ -97,10 +139,10 @@ impl App { }) .await .unwrap() - .map_err(|_| AppError::Unhandled) + .map_err(|_| WriteError::Unhandled) } - pub async fn update_record(&self, record: Record) -> Result<(), AppError> { + async fn update_record(&self, record: Record) -> Result<(), WriteError> { let db = self.database.clone(); self.runtime .spawn_blocking(move || { @@ -112,18 +154,10 @@ impl App { }) .await .unwrap() - .map_err(|_| AppError::Unhandled) + .map_err(|_| WriteError::Unhandled) } - pub async fn open_db(&self, path: PathBuf) -> Result<(), AppError> { - let db_ref = self.database.clone(); - self.runtime - .spawn_blocking(move || { - let db = Series::open(path).map_err(|_| AppError::FailedToOpenDatabase)?; - *db_ref.write().unwrap() = Some(db); - Ok(()) - }) - .await - .unwrap() + async fn delete_record(&self, id: RecordId) -> Result<(), WriteError> { + unimplemented!() } } diff --git a/fitnesstrax/app/src/app_window.rs b/fitnesstrax/app/src/app_window.rs index a747262..2359f62 100644 --- a/fitnesstrax/app/src/app_window.rs +++ b/fitnesstrax/app/src/app_window.rs @@ -15,7 +15,7 @@ You should have received a copy of the GNU General Public License along with Fit */ use crate::{ - app::App, + app::{App, RecordProvider}, types::DayInterval, view_models::DayDetailViewModel, views::{DayDetailView, HistoricalView, PlaceholderView, View, WelcomeView}, @@ -142,7 +142,7 @@ impl AppWindow { glib::spawn_future_local(async move { let layout = gtk::Box::new(gtk::Orientation::Vertical, 0); layout.append(&adw::HeaderBar::new()); - let view_model = DayDetailViewModel::new(date, s.app.clone()).await; + let view_model = DayDetailViewModel::new(date, s.app.clone()).await.unwrap(); layout.append(&DayDetailView::new(view_model)); let page = &adw::NavigationPage::builder() .title(date.format("%Y-%m-%d").to_string()) diff --git a/fitnesstrax/app/src/view_models/day_detail.rs b/fitnesstrax/app/src/view_models/day_detail.rs index fa5c39d..46aee59 100644 --- a/fitnesstrax/app/src/view_models/day_detail.rs +++ b/fitnesstrax/app/src/view_models/day_detail.rs @@ -14,7 +14,11 @@ General Public License for more details. You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see . */ -use crate::{app::App, types::WeightFormatter}; +use crate::{ + app::{ReadError, RecordProvider, WriteError}, + types::WeightFormatter, +}; +use chrono::NaiveDate; use emseries::{Record, RecordId, Recordable}; use ft_core::TraxRecord; use std::{ @@ -77,7 +81,7 @@ impl Deref for RecordState { #[derive(Clone)] pub struct DayDetailViewModel { - app: App, + provider: Arc, pub date: chrono::NaiveDate, weight: Arc>>>, steps: Arc>>>, @@ -85,15 +89,20 @@ pub struct DayDetailViewModel { } impl DayDetailViewModel { - pub async fn new(date: chrono::NaiveDate, app: App) -> Self { - let records = app.records(date, date).await.unwrap(); - + pub async fn new( + date: chrono::NaiveDate, + provider: impl RecordProvider + 'static, + ) -> Result { let (weight_records, records): (Vec>, Vec>) = - records.into_iter().partition(|r| r.data.is_weight()); + provider + .records(date, date) + .await? + .into_iter() + .partition(|r| r.data.is_weight()); let (step_records, records): (Vec>, Vec>) = records.into_iter().partition(|r| r.data.is_steps()); - Self { - app, + Ok(Self { + provider: Arc::new(provider), date, weight: Arc::new(RwLock::new( weight_records @@ -120,7 +129,7 @@ impl DayDetailViewModel { .map(|r| (r.id.clone(), RecordState::Original(r))) .collect::>>(), )), - } + }) } pub fn weight(&self) -> Option { @@ -170,12 +179,12 @@ impl DayDetailViewModel { let weight_record = s.weight.read().unwrap().clone(); match weight_record { Some(RecordState::New(data)) => { - let _ = s.app.put_record(TraxRecord::Weight(data)).await; + let _ = s.provider.put_record(TraxRecord::Weight(data)).await; } Some(RecordState::Original(_)) => {} Some(RecordState::Updated(weight)) => { let _ = s - .app + .provider .update_record(Record { id: weight.id, data: TraxRecord::Weight(weight.data), @@ -189,12 +198,12 @@ impl DayDetailViewModel { let steps_record = s.steps.read().unwrap().clone(); match steps_record { Some(RecordState::New(data)) => { - let _ = s.app.put_record(TraxRecord::Steps(data)).await; + let _ = s.provider.put_record(TraxRecord::Steps(data)).await; } Some(RecordState::Original(_)) => {} Some(RecordState::Updated(steps)) => { let _ = s - .app + .provider .update_record(Record { id: steps.id, data: TraxRecord::Steps(steps.data), @@ -216,11 +225,11 @@ impl DayDetailViewModel { for record in records { match record { RecordState::New(data) => { - let _ = s.app.put_record(data).await; + let _ = s.provider.put_record(data).await; } RecordState::Original(_) => {} RecordState::Updated(r) => { - let _ = s.app.update_record(r.clone()).await; + let _ = s.provider.update_record(r.clone()).await; } RecordState::Deleted(_) => unimplemented!(), } @@ -233,3 +242,131 @@ impl DayDetailViewModel { unimplemented!(); } } + +#[cfg(test)] +mod test { + use super::*; + use async_trait::async_trait; + use dimensioned::si; + use emseries::Record; + + struct MockProvider { + records: Vec>, + } + + #[async_trait] + impl RecordProvider for MockProvider { + async fn records( + &self, + start: NaiveDate, + end: NaiveDate, + ) -> Result>, ReadError> { + let start = emseries::Timestamp::Date(start); + let end = emseries::Timestamp::Date(end); + Ok(self + .records + .iter() + .filter(|r| r.timestamp() >= start && r.timestamp() <= end) + .cloned() + .collect::>>()) + } + + async fn put_record(&self, record: TraxRecord) -> Result { + Err(WriteError::NoDatabase) + } + + async fn update_record(&self, record: Record) -> Result<(), WriteError> { + Err(WriteError::NoDatabase) + } + + async fn delete_record(&self, id: RecordId) -> Result<(), WriteError> { + Err(WriteError::NoDatabase) + } + } + + async fn with_view_model(test: TestFn) + where + TestFn: Fn(DayDetailViewModel), + { + let oct_12 = chrono::NaiveDate::from_ymd_opt(2023, 10, 12).unwrap(); + let oct_13 = chrono::NaiveDate::from_ymd_opt(2023, 10, 13).unwrap(); + let provider = MockProvider { + records: vec![ + Record { + id: RecordId::default(), + data: TraxRecord::Weight(ft_core::Weight { + date: oct_12.clone(), + weight: 93. * si::KG, + }), + }, + Record { + id: RecordId::default(), + data: TraxRecord::Weight(ft_core::Weight { + date: oct_13.clone(), + weight: 95. * si::KG, + }), + }, + Record { + id: RecordId::default(), + data: TraxRecord::Steps(ft_core::Steps { + date: oct_13.clone(), + count: 2500, + }), + }, + Record { + id: RecordId::default(), + data: TraxRecord::Weight(ft_core::Weight { + date: oct_13.clone(), + weight: 91. * si::KG, + }), + }, + Record { + id: RecordId::default(), + data: TraxRecord::Steps(ft_core::Steps { + date: oct_13.clone(), + count: 2750, + }), + }, + ], + }; + let model = DayDetailViewModel::new(oct_13, provider).await.unwrap(); + test(model) + } + + #[tokio::test] + async fn it_honors_only_the_first_weight_and_step_record() { + with_view_model(|view_model| { + assert_eq!(view_model.weight().map(|val| *val), Some(95. * si::KG)); + assert_eq!(view_model.steps(), Some(2500)); + }) + .await; + } + + #[test] + #[ignore] + fn it_enforces_one_weight_and_stepcount_per_day() {} + + #[test] + #[ignore] + fn it_can_construct_new_records() {} + + #[test] + #[ignore] + fn it_can_update_an_existing_record() {} + + #[test] + #[ignore] + fn it_can_remove_a_new_record() {} + + #[test] + #[ignore] + fn it_can_delete_an_existing_record() {} + + #[test] + #[ignore] + fn it_retrieve_records_by_workout() {} + + #[test] + #[ignore] + fn it_summarizes_records_by_workout_type() {} +} diff --git a/fitnesstrax/app/src/views/historical_view.rs b/fitnesstrax/app/src/views/historical_view.rs index 315ac00..cd7c782 100644 --- a/fitnesstrax/app/src/views/historical_view.rs +++ b/fitnesstrax/app/src/views/historical_view.rs @@ -77,8 +77,11 @@ impl ObjectSubclass for HistoricalViewPrivate { .expect("should be a DaySummary"); if let Some(app) = app.borrow().clone() { - let view_model = DayDetailViewModel::new(date.date(), app.clone()).await; - summary.set_data(view_model); + glib::spawn_future_local(async move { + let view_model = + DayDetailViewModel::new(date.date(), app).await.unwrap(); + summary.set_data(view_model); + }); } }); } @@ -130,7 +133,7 @@ impl HistoricalView { s } - pub fn set_intervals(&self, interval: DayInterval) { + pub fn set_interval(&self, interval: DayInterval) { let mut model = gio::ListStore::new::(); model.extend(interval.days().map(Date::new)); self.imp()