//! Contexts as the weekly review and the day plan see them. //! //! The model's read side: a context is authored as a span and every view asks //! whether a day is inside one. These go through the command layer rather than //! the repository, because the projection is where the old `vacation_days` //! shape used to live and the projection is what changed. use chrono::NaiveDate; use goingson_core::ContextKind; use crate::commands::weekly_review::{set_vacation_week, vacation_days_of_week}; use crate::state::DESKTOP_USER_ID; use crate::test_utils::setup_test_state; fn day(date: &str) -> NaiveDate { date.parse().expect("a date") } /// A state whose desktop user exists. /// /// The commands under test address `DESKTOP_USER_ID`, the way every command in /// this app does, while `setup_test_state` creates a user with a fresh uuid. A /// context has a foreign key onto `users`, so the row has to be there. async fn desktop_state() -> std::sync::Arc { let (state, _) = setup_test_state().await; state .db .conn() .expect("a connection") .execute( "INSERT OR IGNORE INTO users (id, email, password_hash, display_name, created_at) VALUES (?1, 'desktop@example.com', 'x', 'Desktop', datetime('now'))", rusqlite::params![DESKTOP_USER_ID.to_string()], ) .expect("the desktop user"); state } #[tokio::test] async fn ticking_two_days_writes_one_span() { let state = desktop_state().await; // Week of Monday 2026-08-03. Thursday and Friday off. set_vacation_week(&state, day("2026-08-03"), &[3, 4]).expect("written"); let stored = state .contexts .list_all(DESKTOP_USER_ID) .expect("contexts read"); assert_eq!( stored.len(), 1, "two adjacent days are one span: {stored:?}" ); assert_eq!(stored[0].starts_on, day("2026-08-06")); assert_eq!(stored[0].ends_on, day("2026-08-07")); assert_eq!(stored[0].kind, ContextKind::Vacation); } #[tokio::test] async fn a_run_across_the_week_boundary_becomes_one_context() { let state = desktop_state().await; // Thu-Sun of one week, then Mon-Tue of the next. The projection this model // replaced stored these as two rows and could not say they were one thing. set_vacation_week(&state, day("2026-08-03"), &[3, 4, 5, 6]).expect("written"); set_vacation_week(&state, day("2026-08-10"), &[0, 1]).expect("written"); let stored = state.contexts.list_all(DESKTOP_USER_ID).expect("read"); assert_eq!(stored.len(), 1, "one holiday, not two: {stored:?}"); assert_eq!(stored[0].starts_on, day("2026-08-06")); assert_eq!(stored[0].ends_on, day("2026-08-11")); assert_eq!(stored[0].days(), 6); } #[tokio::test] async fn unticking_a_middle_day_splits_the_span() { let state = desktop_state().await; set_vacation_week(&state, day("2026-08-03"), &[0, 1, 2, 3, 4]).expect("written"); // Back at the desk on Wednesday. set_vacation_week(&state, day("2026-08-03"), &[0, 1, 3, 4]).expect("written"); let stored = state.contexts.list_all(DESKTOP_USER_ID).expect("read"); assert_eq!(stored.len(), 2, "a working day between is two spans"); assert_eq!(stored[0].ends_on, day("2026-08-04")); assert_eq!(stored[1].starts_on, day("2026-08-06")); } #[tokio::test] async fn editing_one_week_keeps_the_half_that_ran_in_from_before() { let state = desktop_state().await; // A holiday Fri..Tue, authored across two weeks. set_vacation_week(&state, day("2026-08-03"), &[4, 5, 6]).expect("written"); set_vacation_week(&state, day("2026-08-10"), &[0, 1]).expect("written"); // Now cut the second week back to Monday only. The Friday-to-Sunday half is // not this week's business and must survive. set_vacation_week(&state, day("2026-08-10"), &[0]).expect("written"); let stored = state.contexts.list_all(DESKTOP_USER_ID).expect("read"); assert_eq!(stored.len(), 1, "still one run: {stored:?}"); assert_eq!(stored[0].starts_on, day("2026-08-07"), "Friday survives"); assert_eq!(stored[0].ends_on, day("2026-08-10"), "Tuesday is back on"); } #[tokio::test] async fn the_week_projection_reads_back_what_was_ticked() { let state = desktop_state().await; set_vacation_week(&state, day("2026-08-03"), &[3, 4]).expect("written"); assert_eq!( vacation_days_of_week(&state, day("2026-08-03")).expect("read"), vec![3, 4] ); // And a week the span merely reaches into reads only its own days. set_vacation_week(&state, day("2026-08-10"), &[0]).expect("written"); assert_eq!( vacation_days_of_week(&state, day("2026-08-10")).expect("read"), vec![0] ); } #[tokio::test] async fn a_context_migrated_from_an_event_is_never_dissolved_by_a_tick() { // Ticking a checkbox is not a statement about a migration, and dissolving a // migrated context would put its event back on the timeline behind the // user's back. let state = desktop_state().await; let migrated = state .contexts .create( DESKTOP_USER_ID, "Conference", ContextKind::Vacation, day("2026-08-05"), day("2026-08-07"), ) .expect("recorded"); { let event_id = uuid::Uuid::new_v4().to_string(); let conn = state.db.conn().expect("a connection"); conn.execute( "INSERT INTO events (id, user_id, title, start_time, end_time, converted_to_context_id) VALUES (?1, ?2, 'Conference', '2026-08-05 14:00:00', '2026-08-07 17:00:00', ?3)", rusqlite::params![ event_id, DESKTOP_USER_ID.to_string(), migrated.id.to_string() ], ) .expect("the hidden event"); conn.execute( "UPDATE contexts SET migrated_from_event_id = ?2 WHERE id = ?1", rusqlite::params![migrated.id.to_string(), event_id], ) .expect("the provenance mark"); } set_vacation_week(&state, day("2026-08-03"), &[0]).expect("written"); let stored = state.contexts.list_all(DESKTOP_USER_ID).expect("read"); assert_eq!(stored.len(), 2, "the migrated one is untouched: {stored:?}"); assert!( stored .iter() .any(|c| c.label == "Conference" && c.migrated_from_event_id.is_some()), "and it kept its provenance" ); } #[tokio::test] async fn the_day_plan_reads_its_contexts_rather_than_a_weekly_flag() { let state = desktop_state().await; state .contexts .create( DESKTOP_USER_ID, "Berlin", ContextKind::Trip, day("2026-08-05"), day("2026-08-07"), ) .expect("recorded"); state .contexts .create( DESKTOP_USER_ID, "Leave", ContextKind::Vacation, day("2026-08-06"), day("2026-08-06"), ) .expect("recorded"); let plan = crate::commands::day_planning::day_plan(&state, day("2026-08-06")).expect("a plan"); assert_eq!(plan.contexts.len(), 2, "both frame the day"); assert!(plan.is_vacation_day, "one of them is leave"); let plan = crate::commands::day_planning::day_plan(&state, day("2026-08-05")).expect("a plan"); assert_eq!(plan.contexts.len(), 1); assert!( !plan.is_vacation_day, "a trip is a context and not a day off" ); let plan = crate::commands::day_planning::day_plan(&state, day("2026-08-08")).expect("a plan"); assert!(plan.contexts.is_empty()); }