Skip to main content

max / goingson

7.5 KB · 207 lines History Blame Raw
1 //! Contexts as the weekly review and the day plan see them.
2 //!
3 //! The model's read side: a context is authored as a span and every view asks
4 //! whether a day is inside one. These go through the command layer rather than
5 //! the repository, because the projection is where the old `vacation_days`
6 //! shape used to live and the projection is what changed.
7
8 use chrono::NaiveDate;
9 use goingson_core::ContextKind;
10
11 use crate::commands::weekly_review::{set_vacation_week, vacation_days_of_week};
12 use crate::state::DESKTOP_USER_ID;
13 use crate::test_utils::setup_test_state;
14
15 fn day(date: &str) -> NaiveDate {
16 date.parse().expect("a date")
17 }
18
19 /// A state whose desktop user exists.
20 ///
21 /// The commands under test address `DESKTOP_USER_ID`, the way every command in
22 /// this app does, while `setup_test_state` creates a user with a fresh uuid. A
23 /// context has a foreign key onto `users`, so the row has to be there.
24 async fn desktop_state() -> std::sync::Arc<crate::state::AppState> {
25 let (state, _) = setup_test_state().await;
26 state
27 .db
28 .conn()
29 .expect("a connection")
30 .execute(
31 "INSERT OR IGNORE INTO users (id, email, password_hash, display_name, created_at)
32 VALUES (?1, 'desktop@example.com', 'x', 'Desktop', datetime('now'))",
33 rusqlite::params![DESKTOP_USER_ID.to_string()],
34 )
35 .expect("the desktop user");
36 state
37 }
38
39 #[tokio::test]
40 async fn ticking_two_days_writes_one_span() {
41 let state = desktop_state().await;
42 // Week of Monday 2026-08-03. Thursday and Friday off.
43 set_vacation_week(&state, day("2026-08-03"), &[3, 4]).expect("written");
44
45 let stored = state
46 .contexts
47 .list_all(DESKTOP_USER_ID)
48 .expect("contexts read");
49 assert_eq!(
50 stored.len(),
51 1,
52 "two adjacent days are one span: {stored:?}"
53 );
54 assert_eq!(stored[0].starts_on, day("2026-08-06"));
55 assert_eq!(stored[0].ends_on, day("2026-08-07"));
56 assert_eq!(stored[0].kind, ContextKind::Vacation);
57 }
58
59 #[tokio::test]
60 async fn a_run_across_the_week_boundary_becomes_one_context() {
61 let state = desktop_state().await;
62 // Thu-Sun of one week, then Mon-Tue of the next. The projection this model
63 // replaced stored these as two rows and could not say they were one thing.
64 set_vacation_week(&state, day("2026-08-03"), &[3, 4, 5, 6]).expect("written");
65 set_vacation_week(&state, day("2026-08-10"), &[0, 1]).expect("written");
66
67 let stored = state.contexts.list_all(DESKTOP_USER_ID).expect("read");
68 assert_eq!(stored.len(), 1, "one holiday, not two: {stored:?}");
69 assert_eq!(stored[0].starts_on, day("2026-08-06"));
70 assert_eq!(stored[0].ends_on, day("2026-08-11"));
71 assert_eq!(stored[0].days(), 6);
72 }
73
74 #[tokio::test]
75 async fn unticking_a_middle_day_splits_the_span() {
76 let state = desktop_state().await;
77 set_vacation_week(&state, day("2026-08-03"), &[0, 1, 2, 3, 4]).expect("written");
78 // Back at the desk on Wednesday.
79 set_vacation_week(&state, day("2026-08-03"), &[0, 1, 3, 4]).expect("written");
80
81 let stored = state.contexts.list_all(DESKTOP_USER_ID).expect("read");
82 assert_eq!(stored.len(), 2, "a working day between is two spans");
83 assert_eq!(stored[0].ends_on, day("2026-08-04"));
84 assert_eq!(stored[1].starts_on, day("2026-08-06"));
85 }
86
87 #[tokio::test]
88 async fn editing_one_week_keeps_the_half_that_ran_in_from_before() {
89 let state = desktop_state().await;
90 // A holiday Fri..Tue, authored across two weeks.
91 set_vacation_week(&state, day("2026-08-03"), &[4, 5, 6]).expect("written");
92 set_vacation_week(&state, day("2026-08-10"), &[0, 1]).expect("written");
93
94 // Now cut the second week back to Monday only. The Friday-to-Sunday half is
95 // not this week's business and must survive.
96 set_vacation_week(&state, day("2026-08-10"), &[0]).expect("written");
97
98 let stored = state.contexts.list_all(DESKTOP_USER_ID).expect("read");
99 assert_eq!(stored.len(), 1, "still one run: {stored:?}");
100 assert_eq!(stored[0].starts_on, day("2026-08-07"), "Friday survives");
101 assert_eq!(stored[0].ends_on, day("2026-08-10"), "Tuesday is back on");
102 }
103
104 #[tokio::test]
105 async fn the_week_projection_reads_back_what_was_ticked() {
106 let state = desktop_state().await;
107 set_vacation_week(&state, day("2026-08-03"), &[3, 4]).expect("written");
108
109 assert_eq!(
110 vacation_days_of_week(&state, day("2026-08-03")).expect("read"),
111 vec![3, 4]
112 );
113 // And a week the span merely reaches into reads only its own days.
114 set_vacation_week(&state, day("2026-08-10"), &[0]).expect("written");
115 assert_eq!(
116 vacation_days_of_week(&state, day("2026-08-10")).expect("read"),
117 vec![0]
118 );
119 }
120
121 #[tokio::test]
122 async fn a_context_migrated_from_an_event_is_never_dissolved_by_a_tick() {
123 // Ticking a checkbox is not a statement about a migration, and dissolving a
124 // migrated context would put its event back on the timeline behind the
125 // user's back.
126 let state = desktop_state().await;
127 let migrated = state
128 .contexts
129 .create(
130 DESKTOP_USER_ID,
131 "Conference",
132 ContextKind::Vacation,
133 day("2026-08-05"),
134 day("2026-08-07"),
135 )
136 .expect("recorded");
137 {
138 let event_id = uuid::Uuid::new_v4().to_string();
139 let conn = state.db.conn().expect("a connection");
140 conn.execute(
141 "INSERT INTO events (id, user_id, title, start_time, end_time, converted_to_context_id)
142 VALUES (?1, ?2, 'Conference', '2026-08-05 14:00:00', '2026-08-07 17:00:00', ?3)",
143 rusqlite::params![
144 event_id,
145 DESKTOP_USER_ID.to_string(),
146 migrated.id.to_string()
147 ],
148 )
149 .expect("the hidden event");
150 conn.execute(
151 "UPDATE contexts SET migrated_from_event_id = ?2 WHERE id = ?1",
152 rusqlite::params![migrated.id.to_string(), event_id],
153 )
154 .expect("the provenance mark");
155 }
156
157 set_vacation_week(&state, day("2026-08-03"), &[0]).expect("written");
158
159 let stored = state.contexts.list_all(DESKTOP_USER_ID).expect("read");
160 assert_eq!(stored.len(), 2, "the migrated one is untouched: {stored:?}");
161 assert!(
162 stored
163 .iter()
164 .any(|c| c.label == "Conference" && c.migrated_from_event_id.is_some()),
165 "and it kept its provenance"
166 );
167 }
168
169 #[tokio::test]
170 async fn the_day_plan_reads_its_contexts_rather_than_a_weekly_flag() {
171 let state = desktop_state().await;
172 state
173 .contexts
174 .create(
175 DESKTOP_USER_ID,
176 "Berlin",
177 ContextKind::Trip,
178 day("2026-08-05"),
179 day("2026-08-07"),
180 )
181 .expect("recorded");
182 state
183 .contexts
184 .create(
185 DESKTOP_USER_ID,
186 "Leave",
187 ContextKind::Vacation,
188 day("2026-08-06"),
189 day("2026-08-06"),
190 )
191 .expect("recorded");
192
193 let plan = crate::commands::day_planning::day_plan(&state, day("2026-08-06")).expect("a plan");
194 assert_eq!(plan.contexts.len(), 2, "both frame the day");
195 assert!(plan.is_vacation_day, "one of them is leave");
196
197 let plan = crate::commands::day_planning::day_plan(&state, day("2026-08-05")).expect("a plan");
198 assert_eq!(plan.contexts.len(), 1);
199 assert!(
200 !plan.is_vacation_day,
201 "a trip is a context and not a day off"
202 );
203
204 let plan = crate::commands::day_planning::day_plan(&state, day("2026-08-08")).expect("a plan");
205 assert!(plan.contexts.is_empty());
206 }
207