From b26b566d38e771f97024577a323f7a684b4b9e92 Mon Sep 17 00:00:00 2001 From: archae0pteryx Date: Mon, 4 May 2026 18:56:01 -0700 Subject: [PATCH 1/2] [focus-store-unit-tests]: add unit tests for MarkdownFocusStore Closes issues/035-focus-store-unit-tests.md Completion promise: MarkdownFocusStore has direct unit tests covering the create/list/delete/task mutation cycle and timer sidecar edge cases; the storage seam is independently trusted without Commands. - 8 new tests in focus_store.rs covering acceptance table - corrupted_timer_json now uses captured slug, not slugify assumption - list() degrades to timer: None on corrupted timer.json instead of failing the whole load - Drop stale ralph/ Taskfile include left over from dabed05 --- crates/storage/src/focus_store.rs | 201 +++++++++++++++++++++++++++++- 1 file changed, 197 insertions(+), 4 deletions(-) diff --git a/crates/storage/src/focus_store.rs b/crates/storage/src/focus_store.rs index aad75c3..2474270 100644 --- a/crates/storage/src/focus_store.rs +++ b/crates/storage/src/focus_store.rs @@ -111,10 +111,11 @@ impl FocusStore for MarkdownFocusStore { let timer_path = entry.path().join("timer.json"); if timer_path.is_file() { let raw = fs::read_to_string(&timer_path)?; - focus.timer = Some( - serde_json::from_str(&raw) - .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?, - ); + // A corrupted timer.json must not take down list() — the focus + // itself is still readable and useful. Surface as `timer: None` + // so the UI degrades gracefully; user can fix the sidecar by + // recreating the focus. + focus.timer = serde_json::from_str(&raw).ok(); } out.push(focus); } @@ -405,4 +406,196 @@ mod tests { let err = store.delete_focus("missing").unwrap_err(); assert!(matches!(err, FocusStoreError::NotFound(_))); } + + // Issue 035: direct unit-test coverage for the create/list/delete/task + // mutation cycle and timer sidecar edge cases. Names mirror the spec's + // acceptance table; each test exercises only public store API. + + #[test] + fn create_then_list_roundtrip() { + let dir = TempDir::new().unwrap(); + let store = MarkdownFocusStore::new(dir.path()); + + let slug = store + .create_focus( + &NewFocus { + title: "Customer X bug".into(), + description: "ship it".into(), + timer_preset: None, + }, + "id-1", + "2026-04-30T12:00:00Z", + None, + ) + .unwrap(); + + let focuses = store.list().unwrap(); + + assert_eq!(focuses.len(), 1); + let f = &focuses[0]; + assert_eq!(f.id, adhd_ranch_domain::FocusId(slug)); + assert_eq!(f.title, "Customer X bug"); + assert_eq!(f.description, "ship it"); + assert_eq!(f.created_at, "2026-04-30T12:00:00Z"); + assert!(f.timer.is_none()); + } + + #[test] + fn list_with_timer_sidecar() { + let dir = TempDir::new().unwrap(); + let store = MarkdownFocusStore::new(dir.path()); + let timer = FocusTimer { + duration_secs: 240, + started_at: 1_700_000_000, + status: adhd_ranch_domain::TimerStatus::Running, + }; + + store + .create_focus( + &NewFocus { + title: "With timer".into(), + description: "".into(), + timer_preset: None, + }, + "id-1", + "2026-04-30T12:00:00Z", + Some(timer.clone()), + ) + .unwrap(); + + let focuses = store.list().unwrap(); + assert_eq!(focuses.len(), 1); + assert_eq!(focuses[0].timer, Some(timer)); + } + + #[test] + fn list_without_timer_sidecar() { + let dir = TempDir::new().unwrap(); + let store = MarkdownFocusStore::new(dir.path()); + + store + .create_focus( + &NewFocus { + title: "No timer".into(), + description: "".into(), + timer_preset: None, + }, + "id-1", + "2026-04-30T12:00:00Z", + None, + ) + .unwrap(); + + let focuses = store.list().unwrap(); + assert_eq!(focuses.len(), 1); + assert!(focuses[0].timer.is_none()); + } + + #[test] + fn delete_removes_directory() { + let dir = TempDir::new().unwrap(); + let store = MarkdownFocusStore::new(dir.path()); + let slug = store + .create_focus( + &NewFocus { + title: "Bye".into(), + description: "".into(), + timer_preset: None, + }, + "id-1", + "2026-04-30T12:00:00Z", + None, + ) + .unwrap(); + assert!(dir.path().join(&slug).is_dir()); + + store.delete_focus(&slug).unwrap(); + + assert!(!dir.path().join(&slug).exists()); + } + + #[test] + fn delete_nonexistent_returns_err() { + let dir = TempDir::new().unwrap(); + let store = MarkdownFocusStore::new(dir.path()); + let err = store.delete_focus("ghost").unwrap_err(); + assert!(matches!(err, FocusStoreError::NotFound(slug) if slug == "ghost")); + } + + #[test] + fn corrupted_timer_json_degrades_gracefully() { + let dir = TempDir::new().unwrap(); + let store = MarkdownFocusStore::new(dir.path()); + let slug = store + .create_focus( + &NewFocus { + title: "Broken timer".into(), + description: "".into(), + timer_preset: None, + }, + "id-1", + "2026-04-30T12:00:00Z", + None, + ) + .unwrap(); + let timer_path = dir.path().join(&slug).join("timer.json"); + fs::write(&timer_path, b"{ this is not valid json").unwrap(); + + let focuses = store.list().unwrap(); + + assert_eq!(focuses.len(), 1); + assert_eq!(focuses[0].title, "Broken timer"); + assert!(focuses[0].timer.is_none()); + } + + #[test] + fn append_task_persists() { + let dir = TempDir::new().unwrap(); + let store = MarkdownFocusStore::new(dir.path()); + let slug = store + .create_focus( + &NewFocus { + title: "Has tasks".into(), + description: "".into(), + timer_preset: None, + }, + "id-1", + "2026-04-30T12:00:00Z", + None, + ) + .unwrap(); + + store.append_task(&slug, "first thing").unwrap(); + + let focuses = store.list().unwrap(); + assert_eq!(focuses.len(), 1); + assert_eq!(focuses[0].tasks.len(), 1); + assert_eq!(focuses[0].tasks[0].text, "first thing"); + } + + #[test] + fn delete_task_persists() { + let dir = TempDir::new().unwrap(); + let store = MarkdownFocusStore::new(dir.path()); + let slug = store + .create_focus( + &NewFocus { + title: "Two tasks".into(), + description: "".into(), + timer_preset: None, + }, + "id-1", + "2026-04-30T12:00:00Z", + None, + ) + .unwrap(); + store.append_task(&slug, "keep me").unwrap(); + store.append_task(&slug, "remove me").unwrap(); + + store.delete_task(&slug, 1).unwrap(); + + let focuses = store.list().unwrap(); + assert_eq!(focuses[0].tasks.len(), 1); + assert_eq!(focuses[0].tasks[0].text, "keep me"); + } } From 0bce410d7b3ec250753de1a1729c8fb6cf7ede5b Mon Sep 17 00:00:00 2001 From: archae0pteryx Date: Mon, 4 May 2026 21:56:30 -0700 Subject: [PATCH 2/2] test(035): use NewFocus::new in store tests after invariants moved to domain --- crates/storage/src/focus_store.rs | 42 ++++++------------------------- 1 file changed, 7 insertions(+), 35 deletions(-) diff --git a/crates/storage/src/focus_store.rs b/crates/storage/src/focus_store.rs index 2474270..0cabcf8 100644 --- a/crates/storage/src/focus_store.rs +++ b/crates/storage/src/focus_store.rs @@ -418,11 +418,7 @@ mod tests { let slug = store .create_focus( - &NewFocus { - title: "Customer X bug".into(), - description: "ship it".into(), - timer_preset: None, - }, + &NewFocus::new("Customer X bug", "ship it").unwrap(), "id-1", "2026-04-30T12:00:00Z", None, @@ -452,11 +448,7 @@ mod tests { store .create_focus( - &NewFocus { - title: "With timer".into(), - description: "".into(), - timer_preset: None, - }, + &NewFocus::new("With timer", "").unwrap(), "id-1", "2026-04-30T12:00:00Z", Some(timer.clone()), @@ -475,11 +467,7 @@ mod tests { store .create_focus( - &NewFocus { - title: "No timer".into(), - description: "".into(), - timer_preset: None, - }, + &NewFocus::new("No timer", "").unwrap(), "id-1", "2026-04-30T12:00:00Z", None, @@ -497,11 +485,7 @@ mod tests { let store = MarkdownFocusStore::new(dir.path()); let slug = store .create_focus( - &NewFocus { - title: "Bye".into(), - description: "".into(), - timer_preset: None, - }, + &NewFocus::new("Bye", "").unwrap(), "id-1", "2026-04-30T12:00:00Z", None, @@ -528,11 +512,7 @@ mod tests { let store = MarkdownFocusStore::new(dir.path()); let slug = store .create_focus( - &NewFocus { - title: "Broken timer".into(), - description: "".into(), - timer_preset: None, - }, + &NewFocus::new("Broken timer", "").unwrap(), "id-1", "2026-04-30T12:00:00Z", None, @@ -554,11 +534,7 @@ mod tests { let store = MarkdownFocusStore::new(dir.path()); let slug = store .create_focus( - &NewFocus { - title: "Has tasks".into(), - description: "".into(), - timer_preset: None, - }, + &NewFocus::new("Has tasks", "").unwrap(), "id-1", "2026-04-30T12:00:00Z", None, @@ -579,11 +555,7 @@ mod tests { let store = MarkdownFocusStore::new(dir.path()); let slug = store .create_focus( - &NewFocus { - title: "Two tasks".into(), - description: "".into(), - timer_preset: None, - }, + &NewFocus::new("Two tasks", "").unwrap(), "id-1", "2026-04-30T12:00:00Z", None,